@duckcodeailabs/dql-cli 1.6.31 → 1.6.32

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.
Files changed (78) hide show
  1. package/dist/apps-api.d.ts.map +1 -1
  2. package/dist/apps-api.js +3 -1
  3. package/dist/apps-api.js.map +1 -1
  4. package/dist/args.d.ts +18 -0
  5. package/dist/args.d.ts.map +1 -1
  6. package/dist/args.js +37 -0
  7. package/dist/args.js.map +1 -1
  8. package/dist/assets/dql-notebook/assets/index-B6x1cRJo.js +5698 -0
  9. package/dist/assets/dql-notebook/index.html +1 -1
  10. package/dist/commands/agent.d.ts +47 -1
  11. package/dist/commands/agent.d.ts.map +1 -1
  12. package/dist/commands/agent.js +635 -27
  13. package/dist/commands/agent.js.map +1 -1
  14. package/dist/commands/app.d.ts.map +1 -1
  15. package/dist/commands/app.js +3 -1
  16. package/dist/commands/app.js.map +1 -1
  17. package/dist/commands/diff.d.ts.map +1 -1
  18. package/dist/commands/diff.js +111 -22
  19. package/dist/commands/diff.js.map +1 -1
  20. package/dist/commands/eval-judge.d.ts +37 -0
  21. package/dist/commands/eval-judge.d.ts.map +1 -0
  22. package/dist/commands/eval-judge.js +99 -0
  23. package/dist/commands/eval-judge.js.map +1 -0
  24. package/dist/commands/eval.d.ts +27 -1
  25. package/dist/commands/eval.d.ts.map +1 -1
  26. package/dist/commands/eval.js +143 -6
  27. package/dist/commands/eval.js.map +1 -1
  28. package/dist/commands/mcp.js +27 -3
  29. package/dist/commands/mcp.js.map +1 -1
  30. package/dist/index.js +6 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/llm/analytics-tools.d.ts.map +1 -1
  33. package/dist/llm/analytics-tools.js +12 -7
  34. package/dist/llm/analytics-tools.js.map +1 -1
  35. package/dist/llm/answer-loop-tools.d.ts +7 -0
  36. package/dist/llm/answer-loop-tools.d.ts.map +1 -0
  37. package/dist/llm/answer-loop-tools.js +67 -0
  38. package/dist/llm/answer-loop-tools.js.map +1 -0
  39. package/dist/llm/proposal-metadata.d.ts +5 -0
  40. package/dist/llm/proposal-metadata.d.ts.map +1 -0
  41. package/dist/llm/proposal-metadata.js +27 -0
  42. package/dist/llm/proposal-metadata.js.map +1 -0
  43. package/dist/llm/providers/claude-agent-sdk.d.ts +6 -0
  44. package/dist/llm/providers/claude-agent-sdk.d.ts.map +1 -1
  45. package/dist/llm/providers/claude-agent-sdk.js +12 -2
  46. package/dist/llm/providers/claude-agent-sdk.js.map +1 -1
  47. package/dist/llm/providers/claude-code.d.ts +7 -1
  48. package/dist/llm/providers/claude-code.d.ts.map +1 -1
  49. package/dist/llm/providers/claude-code.js +11 -2
  50. package/dist/llm/providers/claude-code.js.map +1 -1
  51. package/dist/llm/providers/dql-agent-provider.d.ts +29 -0
  52. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  53. package/dist/llm/providers/dql-agent-provider.js +757 -26
  54. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  55. package/dist/llm/tools.d.ts.map +1 -1
  56. package/dist/llm/tools.js +34 -137
  57. package/dist/llm/tools.js.map +1 -1
  58. package/dist/llm/types.d.ts +67 -2
  59. package/dist/llm/types.d.ts.map +1 -1
  60. package/dist/local-runtime.d.ts +160 -5
  61. package/dist/local-runtime.d.ts.map +1 -1
  62. package/dist/local-runtime.js +1753 -181
  63. package/dist/local-runtime.js.map +1 -1
  64. package/dist/package.json +10 -10
  65. package/dist/providers/oauth/claude-oauth.d.ts +3 -3
  66. package/dist/providers/oauth/claude-oauth.d.ts.map +1 -1
  67. package/dist/providers/oauth/claude-oauth.js +3 -3
  68. package/dist/providers/oauth/claude-oauth.js.map +1 -1
  69. package/dist/providers/oauth/codex-oauth.d.ts +2 -3
  70. package/dist/providers/oauth/codex-oauth.d.ts.map +1 -1
  71. package/dist/providers/oauth/codex-oauth.js +6 -3
  72. package/dist/providers/oauth/codex-oauth.js.map +1 -1
  73. package/dist/settings/provider-settings.d.ts +1 -0
  74. package/dist/settings/provider-settings.d.ts.map +1 -1
  75. package/dist/settings/provider-settings.js +1 -1
  76. package/dist/settings/provider-settings.js.map +1 -1
  77. package/package.json +11 -11
  78. package/dist/assets/dql-notebook/assets/index-BpPs6_rz.js +0 -5648
@@ -1,5 +1,8 @@
1
- import { ClaudeProvider, KGStore, MemoryStore, defaultKgPath, defaultMemoryPath, GeminiProvider, OllamaProvider, OpenAIProvider, answer, buildLocalContextPack, loadSkills, reindexProject, } from '@duckcodeailabs/dql-agent';
1
+ import { ClaudeProvider, KGStore, MemoryStore, defaultKgPath, defaultMemoryPath, GeminiProvider, loadAgentSemanticLayer, OllamaProvider, OpenAIProvider, answer, buildAnalysisQuestionPlan, buildLocalContextPack, contextRetrievalBudgetForQuestion, loadSkills, reindexProject, } from '@duckcodeailabs/dql-agent';
2
+ import { normalizeDqlArtifactReference } from '@duckcodeailabs/dql-core';
2
3
  import { existsSync } from 'node:fs';
4
+ import { buildAnswerLoopTools, createGroundingContextExpander } from '../answer-loop-tools.js';
5
+ import { blockProposalDqlMetadata } from '../proposal-metadata.js';
3
6
  import { getEffectiveProviderConfig } from '../../settings/provider-settings.js';
4
7
  import { ClaudeCodeCliProvider, CodexCliProvider } from '../../providers/subscription-cli.js';
5
8
  import { ClaudeOAuthProvider, claudeOAuthConnected } from '../../providers/oauth/claude-oauth.js';
@@ -67,6 +70,104 @@ const SPECS = {
67
70
  },
68
71
  },
69
72
  };
73
+ function createCertifiedFitConfirmation(provider, signal) {
74
+ return async ({ question, questionPlan, block, fit }) => {
75
+ const response = await provider.generate([
76
+ {
77
+ role: 'system',
78
+ content: [
79
+ 'You are a strict governed analytics routing judge.',
80
+ 'Decide whether the certified block can directly answer the user question.',
81
+ 'Allow only when the block covers the requested metric, grain, dimensions, filters, required columns, ranking, and top-N.',
82
+ 'If the block is merely useful context, close but wrong grain, missing requested columns, or ambiguous, reject it.',
83
+ 'Return JSON only: {"allow":boolean,"confidence":"high|medium|low","reason":"short reason"}.',
84
+ ].join(' '),
85
+ },
86
+ {
87
+ role: 'user',
88
+ content: JSON.stringify({
89
+ question,
90
+ requestedShape: questionPlan.requestedShape,
91
+ candidateBlock: summarizeBlockForFitConfirmation(block),
92
+ deterministicFit: {
93
+ kind: fit.kind,
94
+ confidence: fit.confidence,
95
+ reasons: fit.reasons,
96
+ missingOutputs: fit.missingOutputs,
97
+ missingDimensions: fit.missingDimensions,
98
+ unsupportedFilters: fit.unsupportedFilters,
99
+ grainMismatch: fit.grainMismatch,
100
+ topNAction: fit.topNAction,
101
+ inferredContract: fit.inferredContract,
102
+ },
103
+ }, null, 2),
104
+ },
105
+ ], { maxTokens: 220, temperature: 0, signal });
106
+ return parseCertifiedFitConfirmation(response);
107
+ };
108
+ }
109
+ function summarizeBlockForFitConfirmation(block) {
110
+ const payload = block.payload ?? {};
111
+ return {
112
+ objectKey: block.objectKey,
113
+ objectType: block.objectType,
114
+ name: block.name,
115
+ status: block.status,
116
+ description: block.description ?? stringValue(payload.description),
117
+ grain: stringValue(payload.grain),
118
+ dimensions: stringArray(payload.dimensions),
119
+ entities: stringArray(payload.entities),
120
+ declaredOutputs: stringArray(payload.declaredOutputs),
121
+ outputContract: payload.outputContract,
122
+ allowedFilters: stringArray(payload.allowedFilters),
123
+ sql: truncateForFitPrompt(stringValue(payload.sql), 1200),
124
+ llmContext: truncateForFitPrompt(stringValue(payload.llmContext), 800),
125
+ };
126
+ }
127
+ function parseCertifiedFitConfirmation(text) {
128
+ const jsonText = extractJsonObject(text);
129
+ if (!jsonText) {
130
+ return { allow: false, confidence: 'low', reason: 'fit confirmation did not return JSON' };
131
+ }
132
+ try {
133
+ const parsed = JSON.parse(jsonText);
134
+ const confidence = parsed.confidence === 'high' || parsed.confidence === 'medium' || parsed.confidence === 'low'
135
+ ? parsed.confidence
136
+ : undefined;
137
+ return {
138
+ allow: parsed.allow === true,
139
+ ...(confidence ? { confidence } : {}),
140
+ reason: typeof parsed.reason === 'string' ? parsed.reason : undefined,
141
+ };
142
+ }
143
+ catch {
144
+ return { allow: false, confidence: 'low', reason: 'fit confirmation returned malformed JSON' };
145
+ }
146
+ }
147
+ function extractJsonObject(text) {
148
+ const trimmed = text.trim();
149
+ if (trimmed.startsWith('{') && trimmed.endsWith('}'))
150
+ return trimmed;
151
+ const fenced = trimmed.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/i);
152
+ if (fenced?.[1])
153
+ return fenced[1];
154
+ const start = trimmed.indexOf('{');
155
+ const end = trimmed.lastIndexOf('}');
156
+ return start >= 0 && end > start ? trimmed.slice(start, end + 1) : undefined;
157
+ }
158
+ function stringArray(value) {
159
+ return Array.isArray(value)
160
+ ? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
161
+ : [];
162
+ }
163
+ function stringValue(value) {
164
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
165
+ }
166
+ function truncateForFitPrompt(value, max) {
167
+ if (!value)
168
+ return undefined;
169
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
170
+ }
70
171
  function emitProposalFromText(text, emit) {
71
172
  const match = text.match(/DQL_BLOCK_PROPOSAL\s*[:=]?\s*(\{[\s\S]*\})\s*$/);
72
173
  if (!match)
@@ -84,6 +185,7 @@ function emitProposalFromText(text, emit) {
84
185
  owner: String(raw.owner ?? ''),
85
186
  description: String(raw.description ?? ''),
86
187
  sql: String(raw.sql),
188
+ ...blockProposalDqlMetadata(raw),
87
189
  tags: Array.isArray(raw.tags) ? raw.tags.map(String) : undefined,
88
190
  chartType: typeof raw.chartType === 'string' ? raw.chartType : undefined,
89
191
  },
@@ -111,14 +213,18 @@ export function createDqlAgentProviderRunner(id) {
111
213
  emit({ kind: 'thinking', text: 'Building the local agent knowledge graph from terms, business views, blocks, apps, dashboards, dbt, and semantic metadata.' });
112
214
  }
113
215
  await reindexProject(req.projectRoot, { kgPath });
114
- const question = resolveEffectiveQuestion(req);
115
- if (!question) {
216
+ const rawQuestion = resolveEffectiveQuestion(req);
217
+ if (!rawQuestion) {
116
218
  emit({ kind: 'error', message: 'No user question found.' });
117
219
  return;
118
220
  }
119
221
  const memory = new MemoryStore(defaultMemoryPath(req.projectRoot));
120
222
  const kg = new KGStore(kgPath);
121
223
  try {
224
+ const conversationSnapshot = conversationSnapshotFromContext(req.conversationContext);
225
+ const rawFollowUp = followUpFromConversationContext(req, rawQuestion) ?? inferFollowUpContext(req, rawQuestion);
226
+ const followUp = applyTopicShiftGuard(rawFollowUp, conversationSnapshot);
227
+ const question = rewriteFollowUpQuestion(rawQuestion, followUp);
122
228
  // Retrieve durable learnings only — notebook/project/user/artifact scope.
123
229
  // `thread` (per-conversation) memory is intentionally excluded: it is
124
230
  // raw-chat residue, not a governed learning, and bloats the prompt.
@@ -132,13 +238,26 @@ export function createDqlAgentProviderRunner(id) {
132
238
  ? await req.getSchemaContext(question).catch(() => [])
133
239
  : [];
134
240
  const skills = loadSkills(req.projectRoot).skills;
135
- const followUp = followUpFromConversationContext(req, question) ?? inferFollowUpContext(req, question);
241
+ const semanticLayer = loadAgentSemanticLayer(req.projectRoot);
242
+ const questionPlan = buildAnalysisQuestionPlan(question, followUp);
243
+ const contextBudget = contextRetrievalBudgetForQuestion({
244
+ questionPlan,
245
+ requestedDepth: req.analysisDepth,
246
+ reasoningEffort: req.reasoningEffort,
247
+ });
136
248
  const selectedContext = selectedContextForMetadata(req, question);
137
249
  const contextPack = await buildLocalContextPack(req.projectRoot, {
138
250
  question,
139
251
  surface: 'notebook',
140
252
  followUp,
141
253
  selectedContext,
254
+ strictness: contextBudget.strictness,
255
+ limit: contextBudget.limit,
256
+ confirmCertifiedFit: createCertifiedFitConfirmation(provider, signal),
257
+ // Conversation-aware reuse: same-topic follow-ups seed (or, for
258
+ // filter-only refinements, re-stamp) the prior turn's context pack.
259
+ priorContextPackId: priorContextPackIdFromSnapshot(conversationSnapshot),
260
+ conversationTopicRelation: conversationSnapshot?.topicRelation,
142
261
  runtimeSchemaSnapshot: schemaContext.length > 0
143
262
  ? {
144
263
  source: 'agent provider schema context',
@@ -169,13 +288,20 @@ export function createDqlAgentProviderRunner(id) {
169
288
  skills,
170
289
  blockHints,
171
290
  followUp,
291
+ conversationSnapshot,
172
292
  memoryContext,
173
293
  schemaContext,
294
+ semanticLayer,
174
295
  contextPack,
175
296
  signal,
176
297
  reasoningEffort: req.reasoningEffort,
298
+ analysisDepth: contextBudget.analysisDepth,
299
+ ...(req.semanticDriver ? { semanticDriver: req.semanticDriver } : {}),
300
+ ...(req.semanticTableMapping ? { semanticTableMapping: req.semanticTableMapping } : {}),
177
301
  executeCertifiedBlock: req.executeCertifiedBlock,
178
302
  executeGeneratedSql: req.executeGeneratedSql,
303
+ expandGroundingContext: createGroundingContextExpander(req.projectRoot),
304
+ answerLoopTools: buildAnswerLoopTools(req.projectRoot),
179
305
  // NOTE: no captureGeneratedDraft here — a plain answer/research question must NOT
180
306
  // auto-write a draft into the blocks space. A draft is created only when the user
181
307
  // explicitly acts (the "Create DQL draft" action → the dql_block_draft route).
@@ -258,6 +384,61 @@ export function resolveEffectiveQuestion(req) {
258
384
  return current;
259
385
  return `${original} — clarification: ${current}`;
260
386
  }
387
+ export function rewriteFollowUpQuestion(question, followUp) {
388
+ if (!followUp || followUp.kind === 'contextual')
389
+ return question;
390
+ const pieces = [
391
+ `Follow-up request: ${question}`,
392
+ followUp.sourceQuestion ? `Prior question: ${followUp.sourceQuestion}` : '',
393
+ followUp.sourceBlockName ? `Prior certified block: ${followUp.sourceBlockName}` : '',
394
+ followUp.priorResultRef ? formatPriorResultRefForQuestion(followUp.priorResultRef) : '',
395
+ followUp.priorDqlArtifact ? formatPriorDqlArtifactForQuestion(followUp.priorDqlArtifact) : '',
396
+ followUp.priorResultValues ? `Resolved prior result values: ${formatResultDimensionValues(followUp.priorResultValues)}` : '',
397
+ followUp.filters?.length ? `Apply referenced filters: ${formatFollowUpFilters(followUp)}` : '',
398
+ followUp.dimensions?.length ? `Referenced dimensions: ${followUp.dimensions.join(', ')}` : '',
399
+ followUp.priorMeasures?.length ? `Prior measures: ${followUp.priorMeasures.join(', ')}` : '',
400
+ ].filter(Boolean);
401
+ return pieces.length > 1 ? pieces.join('\n') : question;
402
+ }
403
+ function formatPriorResultRefForQuestion(ref) {
404
+ const parts = [
405
+ `Prior result ref: result:${ref.id}`,
406
+ ref.columns.length ? `schema=[${ref.columns.slice(0, 24).join(', ')}]` : '',
407
+ typeof ref.rowCount === 'number' ? `row_count=${ref.rowCount}` : '',
408
+ ref.sourceSql ? `source_sql=${compactInline(ref.sourceSql, 500)}` : '',
409
+ ].filter(Boolean);
410
+ return parts.join('; ');
411
+ }
412
+ function formatPriorDqlArtifactForQuestion(artifact) {
413
+ const parts = [
414
+ `Prior DQL artifact: kind=${artifact.kind}`,
415
+ artifact.name ? `name=${artifact.name}` : '',
416
+ artifact.sourcePath ? `path=${artifact.sourcePath}` : '',
417
+ artifact.metrics?.length ? `metrics=[${artifact.metrics.slice(0, 12).join(', ')}]` : '',
418
+ artifact.dimensions?.length ? `dimensions=[${artifact.dimensions.slice(0, 12).join(', ')}]` : '',
419
+ artifact.filters?.length ? `filters=[${artifact.filters.slice(0, 8).map(formatDqlArtifactFilterInline).join('; ')}]` : '',
420
+ artifact.timeDimension ? `time=${artifact.timeDimension.name}/${artifact.timeDimension.granularity}` : '',
421
+ artifact.orderBy?.length ? `order_by=[${artifact.orderBy.slice(0, 8).map((order) => `${order.name} ${order.direction}`).join(', ')}]` : '',
422
+ typeof artifact.limit === 'number' ? `limit=${artifact.limit}` : '',
423
+ artifact.source ? `source=${compactInline(artifact.source, 900)}` : '',
424
+ ].filter(Boolean);
425
+ return parts.join('; ');
426
+ }
427
+ function formatDqlArtifactFilterInline(filter) {
428
+ return `${filter.dimension} ${filter.operator} ${filter.values.slice(0, 8).join(', ')}`;
429
+ }
430
+ function formatFollowUpFilters(followUp) {
431
+ if (!followUp.filters?.length)
432
+ return '';
433
+ if (followUp.dimensions?.length === 1) {
434
+ return `${followUp.dimensions[0]} in [${followUp.filters.join(', ')}]`;
435
+ }
436
+ return followUp.filters.join(', ');
437
+ }
438
+ function compactInline(value, max) {
439
+ const compact = value.replace(/\s+/g, ' ').trim();
440
+ return compact.length > max ? `${compact.slice(0, max - 3).trimEnd()}...` : compact;
441
+ }
261
442
  function renderExtraContext(req, followUp) {
262
443
  const parts = [];
263
444
  const upstream = req.upstream?.sql?.trim();
@@ -268,7 +449,10 @@ function renderExtraContext(req, followUp) {
268
449
  parts.push(`${label}:\n${upstream}`);
269
450
  }
270
451
  const context = req.conversationContext;
271
- if (context) {
452
+ // Thread-scoped runs render the structured conversation snapshot in its own
453
+ // prompt section (answer-loop) — skip this text recap to avoid double-carrying.
454
+ const hasServerSnapshot = Boolean(context?.serverSnapshot);
455
+ if (context && !hasServerSnapshot) {
272
456
  // Bound the carried-forward "conversation memory" defensively: only the most
273
457
  // recent turn's signals, with hard caps on the summary length and list sizes
274
458
  // so prompts don't grow across a long multi-turn chat.
@@ -282,9 +466,12 @@ function renderExtraContext(req, followUp) {
282
466
  context.trustLabel ? `trust label: ${context.trustLabel}` : '',
283
467
  context.reviewStatus ? `review status: ${context.reviewStatus}` : '',
284
468
  context.draftBlockPath ? `draft block: ${context.draftBlockPath}` : '',
469
+ context.dqlArtifact ? `prior DQL artifact:\n${formatPriorDqlArtifactForQuestion(context.dqlArtifact)}` : '',
285
470
  context.requestedFilters?.length ? `remembered filters: ${clampList(context.requestedFilters)}` : '',
286
471
  context.requestedDimensions?.length ? `remembered dimensions: ${clampList(context.requestedDimensions)}` : '',
287
472
  context.outputColumns?.length ? `prior output columns: ${clampList(context.outputColumns)}` : '',
473
+ context.resultDimensionValues ? `prior result values: ${formatResultDimensionValues(context.resultDimensionValues)}` : '',
474
+ context.turns?.length ? `recent analytical turns:\n${formatConversationTurnsForPrompt(context.turns)}` : '',
288
475
  ].filter(Boolean);
289
476
  if (contextLines.length > 0) {
290
477
  parts.push(`Conversation memory:\n${contextLines.join('\n')}`);
@@ -293,8 +480,13 @@ function renderExtraContext(req, followUp) {
293
480
  if (followUp?.sourceBlockName) {
294
481
  const suffix = followUp.kind === 'drilldown'
295
482
  ? 'Use it as source context, but prefer a distinct certified drilldown block or a review-required draft.'
296
- : 'Reuse it for this generic follow-up.';
297
- parts.push(`Follow-up context: the user is referring to certified block "${followUp.sourceBlockName}". ${suffix}`);
483
+ : followUp.kind === 'contextual'
484
+ ? 'This is advisory prior-turn context — use it only if the question refers to it; on a new topic, ignore it.'
485
+ : 'Reuse it for this generic follow-up.';
486
+ const lead = followUp.kind === 'contextual'
487
+ ? `Prior turn used certified block "${followUp.sourceBlockName}".`
488
+ : `Follow-up context: the user is referring to certified block "${followUp.sourceBlockName}".`;
489
+ parts.push(`${lead} ${suffix}`);
298
490
  }
299
491
  if (followUp?.filters?.length) {
300
492
  parts.push(`Requested follow-up filters: ${followUp.filters.join(', ')}`);
@@ -302,6 +494,12 @@ function renderExtraContext(req, followUp) {
302
494
  if (followUp?.dimensions?.length) {
303
495
  parts.push(`Requested follow-up dimensions: ${followUp.dimensions.join(', ')}`);
304
496
  }
497
+ if (followUp?.priorResultRef) {
498
+ parts.push(`Prior result reference:\n${formatPriorResultRefForQuestion(followUp.priorResultRef)}`);
499
+ }
500
+ if (followUp?.priorDqlArtifact) {
501
+ parts.push(`Prior DQL artifact reference:\n${formatPriorDqlArtifactForQuestion(followUp.priorDqlArtifact)}`);
502
+ }
305
503
  return parts.length > 0 ? parts.join('\n\n') : undefined;
306
504
  }
307
505
  function selectedContextForMetadata(req, question) {
@@ -365,9 +563,9 @@ function extractSelectedBlockHints(req) {
365
563
  }
366
564
  }
367
565
  function inferFollowUpContext(req, question) {
368
- const kind = isGenericFollowUp(question) ? 'generic' : isDrilldownFollowUp(question) ? 'drilldown' : null;
369
- if (!kind)
370
- return undefined;
566
+ // Messages-only fallback (no structured conversationContext). Regexes classify the
567
+ // kind; a non-matching question still carries the prior turn as advisory 'contextual'.
568
+ const kind = isGenericFollowUp(question) ? 'generic' : isDrilldownFollowUp(question) ? 'drilldown' : 'contextual';
371
569
  for (let i = req.messages.length - 1; i >= 0; i--) {
372
570
  const msg = req.messages[i];
373
571
  if (msg.role !== 'assistant')
@@ -385,38 +583,335 @@ function inferFollowUpContext(req, question) {
385
583
  }
386
584
  return undefined;
387
585
  }
586
+ function priorContextPackIdFromSnapshot(snapshot) {
587
+ const fromState = snapshot?.workingState?.lastContextPackId;
588
+ if (typeof fromState === 'string' && fromState.trim())
589
+ return fromState;
590
+ const fromTurns = snapshot?.recentTurns?.length
591
+ ? snapshot.recentTurns[snapshot.recentTurns.length - 1]?.contextPackId
592
+ : undefined;
593
+ return typeof fromTurns === 'string' && fromTurns.trim() ? fromTurns : undefined;
594
+ }
595
+ /** Parse the server-attached conversation snapshot (thread-scoped runs only). */
596
+ function conversationSnapshotFromContext(context) {
597
+ const raw = context?.serverSnapshot;
598
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
599
+ return undefined;
600
+ const snapshot = raw;
601
+ return typeof snapshot.threadId === 'string' && Array.isArray(snapshot.recentTurns)
602
+ ? snapshot
603
+ : undefined;
604
+ }
605
+ /**
606
+ * Deterministic stale-context protection: when the persisted working state says
607
+ * the new question is a topic SHIFT, prior-turn filters must not be forced into
608
+ * the follow-up (a "by X" phrasing can regex-classify as drilldown even on a
609
+ * genuinely new topic). Question-derived filters are kept; carried ones drop.
610
+ */
611
+ function applyTopicShiftGuard(followUp, snapshot) {
612
+ if (!followUp || snapshot?.topicRelation !== 'shift')
613
+ return followUp;
614
+ if (followUp.kind !== 'drilldown')
615
+ return followUp;
616
+ return {
617
+ ...followUp,
618
+ kind: 'contextual',
619
+ filters: undefined,
620
+ dimensions: undefined,
621
+ priorResultValues: undefined,
622
+ priorMeasures: undefined,
623
+ priorLimit: undefined,
624
+ };
625
+ }
388
626
  function followUpFromConversationContext(req, question) {
389
627
  const context = req.conversationContext;
390
628
  if (!context)
391
629
  return undefined;
392
- const sourceBlockName = cleanOptionalString(context.sourceCertifiedBlock);
393
- if (!sourceBlockName)
630
+ const turns = conversationTurnsFromContext(context);
631
+ const activeTurn = activeConversationTurn(context, turns, question);
632
+ const activeResult = activeTurn?.result && typeof activeTurn.result === 'object' && !Array.isArray(activeTurn.result)
633
+ ? activeTurn.result
634
+ : undefined;
635
+ const sourceBlockName = cleanOptionalString(activeTurn?.sourceCertifiedBlock) ?? cleanOptionalString(context.sourceCertifiedBlock);
636
+ const priorResultValues = cleanStringRecordArray(activeResult?.dimensionValues) ?? cleanStringRecordArray(context.resultDimensionValues);
637
+ const priorResultColumns = mergeStrings(arrayValue(activeResult?.columns), context.resultColumns, context.outputColumns);
638
+ const priorResultRef = priorResultRefFromTurn(activeTurn, activeResult, priorResultColumns);
639
+ const priorDqlArtifact = cleanDqlArtifactReference(activeTurn?.dqlArtifact) ?? cleanDqlArtifactReference(context.dqlArtifact);
640
+ const resolvedReferences = resolveConversationReferences(question, turns, priorResultValues);
641
+ const hasUsefulContext = Boolean(sourceBlockName || priorResultColumns?.length || priorResultValues || priorDqlArtifact);
642
+ if (!hasUsefulContext)
394
643
  return undefined;
395
644
  const inferredKind = isGenericFollowUp(question)
396
645
  ? 'generic'
397
646
  : isDrilldownFollowUp(question)
398
647
  ? 'drilldown'
399
648
  : null;
400
- const kind = inferredKind ?? (context.followupKind === 'generic' || context.followupKind === 'drilldown' ? context.followupKind : null);
401
- if (!kind)
402
- return undefined;
649
+ // Always-on carry: the regexes only CLASSIFY the follow-up kind they no longer
650
+ // gate whether conversation context exists at all. A question that matches neither
651
+ // pattern still carries the prior turn as advisory 'contextual' state; the model
652
+ // (not a regex) decides whether it's relevant to the new question.
653
+ const kind = inferredKind
654
+ ?? (context.followupKind === 'generic' || context.followupKind === 'drilldown' ? context.followupKind : null)
655
+ ?? 'contextual';
403
656
  return {
404
657
  kind,
658
+ sourceTurnId: cleanOptionalString(activeTurn?.id) ?? cleanOptionalString(context.sourceAnswerId),
405
659
  sourceBlockName,
406
- sourceQuestion: cleanOptionalString(context.sourceQuestion),
407
- sourceAnswer: cleanOptionalString(context.sourceAnswerSummary),
660
+ sourceQuestion: cleanOptionalString(activeTurn?.question) ?? cleanOptionalString(context.sourceQuestion),
661
+ sourceAnswer: cleanOptionalString(activeTurn?.answerSummary) ?? cleanOptionalString(context.sourceAnswerSummary),
408
662
  filters: kind === 'drilldown'
409
- ? mergeStrings(context.requestedFilters, extractDrilldownFilters(question))
663
+ ? mergeStrings(activeTurnStringArray(activeTurn, 'requestedFilters'), context.requestedFilters, extractDrilldownFilters(question), resolvedReferences.filters)
410
664
  : undefined,
411
665
  dimensions: kind === 'drilldown'
412
- ? mergeStrings(context.requestedDimensions, extractDrilldownDimensions(question))
666
+ ? mergeStrings(activeTurnStringArray(activeTurn, 'requestedDimensions'), context.requestedDimensions, extractDrilldownDimensions(question), resolvedReferences.dimensions)
413
667
  : undefined,
668
+ priorResultColumns,
669
+ priorResultValues,
670
+ priorResultRef,
671
+ priorDqlArtifact,
672
+ priorLimit: activeTurnNumber(activeTurn, 'topN') ?? (typeof context.priorLimit === 'number' ? context.priorLimit : undefined),
673
+ priorMeasures: mergeStrings(activeTurnStringArray(activeTurn, 'requestedMeasures'), arrayValue(activeResult?.measureColumns), context.priorMeasures, inferredMeasuresFromAnswerContract(context.answerContract), inferredMeasureColumns(priorResultColumns)),
674
+ resolvedReferences: resolvedReferences.labels,
675
+ unresolvedReferences: resolvedReferences.unresolved,
676
+ };
677
+ }
678
+ function conversationTurnsFromContext(context) {
679
+ const explicit = Array.isArray(context?.turns)
680
+ ? context.turns.map(cleanRecord).filter((turn) => Boolean(turn))
681
+ : [];
682
+ const snapshot = conversationSnapshotFromContext(context);
683
+ const snapshotTurns = [
684
+ ...(snapshot?.recalledTurns ?? []).map((turn) => snapshotTurnToConversationRecord(turn, 'recalled')),
685
+ ...(snapshot?.recentTurns ?? []).map((turn) => snapshotTurnToConversationRecord(turn, 'recent')),
686
+ ].filter((turn) => Boolean(turn));
687
+ const merged = mergeConversationTurnRecords([...snapshotTurns, ...explicit]);
688
+ if (merged.length > 0)
689
+ return merged.slice(-12);
690
+ const legacy = cleanRecord({
691
+ id: context?.sourceAnswerId,
692
+ question: context?.sourceQuestion,
693
+ answerSummary: context?.sourceAnswerSummary,
694
+ sourceCertifiedBlock: context?.sourceCertifiedBlock,
695
+ requestedFilters: context?.requestedFilters,
696
+ requestedDimensions: context?.requestedDimensions,
697
+ requestedMeasures: context?.priorMeasures,
698
+ topN: context?.priorLimit,
699
+ result: {
700
+ columns: context?.resultColumns ?? context?.outputColumns,
701
+ rowsSample: context?.resultRowsSample,
702
+ dimensionValues: context?.resultDimensionValues,
703
+ measureColumns: context?.priorMeasures,
704
+ },
705
+ route: context?.route,
706
+ trustLabel: context?.trustLabel,
707
+ reviewStatus: context?.reviewStatus,
708
+ certification: context?.certification,
709
+ contextPackId: context?.contextPackId,
710
+ sourceSql: context?.sourceSql,
711
+ dqlArtifact: context?.dqlArtifact,
712
+ cascade: context?.cascade,
713
+ });
714
+ return legacy ? [legacy] : [];
715
+ }
716
+ function snapshotTurnToConversationRecord(turn, snapshotSource) {
717
+ return compactConversationRecord({
718
+ id: turn.id,
719
+ question: turn.question,
720
+ answerSummary: turn.answerSummary,
721
+ sourceCertifiedBlock: turn.sourceCertifiedBlock,
722
+ route: turn.route,
723
+ contextPackId: turn.contextPackId,
724
+ sourceSql: turn.sourceSql,
725
+ dqlArtifact: turn.dqlArtifact,
726
+ cascade: turn.cascade,
727
+ snapshotSource,
728
+ result: compactConversationRecord({
729
+ columns: turn.resultColumns,
730
+ dimensionValues: turn.resultDimensionValues,
731
+ rowCount: turn.resultRowCount,
732
+ }),
733
+ });
734
+ }
735
+ function mergeConversationTurnRecords(turns) {
736
+ const byId = new Map();
737
+ const anonymous = [];
738
+ for (const turn of turns) {
739
+ const id = cleanOptionalString(turn.id);
740
+ if (!id) {
741
+ anonymous.push(turn);
742
+ continue;
743
+ }
744
+ const current = byId.get(id);
745
+ byId.set(id, current ? { ...current, ...turn } : turn);
746
+ }
747
+ return [...anonymous, ...byId.values()];
748
+ }
749
+ function activeConversationTurn(context, turns, question) {
750
+ if (turns.length === 0)
751
+ return undefined;
752
+ const activeId = cleanOptionalString(context?.activeTurnId);
753
+ const activeMatch = activeId
754
+ ? turns.find((turn) => cleanOptionalString(turn.id) === activeId)
755
+ : undefined;
756
+ const recalled = turns.filter((turn) => cleanOptionalString(turn.snapshotSource) === 'recalled' && turnHasUsefulResult(turn));
757
+ if (recalled.length > 0) {
758
+ const bestRecalled = recalled
759
+ .map((turn) => ({ turn, score: scoreTurnForQuestion(turn, question) }))
760
+ .sort((a, b) => b.score - a.score)[0];
761
+ const activeScore = activeMatch ? scoreTurnForQuestion(activeMatch, question) : 0;
762
+ if (bestRecalled && shouldPreferRecalledPriorResult(question, bestRecalled.score, activeScore)) {
763
+ return bestRecalled.turn;
764
+ }
765
+ }
766
+ if (activeId) {
767
+ if (activeMatch)
768
+ return activeMatch;
769
+ }
770
+ return [...turns].reverse().find((turn) => {
771
+ return turnHasUsefulResult(turn);
772
+ }) ?? turns[turns.length - 1];
773
+ }
774
+ function turnHasUsefulResult(turn) {
775
+ const result = cleanRecord(turn.result);
776
+ const columns = arrayValue(result?.columns);
777
+ const rows = arrayValue(result?.rowsSample);
778
+ const values = cleanStringRecordArray(result?.dimensionValues);
779
+ return Boolean(columns?.length || rows?.length || values);
780
+ }
781
+ function scoreTurnForQuestion(turn, question) {
782
+ const queryTokens = tokenSet(question);
783
+ if (queryTokens.size === 0)
784
+ return 0;
785
+ const result = cleanRecord(turn.result);
786
+ const values = cleanStringRecordArray(result?.dimensionValues);
787
+ const text = [
788
+ cleanOptionalString(turn.question),
789
+ cleanOptionalString(turn.answerSummary),
790
+ ...(arrayValue(result?.columns) ?? []).map((value) => cleanOptionalString(value)).filter((value) => Boolean(value)),
791
+ ...Object.entries(values ?? {}).flatMap(([key, list]) => [key, ...list.slice(0, 8)]),
792
+ ].filter(Boolean).join(' ');
793
+ let score = 0;
794
+ const turnTokens = tokenSet(text);
795
+ for (const token of queryTokens) {
796
+ if (turnTokens.has(token))
797
+ score += 1;
798
+ }
799
+ return score;
800
+ }
801
+ function shouldPreferRecalledPriorResult(question, recalledScore, activeScore) {
802
+ if (recalledScore >= Math.max(activeScore + 2, 2))
803
+ return true;
804
+ if (!wantsPriorResultReference(question))
805
+ return false;
806
+ return recalledScore > 0 && recalledScore > activeScore;
807
+ }
808
+ function wantsPriorResultReference(question) {
809
+ return /\b(?:previous|prior|earlier|above)\s+(?:results?|outputs?|rows?|turns?)\b/i.test(question)
810
+ || /\b(?:with|from|using|include)\s+(?:the\s+)?(?:previous|prior|earlier|above)\b/i.test(question);
811
+ }
812
+ function tokenSet(value) {
813
+ const tokens = value
814
+ .toLowerCase()
815
+ .match(/[a-z0-9_]+/g) ?? [];
816
+ return new Set(tokens
817
+ .map((token) => token.replace(/s$/, ''))
818
+ .filter((token) => token.length > 2 && !GENERIC_FOLLOW_UP_WORDS.has(token)));
819
+ }
820
+ function priorResultRefFromTurn(activeTurn, activeResult, priorResultColumns) {
821
+ const columns = priorResultColumns?.slice(0, 32) ?? [];
822
+ if (columns.length === 0)
823
+ return undefined;
824
+ const id = cleanOptionalString(activeTurn?.id) ?? 'previous';
825
+ const rowCount = typeof activeResult?.rowCount === 'number' && Number.isFinite(activeResult.rowCount)
826
+ ? activeResult.rowCount
827
+ : Array.isArray(activeResult?.rowsSample)
828
+ ? activeResult.rowsSample.length
829
+ : undefined;
830
+ const sourceSql = cleanOptionalString(activeTurn?.sourceSql);
831
+ return {
832
+ id,
833
+ question: cleanOptionalString(activeTurn?.question),
834
+ columns,
835
+ rowCount,
836
+ sourceSql: sourceSql ? sourceSql.slice(0, 1200) : undefined,
414
837
  };
415
838
  }
839
+ function resolveConversationReferences(question, turns, activeValues) {
840
+ const dimensions = resolveDeicticDimensions(question, activeValues) ?? [];
841
+ let filters = resolveDeicticFilters(question, activeValues) ?? [];
842
+ const labels = [];
843
+ if (dimensions.length > 0 && filters.length === 0) {
844
+ for (const turn of [...turns].reverse()) {
845
+ const values = cleanStringRecordArray(cleanRecord(turn.result)?.dimensionValues);
846
+ if (!values)
847
+ continue;
848
+ filters = dimensions.flatMap((dimension) => valuesForPriorDimension(values, dimension));
849
+ if (filters.length > 0)
850
+ break;
851
+ }
852
+ }
853
+ for (const dimension of dimensions) {
854
+ const values = (activeValues ? valuesForPriorDimension(activeValues, dimension) : []).slice(0, 5);
855
+ labels.push(values.length ? `${dimension}: ${values.join(', ')}` : `${dimension}: unresolved`);
856
+ }
857
+ const unresolved = referencesNeedValues(question) && filters.length === 0
858
+ ? ['Could not resolve the referenced prior result values from conversation state.']
859
+ : undefined;
860
+ return {
861
+ filters: filters.length > 0 ? Array.from(new Set(filters)).slice(0, 24) : undefined,
862
+ dimensions: dimensions.length > 0 ? dimensions : undefined,
863
+ labels: labels.length > 0 ? labels : undefined,
864
+ unresolved,
865
+ };
866
+ }
867
+ function cleanRecord(value) {
868
+ return value && typeof value === 'object' && !Array.isArray(value)
869
+ ? value
870
+ : undefined;
871
+ }
872
+ function compactConversationRecord(record) {
873
+ const entries = Object.entries(record).filter(([, value]) => value !== undefined && value !== null);
874
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
875
+ }
876
+ function cleanDqlArtifactReference(value) {
877
+ const artifact = normalizeDqlArtifactReference(value);
878
+ if (!artifact)
879
+ return undefined;
880
+ return {
881
+ ...artifact,
882
+ source: artifact.source.slice(0, 3000),
883
+ metrics: uniqueStringList(artifact.metrics),
884
+ dimensions: uniqueStringList(artifact.dimensions),
885
+ filters: artifact.filters
886
+ ?.filter((filter) => filter.values.length > 0)
887
+ .slice(0, 12)
888
+ .map((filter) => ({ ...filter, values: uniqueStringList(filter.values)?.slice(0, 12) ?? [] })),
889
+ orderBy: artifact.orderBy?.slice(0, 12),
890
+ };
891
+ }
892
+ function uniqueStringList(value) {
893
+ if (!value?.length)
894
+ return undefined;
895
+ const unique = Array.from(new Set(value)).slice(0, 24);
896
+ return unique.length > 0 ? unique : undefined;
897
+ }
898
+ function activeTurnStringArray(turn, key) {
899
+ return arrayValue(turn?.[key]);
900
+ }
901
+ function activeTurnNumber(turn, key) {
902
+ const value = turn?.[key];
903
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
904
+ }
905
+ function referencesNeedValues(question) {
906
+ return /\b(?:this|that|these|those|same|above|previous|prior)\b/i.test(question);
907
+ }
416
908
  function extractCertifiedBlockName(content) {
417
909
  const fromAnswer = content.match(/Answered by certified block \*\*([^*]+)\*\*/i)?.[1];
910
+ const fromRoute = content.match(/Answered from certified block\s+([A-Za-z0-9_.-]+)/i)?.[1];
418
911
  const fromCitation = content.match(/^- block:\s*([A-Za-z0-9_.-]+)/im)?.[1];
419
- return (fromAnswer ?? fromCitation)?.trim();
912
+ // The name pattern admits dots, so a sentence period lands in the match
913
+ // ("... block food_vs_drink_revenue.") — strip trailing punctuation.
914
+ return (fromAnswer ?? fromRoute ?? fromCitation)?.trim().replace(/[.,;:]+$/, '') || undefined;
420
915
  }
421
916
  function cleanOptionalString(value) {
422
917
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
@@ -429,6 +924,168 @@ function mergeStrings(...groups) {
429
924
  const unique = Array.from(new Set(values)).slice(0, 24);
430
925
  return unique.length > 0 ? unique : undefined;
431
926
  }
927
+ function inferredMeasuresFromAnswerContract(value) {
928
+ if (!value || typeof value !== 'object' || Array.isArray(value))
929
+ return undefined;
930
+ const record = value;
931
+ const requestedShape = record.requestedShape && typeof record.requestedShape === 'object' && !Array.isArray(record.requestedShape)
932
+ ? record.requestedShape
933
+ : undefined;
934
+ return mergeStrings(arrayValue(record.measures), arrayValue(requestedShape?.measures));
935
+ }
936
+ function inferredMeasureColumns(columns) {
937
+ if (!columns?.length)
938
+ return undefined;
939
+ const measures = columns.filter((column) => /\b(revenue|sales|amount|total|count|average|avg|sum|spend|cost|margin|profit|value|points?|score|quantity|rate|volume)\b/i.test(column.replace(/_/g, ' ')));
940
+ return measures.length > 0 ? measures : undefined;
941
+ }
942
+ function arrayValue(value) {
943
+ return Array.isArray(value) ? value : undefined;
944
+ }
945
+ function cleanStringRecordArray(value) {
946
+ if (!value || typeof value !== 'object' || Array.isArray(value))
947
+ return undefined;
948
+ const out = {};
949
+ for (const [key, raw] of Object.entries(value)) {
950
+ const values = Array.isArray(raw)
951
+ ? raw.map(cleanOptionalString).filter((item) => Boolean(item)).slice(0, 24)
952
+ : [];
953
+ if (key.trim() && values.length > 0)
954
+ out[key.trim()] = values;
955
+ }
956
+ return Object.keys(out).length > 0 ? out : undefined;
957
+ }
958
+ function resolveDeicticFilters(question, priorValues) {
959
+ if (!priorValues)
960
+ return undefined;
961
+ const hasExplicitPluralReference = /\b(?:these|those|same)\s+(?:customers?|products?|cat(?:egor|agor|ogor)(?:y|ies)|segments?|regions?)\b/i.test(question);
962
+ if (!hasExplicitPluralReference) {
963
+ const singular = resolveSingularDeicticDimension(question, priorValues);
964
+ if (singular) {
965
+ const values = valuesForPriorDimension(priorValues, singular).slice(0, 1);
966
+ return values.length > 0 ? values : undefined;
967
+ }
968
+ }
969
+ const dims = resolveDeicticDimensions(question, priorValues) ?? [];
970
+ const values = dims.flatMap((dim) => valuesForPriorDimension(priorValues, dim));
971
+ return values.length > 0 ? Array.from(new Set(values)).slice(0, 24) : undefined;
972
+ }
973
+ function resolveDeicticDimensions(question, priorValues) {
974
+ if (!priorValues)
975
+ return undefined;
976
+ const lower = question.toLowerCase();
977
+ const dims = [];
978
+ const candidates = [
979
+ [/\b(?:these|those|the)\s+cat(?:egor|agor|ogor)(?:y|ies)\b/, 'category'],
980
+ [/\b(?:this|that|these|those|the)\s+products?\b/, 'product'],
981
+ [/\b(?:this|that|these|those|the)\s+customers?\b/, 'customer'],
982
+ [/\b(?:above|previous|prior)\s+(?:orders?|results?|rows?)\b/, 'customer'],
983
+ [/\b(?:this|that|these|those|the)\s+segments?\b/, 'segment'],
984
+ [/\b(?:this|that|these|those|the)\s+regions?\b/, 'region'],
985
+ ];
986
+ for (const [pattern, dim] of candidates) {
987
+ if (!pattern.test(lower))
988
+ continue;
989
+ if (dim === 'product' && /\bthe\s+product\s+cat(?:egor|agor|ogor)(?:y|ies)\b/.test(lower))
990
+ continue;
991
+ if (valuesForPriorDimension(priorValues, dim).length)
992
+ dims.push(dim);
993
+ }
994
+ if (dims.length === 0 && /\b(?:these|those|that|them|same|above|previous|prior)\b/.test(lower)) {
995
+ const single = singlePriorValueDimension(priorValues);
996
+ if (single)
997
+ dims.push(single);
998
+ }
999
+ return dims.length > 0 ? Array.from(new Set(dims)) : undefined;
1000
+ }
1001
+ function resolveSingularDeicticDimension(question, priorValues) {
1002
+ const lower = question.toLowerCase();
1003
+ const candidates = [
1004
+ [/\b(?:this|that|the)\s+product\b/, 'product'],
1005
+ [/\b(?:this|that|the)\s+customer\b/, 'customer'],
1006
+ [/\b(?:this|that|the)\s+category\b/, 'category'],
1007
+ [/\b(?:this|that|the)\s+segment\b/, 'segment'],
1008
+ [/\b(?:this|that|the)\s+region\b/, 'region'],
1009
+ ];
1010
+ for (const [pattern, dim] of candidates) {
1011
+ if (pattern.test(lower) && valuesForPriorDimension(priorValues, dim).length)
1012
+ return dim;
1013
+ }
1014
+ return undefined;
1015
+ }
1016
+ function valuesForPriorDimension(values, dim) {
1017
+ const aliases = [dim, `${dim}_name`];
1018
+ if (dim === 'product')
1019
+ aliases.push('sku');
1020
+ if (dim === 'category')
1021
+ aliases.push('product_type', 'category_name');
1022
+ return Array.from(new Set(aliases.flatMap((alias) => values[alias] ?? []))).filter(Boolean);
1023
+ }
1024
+ function formatResultDimensionValues(value) {
1025
+ return Object.entries(value)
1026
+ .slice(0, 8)
1027
+ .map(([key, values]) => `${key}=[${values.slice(0, 8).join(', ')}]`)
1028
+ .join('; ');
1029
+ }
1030
+ function formatConversationTurnsForPrompt(turns) {
1031
+ return turns
1032
+ .map(cleanRecord)
1033
+ .filter((turn) => Boolean(turn))
1034
+ .slice(-4)
1035
+ .map((turn, index) => {
1036
+ const result = cleanRecord(turn.result);
1037
+ const columns = arrayValue(result?.columns)?.map(cleanOptionalString).filter(Boolean).slice(0, 6) ?? [];
1038
+ const values = cleanStringRecordArray(result?.dimensionValues);
1039
+ const valueText = values ? formatResultDimensionValues(values) : '';
1040
+ const cascade = cleanRecord(turn.cascade);
1041
+ const cascadeText = [
1042
+ cleanOptionalString(cascade?.terminalLane),
1043
+ cleanOptionalString(cascade?.routeTier),
1044
+ ].filter(Boolean).join('/');
1045
+ return [
1046
+ `${index + 1}. ${cleanOptionalString(turn.question) ?? 'prior turn'}`,
1047
+ cascadeText ? `cascade=${cascadeText}` : '',
1048
+ cleanOptionalString(turn.answerSummary) ? `summary=${cleanOptionalString(turn.answerSummary)}` : '',
1049
+ columns.length ? `columns=${columns.join(', ')}` : '',
1050
+ valueText ? `values=${valueText}` : '',
1051
+ ].filter(Boolean).join(' | ');
1052
+ })
1053
+ .join('\n');
1054
+ }
1055
+ function singlePriorValueDimension(priorValues) {
1056
+ const dims = Array.from(new Set(Object.keys(priorValues).map(normalizePriorValueDimension).filter(Boolean)));
1057
+ return dims.length === 1 ? dims[0] : undefined;
1058
+ }
1059
+ function normalizePriorValueDimension(value) {
1060
+ const lower = value.toLowerCase();
1061
+ if (lower.includes('category'))
1062
+ return 'category';
1063
+ if (lower.includes('product'))
1064
+ return 'product';
1065
+ if (lower.includes('customer'))
1066
+ return 'customer';
1067
+ if (lower.includes('account'))
1068
+ return 'account';
1069
+ if (lower.includes('user'))
1070
+ return 'user';
1071
+ if (lower.includes('region'))
1072
+ return 'region';
1073
+ if (lower.includes('segment'))
1074
+ return 'segment';
1075
+ if (lower.includes('channel'))
1076
+ return 'channel';
1077
+ return lower.replace(/[_\s-]+name$/, '').replace(/[^a-z0-9_ -]+/g, '').trim();
1078
+ }
1079
+ export const __test__ = {
1080
+ applyTopicShiftGuard,
1081
+ buildAnswerLoopTools,
1082
+ createCertifiedFitConfirmation,
1083
+ followUpFromConversationContext,
1084
+ inferFollowUpContext,
1085
+ formatCascadeOutcome,
1086
+ parseCertifiedFitConfirmation,
1087
+ rewriteFollowUpQuestion,
1088
+ };
432
1089
  const GENERIC_FOLLOW_UP_WORDS = new Set([
433
1090
  'a', 'about', 'again', 'all', 'also', 'and', 'as', 'be', 'block', 'can', 'could', 'data', 'do',
434
1091
  'execute', 'for', 'from', 'get', 'give', 'import', 'it', 'its', 'let', 'lets', 'me', 'metrics',
@@ -437,6 +1094,9 @@ const GENERIC_FOLLOW_UP_WORDS = new Set([
437
1094
  ]);
438
1095
  function isGenericFollowUp(question) {
439
1096
  const lower = question.toLowerCase();
1097
+ if (/\b(?:combine|merge|join|final|summari[sz]e)\b/.test(lower) && /\b(?:previous|prior|above|these|those|results?|outputs?|turns?)\b/.test(lower)) {
1098
+ return true;
1099
+ }
440
1100
  if (!/\b(block|data|execute|import|it|result|results|run|solution|that|this)\b/.test(lower))
441
1101
  return false;
442
1102
  const tokens = lower.match(/[a-z0-9_]+/g) ?? [];
@@ -445,8 +1105,9 @@ function isGenericFollowUp(question) {
445
1105
  }
446
1106
  function isDrilldownFollowUp(question) {
447
1107
  const lower = question.toLowerCase();
448
- return /\b(drill|break\s*down|slice|segment|filter|compare|split|why|changed?|change|driver|root cause|increase|decrease|drop|spike|variance|by|for|only|where|last week|this week|last month|this month|enterprise|region|customer|channel|product)\b/.test(lower)
449
- && !/\b(what is|what are|define|definition|meaning of)\b/.test(lower);
1108
+ const deicticDrilldown = /\b(?:this|that|these|those|same|above|previous|prior)\s+(?:orders?|results?|rows?|customers?|products?|cat(?:egor|agor|ogor)(?:y|ies)|segments?|regions?)\b/.test(lower);
1109
+ return /\b(drill|break\s*down|slice|segment|filter|compare|split|why|changed?|change|driver|root cause|increase|decrease|drop|spike|variance|by|for|only|where|last week|this week|last month|this month|enterprise|region|customer|channel|product|category|categories|catagor(?:y|ies)|catogor(?:y|ies))\b/.test(lower)
1110
+ && (deicticDrilldown || !/\b(what is|what are|define|definition|meaning of)\b/.test(lower));
450
1111
  }
451
1112
  function extractDrilldownFilters(question) {
452
1113
  const filters = [];
@@ -476,7 +1137,7 @@ function extractDrilldownDimensions(question) {
476
1137
  if (value)
477
1138
  dims.push(value);
478
1139
  }
479
- for (const dim of ['segment', 'region', 'customer', 'channel', 'product', 'week', 'month']) {
1140
+ for (const dim of ['segment', 'region', 'customer', 'channel', 'product', 'category', 'week', 'month']) {
480
1141
  if (new RegExp(`\\b${dim}\\b`, 'i').test(question))
481
1142
  dims.push(dim);
482
1143
  }
@@ -493,9 +1154,58 @@ function formatAgentAnswer(result) {
493
1154
  const citations = result.citations.length > 0
494
1155
  ? '\n\nCitations:\n' + result.citations.map((c) => `- ${c.kind}: ${c.name}${c.provenance ? ` (${c.provenance})` : ''}`).join('\n')
495
1156
  : '';
1157
+ const cascade = formatCascadeOutcome(result.cascade);
1158
+ const cascadeLine = cascade ? `\nCascade: ${cascade}` : '';
496
1159
  const resultPreview = formatResultPreview(result.result);
497
- const sql = result.proposedSql ? `\n\nProposed SQL:\n\`\`\`sql\n${result.proposedSql}\n\`\`\`` : '';
498
- return `[${badge}]\n\n${result.text}${resultPreview}${citations}${sql}`;
1160
+ const dql = result.dqlArtifact?.source?.trim()
1161
+ ? `\n\nDQL Artifact (${result.dqlArtifact.kind}):\n\`\`\`dql\n${result.dqlArtifact.source.trim()}\n\`\`\``
1162
+ : '';
1163
+ const sql = result.proposedSql ? `\n\nCompiled SQL preview:\n\`\`\`sql\n${result.proposedSql}\n\`\`\`` : '';
1164
+ return `[${badge}]${cascadeLine}\n\n${result.text}${resultPreview}${citations}${dql}${sql}`;
1165
+ }
1166
+ function formatCascadeOutcome(cascade) {
1167
+ if (!cascade?.terminalLane && !cascade?.routeTier)
1168
+ return undefined;
1169
+ const lane = formatCascadeLane(cascade.terminalLane);
1170
+ const tier = formatCascadeTier(cascade.routeTier);
1171
+ return [lane, tier].filter(Boolean).join(' · ') || undefined;
1172
+ }
1173
+ function formatCascadeLane(value) {
1174
+ switch (value) {
1175
+ case 'certified':
1176
+ return 'Lane 1 certified';
1177
+ case 'semantic':
1178
+ return 'Lane 2 semantic';
1179
+ case 'generated':
1180
+ return 'Lane 3 generated';
1181
+ case 'refusal':
1182
+ return 'Lane 4 refusal';
1183
+ default:
1184
+ return value ? formatLabel(value) : undefined;
1185
+ }
1186
+ }
1187
+ function formatCascadeTier(value) {
1188
+ switch (value) {
1189
+ case 'certified_block':
1190
+ return 'Certified block';
1191
+ case 'semantic_metric':
1192
+ return 'Semantic metric';
1193
+ case 'generated_sql':
1194
+ return 'Generated SQL';
1195
+ case 'business_context':
1196
+ return 'Business context';
1197
+ case 'no_answer':
1198
+ return 'No answer';
1199
+ default:
1200
+ return value ? formatLabel(value) : undefined;
1201
+ }
1202
+ }
1203
+ function formatLabel(value) {
1204
+ return value
1205
+ .replace(/[_-]+/g, ' ')
1206
+ .replace(/\s+/g, ' ')
1207
+ .trim()
1208
+ .replace(/\b\w/g, (char) => char.toUpperCase());
499
1209
  }
500
1210
  function formatResultPreview(result) {
501
1211
  if (!result)
@@ -542,13 +1252,34 @@ function escapeMarkdownTable(value) {
542
1252
  }
543
1253
  function emitDraftProposal(result, question, emit) {
544
1254
  const isDrilldown = result.evidence?.route.some((step) => step.tool === 'propose_drilldown' && step.status === 'checked') ?? false;
1255
+ const dqlArtifact = result.dqlArtifact;
1256
+ const semanticArtifact = dqlArtifact?.kind === 'semantic_block' ? dqlArtifact : undefined;
545
1257
  const proposal = {
546
1258
  name: slugify(question).slice(0, 56) || 'ai_generated_analysis',
547
1259
  domain: inferProposalDomain(result) ?? '',
548
1260
  owner: `${process.env.USER ?? 'analyst'}@local`,
549
1261
  description: result.text.slice(0, 240),
550
1262
  sql: result.proposedSql,
551
- tags: ['ai-generated', 'needs-review', result.sourceTier ?? 'dbt_manifest', ...(isDrilldown ? ['drilldown'] : [])],
1263
+ blockType: semanticArtifact ? 'semantic' : 'custom',
1264
+ ...(dqlArtifact
1265
+ ? {
1266
+ dqlSource: dqlArtifact.source,
1267
+ }
1268
+ : {}),
1269
+ ...(semanticArtifact
1270
+ ? {
1271
+ metrics: semanticArtifact.metrics,
1272
+ dimensions: semanticArtifact.dimensions,
1273
+ ...(semanticArtifact.filters ? { filters: semanticArtifact.filters } : {}),
1274
+ ...(semanticArtifact.timeDimension ? { timeDimension: semanticArtifact.timeDimension } : {}),
1275
+ }
1276
+ : {}),
1277
+ tags: [
1278
+ 'ai-generated',
1279
+ 'needs-review',
1280
+ semanticArtifact ? 'semantic' : result.sourceTier ?? 'dbt_manifest',
1281
+ ...(isDrilldown ? ['drilldown'] : []),
1282
+ ],
552
1283
  chartType: result.suggestedViz,
553
1284
  };
554
1285
  emit({