@duckcodeailabs/dql-agent 1.6.5 → 1.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/answer-loop.d.ts +23 -1
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +731 -28
- package/dist/answer-loop.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/kg/build.d.ts.map +1 -1
- package/dist/kg/build.js +1 -0
- package/dist/kg/build.js.map +1 -1
- package/dist/kg/types.d.ts +2 -0
- package/dist/kg/types.d.ts.map +1 -1
- package/dist/metadata/catalog.d.ts +289 -0
- package/dist/metadata/catalog.d.ts.map +1 -0
- package/dist/metadata/catalog.js +2049 -0
- package/dist/metadata/catalog.js.map +1 -0
- package/dist/metadata/drafts.d.ts +25 -0
- package/dist/metadata/drafts.d.ts.map +1 -0
- package/dist/metadata/drafts.js +108 -0
- package/dist/metadata/drafts.js.map +1 -0
- package/dist/metadata/sql-context-validation.d.ts +28 -0
- package/dist/metadata/sql-context-validation.d.ts.map +1 -0
- package/dist/metadata/sql-context-validation.js +340 -0
- package/dist/metadata/sql-context-validation.js.map +1 -0
- package/dist/providers/ollama.d.ts.map +1 -1
- package/dist/providers/ollama.js +2 -1
- package/dist/providers/ollama.js.map +1 -1
- package/package.json +4 -4
package/dist/answer-loop.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* full pipeline.
|
|
16
16
|
*/
|
|
17
17
|
import { buildSkillBlockHints, buildSkillsPrompt } from './skills/loader.js';
|
|
18
|
+
import { validateSqlAgainstLocalContext } from './metadata/sql-context-validation.js';
|
|
18
19
|
const CERTIFIED_HIT_THRESHOLD = 0.18;
|
|
19
20
|
const HARD_NEGATIVE_RATIO = 0.5;
|
|
20
21
|
const EXECUTABLE_ARTIFACT_KINDS = ['block', 'dashboard', 'app', 'notebook'];
|
|
@@ -40,7 +41,8 @@ export async function answer(input) {
|
|
|
40
41
|
const semanticHits = kg.search({ query: question, domain, kinds: SEMANTIC_KINDS, limit: 12 });
|
|
41
42
|
const manifestHits = kg.search({ query: question, domain, kinds: MANIFEST_KINDS, limit: 12 });
|
|
42
43
|
const considered = mergeHits(artifactHits, semanticHits, manifestHits, kg.search({ query: question, domain, limit: 10 })).slice(0, 30);
|
|
43
|
-
const
|
|
44
|
+
const catalogRoute = input.contextPack?.routeDecision;
|
|
45
|
+
const fallbackIntent = classifyAgentIntent({
|
|
44
46
|
question,
|
|
45
47
|
followUp: input.followUp,
|
|
46
48
|
artifactHits,
|
|
@@ -48,19 +50,30 @@ export async function answer(input) {
|
|
|
48
50
|
manifestHits,
|
|
49
51
|
schemaContext: input.schemaContext ?? [],
|
|
50
52
|
});
|
|
53
|
+
const intent = catalogRoute ? agentIntentFromCatalogRoute(catalogRoute) : fallbackIntent;
|
|
51
54
|
// Stage 1: certified artifact match. Blocks can be executed; dashboards,
|
|
52
55
|
// Apps, and notebooks are returned as governed citations/navigation targets.
|
|
53
|
-
const
|
|
54
|
-
?
|
|
55
|
-
artifactHits,
|
|
56
|
+
const drilldownCertifiedHit = input.followUp?.kind === 'drilldown'
|
|
57
|
+
? pickCertifiedDrilldownArtifact({
|
|
56
58
|
executableArtifactHits,
|
|
57
|
-
businessHits,
|
|
58
59
|
question,
|
|
59
|
-
|
|
60
|
+
followUp: input.followUp,
|
|
60
61
|
excludedArtifactIds,
|
|
61
62
|
kg,
|
|
62
63
|
})
|
|
63
64
|
: null;
|
|
65
|
+
const artifactHit = drilldownCertifiedHit ?? (shouldUseCertifiedRoute(catalogRoute, intent)
|
|
66
|
+
? certifiedHitFromContextPack(input.contextPack, kg)
|
|
67
|
+
?? pickCertifiedArtifact({
|
|
68
|
+
artifactHits,
|
|
69
|
+
executableArtifactHits,
|
|
70
|
+
businessHits,
|
|
71
|
+
question,
|
|
72
|
+
blockHints: input.followUp?.kind === 'drilldown' ? [] : effectiveBlockHints,
|
|
73
|
+
excludedArtifactIds,
|
|
74
|
+
kg,
|
|
75
|
+
})
|
|
76
|
+
: null);
|
|
64
77
|
if (artifactHit) {
|
|
65
78
|
let result;
|
|
66
79
|
let executionError;
|
|
@@ -89,7 +102,7 @@ export async function answer(input) {
|
|
|
89
102
|
const analysisPlan = buildAnalysisPlan({
|
|
90
103
|
question,
|
|
91
104
|
intent,
|
|
92
|
-
routeReason: 'The question matched a certified DQL artifact closely enough to answer without generating new SQL.',
|
|
105
|
+
routeReason: catalogRoute?.reason ?? 'The question matched a certified DQL artifact closely enough to answer without generating new SQL.',
|
|
93
106
|
selectedNodes: [artifactHit.node],
|
|
94
107
|
schemaContext: input.schemaContext ?? [],
|
|
95
108
|
sql: result?.sql,
|
|
@@ -107,6 +120,10 @@ export async function answer(input) {
|
|
|
107
120
|
result,
|
|
108
121
|
executionError,
|
|
109
122
|
sql: result?.sql,
|
|
123
|
+
trustLabel: input.contextPack?.trustLabel ?? 'certified',
|
|
124
|
+
sourceCertifiedBlock: artifactHit.node.kind === 'block' ? artifactHit.node.name : undefined,
|
|
125
|
+
contextPackId: input.contextPack?.id,
|
|
126
|
+
selectedEvidence: input.contextPack?.evidenceRoles?.slice(0, 12),
|
|
110
127
|
citations,
|
|
111
128
|
memoryContext: input.memoryContext,
|
|
112
129
|
analysisPlan,
|
|
@@ -124,19 +141,22 @@ export async function answer(input) {
|
|
|
124
141
|
memoryContext: input.memoryContext ?? [],
|
|
125
142
|
analysisPlan,
|
|
126
143
|
}),
|
|
144
|
+
contextPack: input.contextPack,
|
|
127
145
|
considered,
|
|
128
146
|
providerUsed: provider.name,
|
|
129
147
|
};
|
|
130
148
|
}
|
|
131
|
-
if (intent === 'clarify') {
|
|
132
|
-
const text = composeClarificationText(question, considered, input.schemaContext ?? []);
|
|
149
|
+
if (intent === 'clarify' || catalogRoute?.route === 'clarify') {
|
|
150
|
+
const text = composeCatalogClarificationText(question, catalogRoute) ?? composeClarificationText(question, considered, input.schemaContext ?? []);
|
|
133
151
|
const analysisPlan = buildAnalysisPlan({
|
|
134
152
|
question,
|
|
135
153
|
intent,
|
|
136
|
-
routeReason: 'No certified artifact, semantic object, dbt/source table, or runtime schema match was strong enough to safely generate SQL.',
|
|
154
|
+
routeReason: catalogRoute?.reason ?? 'No certified artifact, semantic object, dbt/source table, or runtime schema match was strong enough to safely generate SQL.',
|
|
137
155
|
selectedNodes: considered.slice(0, 4).map((hit) => hit.node),
|
|
138
156
|
schemaContext: input.schemaContext ?? [],
|
|
139
|
-
assumptions:
|
|
157
|
+
assumptions: catalogRoute?.missingContext.length
|
|
158
|
+
? catalogRoute.missingContext.map((item) => item.message)
|
|
159
|
+
: ['Need a clearer business object, measure, or grain before querying.'],
|
|
140
160
|
});
|
|
141
161
|
return {
|
|
142
162
|
kind: 'no_answer',
|
|
@@ -160,17 +180,18 @@ export async function answer(input) {
|
|
|
160
180
|
memoryContext: input.memoryContext ?? [],
|
|
161
181
|
analysisPlan,
|
|
162
182
|
}),
|
|
183
|
+
contextPack: input.contextPack,
|
|
163
184
|
considered,
|
|
164
185
|
providerUsed: provider.name,
|
|
165
186
|
};
|
|
166
187
|
}
|
|
167
188
|
// Stage 2/3: generate only after certified artifacts miss. Semantic context
|
|
168
189
|
// wins over raw dbt manifest context; memory is appended last as advisory.
|
|
169
|
-
const activeTier = semanticHits.length > 0
|
|
190
|
+
const activeTier = sourceTierFromContextPack(input.contextPack) ?? (semanticHits.length > 0
|
|
170
191
|
? 'semantic_layer'
|
|
171
192
|
: manifestHits.length > 0
|
|
172
193
|
? 'dbt_manifest'
|
|
173
|
-
: 'dbt_manifest';
|
|
194
|
+
: 'dbt_manifest');
|
|
174
195
|
const reviewRequiredArtifactHits = artifactHits
|
|
175
196
|
.filter((hit) => hit.score >= CERTIFIED_HIT_THRESHOLD && !isCertifiedHit(hit, kg))
|
|
176
197
|
.slice(0, 4);
|
|
@@ -192,13 +213,19 @@ export async function answer(input) {
|
|
|
192
213
|
messages.push({ role: 'system', content: skillsPrompt });
|
|
193
214
|
messages.push({
|
|
194
215
|
role: 'system',
|
|
195
|
-
content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, input.schemaContext ?? [], intent),
|
|
216
|
+
content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, input.schemaContext ?? [], intent, input.contextPack),
|
|
196
217
|
});
|
|
197
218
|
messages.push({ role: 'user', content: question });
|
|
198
219
|
const localProposal = buildSchemaAwareProposal({
|
|
199
220
|
question,
|
|
200
221
|
intent,
|
|
201
222
|
schemaContext: input.schemaContext ?? [],
|
|
223
|
+
followUp: input.followUp,
|
|
224
|
+
contextPack: input.contextPack,
|
|
225
|
+
}) ?? buildContextPackAwareProposal({
|
|
226
|
+
question,
|
|
227
|
+
intent,
|
|
228
|
+
contextPack: input.contextPack,
|
|
202
229
|
});
|
|
203
230
|
let proposed = '';
|
|
204
231
|
let parsed;
|
|
@@ -231,6 +258,7 @@ export async function answer(input) {
|
|
|
231
258
|
considered,
|
|
232
259
|
memoryContext: input.memoryContext ?? [],
|
|
233
260
|
}),
|
|
261
|
+
contextPack: input.contextPack,
|
|
234
262
|
considered,
|
|
235
263
|
providerUsed: provider.name,
|
|
236
264
|
};
|
|
@@ -259,11 +287,67 @@ export async function answer(input) {
|
|
|
259
287
|
considered,
|
|
260
288
|
memoryContext: input.memoryContext ?? [],
|
|
261
289
|
}),
|
|
290
|
+
contextPack: input.contextPack,
|
|
262
291
|
considered,
|
|
263
292
|
providerUsed: provider.name,
|
|
264
293
|
};
|
|
265
294
|
}
|
|
295
|
+
const contextValidation = validateSqlAgainstLocalContext(parsed.sql, input.contextPack, {
|
|
296
|
+
question,
|
|
297
|
+
intent,
|
|
298
|
+
filterValues: input.followUp?.filters,
|
|
299
|
+
});
|
|
300
|
+
if (!contextValidation.ok) {
|
|
301
|
+
const text = `I could not safely prepare this generated SQL from the inspected context. ${contextValidation.error}`;
|
|
302
|
+
const analysisPlan = buildAnalysisPlan({
|
|
303
|
+
question,
|
|
304
|
+
intent,
|
|
305
|
+
routeReason: catalogRoute?.reason ?? 'Generated SQL failed metadata context validation before preview execution or draft capture.',
|
|
306
|
+
selectedNodes: contextNodes,
|
|
307
|
+
schemaContext: input.schemaContext ?? [],
|
|
308
|
+
sql: parsed.sql,
|
|
309
|
+
suggestedViz: parsed.viz ?? 'table',
|
|
310
|
+
assumptions: [
|
|
311
|
+
'Generated SQL was rejected before execution because it did not match inspected metadata context.',
|
|
312
|
+
...contextValidation.warnings,
|
|
313
|
+
],
|
|
314
|
+
});
|
|
315
|
+
return {
|
|
316
|
+
kind: 'no_answer',
|
|
317
|
+
sourceTier: 'no_answer',
|
|
318
|
+
certification: 'analyst_review_required',
|
|
319
|
+
reviewStatus: 'none',
|
|
320
|
+
confidence: 0.15,
|
|
321
|
+
text,
|
|
322
|
+
answer: text,
|
|
323
|
+
proposedSql: parsed.sql,
|
|
324
|
+
sql: parsed.sql,
|
|
325
|
+
trustLabel: input.contextPack?.trustLabel,
|
|
326
|
+
sourceCertifiedBlock: followUpSourceBlock?.name ?? input.followUp?.sourceBlockName,
|
|
327
|
+
contextPackId: input.contextPack?.id,
|
|
328
|
+
validationWarnings: contextValidation.warnings,
|
|
329
|
+
selectedEvidence: input.contextPack?.evidenceRoles?.slice(0, 12),
|
|
330
|
+
citations: [],
|
|
331
|
+
memoryContext: input.memoryContext,
|
|
332
|
+
analysisPlan,
|
|
333
|
+
evidence: buildNoAnswerEvidence({
|
|
334
|
+
question,
|
|
335
|
+
reason: contextValidation.error,
|
|
336
|
+
artifactHits,
|
|
337
|
+
businessHits,
|
|
338
|
+
semanticHits,
|
|
339
|
+
manifestHits,
|
|
340
|
+
considered,
|
|
341
|
+
memoryContext: input.memoryContext ?? [],
|
|
342
|
+
analysisPlan,
|
|
343
|
+
}),
|
|
344
|
+
contextPack: input.contextPack,
|
|
345
|
+
considered,
|
|
346
|
+
providerUsed: localProposal ? 'schema_planner' : provider.name,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
266
349
|
const generatedCitations = [
|
|
350
|
+
...contextPackCitations(input.contextPack, 4),
|
|
267
351
|
...contextNodes.slice(0, 4).map((n) => ({
|
|
268
352
|
nodeId: n.nodeId,
|
|
269
353
|
kind: n.kind,
|
|
@@ -334,33 +418,74 @@ export async function answer(input) {
|
|
|
334
418
|
const analysisPlan = buildAnalysisPlan({
|
|
335
419
|
question,
|
|
336
420
|
intent,
|
|
337
|
-
routeReason: intent === 'drillthrough'
|
|
421
|
+
routeReason: catalogRoute?.reason ?? (intent === 'drillthrough'
|
|
338
422
|
? 'The user asked for a drill-through or follow-up, so DQL generated review-required SQL from the prior context and current metadata.'
|
|
339
|
-
: 'The question asks for a custom analysis, ranking, breakdown, comparison, or grain that should not be answered by a loose certified block match.',
|
|
423
|
+
: 'The question asks for a custom analysis, ranking, breakdown, comparison, or grain that should not be answered by a loose certified block match.'),
|
|
340
424
|
selectedNodes: contextNodes,
|
|
341
425
|
schemaContext: input.schemaContext ?? [],
|
|
342
426
|
sql: parsed.sql,
|
|
343
427
|
suggestedViz: parsed.viz ?? 'table',
|
|
344
428
|
assumptions: [
|
|
345
429
|
'Generated SQL is an uncertified preview until an analyst reviews and promotes it.',
|
|
346
|
-
...(localProposal ? ['A
|
|
430
|
+
...(localProposal ? ['A local metadata planner selected a review-required SQL grain before provider generation.'] : []),
|
|
347
431
|
...(executionError ? ['The preview execution error must be reviewed before reuse.'] : []),
|
|
348
432
|
],
|
|
349
433
|
repairAttempts,
|
|
350
434
|
});
|
|
435
|
+
const validationWarnings = [
|
|
436
|
+
...(input.contextPack?.warnings ?? []),
|
|
437
|
+
...(executionError ? ['The preview execution error must be reviewed before reuse.'] : []),
|
|
438
|
+
];
|
|
439
|
+
let draftBlock;
|
|
440
|
+
let draftCaptureError;
|
|
441
|
+
if (input.captureGeneratedDraft && parsed.sql) {
|
|
442
|
+
try {
|
|
443
|
+
draftBlock = await input.captureGeneratedDraft({
|
|
444
|
+
question,
|
|
445
|
+
sql: parsed.sql,
|
|
446
|
+
intent,
|
|
447
|
+
followUp: input.followUp,
|
|
448
|
+
contextPack: input.contextPack,
|
|
449
|
+
sourceBlock: followUpSourceBlock ?? undefined,
|
|
450
|
+
validationWarnings,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
draftCaptureError = err instanceof Error ? err.message : String(err);
|
|
455
|
+
validationWarnings.push(`Draft capture failed: ${draftCaptureError}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const sourceCertifiedBlock = followUpSourceBlock?.name ?? input.followUp?.sourceBlockName;
|
|
459
|
+
const trustExplanation = generatedTrustExplanation({
|
|
460
|
+
followUp: input.followUp,
|
|
461
|
+
sourceCertifiedBlock,
|
|
462
|
+
draftBlock,
|
|
463
|
+
});
|
|
464
|
+
const cleanedSummary = cleanGeneratedSummary(parsed.text);
|
|
465
|
+
const generatedText = trustExplanation
|
|
466
|
+
? [trustExplanation, cleanedSummary].filter(Boolean).join('\n\n')
|
|
467
|
+
: cleanedSummary;
|
|
351
468
|
return {
|
|
352
469
|
kind: 'uncertified',
|
|
353
470
|
sourceTier: activeTier,
|
|
354
471
|
certification: 'ai_generated',
|
|
355
472
|
reviewStatus: 'draft_ready',
|
|
356
473
|
confidence: activeTier === 'semantic_layer' ? 0.72 : 0.55,
|
|
357
|
-
text:
|
|
358
|
-
answer:
|
|
474
|
+
text: generatedText,
|
|
475
|
+
answer: generatedText,
|
|
359
476
|
proposedSql: parsed.sql,
|
|
360
477
|
sql: parsed.sql,
|
|
361
478
|
result,
|
|
362
479
|
executionError,
|
|
363
480
|
suggestedViz: parsed.viz ?? 'table',
|
|
481
|
+
draftBlock,
|
|
482
|
+
draftBlockId: draftBlock?.path,
|
|
483
|
+
promoteCommand: draftBlock ? `dql certify --from-draft ${draftBlock.path}` : undefined,
|
|
484
|
+
trustLabel: input.contextPack?.trustLabel,
|
|
485
|
+
sourceCertifiedBlock,
|
|
486
|
+
contextPackId: input.contextPack?.id,
|
|
487
|
+
validationWarnings,
|
|
488
|
+
selectedEvidence: input.contextPack?.evidenceRoles?.slice(0, 12),
|
|
364
489
|
citations: generatedCitations,
|
|
365
490
|
memoryContext: input.memoryContext,
|
|
366
491
|
analysisPlan,
|
|
@@ -382,6 +507,7 @@ export async function answer(input) {
|
|
|
382
507
|
executorWasAvailable: Boolean(input.executeGeneratedSql),
|
|
383
508
|
analysisPlan,
|
|
384
509
|
}),
|
|
510
|
+
contextPack: input.contextPack,
|
|
385
511
|
considered,
|
|
386
512
|
providerUsed: localProposal ? 'schema_planner' : provider.name,
|
|
387
513
|
};
|
|
@@ -409,8 +535,11 @@ Rules:
|
|
|
409
535
|
proposed SQL. Do not emit INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, COPY,
|
|
410
536
|
PRAGMA, SET, or multiple statements.
|
|
411
537
|
8. If the schema is insufficient to answer, say so explicitly and ask a
|
|
412
|
-
clarifying question instead of guessing
|
|
413
|
-
|
|
538
|
+
clarifying question instead of guessing.
|
|
539
|
+
9. Write directly to the analyst. Do not say "the user is asking", "the user
|
|
540
|
+
requested", "I will generate", or describe internal routing. State the
|
|
541
|
+
answer, the certified context used, and the review requirement.`;
|
|
542
|
+
function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis', contextPack) {
|
|
414
543
|
const intentSection = `## Routing intent\n\nintent: ${intent}\n${intent === 'exact_certified_lookup'
|
|
415
544
|
? 'Use a certified artifact only if it exactly answers the question.'
|
|
416
545
|
: 'Generate review-required SQL for this question. Certified blocks are trusted context, not a reason to answer the wrong grain.'}`;
|
|
@@ -458,7 +587,146 @@ function renderContextPrompt(blocks, businessContext, others, activeTier, memory
|
|
|
458
587
|
const followUpSection = followUp
|
|
459
588
|
? `\n\n## Follow-up routing context\n\n${renderFollowUpContext(followUp)}`
|
|
460
589
|
: '';
|
|
461
|
-
|
|
590
|
+
const contextPackSection = contextPack
|
|
591
|
+
? `\n\n## Local metadata context pack\n\n${renderContextPackForPrompt(contextPack)}`
|
|
592
|
+
: '';
|
|
593
|
+
return `${intentSection}\n\n${blockSection}${businessSection}${otherSection}${schemaSection}${contextPackSection}${memorySection}${extraSection}${followUpSection}`;
|
|
594
|
+
}
|
|
595
|
+
function renderContextPackForPrompt(contextPack) {
|
|
596
|
+
const warnings = contextPack.warnings.length
|
|
597
|
+
? `Warnings:\n${contextPack.warnings.slice(0, 8).map((warning) => `- ${warning}`).join('\n')}\n`
|
|
598
|
+
: '';
|
|
599
|
+
const objects = contextPack.objects.slice(0, 18).map((object) => {
|
|
600
|
+
const detail = [
|
|
601
|
+
object.objectType,
|
|
602
|
+
object.domain ? `domain: ${object.domain}` : '',
|
|
603
|
+
object.status ? `status: ${object.status}` : '',
|
|
604
|
+
object.description ? `description: ${object.description}` : '',
|
|
605
|
+
].filter(Boolean).join('; ');
|
|
606
|
+
return `- ${object.objectKey} (${detail})`;
|
|
607
|
+
}).join('\n');
|
|
608
|
+
const conflicts = contextPack.retrievalDiagnostics.candidateConflicts.length
|
|
609
|
+
? `\nCandidate conflicts:\n${contextPack.retrievalDiagnostics.candidateConflicts.slice(0, 4).map((conflict) => `- ${conflict.reason} ${conflict.prompt}`).join('\n')}`
|
|
610
|
+
: '';
|
|
611
|
+
const route = contextPack.routeDecision
|
|
612
|
+
? `\nRoute decision: ${contextPack.routeDecision.route} / ${contextPack.routeDecision.intent}\nReason: ${contextPack.routeDecision.reason}\nMissing context: ${contextPack.routeDecision.missingContext.map((item) => item.message).join(' ') || 'none'}`
|
|
613
|
+
: '';
|
|
614
|
+
const allowed = contextPack.allowedSqlContext?.relations.length
|
|
615
|
+
? `\nAllowed SQL relations:\n${contextPack.allowedSqlContext.relations.slice(0, 12).map((relation) => `- ${relation.relation}: ${relation.columns.slice(0, 24).map((column) => column.name).join(', ') || '(columns unavailable)'}`).join('\n')}`
|
|
616
|
+
: '';
|
|
617
|
+
return [
|
|
618
|
+
`context_pack_id: ${contextPack.id}`,
|
|
619
|
+
`trust_label: ${contextPack.trustLabel}`,
|
|
620
|
+
route.trim(),
|
|
621
|
+
warnings.trim(),
|
|
622
|
+
`Selected evidence:\n${objects || '- none'}`,
|
|
623
|
+
allowed.trim(),
|
|
624
|
+
conflicts.trim(),
|
|
625
|
+
].filter(Boolean).join('\n');
|
|
626
|
+
}
|
|
627
|
+
function contextPackCitations(contextPack, limit) {
|
|
628
|
+
if (!contextPack)
|
|
629
|
+
return [];
|
|
630
|
+
return contextPack.objects.slice(0, limit).map((object) => ({
|
|
631
|
+
nodeId: object.objectKey,
|
|
632
|
+
kind: metadataObjectKindForCitation(object.objectType),
|
|
633
|
+
name: object.name,
|
|
634
|
+
sourceTier: metadataObjectSourceTier(object.objectType),
|
|
635
|
+
provenance: object.sourceSystem,
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
function agentIntentFromCatalogRoute(route) {
|
|
639
|
+
if (route.route === 'clarify')
|
|
640
|
+
return 'clarify';
|
|
641
|
+
if (route.route === 'certified')
|
|
642
|
+
return route.intent === 'definition_lookup' ? 'definition_lookup' : 'exact_certified_lookup';
|
|
643
|
+
return route.intent;
|
|
644
|
+
}
|
|
645
|
+
function shouldUseCertifiedRoute(route, intent) {
|
|
646
|
+
if (route)
|
|
647
|
+
return route.route === 'certified';
|
|
648
|
+
return intent === 'exact_certified_lookup' || intent === 'definition_lookup';
|
|
649
|
+
}
|
|
650
|
+
function certifiedHitFromContextPack(contextPack, kg) {
|
|
651
|
+
const key = contextPack?.routeDecision.exactObjectKey;
|
|
652
|
+
if (!key)
|
|
653
|
+
return null;
|
|
654
|
+
const object = contextPack.objects.find((item) => item.objectKey === key);
|
|
655
|
+
if (!object)
|
|
656
|
+
return null;
|
|
657
|
+
const nodeId = object.objectType === 'dql_block'
|
|
658
|
+
? `block:${object.name}`
|
|
659
|
+
: object.objectType === 'dql_term'
|
|
660
|
+
? `term:${object.name}`
|
|
661
|
+
: object.objectType === 'business_view'
|
|
662
|
+
? `business_view:${object.name}`
|
|
663
|
+
: undefined;
|
|
664
|
+
const node = nodeId ? kg.getNode(nodeId) : null;
|
|
665
|
+
return node ? { node, score: 1, snippet: object.snippet } : null;
|
|
666
|
+
}
|
|
667
|
+
function composeCatalogClarificationText(question, route) {
|
|
668
|
+
if (!route?.missingContext.length)
|
|
669
|
+
return undefined;
|
|
670
|
+
const missing = route.missingContext.map((item) => item.message).join(' ');
|
|
671
|
+
const followUp = route.followUps[0] ? ` ${route.followUps[0]}?` : '';
|
|
672
|
+
return `I need one more detail before querying "${question}". ${missing}${followUp}`;
|
|
673
|
+
}
|
|
674
|
+
function sourceTierFromContextPack(contextPack) {
|
|
675
|
+
if (!contextPack)
|
|
676
|
+
return undefined;
|
|
677
|
+
if (contextPack.objects.some((object) => object.objectType === 'semantic_metric'))
|
|
678
|
+
return 'semantic_layer';
|
|
679
|
+
if (contextPack.objects.some((object) => object.objectType.startsWith('dbt_') || object.objectType === 'warehouse_table' || object.objectType === 'runtime_table'))
|
|
680
|
+
return 'dbt_manifest';
|
|
681
|
+
if (contextPack.objects.some((object) => object.objectType === 'dql_term' || object.objectType === 'business_view'))
|
|
682
|
+
return 'business_context';
|
|
683
|
+
if (contextPack.objects.some((object) => object.objectType === 'dql_block'))
|
|
684
|
+
return 'certified_artifact';
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
function isGeneratedAgentIntent(intent) {
|
|
688
|
+
return intent === 'ad_hoc_analysis'
|
|
689
|
+
|| intent === 'drillthrough'
|
|
690
|
+
|| intent === 'ad_hoc_ranking'
|
|
691
|
+
|| intent === 'driver_breakdown'
|
|
692
|
+
|| intent === 'diagnose_change'
|
|
693
|
+
|| intent === 'segment_compare'
|
|
694
|
+
|| intent === 'entity_drilldown'
|
|
695
|
+
|| intent === 'anomaly_investigation';
|
|
696
|
+
}
|
|
697
|
+
function metadataObjectKindForCitation(objectType) {
|
|
698
|
+
if (objectType === 'dql_block')
|
|
699
|
+
return 'block';
|
|
700
|
+
if (objectType === 'dql_term')
|
|
701
|
+
return 'term';
|
|
702
|
+
if (objectType === 'business_view')
|
|
703
|
+
return 'business_view';
|
|
704
|
+
if (objectType === 'semantic_metric')
|
|
705
|
+
return 'metric';
|
|
706
|
+
if (objectType === 'semantic_dimension')
|
|
707
|
+
return 'dimension';
|
|
708
|
+
if (objectType === 'dbt_model')
|
|
709
|
+
return 'dbt_model';
|
|
710
|
+
if (objectType === 'dbt_source' || objectType === 'warehouse_table')
|
|
711
|
+
return 'dbt_source';
|
|
712
|
+
if (objectType === 'notebook')
|
|
713
|
+
return 'notebook';
|
|
714
|
+
if (objectType === 'dashboard')
|
|
715
|
+
return 'dashboard';
|
|
716
|
+
if (objectType === 'app')
|
|
717
|
+
return 'app';
|
|
718
|
+
return 'runtime_schema';
|
|
719
|
+
}
|
|
720
|
+
function metadataObjectSourceTier(objectType) {
|
|
721
|
+
if (objectType === 'dql_block')
|
|
722
|
+
return 'certified_artifact';
|
|
723
|
+
if (objectType === 'dql_term' || objectType === 'business_view')
|
|
724
|
+
return 'business_context';
|
|
725
|
+
if (objectType.startsWith('semantic_'))
|
|
726
|
+
return 'semantic_layer';
|
|
727
|
+
if (objectType.startsWith('dbt_') || objectType === 'warehouse_table')
|
|
728
|
+
return 'dbt_manifest';
|
|
729
|
+
return 'business_context';
|
|
462
730
|
}
|
|
463
731
|
function renderFollowUpContext(followUp) {
|
|
464
732
|
const parts = [
|
|
@@ -474,6 +742,30 @@ function renderFollowUpContext(followUp) {
|
|
|
474
742
|
: 'routing rule: reuse the prior certified block when the user asks a generic follow-up.';
|
|
475
743
|
return [...parts, rule].join('\n');
|
|
476
744
|
}
|
|
745
|
+
function generatedTrustExplanation(input) {
|
|
746
|
+
if (input.followUp?.kind !== 'drilldown')
|
|
747
|
+
return undefined;
|
|
748
|
+
const source = input.sourceCertifiedBlock
|
|
749
|
+
? ` I used the certified \`${input.sourceCertifiedBlock}\` block for the business definition,`
|
|
750
|
+
: ' I used certified context where available,';
|
|
751
|
+
const filters = [
|
|
752
|
+
...(input.followUp.filters ?? []),
|
|
753
|
+
...(input.followUp.dimensions ?? []),
|
|
754
|
+
];
|
|
755
|
+
const grain = filters.length ? ` at the requested ${filters.join('/')} grain` : ' at the requested drilldown grain';
|
|
756
|
+
const draft = input.draftBlock
|
|
757
|
+
? ` The draft was saved at \`${input.draftBlock.path}\` for review.`
|
|
758
|
+
: ' The generated SQL still needs analyst review before certification.';
|
|
759
|
+
return `This is an uncertified drilldown.${source} then generated new SQL${grain}.${draft}`;
|
|
760
|
+
}
|
|
761
|
+
function cleanGeneratedSummary(text) {
|
|
762
|
+
return text
|
|
763
|
+
.trim()
|
|
764
|
+
.replace(/^(?:the user (?:is asking|asked|wants|requested)[^.]*\.\s*)+/i, '')
|
|
765
|
+
.replace(/\s*(?:therefore,\s*)?i will generate review-required sql[^.]*\.\s*/gi, ' ')
|
|
766
|
+
.replace(/\s*(?:therefore,\s*)?i will generate[^.]*\.\s*/gi, ' ')
|
|
767
|
+
.trim();
|
|
768
|
+
}
|
|
477
769
|
/**
|
|
478
770
|
* Public for tests. Pulls the first ```sql block and an optional Viz: line
|
|
479
771
|
* out of an LLM response.
|
|
@@ -491,8 +783,12 @@ export function parseProposal(raw) {
|
|
|
491
783
|
return { text, sql, viz };
|
|
492
784
|
}
|
|
493
785
|
function buildSchemaAwareProposal(input) {
|
|
494
|
-
if (input.intent
|
|
786
|
+
if (!isGeneratedAgentIntent(input.intent))
|
|
495
787
|
return undefined;
|
|
788
|
+
const schemaContext = schemaContextWithAllowedSqlContext(input.schemaContext, input.contextPack);
|
|
789
|
+
const drilldownProposal = buildMatchedEntityDrilldownProposal({ ...input, schemaContext });
|
|
790
|
+
if (drilldownProposal)
|
|
791
|
+
return drilldownProposal;
|
|
496
792
|
if (isFilteredEntityQuestion(input.question))
|
|
497
793
|
return undefined;
|
|
498
794
|
const lower = input.question.toLowerCase();
|
|
@@ -501,7 +797,7 @@ function buildSchemaAwareProposal(input) {
|
|
|
501
797
|
&& !/\b(order details|specific orders|each order|all orders|order line|line item)\b/.test(lower);
|
|
502
798
|
if (!asksForCustomerPerformance)
|
|
503
799
|
return undefined;
|
|
504
|
-
const customers = findSchemaTable(
|
|
800
|
+
const customers = findSchemaTable(schemaContext, ['customers', 'customer']);
|
|
505
801
|
if (!customers)
|
|
506
802
|
return undefined;
|
|
507
803
|
const customerName = findSchemaColumn(customers, ['customer_name', 'name', 'full_name']);
|
|
@@ -523,7 +819,7 @@ function buildSchemaAwareProposal(input) {
|
|
|
523
819
|
};
|
|
524
820
|
}
|
|
525
821
|
const customerId = findSchemaColumn(customers, ['customer_id', 'id']);
|
|
526
|
-
const orders = findSchemaTable(
|
|
822
|
+
const orders = findSchemaTable(schemaContext, ['orders', 'order']);
|
|
527
823
|
if (!orders || !customerName || !customerId)
|
|
528
824
|
return undefined;
|
|
529
825
|
const orderCustomerId = findSchemaColumn(orders, ['customer_id', 'customer']);
|
|
@@ -548,6 +844,341 @@ function buildSchemaAwareProposal(input) {
|
|
|
548
844
|
viz: 'table',
|
|
549
845
|
};
|
|
550
846
|
}
|
|
847
|
+
function buildMatchedEntityDrilldownProposal(input) {
|
|
848
|
+
if (input.intent !== 'entity_drilldown' && input.followUp?.kind !== 'drilldown')
|
|
849
|
+
return undefined;
|
|
850
|
+
const table = pickDrilldownTable(input.schemaContext, input.question, input.followUp);
|
|
851
|
+
if (!table)
|
|
852
|
+
return undefined;
|
|
853
|
+
const dimension = inferDrilldownDimension(table, input.question, input.followUp);
|
|
854
|
+
if (!dimension)
|
|
855
|
+
return undefined;
|
|
856
|
+
const entityFilters = matchedEntityFiltersForQuestion(table, input.question, input.followUp);
|
|
857
|
+
if (entityFilters.length === 0)
|
|
858
|
+
return undefined;
|
|
859
|
+
if (entityFilters.some((filter) => namesEqualLoose(filter.column, dimension)))
|
|
860
|
+
return undefined;
|
|
861
|
+
const sourceSql = selectSourceBlockSql(input.contextPack, input.followUp?.sourceBlockName);
|
|
862
|
+
const metric = inferDrilldownMetric(table, input.question, sourceSql);
|
|
863
|
+
if (!metric)
|
|
864
|
+
return undefined;
|
|
865
|
+
const timePredicates = drilldownTimePredicates({
|
|
866
|
+
question: input.question,
|
|
867
|
+
followUp: input.followUp,
|
|
868
|
+
table,
|
|
869
|
+
sourceSql,
|
|
870
|
+
});
|
|
871
|
+
if (mentionsRelativeTime(input.question, input.followUp) && timePredicates.length === 0)
|
|
872
|
+
return undefined;
|
|
873
|
+
const predicates = [
|
|
874
|
+
...entityFilters.map((filter) => `${sqlIdentifier(filter.column)} = ${sqlStringLiteral(filter.value)}`),
|
|
875
|
+
...timePredicates,
|
|
876
|
+
];
|
|
877
|
+
const where = predicates.length ? [`WHERE ${predicates.join(' AND ')}`] : [];
|
|
878
|
+
return {
|
|
879
|
+
text: [
|
|
880
|
+
`Prepared a review-required ${humanizeIdentifier(dimension)} drilldown from inspected metadata.`,
|
|
881
|
+
`The entity filter uses ${entityFilters.map((filter) => `${filter.column} = ${filter.value}`).join(', ')} from matched sample values.`,
|
|
882
|
+
'This result is uncertified until reviewed and promoted.',
|
|
883
|
+
].join(' '),
|
|
884
|
+
sql: [
|
|
885
|
+
'SELECT',
|
|
886
|
+
` ${sqlIdentifier(dimension)} AS ${sqlIdentifier(dimension)},`,
|
|
887
|
+
` ${metric.expression} AS ${sqlIdentifier(metric.alias)}`,
|
|
888
|
+
`FROM ${sqlRelation(table.relation)}`,
|
|
889
|
+
...where,
|
|
890
|
+
`GROUP BY ${sqlIdentifier(dimension)}`,
|
|
891
|
+
`ORDER BY ${sqlIdentifier(metric.alias)} DESC`,
|
|
892
|
+
'LIMIT 50',
|
|
893
|
+
].join('\n'),
|
|
894
|
+
viz: 'bar',
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
function schemaContextWithAllowedSqlContext(schemaContext, contextPack) {
|
|
898
|
+
const byRelation = new Map();
|
|
899
|
+
for (const table of schemaContext) {
|
|
900
|
+
byRelation.set(normalizeRelationKey(table.relation), {
|
|
901
|
+
...table,
|
|
902
|
+
columns: table.columns.map((column) => ({ ...column, sampleValues: column.sampleValues?.slice() })),
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
for (const relation of contextPack?.allowedSqlContext?.relations ?? []) {
|
|
906
|
+
const key = normalizeRelationKey(relation.relation);
|
|
907
|
+
const existing = byRelation.get(key);
|
|
908
|
+
if (!existing) {
|
|
909
|
+
byRelation.set(key, {
|
|
910
|
+
relation: relation.relation,
|
|
911
|
+
name: relation.name,
|
|
912
|
+
columns: relation.columns.map((column) => ({
|
|
913
|
+
name: column.name,
|
|
914
|
+
type: column.type,
|
|
915
|
+
description: column.description,
|
|
916
|
+
sampleValues: column.sampleValues?.slice(),
|
|
917
|
+
})),
|
|
918
|
+
source: relation.source,
|
|
919
|
+
});
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
const columns = new Map(existing.columns.map((column) => [column.name.toLowerCase(), column]));
|
|
923
|
+
for (const column of relation.columns) {
|
|
924
|
+
const existingColumn = columns.get(column.name.toLowerCase());
|
|
925
|
+
if (!existingColumn) {
|
|
926
|
+
existing.columns.push({
|
|
927
|
+
name: column.name,
|
|
928
|
+
type: column.type,
|
|
929
|
+
description: column.description,
|
|
930
|
+
sampleValues: column.sampleValues?.slice(),
|
|
931
|
+
});
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
existingColumn.sampleValues = uniqueDrilldownStrings([
|
|
935
|
+
...(existingColumn.sampleValues ?? []),
|
|
936
|
+
...(column.sampleValues ?? []),
|
|
937
|
+
]).slice(0, 8);
|
|
938
|
+
existingColumn.type ??= column.type;
|
|
939
|
+
existingColumn.description ??= column.description;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return Array.from(byRelation.values());
|
|
943
|
+
}
|
|
944
|
+
function pickDrilldownTable(schemaContext, question, followUp) {
|
|
945
|
+
const scored = schemaContext
|
|
946
|
+
.map((table) => ({
|
|
947
|
+
table,
|
|
948
|
+
filters: matchedEntityFiltersForQuestion(table, question, followUp).length,
|
|
949
|
+
dimension: inferDrilldownDimension(table, question, followUp) ? 1 : 0,
|
|
950
|
+
measure: inferDrilldownMetric(table, question, undefined) ? 1 : 0,
|
|
951
|
+
}))
|
|
952
|
+
.filter((candidate) => candidate.filters > 0 && candidate.dimension > 0 && candidate.measure > 0)
|
|
953
|
+
.sort((a, b) => (b.filters + b.dimension + b.measure) - (a.filters + a.dimension + a.measure));
|
|
954
|
+
return scored[0]?.table;
|
|
955
|
+
}
|
|
956
|
+
function inferDrilldownDimension(table, question, followUp) {
|
|
957
|
+
const lower = question.toLowerCase();
|
|
958
|
+
const requested = [
|
|
959
|
+
...(followUp?.dimensions ?? []),
|
|
960
|
+
...Array.from(lower.matchAll(/\bby\s+([a-z][a-z0-9_ -]{1,40})/g)).map((match) => match[1] ?? ''),
|
|
961
|
+
]
|
|
962
|
+
.flatMap((value) => value.split(/\band\b|,|\//i))
|
|
963
|
+
.map((value) => value.replace(/\b(last|this|next|previous|prior|current)\s+(day|week|month|quarter|year)\b/gi, '').trim())
|
|
964
|
+
.filter(Boolean);
|
|
965
|
+
const direct = findSchemaColumn(table, requested);
|
|
966
|
+
if (direct)
|
|
967
|
+
return direct;
|
|
968
|
+
if (/\bcustomers?\b/.test(lower)) {
|
|
969
|
+
const customer = findSchemaColumn(table, ['customer', 'customer_name', 'account', 'account_name']);
|
|
970
|
+
if (customer)
|
|
971
|
+
return customer;
|
|
972
|
+
}
|
|
973
|
+
if (/\bsegments?\b/.test(lower)) {
|
|
974
|
+
const segment = findSchemaColumn(table, ['segment', 'customer_segment', 'market_segment']);
|
|
975
|
+
if (segment)
|
|
976
|
+
return segment;
|
|
977
|
+
}
|
|
978
|
+
if (/\bproducts?\b/.test(lower)) {
|
|
979
|
+
const product = findSchemaColumn(table, ['product', 'product_name', 'sku']);
|
|
980
|
+
if (product)
|
|
981
|
+
return product;
|
|
982
|
+
}
|
|
983
|
+
if (/\bregions?\b/.test(lower)) {
|
|
984
|
+
const region = findSchemaColumn(table, ['region', 'market', 'geo']);
|
|
985
|
+
if (region)
|
|
986
|
+
return region;
|
|
987
|
+
}
|
|
988
|
+
return undefined;
|
|
989
|
+
}
|
|
990
|
+
function matchedEntityFiltersForQuestion(table, question, followUp) {
|
|
991
|
+
const text = normalizeForEntityMatch([
|
|
992
|
+
question,
|
|
993
|
+
...(followUp?.filters ?? []),
|
|
994
|
+
].join(' '));
|
|
995
|
+
const filters = [];
|
|
996
|
+
for (const column of table.columns) {
|
|
997
|
+
for (const sampleValue of column.sampleValues ?? []) {
|
|
998
|
+
if (isTemporalDrilldownValue(sampleValue))
|
|
999
|
+
continue;
|
|
1000
|
+
const needle = normalizeForEntityMatch(sampleValue);
|
|
1001
|
+
if (!needle || !text.includes(needle))
|
|
1002
|
+
continue;
|
|
1003
|
+
filters.push({ column: column.name, value: sampleValue });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return uniqueMatchedEntityFilters(filters);
|
|
1007
|
+
}
|
|
1008
|
+
function inferDrilldownMetric(table, question, sourceSql) {
|
|
1009
|
+
const sourceMetric = sourceSql ? aggregateMetricFromSourceSql(table, sourceSql) : undefined;
|
|
1010
|
+
if (sourceMetric)
|
|
1011
|
+
return sourceMetric;
|
|
1012
|
+
const lower = question.toLowerCase();
|
|
1013
|
+
const candidates = /\brevenue|arr|mrr|sales\b/.test(lower)
|
|
1014
|
+
? ['revenue', 'net_revenue', 'gross_revenue', 'amount', 'order_total', 'total_amount', 'sales', 'arr', 'mrr']
|
|
1015
|
+
: ['amount', 'revenue', 'order_total', 'total_amount', 'value', 'spend'];
|
|
1016
|
+
const column = findSchemaColumn(table, candidates);
|
|
1017
|
+
if (!column)
|
|
1018
|
+
return undefined;
|
|
1019
|
+
const alias = /\brevenue|arr|mrr|sales\b/.test(lower) ? 'revenue_total' : `${column}_total`;
|
|
1020
|
+
return {
|
|
1021
|
+
expression: `SUM(${sqlIdentifier(column)})`,
|
|
1022
|
+
alias,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
function aggregateMetricFromSourceSql(table, sourceSql) {
|
|
1026
|
+
for (const column of table.columns) {
|
|
1027
|
+
const columnPattern = sqlIdentifierPattern(column.name);
|
|
1028
|
+
const aggregatePattern = new RegExp(`\\b(SUM|COUNT|AVG|MIN|MAX)\\s*\\(\\s*(?:["\`]?\\w+["\`]?\\s*\\.\\s*)?(${columnPattern}|\\*)\\s*\\)\\s*(?:AS\\s+(["\`]?\\w+["\`]?))?`, 'i');
|
|
1029
|
+
const match = sourceSql.match(aggregatePattern);
|
|
1030
|
+
if (!match)
|
|
1031
|
+
continue;
|
|
1032
|
+
const fn = (match[1] ?? 'SUM').toUpperCase();
|
|
1033
|
+
const target = match[2] === '*' ? '*' : sqlIdentifier(column.name);
|
|
1034
|
+
const alias = cleanSqlIdentifier(match[3] ?? defaultMetricAlias(fn, column.name));
|
|
1035
|
+
return {
|
|
1036
|
+
expression: `${fn}(${target})`,
|
|
1037
|
+
alias,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
return undefined;
|
|
1041
|
+
}
|
|
1042
|
+
function drilldownTimePredicates(input) {
|
|
1043
|
+
if (!mentionsRelativeTime(input.question, input.followUp))
|
|
1044
|
+
return [];
|
|
1045
|
+
if (!input.sourceSql)
|
|
1046
|
+
return [];
|
|
1047
|
+
const timeColumns = input.table.columns.map((column) => column.name).filter(isTimeLikeDrilldownColumn);
|
|
1048
|
+
if (timeColumns.length === 0)
|
|
1049
|
+
return [];
|
|
1050
|
+
return extractWherePredicates(input.sourceSql)
|
|
1051
|
+
.map(stripSqlAliasQualifiers)
|
|
1052
|
+
.filter((predicate) => isReusableSqlPredicate(predicate))
|
|
1053
|
+
.filter((predicate) => timeColumns.some((column) => predicateReferencesColumn(predicate, column)))
|
|
1054
|
+
.slice(0, 3);
|
|
1055
|
+
}
|
|
1056
|
+
function selectSourceBlockSql(contextPack, sourceBlockName) {
|
|
1057
|
+
const sourceSql = contextPack?.allowedSqlContext?.sourceBlockSql ?? [];
|
|
1058
|
+
if (sourceSql.length === 0)
|
|
1059
|
+
return undefined;
|
|
1060
|
+
const preferred = sourceBlockName
|
|
1061
|
+
? sourceSql.find((source) => namesEqualLoose(source.name, sourceBlockName))
|
|
1062
|
+
: undefined;
|
|
1063
|
+
return (preferred ?? sourceSql.find((source) => source.status === 'certified') ?? sourceSql[0])?.sql;
|
|
1064
|
+
}
|
|
1065
|
+
function extractWherePredicates(sql) {
|
|
1066
|
+
const match = sql.match(/\bWHERE\b([\s\S]*?)(\bGROUP\s+BY\b|\bHAVING\b|\bORDER\s+BY\b|\bLIMIT\b|$)/i);
|
|
1067
|
+
if (!match)
|
|
1068
|
+
return [];
|
|
1069
|
+
return (match[1] ?? '')
|
|
1070
|
+
.split(/\s+AND\s+/i)
|
|
1071
|
+
.map((part) => part.trim().replace(/^\(+|\)+$/g, '').replace(/\s+/g, ' '))
|
|
1072
|
+
.filter(Boolean);
|
|
1073
|
+
}
|
|
1074
|
+
function mentionsRelativeTime(question, followUp) {
|
|
1075
|
+
const text = [question, ...(followUp?.filters ?? [])].join(' ');
|
|
1076
|
+
return /\b(last|this|next|previous|prior|current)\s+(day|week|month|quarter|year)\b/i.test(text)
|
|
1077
|
+
|| /\b(today|yesterday|tomorrow|ytd|mtd|qtd|wtd)\b/i.test(text);
|
|
1078
|
+
}
|
|
1079
|
+
function isTimeLikeDrilldownColumn(name) {
|
|
1080
|
+
return /\b(date|time|day|week|month|quarter|year|period|created_at|updated_at)\b/i.test(name);
|
|
1081
|
+
}
|
|
1082
|
+
function stripSqlAliasQualifiers(predicate) {
|
|
1083
|
+
return predicate.replace(/\b["`]?\w+["`]?\s*\.\s*(["`]?\w+["`]?)/g, '$1');
|
|
1084
|
+
}
|
|
1085
|
+
function isReusableSqlPredicate(predicate) {
|
|
1086
|
+
if (!predicate || predicate.length > 240)
|
|
1087
|
+
return false;
|
|
1088
|
+
if (/[;]/.test(predicate) || /--|\/\*/.test(predicate))
|
|
1089
|
+
return false;
|
|
1090
|
+
return !/\b(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|COPY|PRAGMA|SET)\b/i.test(predicate);
|
|
1091
|
+
}
|
|
1092
|
+
function predicateReferencesColumn(predicate, column) {
|
|
1093
|
+
return new RegExp(`(^|[^\\w])${sqlIdentifierPattern(column)}([^\\w]|$)`, 'i').test(predicate);
|
|
1094
|
+
}
|
|
1095
|
+
function sqlIdentifierPattern(identifier) {
|
|
1096
|
+
const escaped = escapeRegExp(identifier);
|
|
1097
|
+
return `(?:"${escaped}"|\`${escaped}\`|${escaped})`;
|
|
1098
|
+
}
|
|
1099
|
+
function sqlStringLiteral(value) {
|
|
1100
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
1101
|
+
}
|
|
1102
|
+
function defaultMetricAlias(fn, column) {
|
|
1103
|
+
if (fn === 'SUM' && /revenue|amount|sales|arr|mrr/i.test(column))
|
|
1104
|
+
return 'revenue_total';
|
|
1105
|
+
return `${column}_${fn.toLowerCase()}`;
|
|
1106
|
+
}
|
|
1107
|
+
function cleanSqlIdentifier(identifier) {
|
|
1108
|
+
return identifier.replace(/^["`]|["`]$/g, '').trim();
|
|
1109
|
+
}
|
|
1110
|
+
function namesEqualLoose(a, b) {
|
|
1111
|
+
return cleanSqlIdentifier(a).replace(/[_-]+/g, ' ').toLowerCase() === cleanSqlIdentifier(b).replace(/[_-]+/g, ' ').toLowerCase();
|
|
1112
|
+
}
|
|
1113
|
+
function normalizeRelationKey(relation) {
|
|
1114
|
+
return relation.replace(/["`]/g, '').replace(/\s*\.\s*/g, '.').toLowerCase().trim();
|
|
1115
|
+
}
|
|
1116
|
+
function normalizeForEntityMatch(value) {
|
|
1117
|
+
return value.toLowerCase().replace(/[^a-z0-9.%+-]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1118
|
+
}
|
|
1119
|
+
function uniqueMatchedEntityFilters(filters) {
|
|
1120
|
+
const seen = new Set();
|
|
1121
|
+
const unique = [];
|
|
1122
|
+
for (const filter of filters) {
|
|
1123
|
+
const key = `${filter.column.toLowerCase()}\0${filter.value.toLowerCase()}`;
|
|
1124
|
+
if (seen.has(key))
|
|
1125
|
+
continue;
|
|
1126
|
+
seen.add(key);
|
|
1127
|
+
unique.push(filter);
|
|
1128
|
+
}
|
|
1129
|
+
return unique;
|
|
1130
|
+
}
|
|
1131
|
+
function uniqueDrilldownStrings(values) {
|
|
1132
|
+
return Array.from(new Set(values));
|
|
1133
|
+
}
|
|
1134
|
+
function isTemporalDrilldownValue(value) {
|
|
1135
|
+
return mentionsRelativeTime(value, undefined) || /^\d{4}-\d{2}-\d{2}/.test(value);
|
|
1136
|
+
}
|
|
1137
|
+
function escapeRegExp(value) {
|
|
1138
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1139
|
+
}
|
|
1140
|
+
function buildContextPackAwareProposal(input) {
|
|
1141
|
+
if (!isGeneratedAgentIntent(input.intent))
|
|
1142
|
+
return undefined;
|
|
1143
|
+
if (!input.contextPack)
|
|
1144
|
+
return undefined;
|
|
1145
|
+
const lower = input.question.toLowerCase();
|
|
1146
|
+
if (!/\b(least|lowest|fewest|bottom|min(?:imum)?)\b/.test(lower))
|
|
1147
|
+
return undefined;
|
|
1148
|
+
for (const object of input.contextPack.objects) {
|
|
1149
|
+
if (object.objectType !== 'dql_block' || object.status !== 'certified')
|
|
1150
|
+
continue;
|
|
1151
|
+
const sql = typeof object.payload?.sql === 'string' ? object.payload.sql.trim() : '';
|
|
1152
|
+
if (!sql || !/\border\s+by\b/i.test(sql) || !/\bdesc\b/i.test(sql))
|
|
1153
|
+
continue;
|
|
1154
|
+
const inverted = invertRankingSql(sql);
|
|
1155
|
+
if (!inverted || inverted === sql)
|
|
1156
|
+
continue;
|
|
1157
|
+
return {
|
|
1158
|
+
text: `Generated a review-required least-ranking query by using certified block "${object.name}" as context and reversing its ranking direction. This result is uncertified until reviewed and promoted.`,
|
|
1159
|
+
sql: ensurePreviewLimit(inverted, 10),
|
|
1160
|
+
viz: 'table',
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
return undefined;
|
|
1164
|
+
}
|
|
1165
|
+
function invertRankingSql(sql) {
|
|
1166
|
+
const withoutTrailingSemicolon = sql.replace(/;\s*$/, '').trim();
|
|
1167
|
+
const inverted = withoutTrailingSemicolon.replace(/\border\s+by\s+([\s\S]*?)(\blimit\b|$)/i, (match, orderExpr, limitKeyword) => {
|
|
1168
|
+
if (!/\bdesc\b/i.test(orderExpr))
|
|
1169
|
+
return match;
|
|
1170
|
+
const nextExpr = orderExpr
|
|
1171
|
+
.replace(/\bDESC\b/gi, 'ASC')
|
|
1172
|
+
.replace(/\bNULLS\s+FIRST\b/gi, 'NULLS LAST');
|
|
1173
|
+
return `ORDER BY ${nextExpr}${limitKeyword}`;
|
|
1174
|
+
});
|
|
1175
|
+
return inverted !== withoutTrailingSemicolon ? inverted : undefined;
|
|
1176
|
+
}
|
|
1177
|
+
function ensurePreviewLimit(sql, limit) {
|
|
1178
|
+
if (/\blimit\s+\d+\b/i.test(sql))
|
|
1179
|
+
return sql;
|
|
1180
|
+
return `${sql.replace(/;\s*$/, '').trim()}\nLIMIT ${limit}`;
|
|
1181
|
+
}
|
|
551
1182
|
function findSchemaTable(schemaContext, names) {
|
|
552
1183
|
return schemaContext.find((table) => {
|
|
553
1184
|
const tableNames = new Set([table.name, table.relation.split('.').at(-1) ?? table.relation].map((name) => name.toLowerCase()));
|
|
@@ -588,7 +1219,7 @@ function pickCertifiedArtifact(input) {
|
|
|
588
1219
|
// user at a specific block. We still validate it's certified.
|
|
589
1220
|
for (const hint of input.blockHints) {
|
|
590
1221
|
const node = input.kg.getNode(`block:${hint}`);
|
|
591
|
-
if (node && node.status === 'certified') {
|
|
1222
|
+
if (node && node.status === 'certified' && hasCompatibleCertifiedBlockMatch(input.question, node)) {
|
|
592
1223
|
return { node, score: 1, snippet: undefined };
|
|
593
1224
|
}
|
|
594
1225
|
}
|
|
@@ -628,12 +1259,46 @@ function pickFirstCertifiedHit(hits, kg, excludedNodeIds, question) {
|
|
|
628
1259
|
continue;
|
|
629
1260
|
if (!isCertifiedHit(hit, kg))
|
|
630
1261
|
continue;
|
|
631
|
-
if (question && hit.node.kind === 'block' && !
|
|
1262
|
+
if (question && hit.node.kind === 'block' && !hasCompatibleCertifiedBlockMatch(question, hit.node))
|
|
632
1263
|
continue;
|
|
633
1264
|
return hit;
|
|
634
1265
|
}
|
|
635
1266
|
return null;
|
|
636
1267
|
}
|
|
1268
|
+
function pickCertifiedDrilldownArtifact(input) {
|
|
1269
|
+
const requestedTerms = meaningfulTokens([
|
|
1270
|
+
input.question,
|
|
1271
|
+
...(input.followUp.filters ?? []),
|
|
1272
|
+
...(input.followUp.dimensions ?? []),
|
|
1273
|
+
].join(' '));
|
|
1274
|
+
for (const hit of input.executableArtifactHits) {
|
|
1275
|
+
if (hit.score < CERTIFIED_HIT_THRESHOLD)
|
|
1276
|
+
break;
|
|
1277
|
+
if (input.excludedArtifactIds?.has(hit.node.nodeId))
|
|
1278
|
+
continue;
|
|
1279
|
+
if (hit.node.kind !== 'block')
|
|
1280
|
+
continue;
|
|
1281
|
+
if (!isCertifiedHit(hit, input.kg))
|
|
1282
|
+
continue;
|
|
1283
|
+
if (!hasCompatibleCertifiedBlockMatch(input.question, hit.node))
|
|
1284
|
+
continue;
|
|
1285
|
+
if (!hasRequestedDrilldownOverlap(hit.node, requestedTerms))
|
|
1286
|
+
continue;
|
|
1287
|
+
return hit;
|
|
1288
|
+
}
|
|
1289
|
+
return null;
|
|
1290
|
+
}
|
|
1291
|
+
function hasRequestedDrilldownOverlap(node, requestedTerms) {
|
|
1292
|
+
if (requestedTerms.size === 0)
|
|
1293
|
+
return false;
|
|
1294
|
+
const nodeTerms = meaningfulTokens(certifiedBlockSignalText(node));
|
|
1295
|
+
let overlaps = 0;
|
|
1296
|
+
for (const term of requestedTerms) {
|
|
1297
|
+
if (nodeTerms.has(term))
|
|
1298
|
+
overlaps += 1;
|
|
1299
|
+
}
|
|
1300
|
+
return overlaps >= 2 || (requestedTerms.size === 1 && overlaps === 1);
|
|
1301
|
+
}
|
|
637
1302
|
function shouldDeferCertifiedArtifactForReviewPath(input) {
|
|
638
1303
|
if (!isBreakdownOrDrilldownQuestion(input.question))
|
|
639
1304
|
return false;
|
|
@@ -711,6 +1376,44 @@ function hasMeaningfulCertifiedBlockSignal(question, node) {
|
|
|
711
1376
|
}
|
|
712
1377
|
return false;
|
|
713
1378
|
}
|
|
1379
|
+
function hasCompatibleCertifiedBlockMatch(question, node) {
|
|
1380
|
+
return hasMeaningfulCertifiedBlockSignal(question, node)
|
|
1381
|
+
&& hasCompatibleRankingDirection(question, node);
|
|
1382
|
+
}
|
|
1383
|
+
function hasCompatibleRankingDirection(question, node) {
|
|
1384
|
+
const questionDirection = rankingDirectionFromText(question);
|
|
1385
|
+
if (!questionDirection)
|
|
1386
|
+
return true;
|
|
1387
|
+
const blockDirection = rankingDirectionFromText(certifiedBlockSignalText(node));
|
|
1388
|
+
if (!blockDirection)
|
|
1389
|
+
return true;
|
|
1390
|
+
return questionDirection === blockDirection;
|
|
1391
|
+
}
|
|
1392
|
+
function rankingDirectionFromText(text) {
|
|
1393
|
+
const lower = text.toLowerCase();
|
|
1394
|
+
const hasBottomSignal = /\b(bottom|least|fewest|lowest|minimum|min|smallest|worst|underperform(?:ing|ed|er|ers)?)\b/.test(lower);
|
|
1395
|
+
const hasTopSignal = /\b(top|most|highest|maximum|max|greatest|best|leader|leaders|leading)\b/.test(lower);
|
|
1396
|
+
if (hasBottomSignal && !hasTopSignal)
|
|
1397
|
+
return 'bottom';
|
|
1398
|
+
if (hasTopSignal && !hasBottomSignal)
|
|
1399
|
+
return 'top';
|
|
1400
|
+
return undefined;
|
|
1401
|
+
}
|
|
1402
|
+
function certifiedBlockSignalText(node) {
|
|
1403
|
+
const examples = (node.examples ?? [])
|
|
1404
|
+
.flatMap((example) => [example.question, example.sql ?? '']);
|
|
1405
|
+
return [
|
|
1406
|
+
node.name,
|
|
1407
|
+
node.domain ?? '',
|
|
1408
|
+
node.description ?? '',
|
|
1409
|
+
node.llmContext ?? '',
|
|
1410
|
+
node.provenance ?? '',
|
|
1411
|
+
...(node.tags ?? []),
|
|
1412
|
+
...(node.businessRules ?? []),
|
|
1413
|
+
...(node.caveats ?? []),
|
|
1414
|
+
...examples,
|
|
1415
|
+
].join(' ');
|
|
1416
|
+
}
|
|
714
1417
|
function hasExactExecutableArtifactSignal(question, node) {
|
|
715
1418
|
if (!EXECUTABLE_ARTIFACT_KINDS.includes(node.kind))
|
|
716
1419
|
return false;
|
|
@@ -876,7 +1579,7 @@ function isAdHocAnalysisQuestion(question) {
|
|
|
876
1579
|
const lower = question.toLowerCase();
|
|
877
1580
|
if (isBusinessDefinitionQuestion(question))
|
|
878
1581
|
return false;
|
|
879
|
-
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|why|what drove|driver|drivers|top movers?|changed?|change|dropped?|drop|decreased?|decrease|declined?|decline|increased?|increase|anomal(?:y|ies)|exceptions?|root cause|contribut(?:e|ed|ion)|variance|delta|by\s+[a-z][\w\s-]{1,40})\b/i.test(lower)
|
|
1582
|
+
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|least|fewest|minimum|min|smallest|rank|ranking|performed better|better performing|why|what drove|driver|drivers|top movers?|changed?|change|dropped?|drop|decreased?|decrease|declined?|decline|increased?|increase|anomal(?:y|ies)|exceptions?|root cause|contribut(?:e|ed|ion)|variance|delta|by\s+[a-z][\w\s-]{1,40})\b/i.test(lower)
|
|
880
1583
|
|| /\b(show|list|find|give)\b.+\b(account|accounts|customer|customers|product|products|order|orders|region|location|month|week|day|user|users)\b/i.test(lower);
|
|
881
1584
|
}
|
|
882
1585
|
function isFilteredEntityQuestion(question) {
|