@duckcodeailabs/dql-agent 1.6.1 → 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.
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Block-first answer loop.
2
+ * Dynamic-first governed answer loop.
3
3
  *
4
4
  * Stages:
5
- * 1) FTS5 search the KG for blocks matching the question. If a strong
6
- * `block` hit exists, return it as a Certified answer (use the block's
7
- * SQL through `query_via_block`-equivalent semantics caller runs it).
8
- * 2) Otherwise, gather context (matching metrics, dimensions, dbt models,
9
- * dashboards, Skills) and ask the LLM to propose SQL. Mark the answer
10
- * Uncertified.
11
- * 3) Always cite block ids/git SHAs.
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 ARTIFACT_KINDS = ['block', 'dashboard', 'app', 'notebook'];
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 artifactHits = kg.search({ query: question, domain, kinds: ARTIFACT_KINDS, limit: 10 });
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 = pickCertifiedArtifact(artifactHits, blockHints, kg);
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: 'certified_artifact',
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: 'certified_artifact',
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, 10)
93
- : manifestHits.slice(0, 10);
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 contextOther = contextNodes.filter((n) => n.kind !== 'block');
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
- try {
110
- proposed = await provider.generate(messages, { signal: input.signal });
200
+ let parsed;
201
+ if (localProposal) {
202
+ parsed = localProposal;
111
203
  }
112
- catch (err) {
113
- const text = `Provider error: ${err.message}`;
114
- return {
115
- kind: 'no_answer',
116
- sourceTier: 'no_answer',
117
- certification: 'analyst_review_required',
118
- reviewStatus: 'none',
119
- confidence: 0,
120
- text,
121
- answer: text,
122
- citations: [],
123
- memoryContext: input.memoryContext,
124
- evidence: buildNoAnswerEvidence({
125
- question,
126
- reason: text,
127
- artifactHits,
128
- semanticHits,
129
- manifestHits,
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
- memoryContext: input.memoryContext ?? [],
132
- }),
133
- considered,
134
- providerUsed: provider.name,
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: 'analyst_review_required',
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. ALWAYS prefer existing certified DQL blocks. The analytics surface marks every
211
- answer as Certified or AI-generated/Uncertified.
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. If the schema is insufficient to answer, say so explicitly and ask a clarifying question.`;
218
- function renderContextPrompt(blocks, others, activeTier, memoryContext, extraContext) {
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
- ? `## Certified blocks the user already has\n\n${blocks
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
- : '## Certified blocks: (none matched)';
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
- return `${blockSection}${otherSection}${memorySection}${extraSection}`;
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 pickCertifiedArtifact(artifactHits, blockHints, kg) {
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
- // Otherwise: top FTS5 hit must be certified, exceed the score threshold,
266
- // and not have a hard negative ratio in feedback.
267
- for (const hit of artifactHits) {
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.kind === 'block') {
271
- if (hit.node.status !== 'certified')
272
- continue;
273
- const fb = kg.blockFeedbackScore(hit.node.nodeId);
274
- const total = fb.up + fb.down;
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: 'No certified artifact was strong enough for this question',
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: 'skipped',
411
- label: 'Draft block can be created for analyst review',
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 ? 'semantic_object' : 'source_table' })),
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: 'Generated SQL is not certified. It should be validated, reviewed, and promoted only after analyst approval.',
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: 'Generated SQL was returned for review; execution is handled by the host after validation.',
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: input.memoryContext.slice(0, 3).map((memory) => ({
474
- label: 'Memory advisory',
475
- value: `${memory.title}: ${memory.content}`,
476
- source: memory.source,
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 === 'business_context' ? 'project' : node.sourceTier,
1278
+ sourceTier: node.sourceTier,
507
1279
  certification: certificationForNode(node),
508
1280
  provenance: node.provenance,
509
1281
  sourcePath: node.sourcePath,