@duckcodeailabs/dql-agent 1.6.0 → 1.6.2
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/answer-loop.d.ts +74 -10
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +856 -84
- package/dist/answer-loop.js.map +1 -1
- package/dist/app-builder.d.ts +85 -0
- package/dist/app-builder.d.ts.map +1 -0
- package/dist/app-builder.js +677 -0
- package/dist/app-builder.js.map +1 -0
- package/dist/index.d.ts +14 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -19
- package/dist/index.js.map +1 -1
- package/dist/kg/build.d.ts +2 -3
- package/dist/kg/build.d.ts.map +1 -1
- package/dist/kg/build.js +115 -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 -1
- package/dist/kg/sqlite-fts.js.map +1 -1
- package/dist/kg/types.d.ts +7 -6
- package/dist/kg/types.d.ts.map +1 -1
- package/dist/kg/types.js +5 -4
- package/dist/kg/types.js.map +1 -1
- package/dist/memory/sqlite-memory.d.ts.map +1 -1
- package/dist/memory/sqlite-memory.js +8 -1
- package/dist/memory/sqlite-memory.js.map +1 -1
- package/package.json +6 -6
package/dist/answer-loop.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Dynamic-first governed answer loop.
|
|
3
3
|
*
|
|
4
4
|
* Stages:
|
|
5
|
-
* 1)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* 1) Route intent explicitly. Exact saved-block and KPI definition asks can
|
|
6
|
+
* use certified artifacts; ad hoc analysis and drillthroughs generate SQL.
|
|
7
|
+
* 2) Gather ranked context (blocks, terms, business views, models, runtime
|
|
8
|
+
* schema, memories, Skills) and ask the LLM to propose SQL when needed.
|
|
9
|
+
* 3) Execute read-only generated SQL with one repair attempt, then mark the
|
|
10
|
+
* answer review-required until it is promoted and certified.
|
|
11
|
+
* 4) Always cite the artifacts and context used.
|
|
12
12
|
*
|
|
13
13
|
* The loop is *deterministic* — provider invocation is the only stochastic
|
|
14
14
|
* step. Tests can mock the provider with a canned response and exercise the
|
|
@@ -17,18 +17,46 @@
|
|
|
17
17
|
import { buildSkillsPrompt } from './skills/loader.js';
|
|
18
18
|
const CERTIFIED_HIT_THRESHOLD = 0.18;
|
|
19
19
|
const HARD_NEGATIVE_RATIO = 0.5;
|
|
20
|
-
const
|
|
20
|
+
const EXECUTABLE_ARTIFACT_KINDS = ['block', 'dashboard', 'app', 'notebook'];
|
|
21
|
+
const BUSINESS_CONTEXT_KINDS = ['term', 'business_view'];
|
|
22
|
+
const ARTIFACT_KINDS = [...EXECUTABLE_ARTIFACT_KINDS, ...BUSINESS_CONTEXT_KINDS];
|
|
21
23
|
const SEMANTIC_KINDS = ['metric', 'dimension', 'measure', 'entity', 'semantic_model', 'saved_query'];
|
|
22
24
|
const MANIFEST_KINDS = ['dbt_model', 'dbt_source'];
|
|
23
25
|
export async function answer(input) {
|
|
24
26
|
const { question, userId, domain, provider, kg, skills = [], blockHints = [] } = input;
|
|
25
|
-
const
|
|
27
|
+
const followUpSourceBlock = input.followUp?.sourceBlockName
|
|
28
|
+
? kg.getNode(`block:${input.followUp.sourceBlockName}`)
|
|
29
|
+
: null;
|
|
30
|
+
const excludedArtifactIds = input.followUp?.kind === 'drilldown' && followUpSourceBlock
|
|
31
|
+
? new Set([followUpSourceBlock.nodeId])
|
|
32
|
+
: undefined;
|
|
33
|
+
const executableArtifactHits = kg.search({ query: question, domain, kinds: EXECUTABLE_ARTIFACT_KINDS, limit: 10 });
|
|
34
|
+
const businessHits = kg.search({ query: question, domain, kinds: BUSINESS_CONTEXT_KINDS, limit: 10 });
|
|
35
|
+
const artifactHits = mergeHits(executableArtifactHits, businessHits).slice(0, 12);
|
|
26
36
|
const semanticHits = kg.search({ query: question, domain, kinds: SEMANTIC_KINDS, limit: 12 });
|
|
27
37
|
const manifestHits = kg.search({ query: question, domain, kinds: MANIFEST_KINDS, limit: 12 });
|
|
28
38
|
const considered = mergeHits(artifactHits, semanticHits, manifestHits, kg.search({ query: question, domain, limit: 10 })).slice(0, 30);
|
|
39
|
+
const intent = classifyAgentIntent({
|
|
40
|
+
question,
|
|
41
|
+
followUp: input.followUp,
|
|
42
|
+
artifactHits,
|
|
43
|
+
semanticHits,
|
|
44
|
+
manifestHits,
|
|
45
|
+
schemaContext: input.schemaContext ?? [],
|
|
46
|
+
});
|
|
29
47
|
// Stage 1: certified artifact match. Blocks can be executed; dashboards,
|
|
30
48
|
// Apps, and notebooks are returned as governed citations/navigation targets.
|
|
31
|
-
const artifactHit =
|
|
49
|
+
const artifactHit = intent === 'exact_certified_lookup'
|
|
50
|
+
? pickCertifiedArtifact({
|
|
51
|
+
artifactHits,
|
|
52
|
+
executableArtifactHits,
|
|
53
|
+
businessHits,
|
|
54
|
+
question,
|
|
55
|
+
blockHints: input.followUp?.kind === 'drilldown' ? [] : blockHints,
|
|
56
|
+
excludedArtifactIds,
|
|
57
|
+
kg,
|
|
58
|
+
})
|
|
59
|
+
: null;
|
|
32
60
|
if (artifactHit) {
|
|
33
61
|
let result;
|
|
34
62
|
let executionError;
|
|
@@ -41,19 +69,31 @@ export async function answer(input) {
|
|
|
41
69
|
}
|
|
42
70
|
}
|
|
43
71
|
const text = composeCertifiedAnswer(artifactHit.node, question, result, executionError);
|
|
72
|
+
const sourceTier = artifactHit.node.sourceTier === 'business_context'
|
|
73
|
+
? 'business_context'
|
|
74
|
+
: 'certified_artifact';
|
|
44
75
|
const citations = [
|
|
45
76
|
{
|
|
46
77
|
nodeId: artifactHit.node.nodeId,
|
|
47
78
|
kind: artifactHit.node.kind,
|
|
48
79
|
name: artifactHit.node.name,
|
|
49
80
|
gitSha: artifactHit.node.gitSha,
|
|
50
|
-
sourceTier
|
|
81
|
+
sourceTier,
|
|
51
82
|
provenance: artifactHit.node.provenance,
|
|
52
83
|
},
|
|
53
84
|
];
|
|
85
|
+
const analysisPlan = buildAnalysisPlan({
|
|
86
|
+
question,
|
|
87
|
+
intent,
|
|
88
|
+
routeReason: 'The question matched a certified DQL artifact closely enough to answer without generating new SQL.',
|
|
89
|
+
selectedNodes: [artifactHit.node],
|
|
90
|
+
schemaContext: input.schemaContext ?? [],
|
|
91
|
+
sql: result?.sql,
|
|
92
|
+
suggestedViz: result?.chartConfig ? chartNameFromConfig(result.chartConfig) : undefined,
|
|
93
|
+
});
|
|
54
94
|
return {
|
|
55
95
|
kind: 'certified',
|
|
56
|
-
sourceTier
|
|
96
|
+
sourceTier,
|
|
57
97
|
certification: 'certified',
|
|
58
98
|
reviewStatus: 'certified',
|
|
59
99
|
confidence: 0.95,
|
|
@@ -65,9 +105,11 @@ export async function answer(input) {
|
|
|
65
105
|
sql: result?.sql,
|
|
66
106
|
citations,
|
|
67
107
|
memoryContext: input.memoryContext,
|
|
108
|
+
analysisPlan,
|
|
68
109
|
evidence: buildCertifiedEvidence({
|
|
69
110
|
question,
|
|
70
111
|
artifact: artifactHit.node,
|
|
112
|
+
businessHits,
|
|
71
113
|
semanticHits,
|
|
72
114
|
manifestHits,
|
|
73
115
|
considered,
|
|
@@ -76,6 +118,43 @@ export async function answer(input) {
|
|
|
76
118
|
executorWasAvailable: Boolean(input.executeCertifiedBlock),
|
|
77
119
|
citations,
|
|
78
120
|
memoryContext: input.memoryContext ?? [],
|
|
121
|
+
analysisPlan,
|
|
122
|
+
}),
|
|
123
|
+
considered,
|
|
124
|
+
providerUsed: provider.name,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (intent === 'clarify') {
|
|
128
|
+
const text = composeClarificationText(question, considered, input.schemaContext ?? []);
|
|
129
|
+
const analysisPlan = buildAnalysisPlan({
|
|
130
|
+
question,
|
|
131
|
+
intent,
|
|
132
|
+
routeReason: 'No certified artifact, semantic object, dbt/source table, or runtime schema match was strong enough to safely generate SQL.',
|
|
133
|
+
selectedNodes: considered.slice(0, 4).map((hit) => hit.node),
|
|
134
|
+
schemaContext: input.schemaContext ?? [],
|
|
135
|
+
assumptions: ['Need a clearer business object, measure, or grain before querying.'],
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
kind: 'no_answer',
|
|
139
|
+
sourceTier: 'no_answer',
|
|
140
|
+
certification: 'analyst_review_required',
|
|
141
|
+
reviewStatus: 'none',
|
|
142
|
+
confidence: 0.15,
|
|
143
|
+
text,
|
|
144
|
+
answer: text,
|
|
145
|
+
citations: [],
|
|
146
|
+
memoryContext: input.memoryContext,
|
|
147
|
+
analysisPlan,
|
|
148
|
+
evidence: buildNoAnswerEvidence({
|
|
149
|
+
question,
|
|
150
|
+
reason: text,
|
|
151
|
+
artifactHits,
|
|
152
|
+
businessHits,
|
|
153
|
+
semanticHits,
|
|
154
|
+
manifestHits,
|
|
155
|
+
considered,
|
|
156
|
+
memoryContext: input.memoryContext ?? [],
|
|
157
|
+
analysisPlan,
|
|
79
158
|
}),
|
|
80
159
|
considered,
|
|
81
160
|
providerUsed: provider.name,
|
|
@@ -88,12 +167,19 @@ export async function answer(input) {
|
|
|
88
167
|
: manifestHits.length > 0
|
|
89
168
|
? 'dbt_manifest'
|
|
90
169
|
: 'dbt_manifest';
|
|
170
|
+
const reviewRequiredArtifactHits = artifactHits
|
|
171
|
+
.filter((hit) => hit.score >= CERTIFIED_HIT_THRESHOLD && !isCertifiedHit(hit, kg))
|
|
172
|
+
.slice(0, 4);
|
|
173
|
+
const trustedArtifactContext = executableArtifactHits
|
|
174
|
+
.filter((hit) => !excludedArtifactIds?.has(hit.node.nodeId))
|
|
175
|
+
.slice(0, 5);
|
|
91
176
|
const contextHits = activeTier === 'semantic_layer'
|
|
92
|
-
? [...semanticHits, ...manifestHits].slice(0,
|
|
93
|
-
: manifestHits.slice(0,
|
|
94
|
-
const contextNodes = (contextHits.length > 0 ? contextHits : considered.slice(0, 6)).map((h) => h.node);
|
|
177
|
+
? [...trustedArtifactContext, ...reviewRequiredArtifactHits, ...businessHits.slice(0, 4), ...semanticHits, ...manifestHits].slice(0, 14)
|
|
178
|
+
: [...trustedArtifactContext, ...reviewRequiredArtifactHits, ...businessHits.slice(0, 4), ...manifestHits].slice(0, 14);
|
|
179
|
+
const contextNodes = mergeNodes(followUpSourceBlock && input.followUp?.kind === 'drilldown' ? [followUpSourceBlock] : [], (contextHits.length > 0 ? contextHits : considered.slice(0, 6)).map((h) => h.node));
|
|
95
180
|
const contextBlocks = contextNodes.filter((n) => n.kind === 'block');
|
|
96
|
-
const
|
|
181
|
+
const contextBusiness = contextNodes.filter((n) => BUSINESS_CONTEXT_KINDS.includes(n.kind));
|
|
182
|
+
const contextOther = contextNodes.filter((n) => n.kind !== 'block' && !BUSINESS_CONTEXT_KINDS.includes(n.kind));
|
|
97
183
|
const messages = [
|
|
98
184
|
{ role: 'system', content: SYSTEM_PROMPT },
|
|
99
185
|
];
|
|
@@ -102,39 +188,51 @@ export async function answer(input) {
|
|
|
102
188
|
messages.push({ role: 'system', content: skillsPrompt });
|
|
103
189
|
messages.push({
|
|
104
190
|
role: 'system',
|
|
105
|
-
content: renderContextPrompt(contextBlocks, contextOther, activeTier, input.memoryContext ?? [], input.extraContext),
|
|
191
|
+
content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, input.schemaContext ?? [], intent),
|
|
106
192
|
});
|
|
107
193
|
messages.push({ role: 'user', content: question });
|
|
194
|
+
const localProposal = buildSchemaAwareProposal({
|
|
195
|
+
question,
|
|
196
|
+
intent,
|
|
197
|
+
schemaContext: input.schemaContext ?? [],
|
|
198
|
+
});
|
|
108
199
|
let proposed = '';
|
|
109
|
-
|
|
110
|
-
|
|
200
|
+
let parsed;
|
|
201
|
+
if (localProposal) {
|
|
202
|
+
parsed = localProposal;
|
|
111
203
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
204
|
+
else {
|
|
205
|
+
try {
|
|
206
|
+
proposed = await provider.generate(messages, { signal: input.signal });
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
const text = `Provider error: ${err.message}`;
|
|
210
|
+
return {
|
|
211
|
+
kind: 'no_answer',
|
|
212
|
+
sourceTier: 'no_answer',
|
|
213
|
+
certification: 'analyst_review_required',
|
|
214
|
+
reviewStatus: 'none',
|
|
215
|
+
confidence: 0,
|
|
216
|
+
text,
|
|
217
|
+
answer: text,
|
|
218
|
+
citations: [],
|
|
219
|
+
memoryContext: input.memoryContext,
|
|
220
|
+
evidence: buildNoAnswerEvidence({
|
|
221
|
+
question,
|
|
222
|
+
reason: text,
|
|
223
|
+
artifactHits,
|
|
224
|
+
businessHits,
|
|
225
|
+
semanticHits,
|
|
226
|
+
manifestHits,
|
|
227
|
+
considered,
|
|
228
|
+
memoryContext: input.memoryContext ?? [],
|
|
229
|
+
}),
|
|
130
230
|
considered,
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
};
|
|
231
|
+
providerUsed: provider.name,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
parsed = parseProposal(proposed);
|
|
136
235
|
}
|
|
137
|
-
const parsed = parseProposal(proposed);
|
|
138
236
|
if (!parsed.sql) {
|
|
139
237
|
const text = parsed.text || 'No answer (the model declined to propose SQL).';
|
|
140
238
|
return {
|
|
@@ -151,6 +249,7 @@ export async function answer(input) {
|
|
|
151
249
|
question,
|
|
152
250
|
reason: text,
|
|
153
251
|
artifactHits,
|
|
252
|
+
businessHits,
|
|
154
253
|
semanticHits,
|
|
155
254
|
manifestHits,
|
|
156
255
|
considered,
|
|
@@ -166,7 +265,7 @@ export async function answer(input) {
|
|
|
166
265
|
kind: n.kind,
|
|
167
266
|
name: n.name,
|
|
168
267
|
gitSha: n.gitSha,
|
|
169
|
-
sourceTier: activeTier,
|
|
268
|
+
sourceTier: citationSourceTier(n, activeTier),
|
|
170
269
|
provenance: n.provenance,
|
|
171
270
|
})),
|
|
172
271
|
...(input.memoryContext ?? []).slice(0, 2).map((m) => ({
|
|
@@ -177,55 +276,156 @@ export async function answer(input) {
|
|
|
177
276
|
provenance: m.source,
|
|
178
277
|
})),
|
|
179
278
|
];
|
|
279
|
+
let result;
|
|
280
|
+
let executionError;
|
|
281
|
+
let repairAttempts = 0;
|
|
282
|
+
if (input.executeGeneratedSql) {
|
|
283
|
+
try {
|
|
284
|
+
result = await input.executeGeneratedSql(parsed.sql);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
executionError = err instanceof Error ? err.message : String(err);
|
|
288
|
+
if (isRetryableGeneratedSqlError(executionError)) {
|
|
289
|
+
const localRepairSql = repairGeneratedSqlLocally(parsed.sql, executionError, input.schemaContext ?? []);
|
|
290
|
+
if (localRepairSql) {
|
|
291
|
+
repairAttempts = 1;
|
|
292
|
+
parsed.sql = localRepairSql;
|
|
293
|
+
try {
|
|
294
|
+
result = await input.executeGeneratedSql(parsed.sql);
|
|
295
|
+
executionError = undefined;
|
|
296
|
+
}
|
|
297
|
+
catch (retryErr) {
|
|
298
|
+
executionError = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (executionError) {
|
|
302
|
+
const repairedRaw = await requestSqlRepair({
|
|
303
|
+
provider,
|
|
304
|
+
baseMessages: messages,
|
|
305
|
+
question,
|
|
306
|
+
parsed,
|
|
307
|
+
executionError,
|
|
308
|
+
schemaContext: input.schemaContext ?? [],
|
|
309
|
+
signal: input.signal,
|
|
310
|
+
});
|
|
311
|
+
const repaired = parseProposal(repairedRaw);
|
|
312
|
+
if (repaired.sql) {
|
|
313
|
+
repairAttempts += 1;
|
|
314
|
+
parsed.text = repaired.text || parsed.text;
|
|
315
|
+
parsed.sql = repaired.sql;
|
|
316
|
+
parsed.viz = repaired.viz ?? parsed.viz;
|
|
317
|
+
try {
|
|
318
|
+
result = await input.executeGeneratedSql(parsed.sql);
|
|
319
|
+
executionError = undefined;
|
|
320
|
+
}
|
|
321
|
+
catch (retryErr) {
|
|
322
|
+
executionError = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const analysisPlan = buildAnalysisPlan({
|
|
330
|
+
question,
|
|
331
|
+
intent,
|
|
332
|
+
routeReason: intent === 'drillthrough'
|
|
333
|
+
? 'The user asked for a drill-through or follow-up, so DQL generated review-required SQL from the prior context and current metadata.'
|
|
334
|
+
: 'The question asks for a custom analysis, ranking, breakdown, comparison, or grain that should not be answered by a loose certified block match.',
|
|
335
|
+
selectedNodes: contextNodes,
|
|
336
|
+
schemaContext: input.schemaContext ?? [],
|
|
337
|
+
sql: parsed.sql,
|
|
338
|
+
suggestedViz: parsed.viz ?? 'table',
|
|
339
|
+
assumptions: [
|
|
340
|
+
'Generated SQL is an uncertified preview until an analyst reviews and promotes it.',
|
|
341
|
+
...(localProposal ? ['A schema-aware local planner selected the customer ranking grain before provider generation.'] : []),
|
|
342
|
+
...(executionError ? ['The preview execution error must be reviewed before reuse.'] : []),
|
|
343
|
+
],
|
|
344
|
+
repairAttempts,
|
|
345
|
+
});
|
|
180
346
|
return {
|
|
181
347
|
kind: 'uncertified',
|
|
182
348
|
sourceTier: activeTier,
|
|
183
349
|
certification: 'ai_generated',
|
|
184
|
-
reviewStatus: '
|
|
350
|
+
reviewStatus: 'draft_ready',
|
|
185
351
|
confidence: activeTier === 'semantic_layer' ? 0.72 : 0.55,
|
|
186
352
|
text: parsed.text,
|
|
187
353
|
answer: parsed.text,
|
|
188
354
|
proposedSql: parsed.sql,
|
|
189
355
|
sql: parsed.sql,
|
|
356
|
+
result,
|
|
357
|
+
executionError,
|
|
190
358
|
suggestedViz: parsed.viz ?? 'table',
|
|
191
359
|
citations: generatedCitations,
|
|
192
360
|
memoryContext: input.memoryContext,
|
|
361
|
+
analysisPlan,
|
|
193
362
|
evidence: buildGeneratedEvidence({
|
|
194
363
|
question,
|
|
195
364
|
activeTier,
|
|
365
|
+
intent,
|
|
196
366
|
contextNodes,
|
|
367
|
+
followUp: input.followUp,
|
|
368
|
+
businessHits,
|
|
197
369
|
semanticHits,
|
|
198
370
|
manifestHits,
|
|
199
371
|
considered,
|
|
200
372
|
citations: generatedCitations,
|
|
201
373
|
memoryContext: input.memoryContext ?? [],
|
|
374
|
+
result,
|
|
375
|
+
executionError,
|
|
376
|
+
executorWasAvailable: Boolean(input.executeGeneratedSql),
|
|
377
|
+
analysisPlan,
|
|
202
378
|
}),
|
|
203
379
|
considered,
|
|
204
|
-
providerUsed: provider.name,
|
|
380
|
+
providerUsed: localProposal ? 'schema_planner' : provider.name,
|
|
205
381
|
};
|
|
206
382
|
}
|
|
207
383
|
const SYSTEM_PROMPT = `You are the DQL Analytics Agent.
|
|
208
384
|
|
|
209
385
|
Rules:
|
|
210
|
-
1.
|
|
211
|
-
|
|
386
|
+
1. Use certified DQL blocks only when the user's question exactly asks for that
|
|
387
|
+
saved block, direct KPI, or definition. For ad hoc rankings, breakdowns,
|
|
388
|
+
comparisons, drill-throughs, or custom grains, generate review-required SQL
|
|
389
|
+
from the supplied metadata and cite certified context as evidence.
|
|
212
390
|
2. If you must generate SQL, return it inside a single \`\`\`sql code block.
|
|
213
391
|
3. Provide a one-paragraph natural-language summary BEFORE the SQL block.
|
|
214
392
|
4. Suggest a visualization type from this list, on a line starting with "Viz:":
|
|
215
393
|
line, bar, area, pie, single_value, table, pivot, kpi.
|
|
216
394
|
5. NEVER fabricate column names that are not present in the supplied schema context.
|
|
217
|
-
6.
|
|
218
|
-
|
|
395
|
+
6. Return runnable SQL for the local warehouse/runtime. Do NOT use dbt/Jinja
|
|
396
|
+
macros such as {{ ref(...) }} or {{ source(...) }} in proposed SQL.
|
|
397
|
+
7. If the schema is insufficient to answer, say so explicitly and ask a clarifying question.`;
|
|
398
|
+
function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis') {
|
|
399
|
+
const intentSection = `## Routing intent\n\nintent: ${intent}\n${intent === 'exact_certified_lookup'
|
|
400
|
+
? 'Use a certified artifact only if it exactly answers the question.'
|
|
401
|
+
: 'Generate review-required SQL for this question. Certified blocks are trusted context, not a reason to answer the wrong grain.'}`;
|
|
219
402
|
const blockSection = blocks.length > 0
|
|
220
|
-
? `##
|
|
221
|
-
.map((b) => `- \`${b.nodeId}\` (${b.domain ?? 'unscoped'}): ${b.description ?? b.llmContext ?? '(no description)'}`)
|
|
403
|
+
? `## Relevant DQL blocks\n\n${blocks
|
|
404
|
+
.map((b) => `- \`${b.nodeId}\` (${b.domain ?? 'unscoped'}, ${b.status ?? b.certification ?? 'review_required'}): ${b.description ?? b.llmContext ?? '(no description)'}`)
|
|
222
405
|
.join('\n')}`
|
|
223
|
-
: '##
|
|
406
|
+
: '## Relevant DQL blocks: (none matched)';
|
|
407
|
+
const businessSection = businessContext.length > 0
|
|
408
|
+
? `\n\n## Business context from DQL terms and business views\n\n${businessContext
|
|
409
|
+
.map((n) => `- ${n.kind.replace('_', ' ')} \`${n.name}\`${n.domain ? ` (domain: ${n.domain})` : ''}${n.description ? ` — ${n.description}` : ''}${n.llmContext ? `\n ${n.llmContext.replace(/\n/g, '\n ')}` : ''}`)
|
|
410
|
+
.join('\n')}`
|
|
411
|
+
: '';
|
|
224
412
|
const otherSection = others.length > 0
|
|
225
413
|
? `\n\n## Related ${activeTier === 'semantic_layer' ? 'semantic layer' : 'dbt manifest'} context\n\n${others
|
|
226
414
|
.map((n) => `- ${n.kind} \`${n.name}\`${n.domain ? ` (domain: ${n.domain})` : ''}${n.description ? ` — ${n.description}` : ''}${n.llmContext ? `\n ${n.llmContext.replace(/\n/g, '\n ')}` : ''}`)
|
|
227
415
|
.join('\n')}`
|
|
228
416
|
: '';
|
|
417
|
+
const schemaSection = schemaContext.length > 0
|
|
418
|
+
? `\n\n## Runtime schema context\n\nUse only these runtime relations and columns when generating SQL unless the dbt manifest context gives an equivalent relation.\n${schemaContext
|
|
419
|
+
.slice(0, 12)
|
|
420
|
+
.map((table) => {
|
|
421
|
+
const cols = table.columns
|
|
422
|
+
.slice(0, 50)
|
|
423
|
+
.map((col) => `${col.name}${col.type ? ` ${col.type}` : ''}${col.description ? ` (${col.description})` : ''}`)
|
|
424
|
+
.join(', ');
|
|
425
|
+
return `- ${table.relation}${table.description ? ` — ${table.description}` : ''}\n columns: ${cols}`;
|
|
426
|
+
})
|
|
427
|
+
.join('\n')}`
|
|
428
|
+
: '';
|
|
229
429
|
const memorySection = memoryContext.length > 0
|
|
230
430
|
? `\n\n## Advisory local memory\n\nMemory can clarify business language but MUST NOT override certified artifacts, semantic metrics, or dbt metadata.\n${memoryContext
|
|
231
431
|
.slice(0, 6)
|
|
@@ -235,7 +435,24 @@ function renderContextPrompt(blocks, others, activeTier, memoryContext, extraCon
|
|
|
235
435
|
const extraSection = extraContext?.trim()
|
|
236
436
|
? `\n\n## Current notebook/app context\n\nThis context may help interpret the user's request, but it MUST NOT override certified artifacts, semantic metrics, dbt metadata, or generated SQL validation.\n\n${extraContext.trim()}`
|
|
237
437
|
: '';
|
|
238
|
-
|
|
438
|
+
const followUpSection = followUp
|
|
439
|
+
? `\n\n## Follow-up routing context\n\n${renderFollowUpContext(followUp)}`
|
|
440
|
+
: '';
|
|
441
|
+
return `${intentSection}\n\n${blockSection}${businessSection}${otherSection}${schemaSection}${memorySection}${extraSection}${followUpSection}`;
|
|
442
|
+
}
|
|
443
|
+
function renderFollowUpContext(followUp) {
|
|
444
|
+
const parts = [
|
|
445
|
+
`kind: ${followUp.kind}`,
|
|
446
|
+
followUp.sourceBlockName ? `source certified block: ${followUp.sourceBlockName}` : '',
|
|
447
|
+
followUp.sourceQuestion ? `source question: ${followUp.sourceQuestion}` : '',
|
|
448
|
+
followUp.sourceAnswer ? `source answer: ${followUp.sourceAnswer.slice(0, 700)}` : '',
|
|
449
|
+
followUp.filters?.length ? `requested filters: ${followUp.filters.join(', ')}` : '',
|
|
450
|
+
followUp.dimensions?.length ? `requested dimensions: ${followUp.dimensions.join(', ')}` : '',
|
|
451
|
+
].filter(Boolean);
|
|
452
|
+
const rule = followUp.kind === 'drilldown'
|
|
453
|
+
? 'routing rule: find a distinct certified drilldown block first; if none exists, generate review-required SQL as a draft drilldown. Do not silently re-run the source block unless it explicitly supports the requested filter or dimension.'
|
|
454
|
+
: 'routing rule: reuse the prior certified block when the user asks a generic follow-up.';
|
|
455
|
+
return [...parts, rule].join('\n');
|
|
239
456
|
}
|
|
240
457
|
/**
|
|
241
458
|
* Public for tests. Pulls the first ```sql block and an optional Viz: line
|
|
@@ -253,35 +470,490 @@ export function parseProposal(raw) {
|
|
|
253
470
|
.trim();
|
|
254
471
|
return { text, sql, viz };
|
|
255
472
|
}
|
|
256
|
-
function
|
|
473
|
+
function buildSchemaAwareProposal(input) {
|
|
474
|
+
if (input.intent !== 'ad_hoc_analysis' && input.intent !== 'drillthrough')
|
|
475
|
+
return undefined;
|
|
476
|
+
const lower = input.question.toLowerCase();
|
|
477
|
+
const asksForCustomerPerformance = /\bcustomers?\b/.test(lower)
|
|
478
|
+
&& /\border|orders|spend|revenue|perform|performed|better|top|best|rank|ranking\b/.test(lower)
|
|
479
|
+
&& !/\b(order details|specific orders|each order|all orders|order line|line item)\b/.test(lower);
|
|
480
|
+
if (!asksForCustomerPerformance)
|
|
481
|
+
return undefined;
|
|
482
|
+
const customers = findSchemaTable(input.schemaContext, ['customers', 'customer']);
|
|
483
|
+
if (!customers)
|
|
484
|
+
return undefined;
|
|
485
|
+
const customerName = findSchemaColumn(customers, ['customer_name', 'name', 'full_name']);
|
|
486
|
+
const orderCount = findSchemaColumn(customers, ['count_lifetime_orders', 'lifetime_orders', 'order_count', 'orders_count', 'orders']);
|
|
487
|
+
const spend = findSchemaColumn(customers, ['lifetime_spend', 'total_lifetime_spend', 'customer_lifetime_value', 'total_revenue', 'revenue']);
|
|
488
|
+
if (customerName && orderCount && spend) {
|
|
489
|
+
return {
|
|
490
|
+
text: `Top performing customers ranked by ${businessMeasurePhrase(spend)} with ${businessMeasurePhrase(orderCount)} for context. This is AI-generated and needs analyst review before certification.`,
|
|
491
|
+
sql: [
|
|
492
|
+
'SELECT',
|
|
493
|
+
` ${sqlIdentifier(customerName)} AS customer_name,`,
|
|
494
|
+
` ${sqlIdentifier(orderCount)} AS orders,`,
|
|
495
|
+
` ROUND(${sqlIdentifier(spend)}, 2) AS lifetime_spend`,
|
|
496
|
+
`FROM ${sqlRelation(customers.relation)}`,
|
|
497
|
+
`ORDER BY ${sqlIdentifier(spend)} DESC, ${sqlIdentifier(orderCount)} DESC`,
|
|
498
|
+
'LIMIT 10',
|
|
499
|
+
].join('\n'),
|
|
500
|
+
viz: 'table',
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
const customerId = findSchemaColumn(customers, ['customer_id', 'id']);
|
|
504
|
+
const orders = findSchemaTable(input.schemaContext, ['orders', 'order']);
|
|
505
|
+
if (!orders || !customerName || !customerId)
|
|
506
|
+
return undefined;
|
|
507
|
+
const orderCustomerId = findSchemaColumn(orders, ['customer_id', 'customer']);
|
|
508
|
+
const orderTotal = findSchemaColumn(orders, ['order_total', 'total_order_amount', 'total_amount', 'amount', 'subtotal']);
|
|
509
|
+
const orderId = findSchemaColumn(orders, ['order_id', 'id']);
|
|
510
|
+
if (!orderCustomerId || !orderTotal)
|
|
511
|
+
return undefined;
|
|
512
|
+
const countExpression = orderId ? `COUNT(DISTINCT o.${sqlIdentifier(orderId)})` : 'COUNT(*)';
|
|
513
|
+
return {
|
|
514
|
+
text: `Top performing customers ranked from order totals with order count for context. This is AI-generated and needs analyst review before certification.`,
|
|
515
|
+
sql: [
|
|
516
|
+
'SELECT',
|
|
517
|
+
` c.${sqlIdentifier(customerName)} AS customer_name,`,
|
|
518
|
+
` ${countExpression} AS orders,`,
|
|
519
|
+
` ROUND(SUM(o.${sqlIdentifier(orderTotal)}), 2) AS lifetime_spend`,
|
|
520
|
+
`FROM ${sqlRelation(orders.relation)} AS o`,
|
|
521
|
+
`JOIN ${sqlRelation(customers.relation)} AS c ON o.${sqlIdentifier(orderCustomerId)} = c.${sqlIdentifier(customerId)}`,
|
|
522
|
+
`GROUP BY c.${sqlIdentifier(customerName)}`,
|
|
523
|
+
'ORDER BY lifetime_spend DESC, orders DESC',
|
|
524
|
+
'LIMIT 10',
|
|
525
|
+
].join('\n'),
|
|
526
|
+
viz: 'table',
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function findSchemaTable(schemaContext, names) {
|
|
530
|
+
return schemaContext.find((table) => {
|
|
531
|
+
const tableNames = new Set([table.name, table.relation.split('.').at(-1) ?? table.relation].map((name) => name.toLowerCase()));
|
|
532
|
+
return names.some((name) => tableNames.has(name.toLowerCase()));
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
function findSchemaColumn(table, names) {
|
|
536
|
+
const byLower = new Map(table.columns.map((column) => [column.name.toLowerCase(), column.name]));
|
|
537
|
+
for (const name of names) {
|
|
538
|
+
const exact = byLower.get(name.toLowerCase());
|
|
539
|
+
if (exact)
|
|
540
|
+
return exact;
|
|
541
|
+
}
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
function sqlRelation(relation) {
|
|
545
|
+
return relation.split('.').map(sqlIdentifier).join('.');
|
|
546
|
+
}
|
|
547
|
+
function sqlIdentifier(identifier) {
|
|
548
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)
|
|
549
|
+
? identifier
|
|
550
|
+
: `"${identifier.replace(/"/g, '""')}"`;
|
|
551
|
+
}
|
|
552
|
+
function humanizeIdentifier(identifier) {
|
|
553
|
+
return identifier.replace(/[_-]+/g, ' ');
|
|
554
|
+
}
|
|
555
|
+
function businessMeasurePhrase(identifier) {
|
|
556
|
+
const lower = identifier.toLowerCase();
|
|
557
|
+
if (lower.includes('lifetime_spend'))
|
|
558
|
+
return 'lifetime spend';
|
|
559
|
+
if (lower.includes('count_lifetime_orders') || lower.includes('lifetime_orders') || lower.includes('order_count')) {
|
|
560
|
+
return 'lifetime order count';
|
|
561
|
+
}
|
|
562
|
+
return humanizeIdentifier(identifier);
|
|
563
|
+
}
|
|
564
|
+
function pickCertifiedArtifact(input) {
|
|
257
565
|
// Hint match wins immediately: the active Skill's vocabulary points the
|
|
258
566
|
// user at a specific block. We still validate it's certified.
|
|
259
|
-
for (const hint of blockHints) {
|
|
260
|
-
const node = kg.getNode(`block:${hint}`);
|
|
567
|
+
for (const hint of input.blockHints) {
|
|
568
|
+
const node = input.kg.getNode(`block:${hint}`);
|
|
261
569
|
if (node && node.status === 'certified') {
|
|
262
570
|
return { node, score: 1, snippet: undefined };
|
|
263
571
|
}
|
|
264
572
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
573
|
+
if (isBusinessDefinitionQuestion(input.question)) {
|
|
574
|
+
const businessHit = pickFirstCertifiedHit(input.businessHits, input.kg);
|
|
575
|
+
if (businessHit)
|
|
576
|
+
return businessHit;
|
|
577
|
+
}
|
|
578
|
+
const executableHit = pickFirstCertifiedHit(input.executableArtifactHits, input.kg, input.excludedArtifactIds, input.question);
|
|
579
|
+
if (executableHit && shouldDeferCertifiedArtifactForReviewPath({
|
|
580
|
+
hits: input.executableArtifactHits,
|
|
581
|
+
selected: executableHit,
|
|
582
|
+
question: input.question,
|
|
583
|
+
kg: input.kg,
|
|
584
|
+
excludedArtifactIds: input.excludedArtifactIds,
|
|
585
|
+
})) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
if (executableHit)
|
|
589
|
+
return executableHit;
|
|
590
|
+
const hasExecutableCandidate = input.executableArtifactHits.some((hit) => hit.score >= CERTIFIED_HIT_THRESHOLD);
|
|
591
|
+
if (!hasExecutableCandidate) {
|
|
592
|
+
const businessHit = pickFirstCertifiedHit(input.businessHits, input.kg, input.excludedArtifactIds);
|
|
593
|
+
if (businessHit)
|
|
594
|
+
return businessHit;
|
|
595
|
+
}
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
function pickFirstCertifiedHit(hits, kg, excludedNodeIds, question) {
|
|
599
|
+
for (const hit of hits) {
|
|
268
600
|
if (hit.score < CERTIFIED_HIT_THRESHOLD)
|
|
269
601
|
break;
|
|
270
|
-
if (hit.node.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (total > 0 && fb.down / total > HARD_NEGATIVE_RATIO)
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
else if (hit.node.status !== 'certified' && hit.node.certification !== 'certified') {
|
|
602
|
+
if (excludedNodeIds?.has(hit.node.nodeId))
|
|
603
|
+
continue;
|
|
604
|
+
if (!isCertifiedHit(hit, kg))
|
|
605
|
+
continue;
|
|
606
|
+
if (question && hit.node.kind === 'block' && !hasMeaningfulCertifiedBlockSignal(question, hit.node))
|
|
279
607
|
continue;
|
|
280
|
-
}
|
|
281
608
|
return hit;
|
|
282
609
|
}
|
|
283
610
|
return null;
|
|
284
611
|
}
|
|
612
|
+
function shouldDeferCertifiedArtifactForReviewPath(input) {
|
|
613
|
+
if (!isBreakdownOrDrilldownQuestion(input.question))
|
|
614
|
+
return false;
|
|
615
|
+
const selectedIndex = input.hits.findIndex((hit) => hit.node.nodeId === input.selected.node.nodeId);
|
|
616
|
+
if (selectedIndex <= 0)
|
|
617
|
+
return false;
|
|
618
|
+
const strongerReviewHit = input.hits.slice(0, selectedIndex).find((hit) => {
|
|
619
|
+
if (hit.score < CERTIFIED_HIT_THRESHOLD)
|
|
620
|
+
return false;
|
|
621
|
+
if (input.excludedArtifactIds?.has(hit.node.nodeId))
|
|
622
|
+
return false;
|
|
623
|
+
if (isCertifiedHit(hit, input.kg))
|
|
624
|
+
return false;
|
|
625
|
+
return hit.score >= input.selected.score * 0.9;
|
|
626
|
+
});
|
|
627
|
+
return Boolean(strongerReviewHit);
|
|
628
|
+
}
|
|
629
|
+
function isCertifiedHit(hit, kg) {
|
|
630
|
+
if (hit.node.kind === 'block') {
|
|
631
|
+
if (hit.node.status !== 'certified')
|
|
632
|
+
return false;
|
|
633
|
+
const fb = kg.blockFeedbackScore(hit.node.nodeId);
|
|
634
|
+
const total = fb.up + fb.down;
|
|
635
|
+
return !(total > 0 && fb.down / total > HARD_NEGATIVE_RATIO);
|
|
636
|
+
}
|
|
637
|
+
return hit.node.status === 'certified' || hit.node.certification === 'certified';
|
|
638
|
+
}
|
|
639
|
+
function isBusinessDefinitionQuestion(question) {
|
|
640
|
+
return /\b(what is|what are|define|definition|meaning of|what does .+ mean)\b/i.test(question);
|
|
641
|
+
}
|
|
642
|
+
function isBreakdownOrDrilldownQuestion(question) {
|
|
643
|
+
return /\b(break\s*down|breakdown|drill\s*(?:down|into)|slice|segment|split|by\s+[a-z][\w\s-]{1,40})\b/i.test(question);
|
|
644
|
+
}
|
|
645
|
+
const GENERIC_ANALYTIC_TOKENS = new Set([
|
|
646
|
+
'all',
|
|
647
|
+
'and',
|
|
648
|
+
'average',
|
|
649
|
+
'avg',
|
|
650
|
+
'count',
|
|
651
|
+
'data',
|
|
652
|
+
'flag',
|
|
653
|
+
'for',
|
|
654
|
+
'from',
|
|
655
|
+
'group',
|
|
656
|
+
'how',
|
|
657
|
+
'include',
|
|
658
|
+
'list',
|
|
659
|
+
'many',
|
|
660
|
+
'metric',
|
|
661
|
+
'number',
|
|
662
|
+
'preview',
|
|
663
|
+
'record',
|
|
664
|
+
'records',
|
|
665
|
+
'show',
|
|
666
|
+
'sum',
|
|
667
|
+
'table',
|
|
668
|
+
'total',
|
|
669
|
+
'using',
|
|
670
|
+
'value',
|
|
671
|
+
'versus',
|
|
672
|
+
'with',
|
|
673
|
+
]);
|
|
674
|
+
function hasMeaningfulCertifiedBlockSignal(question, node) {
|
|
675
|
+
const questionTokens = meaningfulTokens(question);
|
|
676
|
+
if (questionTokens.size === 0)
|
|
677
|
+
return true;
|
|
678
|
+
const nodeTokens = meaningfulTokens([
|
|
679
|
+
node.name,
|
|
680
|
+
node.domain ?? '',
|
|
681
|
+
...(node.tags ?? []),
|
|
682
|
+
].join(' '));
|
|
683
|
+
for (const token of questionTokens) {
|
|
684
|
+
if (nodeTokens.has(token))
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
function meaningfulTokens(value) {
|
|
690
|
+
const tokens = new Set();
|
|
691
|
+
for (const raw of value.toLowerCase().match(/[a-z0-9_]+/g) ?? []) {
|
|
692
|
+
for (const part of raw.split('_')) {
|
|
693
|
+
const normalized = normalizeToken(part);
|
|
694
|
+
if (!normalized || normalized.length < 3 || GENERIC_ANALYTIC_TOKENS.has(normalized))
|
|
695
|
+
continue;
|
|
696
|
+
tokens.add(normalized);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return tokens;
|
|
700
|
+
}
|
|
701
|
+
function normalizeToken(token) {
|
|
702
|
+
if (token === 'skus')
|
|
703
|
+
return 'sku';
|
|
704
|
+
if (token === 'orders')
|
|
705
|
+
return 'order';
|
|
706
|
+
if (token === 'customers')
|
|
707
|
+
return 'customer';
|
|
708
|
+
if (token === 'supplies')
|
|
709
|
+
return 'supply';
|
|
710
|
+
if (token.endsWith('ies') && token.length > 4)
|
|
711
|
+
return `${token.slice(0, -3)}y`;
|
|
712
|
+
if (token.endsWith('s') && token.length > 4)
|
|
713
|
+
return token.slice(0, -1);
|
|
714
|
+
return token;
|
|
715
|
+
}
|
|
716
|
+
function classifyAgentIntent(input) {
|
|
717
|
+
if (input.followUp?.kind === 'drilldown')
|
|
718
|
+
return 'drillthrough';
|
|
719
|
+
if (isBusinessDefinitionQuestion(input.question))
|
|
720
|
+
return 'exact_certified_lookup';
|
|
721
|
+
if (isExplicitSavedArtifactQuestion(input.question, input.artifactHits))
|
|
722
|
+
return 'exact_certified_lookup';
|
|
723
|
+
const hasContext = input.artifactHits.some((hit) => hit.score >= CERTIFIED_HIT_THRESHOLD) ||
|
|
724
|
+
input.semanticHits.some((hit) => hit.score >= CERTIFIED_HIT_THRESHOLD) ||
|
|
725
|
+
input.manifestHits.some((hit) => hit.score >= CERTIFIED_HIT_THRESHOLD) ||
|
|
726
|
+
input.schemaContext.length > 0;
|
|
727
|
+
if (isAdHocAnalysisQuestion(input.question))
|
|
728
|
+
return hasContext ? 'ad_hoc_analysis' : 'clarify';
|
|
729
|
+
if (looksLikeDataQuestion(input.question) && !hasContext)
|
|
730
|
+
return 'clarify';
|
|
731
|
+
return 'exact_certified_lookup';
|
|
732
|
+
}
|
|
733
|
+
function isExplicitSavedArtifactQuestion(question, artifactHits) {
|
|
734
|
+
const lower = question.toLowerCase();
|
|
735
|
+
if (!/\b(block|certified|saved|existing|approved|governed)\b/.test(lower))
|
|
736
|
+
return false;
|
|
737
|
+
return artifactHits.some((hit) => {
|
|
738
|
+
if (hit.score < CERTIFIED_HIT_THRESHOLD)
|
|
739
|
+
return false;
|
|
740
|
+
const normalizedName = hit.node.name.toLowerCase();
|
|
741
|
+
const spacedName = normalizedName.replace(/[_-]+/g, ' ');
|
|
742
|
+
return lower.includes(normalizedName) || lower.includes(spacedName);
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
function isAdHocAnalysisQuestion(question) {
|
|
746
|
+
const lower = question.toLowerCase();
|
|
747
|
+
if (isBusinessDefinitionQuestion(question))
|
|
748
|
+
return false;
|
|
749
|
+
return /\b(break\s*down|breakdown|drill\s*(?:down|into)|slice|segment|split|compare|versus|vs\.?|trend|over time|top|bottom|best|worst|highest|lowest|rank|ranking|performed better|better performing|by\s+[a-z][\w\s-]{1,40})\b/i.test(lower)
|
|
750
|
+
|| /\b(show|list|find|give)\b.+\b(customer|customers|product|products|order|orders|region|location|month|week|day)\b/i.test(lower);
|
|
751
|
+
}
|
|
752
|
+
function looksLikeDataQuestion(question) {
|
|
753
|
+
return /\b(show|list|find|what|which|how many|how much|compare|trend|revenue|customer|customers|order|orders|product|products|sales|metric|kpi|dashboard|performance|performed)\b/i.test(question);
|
|
754
|
+
}
|
|
755
|
+
function citationSourceTier(node, fallback) {
|
|
756
|
+
if (node.sourceTier === 'certified_artifact')
|
|
757
|
+
return 'certified_artifact';
|
|
758
|
+
if (node.sourceTier === 'business_context')
|
|
759
|
+
return 'business_context';
|
|
760
|
+
if (node.sourceTier === 'semantic_layer')
|
|
761
|
+
return 'semantic_layer';
|
|
762
|
+
if (node.sourceTier === 'dbt_manifest')
|
|
763
|
+
return 'dbt_manifest';
|
|
764
|
+
return fallback;
|
|
765
|
+
}
|
|
766
|
+
function buildAnalysisPlan(input) {
|
|
767
|
+
const tokens = meaningfulTokens(input.question);
|
|
768
|
+
const dimensions = inferDimensions(input.question, input.selectedNodes, input.schemaContext);
|
|
769
|
+
const measures = inferMeasures(input.question, input.selectedNodes, input.schemaContext);
|
|
770
|
+
const candidateTables = input.schemaContext.slice(0, 8).map((table) => ({
|
|
771
|
+
relation: table.relation,
|
|
772
|
+
columns: table.columns.slice(0, 16).map((col) => col.name),
|
|
773
|
+
reason: tableReason(table, tokens),
|
|
774
|
+
}));
|
|
775
|
+
const trustedContext = input.selectedNodes.slice(0, 8).map((node) => ({
|
|
776
|
+
kind: node.kind,
|
|
777
|
+
name: node.name,
|
|
778
|
+
certification: certificationForNode(node),
|
|
779
|
+
sourceTier: node.sourceTier,
|
|
780
|
+
}));
|
|
781
|
+
return {
|
|
782
|
+
question: input.question,
|
|
783
|
+
intent: input.intent,
|
|
784
|
+
routeReason: input.routeReason,
|
|
785
|
+
grain: dimensions.length > 0 ? dimensions.join(', ') : undefined,
|
|
786
|
+
measures,
|
|
787
|
+
dimensions,
|
|
788
|
+
candidateTables,
|
|
789
|
+
trustedContext,
|
|
790
|
+
assumptions: input.assumptions ?? [],
|
|
791
|
+
sql: input.sql,
|
|
792
|
+
suggestedViz: input.suggestedViz,
|
|
793
|
+
followUps: buildFollowUpSuggestions(input.intent, measures, dimensions),
|
|
794
|
+
repairAttempts: input.repairAttempts,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function inferDimensions(question, selectedNodes, schemaContext) {
|
|
798
|
+
const dims = new Set();
|
|
799
|
+
for (const match of question.matchAll(/\bby\s+([a-z][a-z0-9_ -]{1,40})/gi)) {
|
|
800
|
+
const value = match[1].replace(/\b(who|have|has|with|for|where|that|and|over|in)\b.*$/i, '').trim();
|
|
801
|
+
if (value)
|
|
802
|
+
dims.add(normalizeHumanLabel(value));
|
|
803
|
+
}
|
|
804
|
+
for (const dim of ['customer', 'product', 'region', 'location', 'month', 'week', 'day', 'segment', 'channel']) {
|
|
805
|
+
if (new RegExp(`\\b${dim}s?\\b`, 'i').test(question))
|
|
806
|
+
dims.add(dim);
|
|
807
|
+
}
|
|
808
|
+
for (const node of selectedNodes) {
|
|
809
|
+
if (node.kind === 'dimension' || node.kind === 'entity')
|
|
810
|
+
dims.add(node.name);
|
|
811
|
+
}
|
|
812
|
+
for (const table of schemaContext.slice(0, 4)) {
|
|
813
|
+
for (const col of table.columns) {
|
|
814
|
+
const normalized = col.name.toLowerCase();
|
|
815
|
+
if (/(customer|product|region|location|month|week|segment|channel|type|name)$/.test(normalized) && question.toLowerCase().includes(normalized.split('_')[0])) {
|
|
816
|
+
dims.add(col.name);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return Array.from(dims).slice(0, 6);
|
|
821
|
+
}
|
|
822
|
+
function inferMeasures(question, selectedNodes, schemaContext) {
|
|
823
|
+
const measures = new Set();
|
|
824
|
+
const lower = question.toLowerCase();
|
|
825
|
+
for (const metric of ['revenue', 'sales', 'orders', 'order count', 'customers', 'spend', 'value', 'cost', 'margin']) {
|
|
826
|
+
if (lower.includes(metric))
|
|
827
|
+
measures.add(metric);
|
|
828
|
+
}
|
|
829
|
+
for (const node of selectedNodes) {
|
|
830
|
+
if (node.kind === 'metric' || node.kind === 'measure' || node.kind === 'block') {
|
|
831
|
+
for (const token of meaningfulTokens(node.name)) {
|
|
832
|
+
if (!['customer', 'product', 'region', 'location'].includes(token))
|
|
833
|
+
measures.add(token);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
for (const table of schemaContext.slice(0, 4)) {
|
|
838
|
+
for (const col of table.columns) {
|
|
839
|
+
const normalized = col.name.toLowerCase();
|
|
840
|
+
if (/(amount|total|revenue|spend|orders|count|cost|value)$/.test(normalized) && lower.includes(normalized.split('_').at(-1) ?? normalized)) {
|
|
841
|
+
measures.add(col.name);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return Array.from(measures).slice(0, 6);
|
|
846
|
+
}
|
|
847
|
+
function tableReason(table, questionTokens) {
|
|
848
|
+
const tableTokens = meaningfulTokens([table.relation, table.name, table.description ?? ''].join(' '));
|
|
849
|
+
const columnTokens = meaningfulTokens(table.columns.map((col) => col.name).join(' '));
|
|
850
|
+
const matches = [...questionTokens].filter((token) => tableTokens.has(token) || columnTokens.has(token));
|
|
851
|
+
return matches.length > 0 ? `matched ${matches.slice(0, 4).join(', ')}` : table.source;
|
|
852
|
+
}
|
|
853
|
+
function normalizeHumanLabel(value) {
|
|
854
|
+
return value.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
855
|
+
}
|
|
856
|
+
function buildFollowUpSuggestions(intent, measures, dimensions) {
|
|
857
|
+
if (intent === 'clarify') {
|
|
858
|
+
return ['Which metric should define performance?', 'Which business object should be the row grain?', 'What time period should this cover?'];
|
|
859
|
+
}
|
|
860
|
+
const mainMeasure = measures[0] ?? 'the result';
|
|
861
|
+
const mainDimension = dimensions[0] ?? 'segment';
|
|
862
|
+
return [
|
|
863
|
+
`Drill into ${mainMeasure} by ${mainDimension}`,
|
|
864
|
+
'Show the trend over time',
|
|
865
|
+
'Pin this answer to the app for review',
|
|
866
|
+
];
|
|
867
|
+
}
|
|
868
|
+
function chartNameFromConfig(config) {
|
|
869
|
+
if (config && typeof config === 'object' && typeof config.chart === 'string') {
|
|
870
|
+
return config.chart;
|
|
871
|
+
}
|
|
872
|
+
return undefined;
|
|
873
|
+
}
|
|
874
|
+
function composeClarificationText(question, considered, schemaContext) {
|
|
875
|
+
const context = considered.slice(0, 3).map((hit) => hit.node.name).join(', ');
|
|
876
|
+
const tables = schemaContext.slice(0, 3).map((table) => table.relation).join(', ');
|
|
877
|
+
const available = [context ? `matched context: ${context}` : '', tables ? `available tables: ${tables}` : ''].filter(Boolean).join('; ');
|
|
878
|
+
return `I need one more detail before querying: which metric or business object should define the answer for "${question}"?${available ? ` I found ${available}, but not enough to choose a safe grain.` : ''}`;
|
|
879
|
+
}
|
|
880
|
+
async function requestSqlRepair(input) {
|
|
881
|
+
const schema = input.schemaContext.length > 0
|
|
882
|
+
? input.schemaContext
|
|
883
|
+
.slice(0, 8)
|
|
884
|
+
.map((table) => `${table.relation}: ${table.columns.slice(0, 40).map((col) => col.name).join(', ')}`)
|
|
885
|
+
.join('\n')
|
|
886
|
+
: '(no runtime schema supplied)';
|
|
887
|
+
return input.provider.generate([
|
|
888
|
+
...input.baseMessages,
|
|
889
|
+
{
|
|
890
|
+
role: 'assistant',
|
|
891
|
+
content: `${input.parsed.text}\n\n\`\`\`sql\n${input.parsed.sql ?? ''}\n\`\`\`\n\nViz: ${input.parsed.viz ?? 'table'}`,
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
role: 'user',
|
|
895
|
+
content: [
|
|
896
|
+
'The generated SQL failed during bounded preview execution.',
|
|
897
|
+
`Question: ${input.question}`,
|
|
898
|
+
`Execution error: ${input.executionError}`,
|
|
899
|
+
'Return one corrected read-only SQL query using only the runtime schema below.',
|
|
900
|
+
schema,
|
|
901
|
+
].join('\n\n'),
|
|
902
|
+
},
|
|
903
|
+
], { signal: input.signal });
|
|
904
|
+
}
|
|
905
|
+
function isRetryableGeneratedSqlError(error) {
|
|
906
|
+
return !/\b(read-only|readonly|select or with|unsafe|delete|insert|update|drop|alter|create|attach|copy|pragma)\b/i.test(error);
|
|
907
|
+
}
|
|
908
|
+
function repairGeneratedSqlLocally(sql, error, schemaContext) {
|
|
909
|
+
const missing = error.match(/(?:Values list|Referenced table)\s+"([^"]+)"\s+does not have a column named\s+"([^"]+)"/i)
|
|
910
|
+
?? error.match(/Referenced column\s+"([^"]+)"\s+not found/i);
|
|
911
|
+
if (!missing)
|
|
912
|
+
return undefined;
|
|
913
|
+
const badAlias = missing.length >= 3 ? missing[1] : undefined;
|
|
914
|
+
const missingColumn = missing.length >= 3 ? missing[2] : missing[1];
|
|
915
|
+
if (!missingColumn)
|
|
916
|
+
return undefined;
|
|
917
|
+
const aliasToRelation = extractSqlAliases(sql);
|
|
918
|
+
const columnOwnerAliases = aliasesWithColumn(aliasToRelation, schemaContext, missingColumn);
|
|
919
|
+
const replacementAlias = columnOwnerAliases.find((alias) => alias !== badAlias) ?? columnOwnerAliases[0];
|
|
920
|
+
if (!replacementAlias)
|
|
921
|
+
return undefined;
|
|
922
|
+
if (badAlias && new RegExp(`\\b${escapeRegex(badAlias)}\\.${escapeRegex(missingColumn)}\\b`, 'i').test(sql)) {
|
|
923
|
+
return sql.replace(new RegExp(`\\b${escapeRegex(badAlias)}\\.${escapeRegex(missingColumn)}\\b`, 'gi'), `${replacementAlias}.${missingColumn}`);
|
|
924
|
+
}
|
|
925
|
+
return undefined;
|
|
926
|
+
}
|
|
927
|
+
function extractSqlAliases(sql) {
|
|
928
|
+
const aliases = new Map();
|
|
929
|
+
for (const match of sql.matchAll(/\b(?:from|join)\s+([a-zA-Z_][\w.]*)(?:\s+as)?\s+([a-zA-Z_][\w]*)/gi)) {
|
|
930
|
+
const relation = match[1];
|
|
931
|
+
const alias = match[2];
|
|
932
|
+
if (!relation || !alias)
|
|
933
|
+
continue;
|
|
934
|
+
if (/^(where|join|on|group|order|limit)$/i.test(alias))
|
|
935
|
+
continue;
|
|
936
|
+
aliases.set(alias, relation);
|
|
937
|
+
}
|
|
938
|
+
return aliases;
|
|
939
|
+
}
|
|
940
|
+
function aliasesWithColumn(aliasToRelation, schemaContext, column) {
|
|
941
|
+
const aliases = [];
|
|
942
|
+
for (const [alias, relation] of aliasToRelation) {
|
|
943
|
+
const normalizedRelation = relation.toLowerCase();
|
|
944
|
+
const table = schemaContext.find((item) => item.relation.toLowerCase() === normalizedRelation ||
|
|
945
|
+
item.name.toLowerCase() === normalizedRelation ||
|
|
946
|
+
normalizedRelation.endsWith(`.${item.name.toLowerCase()}`));
|
|
947
|
+
if (!table)
|
|
948
|
+
continue;
|
|
949
|
+
if (table.columns.some((col) => col.name.toLowerCase() === column.toLowerCase()))
|
|
950
|
+
aliases.push(alias);
|
|
951
|
+
}
|
|
952
|
+
return aliases;
|
|
953
|
+
}
|
|
954
|
+
function escapeRegex(value) {
|
|
955
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
956
|
+
}
|
|
285
957
|
function composeCertifiedAnswer(artifact, question, result, executionError) {
|
|
286
958
|
const desc = artifact.description ?? artifact.llmContext ?? '';
|
|
287
959
|
const tag = artifact.gitSha ? ` · ${artifact.gitSha.slice(0, 8)}` : '';
|
|
@@ -306,7 +978,21 @@ function mergeHits(...groups) {
|
|
|
306
978
|
}
|
|
307
979
|
return Array.from(byId.values()).sort((a, b) => b.score - a.score);
|
|
308
980
|
}
|
|
981
|
+
function mergeNodes(...groups) {
|
|
982
|
+
const byId = new Map();
|
|
983
|
+
for (const group of groups) {
|
|
984
|
+
for (const node of group) {
|
|
985
|
+
if (!byId.has(node.nodeId))
|
|
986
|
+
byId.set(node.nodeId, node);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return Array.from(byId.values());
|
|
990
|
+
}
|
|
309
991
|
function buildCertifiedEvidence(input) {
|
|
992
|
+
const businessContextAssets = uniqueAssets(input.businessHits
|
|
993
|
+
.map((hit) => hit.node)
|
|
994
|
+
.filter((node) => node.nodeId !== input.artifact.nodeId)
|
|
995
|
+
.map(assetFromNode)).slice(0, 6);
|
|
310
996
|
const semanticObjects = uniqueAssets(input.semanticHits.map((hit) => assetFromNode(hit.node))).slice(0, 6);
|
|
311
997
|
const sourceTables = uniqueAssets(input.manifestHits.map((hit) => assetFromNode(hit.node))).slice(0, 6);
|
|
312
998
|
const relatedConsumers = input.considered
|
|
@@ -333,6 +1019,19 @@ function buildCertifiedEvidence(input) {
|
|
|
333
1019
|
: 'Certified navigation artifact selected',
|
|
334
1020
|
detail: input.executionError ?? (input.result ? `${input.result.rowCount} rows` : undefined),
|
|
335
1021
|
},
|
|
1022
|
+
{
|
|
1023
|
+
tool: 'search_business_context',
|
|
1024
|
+
status: BUSINESS_CONTEXT_KINDS.includes(input.artifact.kind)
|
|
1025
|
+
? 'selected'
|
|
1026
|
+
: input.businessHits.length > 0
|
|
1027
|
+
? 'checked'
|
|
1028
|
+
: 'skipped',
|
|
1029
|
+
label: BUSINESS_CONTEXT_KINDS.includes(input.artifact.kind)
|
|
1030
|
+
? `Selected ${input.artifact.kind.replace('_', ' ')}`
|
|
1031
|
+
: input.businessHits.length > 0
|
|
1032
|
+
? 'Business context attached'
|
|
1033
|
+
: 'No business context needed',
|
|
1034
|
+
},
|
|
336
1035
|
{
|
|
337
1036
|
tool: 'search_semantic_layer',
|
|
338
1037
|
status: input.semanticHits.length > 0 ? 'checked' : 'skipped',
|
|
@@ -347,6 +1046,7 @@ function buildCertifiedEvidence(input) {
|
|
|
347
1046
|
lineage: [
|
|
348
1047
|
questionLineageNode(input.question),
|
|
349
1048
|
{ ...assetFromNode(input.artifact), role: 'selected_asset' },
|
|
1049
|
+
...businessContextAssets.map((asset) => ({ ...asset, role: 'business_context' })),
|
|
350
1050
|
...semanticObjects.map((asset) => ({ ...asset, role: 'semantic_object' })),
|
|
351
1051
|
...sourceTables.map((asset) => ({ ...asset, role: 'source_table' })),
|
|
352
1052
|
...relatedConsumers.map((node) => ({ ...assetFromNode(node), role: 'consumer' })),
|
|
@@ -371,10 +1071,14 @@ function buildCertifiedEvidence(input) {
|
|
|
371
1071
|
},
|
|
372
1072
|
execution: executionEvidence(input.artifact, input.result, input.executionError, input.executorWasAvailable),
|
|
373
1073
|
citations: input.citations,
|
|
1074
|
+
analysisPlan: input.analysisPlan,
|
|
374
1075
|
};
|
|
375
1076
|
}
|
|
376
1077
|
function buildGeneratedEvidence(input) {
|
|
377
1078
|
const selectedNodes = input.contextNodes.slice(0, 4);
|
|
1079
|
+
const businessAssets = uniqueAssets([...input.contextNodes, ...input.businessHits.map((hit) => hit.node)]
|
|
1080
|
+
.filter((node) => BUSINESS_CONTEXT_KINDS.includes(node.kind))
|
|
1081
|
+
.map(assetFromNode)).slice(0, 6);
|
|
378
1082
|
const semanticObjects = uniqueAssets([...input.contextNodes, ...input.semanticHits.map((hit) => hit.node)]
|
|
379
1083
|
.filter((node) => SEMANTIC_KINDS.includes(node.kind))
|
|
380
1084
|
.map(assetFromNode)).slice(0, 6);
|
|
@@ -388,7 +1092,27 @@ function buildGeneratedEvidence(input) {
|
|
|
388
1092
|
{
|
|
389
1093
|
tool: 'search_certified_artifacts',
|
|
390
1094
|
status: 'checked',
|
|
391
|
-
label:
|
|
1095
|
+
label: input.intent === 'ad_hoc_analysis'
|
|
1096
|
+
? 'Certified artifacts considered as context; dynamic SQL selected for the requested grain'
|
|
1097
|
+
: input.followUp?.kind === 'drilldown'
|
|
1098
|
+
? 'No distinct certified drilldown block was strong enough for this question'
|
|
1099
|
+
: 'No certified artifact was strong enough for this question',
|
|
1100
|
+
detail: input.followUp?.sourceBlockName,
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
tool: 'propose_drilldown',
|
|
1104
|
+
status: input.followUp?.kind === 'drilldown' ? 'checked' : 'skipped',
|
|
1105
|
+
label: input.followUp?.kind === 'drilldown'
|
|
1106
|
+
? 'Using prior answer context for a review-required drilldown draft'
|
|
1107
|
+
: 'Not a drilldown follow-up',
|
|
1108
|
+
detail: input.followUp?.filters?.length || input.followUp?.dimensions?.length
|
|
1109
|
+
? [...(input.followUp.filters ?? []), ...(input.followUp.dimensions ?? [])].join(', ')
|
|
1110
|
+
: undefined,
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
tool: 'search_business_context',
|
|
1114
|
+
status: businessAssets.length > 0 ? 'checked' : 'skipped',
|
|
1115
|
+
label: businessAssets.length > 0 ? 'Business context considered' : 'No business context match',
|
|
392
1116
|
},
|
|
393
1117
|
{
|
|
394
1118
|
tool: 'search_semantic_layer',
|
|
@@ -405,15 +1129,36 @@ function buildGeneratedEvidence(input) {
|
|
|
405
1129
|
status: 'checked',
|
|
406
1130
|
label: 'SQL is generated and requires host validation before certification',
|
|
407
1131
|
},
|
|
1132
|
+
{
|
|
1133
|
+
tool: 'execute_generated_sql',
|
|
1134
|
+
status: input.executionError
|
|
1135
|
+
? 'failed'
|
|
1136
|
+
: input.result
|
|
1137
|
+
? 'selected'
|
|
1138
|
+
: input.executorWasAvailable
|
|
1139
|
+
? 'skipped'
|
|
1140
|
+
: 'skipped',
|
|
1141
|
+
label: input.executionError
|
|
1142
|
+
? 'Generated SQL preview failed'
|
|
1143
|
+
: input.result
|
|
1144
|
+
? 'Executed generated SQL as bounded preview'
|
|
1145
|
+
: 'Generated SQL preview not requested',
|
|
1146
|
+
detail: input.executionError ?? (input.result ? `${input.result.rowCount} rows` : undefined),
|
|
1147
|
+
},
|
|
408
1148
|
{
|
|
409
1149
|
tool: 'create_draft_block',
|
|
410
|
-
status: '
|
|
411
|
-
label:
|
|
1150
|
+
status: 'checked',
|
|
1151
|
+
label: input.followUp?.kind === 'drilldown'
|
|
1152
|
+
? 'Drilldown draft is ready for analyst review'
|
|
1153
|
+
: 'Draft block proposal is ready for analyst review',
|
|
412
1154
|
},
|
|
413
1155
|
],
|
|
414
1156
|
lineage: [
|
|
415
1157
|
questionLineageNode(input.question),
|
|
416
|
-
...selectedAssets.map((asset) => ({ ...asset, role: selectedSemantic
|
|
1158
|
+
...selectedAssets.map((asset) => ({ ...asset, role: selectedAssetRole(asset, selectedSemantic) })),
|
|
1159
|
+
...businessAssets
|
|
1160
|
+
.filter((asset) => !selectedAssets.some((selected) => selected.nodeId === asset.nodeId))
|
|
1161
|
+
.map((asset) => ({ ...asset, role: 'business_context' })),
|
|
417
1162
|
...sourceTables.map((asset) => ({ ...asset, role: 'source_table' })),
|
|
418
1163
|
...semanticObjects
|
|
419
1164
|
.filter((asset) => !selectedAssets.some((selected) => selected.nodeId === asset.nodeId))
|
|
@@ -433,13 +1178,22 @@ function buildGeneratedEvidence(input) {
|
|
|
433
1178
|
semanticObjects,
|
|
434
1179
|
validation: {
|
|
435
1180
|
status: 'warning',
|
|
436
|
-
message:
|
|
1181
|
+
message: input.followUp?.kind === 'drilldown'
|
|
1182
|
+
? 'Generated drilldown SQL is not certified. It should be validated, reviewed, and promoted only after analyst approval.'
|
|
1183
|
+
: 'Generated SQL is not certified. It should be validated, reviewed, and promoted only after analyst approval.',
|
|
437
1184
|
},
|
|
438
1185
|
execution: {
|
|
439
|
-
status: 'not_requested',
|
|
440
|
-
message:
|
|
1186
|
+
status: input.executionError ? 'failed' : input.result ? 'executed' : 'not_requested',
|
|
1187
|
+
message: input.executionError
|
|
1188
|
+
? input.executionError
|
|
1189
|
+
: input.result
|
|
1190
|
+
? 'Executed generated SQL as an uncertified bounded preview.'
|
|
1191
|
+
: 'Generated SQL was returned for review; execution is handled by the host after validation.',
|
|
1192
|
+
rowCount: input.result?.rowCount,
|
|
1193
|
+
executionTime: input.result?.executionTime,
|
|
441
1194
|
},
|
|
442
1195
|
citations: input.citations,
|
|
1196
|
+
analysisPlan: input.analysisPlan,
|
|
443
1197
|
};
|
|
444
1198
|
}
|
|
445
1199
|
function buildNoAnswerEvidence(input) {
|
|
@@ -450,6 +1204,11 @@ function buildNoAnswerEvidence(input) {
|
|
|
450
1204
|
status: input.artifactHits.length > 0 ? 'checked' : 'skipped',
|
|
451
1205
|
label: input.artifactHits.length > 0 ? 'Certified artifacts considered but not selected' : 'No certified artifact match',
|
|
452
1206
|
},
|
|
1207
|
+
{
|
|
1208
|
+
tool: 'search_business_context',
|
|
1209
|
+
status: input.businessHits.length > 0 ? 'checked' : 'skipped',
|
|
1210
|
+
label: input.businessHits.length > 0 ? 'Business context considered' : 'No business context match',
|
|
1211
|
+
},
|
|
453
1212
|
{
|
|
454
1213
|
tool: 'search_semantic_layer',
|
|
455
1214
|
status: input.semanticHits.length > 0 ? 'checked' : 'skipped',
|
|
@@ -470,11 +1229,14 @@ function buildNoAnswerEvidence(input) {
|
|
|
470
1229
|
questionLineageNode(input.question),
|
|
471
1230
|
...input.considered.slice(0, 6).map((hit) => ({ ...assetFromNode(hit.node), role: 'selected_asset' })),
|
|
472
1231
|
],
|
|
473
|
-
businessContext:
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
1232
|
+
businessContext: [
|
|
1233
|
+
...input.businessHits.slice(0, 4).flatMap((hit) => businessContextForNode(hit.node)),
|
|
1234
|
+
...input.memoryContext.slice(0, 3).map((memory) => ({
|
|
1235
|
+
label: 'Memory advisory',
|
|
1236
|
+
value: `${memory.title}: ${memory.content}`,
|
|
1237
|
+
source: memory.source,
|
|
1238
|
+
})),
|
|
1239
|
+
],
|
|
478
1240
|
selectedAssets: [],
|
|
479
1241
|
sourceTables: uniqueAssets(input.manifestHits.map((hit) => assetFromNode(hit.node))).slice(0, 6),
|
|
480
1242
|
semanticObjects: uniqueAssets(input.semanticHits.map((hit) => assetFromNode(hit.node))).slice(0, 6),
|
|
@@ -487,6 +1249,7 @@ function buildNoAnswerEvidence(input) {
|
|
|
487
1249
|
message: 'No SQL or certified block was executed.',
|
|
488
1250
|
},
|
|
489
1251
|
citations: [],
|
|
1252
|
+
analysisPlan: input.analysisPlan,
|
|
490
1253
|
};
|
|
491
1254
|
}
|
|
492
1255
|
function questionLineageNode(question) {
|
|
@@ -497,13 +1260,22 @@ function questionLineageNode(question) {
|
|
|
497
1260
|
role: 'question',
|
|
498
1261
|
};
|
|
499
1262
|
}
|
|
1263
|
+
function selectedAssetRole(asset, selectedSemantic) {
|
|
1264
|
+
if (asset.kind === 'term' || asset.kind === 'business_view')
|
|
1265
|
+
return 'business_context';
|
|
1266
|
+
if (asset.kind && SEMANTIC_KINDS.includes(asset.kind))
|
|
1267
|
+
return 'semantic_object';
|
|
1268
|
+
if (asset.kind && MANIFEST_KINDS.includes(asset.kind))
|
|
1269
|
+
return 'source_table';
|
|
1270
|
+
return selectedSemantic ? 'semantic_object' : 'selected_asset';
|
|
1271
|
+
}
|
|
500
1272
|
function assetFromNode(node) {
|
|
501
1273
|
return {
|
|
502
1274
|
nodeId: node.nodeId,
|
|
503
1275
|
kind: node.kind,
|
|
504
1276
|
name: node.name,
|
|
505
1277
|
description: node.description,
|
|
506
|
-
sourceTier: node.sourceTier
|
|
1278
|
+
sourceTier: node.sourceTier,
|
|
507
1279
|
certification: certificationForNode(node),
|
|
508
1280
|
provenance: node.provenance,
|
|
509
1281
|
sourcePath: node.sourcePath,
|