@duckcodeailabs/dql-agent 1.6.6 → 1.6.8

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.
@@ -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'];
@@ -52,7 +53,16 @@ export async function answer(input) {
52
53
  const intent = catalogRoute ? agentIntentFromCatalogRoute(catalogRoute) : fallbackIntent;
53
54
  // Stage 1: certified artifact match. Blocks can be executed; dashboards,
54
55
  // Apps, and notebooks are returned as governed citations/navigation targets.
55
- const artifactHit = shouldUseCertifiedRoute(catalogRoute, intent)
56
+ const drilldownCertifiedHit = input.followUp?.kind === 'drilldown'
57
+ ? pickCertifiedDrilldownArtifact({
58
+ executableArtifactHits,
59
+ question,
60
+ followUp: input.followUp,
61
+ excludedArtifactIds,
62
+ kg,
63
+ })
64
+ : null;
65
+ const artifactHit = drilldownCertifiedHit ?? (shouldUseCertifiedRoute(catalogRoute, intent)
56
66
  ? certifiedHitFromContextPack(input.contextPack, kg)
57
67
  ?? pickCertifiedArtifact({
58
68
  artifactHits,
@@ -63,7 +73,7 @@ export async function answer(input) {
63
73
  excludedArtifactIds,
64
74
  kg,
65
75
  })
66
- : null;
76
+ : null);
67
77
  if (artifactHit) {
68
78
  let result;
69
79
  let executionError;
@@ -110,6 +120,10 @@ export async function answer(input) {
110
120
  result,
111
121
  executionError,
112
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),
113
127
  citations,
114
128
  memoryContext: input.memoryContext,
115
129
  analysisPlan,
@@ -206,11 +220,12 @@ export async function answer(input) {
206
220
  question,
207
221
  intent,
208
222
  schemaContext: input.schemaContext ?? [],
223
+ followUp: input.followUp,
224
+ contextPack: input.contextPack,
209
225
  }) ?? buildContextPackAwareProposal({
210
226
  question,
211
227
  intent,
212
228
  contextPack: input.contextPack,
213
- followUp: input.followUp,
214
229
  });
215
230
  let proposed = '';
216
231
  let parsed;
@@ -277,6 +292,60 @@ export async function answer(input) {
277
292
  providerUsed: provider.name,
278
293
  };
279
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
+ }
280
349
  const generatedCitations = [
281
350
  ...contextPackCitations(input.contextPack, 4),
282
351
  ...contextNodes.slice(0, 4).map((n) => ({
@@ -318,7 +387,7 @@ export async function answer(input) {
318
387
  executionError = retryErr instanceof Error ? retryErr.message : String(retryErr);
319
388
  }
320
389
  }
321
- if (executionError && hasUsableRepairSchema(input.schemaContext ?? [])) {
390
+ if (executionError) {
322
391
  const repairedRaw = await requestSqlRepair({
323
392
  provider,
324
393
  baseMessages: messages,
@@ -331,6 +400,7 @@ export async function answer(input) {
331
400
  const repaired = parseProposal(repairedRaw);
332
401
  if (repaired.sql) {
333
402
  repairAttempts += 1;
403
+ parsed.text = repaired.text || parsed.text;
334
404
  parsed.sql = repaired.sql;
335
405
  parsed.viz = repaired.viz ?? parsed.viz;
336
406
  try {
@@ -345,9 +415,6 @@ export async function answer(input) {
345
415
  }
346
416
  }
347
417
  }
348
- if (executionError) {
349
- parsed.text = composeGeneratedExecutionFailureText(question, executionError);
350
- }
351
418
  const analysisPlan = buildAnalysisPlan({
352
419
  question,
353
420
  intent,
@@ -365,19 +432,60 @@ export async function answer(input) {
365
432
  ],
366
433
  repairAttempts,
367
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;
368
468
  return {
369
469
  kind: 'uncertified',
370
470
  sourceTier: activeTier,
371
471
  certification: 'ai_generated',
372
472
  reviewStatus: 'draft_ready',
373
473
  confidence: activeTier === 'semantic_layer' ? 0.72 : 0.55,
374
- text: parsed.text,
375
- answer: parsed.text,
474
+ text: generatedText,
475
+ answer: generatedText,
376
476
  proposedSql: parsed.sql,
377
477
  sql: parsed.sql,
378
478
  result,
379
479
  executionError,
380
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),
381
489
  citations: generatedCitations,
382
490
  memoryContext: input.memoryContext,
383
491
  analysisPlan,
@@ -427,7 +535,10 @@ Rules:
427
535
  proposed SQL. Do not emit INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, COPY,
428
536
  PRAGMA, SET, or multiple statements.
429
537
  8. If the schema is insufficient to answer, say so explicitly and ask a
430
- clarifying question instead of guessing.`;
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.`;
431
542
  function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis', contextPack) {
432
543
  const intentSection = `## Routing intent\n\nintent: ${intent}\n${intent === 'exact_certified_lookup'
433
544
  ? 'Use a certified artifact only if it exactly answers the question.'
@@ -503,9 +614,6 @@ function renderContextPackForPrompt(contextPack) {
503
614
  const allowed = contextPack.allowedSqlContext?.relations.length
504
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')}`
505
616
  : '';
506
- const sourceSql = contextPack.allowedSqlContext?.sourceBlockSql.length
507
- ? `\nSource block SQL for review-required drafts:\n${contextPack.allowedSqlContext.sourceBlockSql.slice(0, 4).map((source) => `- ${source.name}${source.status ? ` (${source.status})` : ''}:\n${indentSqlForPrompt(source.sql)}`).join('\n')}`
508
- : '';
509
617
  return [
510
618
  `context_pack_id: ${contextPack.id}`,
511
619
  `trust_label: ${contextPack.trustLabel}`,
@@ -513,14 +621,9 @@ function renderContextPackForPrompt(contextPack) {
513
621
  warnings.trim(),
514
622
  `Selected evidence:\n${objects || '- none'}`,
515
623
  allowed.trim(),
516
- sourceSql.trim(),
517
624
  conflicts.trim(),
518
625
  ].filter(Boolean).join('\n');
519
626
  }
520
- function indentSqlForPrompt(sql) {
521
- const normalized = sql.trim().slice(0, 1600);
522
- return normalized.split(/\r?\n/).map((line) => ` ${line}`).join('\n');
523
- }
524
627
  function contextPackCitations(contextPack, limit) {
525
628
  if (!contextPack)
526
629
  return [];
@@ -639,6 +742,30 @@ function renderFollowUpContext(followUp) {
639
742
  : 'routing rule: reuse the prior certified block when the user asks a generic follow-up.';
640
743
  return [...parts, rule].join('\n');
641
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
+ }
642
769
  /**
643
770
  * Public for tests. Pulls the first ```sql block and an optional Viz: line
644
771
  * out of an LLM response.
@@ -658,6 +785,10 @@ export function parseProposal(raw) {
658
785
  function buildSchemaAwareProposal(input) {
659
786
  if (!isGeneratedAgentIntent(input.intent))
660
787
  return undefined;
788
+ const schemaContext = schemaContextWithAllowedSqlContext(input.schemaContext, input.contextPack);
789
+ const drilldownProposal = buildMatchedEntityDrilldownProposal({ ...input, schemaContext });
790
+ if (drilldownProposal)
791
+ return drilldownProposal;
661
792
  if (isFilteredEntityQuestion(input.question))
662
793
  return undefined;
663
794
  const lower = input.question.toLowerCase();
@@ -666,7 +797,7 @@ function buildSchemaAwareProposal(input) {
666
797
  && !/\b(order details|specific orders|each order|all orders|order line|line item)\b/.test(lower);
667
798
  if (!asksForCustomerPerformance)
668
799
  return undefined;
669
- const customers = findSchemaTable(input.schemaContext, ['customers', 'customer']);
800
+ const customers = findSchemaTable(schemaContext, ['customers', 'customer']);
670
801
  if (!customers)
671
802
  return undefined;
672
803
  const customerName = findSchemaColumn(customers, ['customer_name', 'name', 'full_name']);
@@ -688,7 +819,7 @@ function buildSchemaAwareProposal(input) {
688
819
  };
689
820
  }
690
821
  const customerId = findSchemaColumn(customers, ['customer_id', 'id']);
691
- const orders = findSchemaTable(input.schemaContext, ['orders', 'order']);
822
+ const orders = findSchemaTable(schemaContext, ['orders', 'order']);
692
823
  if (!orders || !customerName || !customerId)
693
824
  return undefined;
694
825
  const orderCustomerId = findSchemaColumn(orders, ['customer_id', 'customer']);
@@ -713,20 +844,305 @@ function buildSchemaAwareProposal(input) {
713
844
  viz: 'table',
714
845
  };
715
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
+ }
716
1140
  function buildContextPackAwareProposal(input) {
717
1141
  if (!isGeneratedAgentIntent(input.intent))
718
1142
  return undefined;
719
1143
  if (!input.contextPack)
720
1144
  return undefined;
721
- const contextPack = input.contextPack;
722
1145
  const lower = input.question.toLowerCase();
723
- const filteredSourceProposal = buildSourceBlockFilterProposal({
724
- question: input.question,
725
- contextPack,
726
- followUp: input.followUp,
727
- });
728
- if (filteredSourceProposal)
729
- return filteredSourceProposal;
730
1146
  if (!/\b(least|lowest|fewest|bottom|min(?:imum)?)\b/.test(lower))
731
1147
  return undefined;
732
1148
  for (const object of input.contextPack.objects) {
@@ -746,65 +1162,6 @@ function buildContextPackAwareProposal(input) {
746
1162
  }
747
1163
  return undefined;
748
1164
  }
749
- function buildSourceBlockFilterProposal(input) {
750
- const year = extractYearFilterValue(input.question, input.followUp);
751
- if (!year)
752
- return undefined;
753
- const source = preferredSourceBlockSql(input.contextPack, input.followUp?.sourceBlockName);
754
- if (!source?.sql.trim())
755
- return undefined;
756
- const sql = source.sql.trim();
757
- const filterColumn = pickTimeFilterColumn(sql);
758
- if (!filterColumn)
759
- return undefined;
760
- return {
761
- text: `Generated a review-required ${year} view from certified block "${source.name}" as context. This filters the selected block grain instead of reusing the certified answer directly, so it remains uncertified until reviewed.`,
762
- sql: [
763
- 'SELECT *',
764
- 'FROM (',
765
- indentSubquerySql(stripFinalLimit(sql)),
766
- ') AS dql_source',
767
- `WHERE ${sqlIdentifier(filterColumn)} = ${year}`,
768
- 'LIMIT 200',
769
- ].join('\n'),
770
- viz: 'table',
771
- };
772
- }
773
- function preferredSourceBlockSql(contextPack, sourceBlockName) {
774
- const sources = contextPack.allowedSqlContext?.sourceBlockSql ?? [];
775
- const normalizedSource = sourceBlockName ? normalizeSourceName(sourceBlockName) : undefined;
776
- if (normalizedSource) {
777
- const exact = sources.find((source) => normalizeSourceName(source.name) === normalizedSource);
778
- if (exact)
779
- return exact;
780
- }
781
- return sources.find((source) => source.status === 'certified') ?? sources[0];
782
- }
783
- function extractYearFilterValue(question, followUp) {
784
- const direct = question.match(/\b(19|20)\d{2}\b/)?.[0];
785
- if (direct)
786
- return direct;
787
- const fromFollowUp = followUp?.filters
788
- ?.map((filter) => filter.match(/\b(19|20)\d{2}\b/)?.[0])
789
- .find(Boolean);
790
- return fromFollowUp;
791
- }
792
- function pickTimeFilterColumn(sql) {
793
- if (/\bseason\b/i.test(sql))
794
- return 'season';
795
- if (/\byear\b/i.test(sql))
796
- return 'year';
797
- return undefined;
798
- }
799
- function stripFinalLimit(sql) {
800
- return sql.replace(/;\s*$/, '').replace(/\s+limit\s+\d+\s*$/i, '').trim();
801
- }
802
- function indentSubquerySql(sql) {
803
- return sql.split(/\r?\n/).map((line) => ` ${line}`).join('\n');
804
- }
805
- function normalizeSourceName(value) {
806
- return value.replace(/^block:/i, '').trim().toLowerCase();
807
- }
808
1165
  function invertRankingSql(sql) {
809
1166
  const withoutTrailingSemicolon = sql.replace(/;\s*$/, '').trim();
810
1167
  const inverted = withoutTrailingSemicolon.replace(/\border\s+by\s+([\s\S]*?)(\blimit\b|$)/i, (match, orderExpr, limitKeyword) => {
@@ -908,6 +1265,40 @@ function pickFirstCertifiedHit(hits, kg, excludedNodeIds, question) {
908
1265
  }
909
1266
  return null;
910
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
+ }
911
1302
  function shouldDeferCertifiedArtifactForReviewPath(input) {
912
1303
  if (!isBreakdownOrDrilldownQuestion(input.question))
913
1304
  return false;
@@ -1359,26 +1750,12 @@ async function requestSqlRepair(input) {
1359
1750
  'The generated SQL failed during bounded preview execution.',
1360
1751
  `Question: ${input.question}`,
1361
1752
  `Execution error: ${input.executionError}`,
1362
- 'Return only one corrected read-only SQL query in a fenced sql block using only the runtime schema below.',
1363
- 'If the runtime schema is not enough to repair the SQL, return "NEEDS_CONTEXT:" followed by one short missing-context question. Do not propose proxy tables.',
1753
+ 'Return one corrected read-only SQL query using only the runtime schema below.',
1364
1754
  schema,
1365
1755
  ].join('\n\n'),
1366
1756
  },
1367
1757
  ], { signal: input.signal });
1368
1758
  }
1369
- function hasUsableRepairSchema(schemaContext) {
1370
- return schemaContext.some((table) => table.columns.length > 0);
1371
- }
1372
- function composeGeneratedExecutionFailureText(question, executionError) {
1373
- const compactError = executionError.replace(/\s+/g, ' ').trim();
1374
- const shownError = compactError.length > 220 ? `${compactError.slice(0, 217)}...` : compactError;
1375
- return [
1376
- `I generated a review-required SQL draft for "${question}", but the bounded preview did not run successfully.`,
1377
- 'I did not switch to a proxy table because that could answer a different question.',
1378
- `Execution issue: ${shownError}`,
1379
- 'Refresh the runtime schema or fix the source relation/columns, then rerun the draft.',
1380
- ].join(' ');
1381
- }
1382
1759
  function isRetryableGeneratedSqlError(error) {
1383
1760
  return !/\b(read-only|readonly|select or with|unsafe|delete|insert|update|drop|alter|create|attach|copy|pragma)\b/i.test(error);
1384
1761
  }