@duckcodeailabs/dql-cli 1.11.0 → 1.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@ import { Readable } from "node:stream";
10
10
  import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, planMixedSourceNotebookSql, planMixedSourceSql, } from "./mixed-source-sql.js";
11
11
  import { resolveNpmInvocation } from './npm-runtime.js';
12
12
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
13
- import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, relationshipValidationProofFingerprint, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, } from '@duckcodeailabs/dql-core';
13
+ import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, } from '@duckcodeailabs/dql-core';
14
14
  import { load as loadYaml } from 'js-yaml';
15
15
  import { listBlockTemplates } from './block-templates.js';
16
16
  import { getRunner as getLLMRunner } from './llm/index.js';
@@ -96,6 +96,16 @@ function agentRunRecord(value) {
96
96
  function agentRunString(value) {
97
97
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
98
98
  }
99
+ /** UI catalog fallback labels are not declared project domains. */
100
+ export function normalizeAgentRunDomain(value) {
101
+ const domain = agentRunString(value);
102
+ if (!domain)
103
+ return undefined;
104
+ const normalized = domain.toLowerCase();
105
+ return normalized === 'uncategorized' || normalized === '_uncategorized'
106
+ ? undefined
107
+ : domain;
108
+ }
99
109
  function parseAgentRunRequestedMode(value) {
100
110
  return typeof value === 'string' && AGENT_RUN_REQUESTED_MODES.has(value)
101
111
  ? value
@@ -1081,12 +1091,13 @@ export async function startLocalServer(opts) {
1081
1091
  */
1082
1092
  const buildDirectBuildContext = async (input) => {
1083
1093
  const snapshot = projectSnapshot();
1094
+ const activeDomain = normalizeAgentRunDomain(input.domain);
1084
1095
  const domainContext = resolveDomainContextEnvelope({
1085
1096
  manifest: snapshot.manifest,
1086
- activeDomain: input.domain,
1097
+ activeDomain,
1087
1098
  purpose: input.purpose,
1088
1099
  modelAreaId: input.modelAreaId,
1089
- source: input.domain ? 'explicit_ui' : 'inferred',
1100
+ source: activeDomain ? 'explicit_ui' : 'inferred',
1090
1101
  snapshotId: snapshot.snapshotId,
1091
1102
  });
1092
1103
  const contextPack = await buildLocalContextPack(projectRoot, {
@@ -1095,7 +1106,7 @@ export async function startLocalServer(opts) {
1095
1106
  surface: input.surface,
1096
1107
  selectedContext: {
1097
1108
  activeSurface: input.surface,
1098
- domain: input.domain,
1109
+ domain: activeDomain,
1099
1110
  purpose: input.purpose,
1100
1111
  modelAreaId: input.modelAreaId,
1101
1112
  ...input.selectedContext,
@@ -1249,7 +1260,7 @@ export async function startLocalServer(opts) {
1249
1260
  const semanticTableMapping = semanticLayer && semanticConnection
1250
1261
  ? await resolveSemanticTableMapping(executor, semanticConnection, semanticLayer)
1251
1262
  : undefined;
1252
- const requestedDomain = agentRunWorkspaceValue(request, 'domain');
1263
+ const requestedDomain = normalizeAgentRunDomain(agentRunWorkspaceValue(request, 'domain'));
1253
1264
  const requestedPurpose = agentRunWorkspaceValue(request, 'purpose');
1254
1265
  const requestedModelAreaId = agentRunWorkspaceValue(request, 'modelAreaId');
1255
1266
  const runProjectSnapshot = projectSnapshot();
@@ -1800,6 +1811,20 @@ export async function startLocalServer(opts) {
1800
1811
  status: 'blocked',
1801
1812
  trustState: 'blocked',
1802
1813
  stopReason: 'blocked',
1814
+ artifacts: [agentRunArtifact('answer', 'AI answer provider failed', {
1815
+ kind: 'no_answer',
1816
+ refusalCode: 'provider_error',
1817
+ text: message,
1818
+ answer: message,
1819
+ ...(routeDecision?.resolvedAnalyticalPlan
1820
+ ? { resolvedAnalyticalPlan: routeDecision.resolvedAnalyticalPlan }
1821
+ : {}),
1822
+ providerFailure: {
1823
+ code: 'AI_PROVIDER_FAILURE',
1824
+ message,
1825
+ recoverable: true,
1826
+ },
1827
+ }, undefined, 'blocked')],
1803
1828
  evaluations: [
1804
1829
  agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to governed answer.'),
1805
1830
  agentRunEvaluation('ai-provider', 'AI provider', false, 'blocking', message, { originalErrorType: error instanceof Error ? error.name : typeof error }),
@@ -1825,12 +1850,12 @@ export async function startLocalServer(opts) {
1825
1850
  && !isExploratory;
1826
1851
  const isProviderError = governedAnswer.kind === 'no_answer' && governedAnswer.refusalCode === 'provider_error';
1827
1852
  const isAnalyticalFailure = Boolean(governedAnswer.analyticalFailure);
1828
- const isTerminalFailure = isProviderError || isExecutionFailure || isAnalyticalFailure;
1829
1853
  // AGT-004: a rejected attribution/export/proof policy is a deliberate
1830
1854
  // governance boundary, not an ambiguous user question and not a repairable
1831
1855
  // retrieval miss. Keep the precise policy detail visible, but never burn two
1832
1856
  // more provider calls retrying the same incompatible candidate.
1833
1857
  const isPolicyBlocked = governedAnswer.kind === 'no_answer' && governedAnswer.refusalCode === 'policy_blocked';
1858
+ const isTerminalFailure = isProviderError || isExecutionFailure || isAnalyticalFailure || isPolicyBlocked;
1834
1859
  // The model tried to compose a governed query and declined despite having usable
1835
1860
  // context (e.g. it wasn't confident about a multi-table join). The answer loop
1836
1861
  // has already spent its one evidence-aware repair. Keep this terminal and
@@ -1922,7 +1947,16 @@ export async function startLocalServer(opts) {
1922
1947
  : [{ id: 'review-analytical-failure', label: 'Review the plan, DQL, SQL, and safe repair actions', route: 'blocked' }]
1923
1948
  : isProviderError
1924
1949
  ? [{ id: 'retry-after-provider', label: 'Retry after fixing the AI provider', route: 'generated_answer' }]
1925
- : [{ id: 'retry-after-connection', label: 'Retry after fixing the database connection', route: 'generated_answer' }];
1950
+ : isPolicyBlocked
1951
+ ? [
1952
+ ...(governedAnswer.dqlArtifact
1953
+ ? [{ id: 'edit_dql', label: 'Open DQL to repair', route: 'dql_block_draft', artifactKind: 'dql_block_draft' }]
1954
+ : []),
1955
+ ...(sql
1956
+ ? [{ id: 'open_sql_notebook', label: 'Open SQL in Notebook', route: 'sql_cell', artifactKind: 'sql_cell' }]
1957
+ : []),
1958
+ ]
1959
+ : [{ id: 'retry-after-connection', label: 'Retry after fixing the database connection', route: 'generated_answer' }];
1926
1960
  const nextActions = needsClarification
1927
1961
  ? [{ id: 'clarify', label: 'Clarify question', route: 'generated_answer' }]
1928
1962
  : isTerminalFailure
@@ -1944,7 +1978,11 @@ export async function startLocalServer(opts) {
1944
1978
  answerRefusalCode: governedAnswer.kind === 'no_answer' ? governedAnswer.refusalCode : undefined,
1945
1979
  answerTier: governedAnswer.route?.tier,
1946
1980
  summary: isTerminalFailure
1947
- ? 'The governed query could not be executed.'
1981
+ ? governedAnswer.analyticalFailure?.message
1982
+ ?? governedAnswer.executionError
1983
+ ?? governedAnswer.answer
1984
+ ?? governedAnswer.text
1985
+ ?? 'The governed query could not be executed.'
1948
1986
  : governedAnswer.route?.label ?? (isCertified ? 'Answered from certified DQL context.' : isExploratory ? 'Exploratory DBT-grounded analysis requires review.' : 'Answered with review-required generated analysis.'),
1949
1987
  answer: synthesizedAnswer ?? governedAnswer.answer ?? governedAnswer.text,
1950
1988
  ...(governedAnswer.clarificationOptions?.length
@@ -2373,87 +2411,88 @@ export async function startLocalServer(opts) {
2373
2411
  ],
2374
2412
  };
2375
2413
  },
2376
- dql_block_draft: async ({ request, routeDecision, attempt, repairHint }) => {
2414
+ dql_block_draft: async ({ request, routeDecision, attempt, repairHint, emit, runId, maxRepairAttempts, }) => {
2377
2415
  const contextRecord = agentRunRecord(request.conversationContext);
2378
2416
  const carriedDqlArtifact = normalizeDqlArtifactReference(contextRecord?.dqlArtifact)
2379
2417
  ?? normalizeDqlArtifactReference(request.workspaceContext?.dqlArtifact);
2380
- if (carriedDqlArtifact && (attempt ?? 0) === 0) {
2381
- const sourceQuestion = agentRunString(contextRecord?.sourceQuestion) ?? request.question;
2382
- const session = await createDqlArtifactGenerationSessionForProject(projectRoot, {
2383
- question: sourceQuestion,
2384
- dqlArtifact: carriedDqlArtifact,
2385
- inputMode: 'upload',
2386
- inputPath: carriedDqlArtifact.sourcePath,
2387
- domain: agentRunWorkspaceValue(request, 'domain'),
2388
- owner: agentRunWorkspaceValue(request, 'owner'),
2389
- tags: ['agent-run', 'review-required'],
2390
- contextPackId: agentRunString(contextRecord?.contextPackId),
2391
- routeIntent: agentRunString(contextRecord?.route),
2392
- sourceBlock: carriedDqlArtifact.sourcePath,
2393
- }, semanticLayer);
2394
- const candidate = session.candidates[0];
2395
- const validation = candidate?.validation && typeof candidate.validation === 'object'
2396
- ? candidate.validation
2397
- : undefined;
2398
- const ready = validation?.valid !== false;
2399
- const payload = {
2400
- target: 'block',
2401
- name: candidate?.name ?? carriedDqlArtifact.name ?? 'DQL block draft',
2402
- path: candidate?.draftSave?.path ?? candidate?.savedPath,
2403
- importId: session.id,
2404
- candidateId: candidate?.id,
2405
- source: candidate?.dqlSource,
2406
- dqlSource: candidate?.dqlSource,
2407
- dqlArtifact: carriedDqlArtifact,
2408
- certifierVerdict: {
2409
- ready,
2410
- diagnostics: validation?.diagnostics ?? [],
2411
- },
2412
- generation: session.generation,
2413
- };
2414
- return {
2415
- summary: ready
2416
- ? 'Saved the generated DQL artifact as a review-required block draft.'
2417
- : 'Saved the generated DQL artifact as a block draft with review blockers or warnings.',
2418
- artifacts: [agentRunArtifact('dql_block_draft', payload.name, payload, payload.path)],
2418
+ // UI-016 / E2E-015: a cold Block AI request uses the same governed answer
2419
+ // pipeline as Ask and Notebook. Generation is write-free. The returned
2420
+ // ownerless artifact is persisted only when the user explicitly chooses
2421
+ // Add to Block Studio.
2422
+ const governedResult = carriedDqlArtifact && (attempt ?? 0) === 0
2423
+ ? {
2424
+ summary: 'Reused the governed answer artifact as a transient Block AI draft.',
2425
+ answer: 'Prepared the existing governed DQL as an ownerless review draft. No file was changed.',
2426
+ artifacts: [agentRunArtifact('answer', carriedDqlArtifact.name ?? 'Governed DQL answer', { dqlArtifact: carriedDqlArtifact }, undefined, carriedDqlArtifact.trustState)],
2419
2427
  evaluations: [
2420
- agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to DQL block draft generation.'),
2421
- agentRunEvaluation('dql-artifact-reuse', 'Used generated DQL artifact', true, 'info', 'Persisted the existing DQL artifact without regenerating it from SQL.', { importId: session.id, path: payload.path }),
2422
- agentRunEvaluation('certification-boundary', 'Certification boundary', ready, 'warning', ready
2423
- ? 'The draft has no automatic validation blockers, but certification still requires human review.'
2424
- : 'The draft has validation blockers that must be resolved before certification review.', payload),
2425
- ],
2426
- nextActions: [
2427
- { id: 'open-review', label: 'Open review checklist', artifactKind: 'dql_block_draft' },
2428
- { id: 'build-app', label: 'Build app from block', route: 'app_build', artifactKind: 'app_draft' },
2428
+ agentRunEvaluation('dql-artifact-reuse', 'Reused governed DQL artifact', true, 'info', 'Reused the exact answer artifact without regenerating SQL or writing a block file.'),
2429
2429
  ],
2430
- };
2430
+ }
2431
+ : await answerRunExecutor({
2432
+ request,
2433
+ route: 'generated_answer',
2434
+ routeDecision,
2435
+ attempt,
2436
+ repairHint,
2437
+ emit,
2438
+ runId,
2439
+ maxRepairAttempts,
2440
+ });
2441
+ if (governedResult.status === 'blocked' || governedResult.status === 'needs_clarification') {
2442
+ return governedResult;
2443
+ }
2444
+ const governedPayload = (governedResult.artifacts ?? [])
2445
+ .map((artifact) => agentRunRecord(artifact.payload))
2446
+ .find((payload) => Boolean(normalizeDqlArtifactReference(payload?.dqlArtifact)
2447
+ ?? normalizeDqlArtifactReference(payload)));
2448
+ const directDqlArtifact = normalizeDqlArtifactReference(governedPayload);
2449
+ const governedAnswer = governedPayload
2450
+ ? directDqlArtifact
2451
+ ? { dqlArtifact: directDqlArtifact }
2452
+ : governedPayload
2453
+ : undefined;
2454
+ const dqlArtifact = governedAnswer
2455
+ ? ownerlessReviewDqlArtifactFromAnswer(governedAnswer, request.question)
2456
+ : undefined;
2457
+ if (!dqlArtifact) {
2458
+ return governedResult;
2431
2459
  }
2432
- // P4 (partial): a direct "build a block from a cold prompt" still uses the
2433
- // tool-less buildFromPrompt path. Unlike a SQL cell, a block needs SQL→DQL-block
2434
- // CONSTRUCTION (name, outputs, grain, certifier verdict, reflection), which
2435
- // buildFromPrompt owns — routing this through answer() cleanly requires driving
2436
- // that construction from the tool-rich pipeline (thread captureGeneratedDraft
2437
- // through the governed answer), a larger change deferred to its own session.
2438
- // NOTE: the two-step "ask a question → Create DQL draft" flow ALREADY routes
2439
- // block creation through the tool-rich governed pipeline via the carried-artifact
2440
- // branch above, so only this cold-start direct-build case remains tool-less.
2441
- const result = await buildAgentPromptArtifact(request, 'block', { attempt, repairHint });
2442
- const ready = result.target === 'block' ? result.certifierVerdict.ready : false;
2460
+ const sql = dqlArtifact.compiledSql
2461
+ ?? agentRunString(governedPayload?.proposedSql)
2462
+ ?? agentRunString(governedPayload?.sql);
2463
+ const result = {
2464
+ target: 'block',
2465
+ name: dqlArtifact.name ?? deriveGeneratedDraftSlug(request.question),
2466
+ source: dqlArtifact.source,
2467
+ dqlSource: dqlArtifact.source,
2468
+ dqlArtifact,
2469
+ ...(sql ? { sql, sqlPreview: sql } : {}),
2470
+ ...(governedPayload?.result ? { result: governedPayload.result } : {}),
2471
+ description: request.question,
2472
+ dimensions: dqlArtifact.dimensions ?? [],
2473
+ outputs: [...(dqlArtifact.dimensions ?? []), ...(dqlArtifact.metrics ?? [])],
2474
+ certifierVerdict: {
2475
+ ready: true,
2476
+ blocking: [],
2477
+ warnings: [
2478
+ 'Ownership is intentionally absent from the AI draft and is assigned by a human during promotion.',
2479
+ 'Review the generated DQL, compiled SQL, grain, and result before promotion.',
2480
+ ],
2481
+ },
2482
+ };
2443
2483
  return {
2444
- summary: ready
2445
- ? 'Created a DQL block draft that is ready for human certification review.'
2446
- : 'Created a DQL block draft with review blockers or warnings.',
2447
- artifacts: [agentRunArtifact('dql_block_draft', result.target === 'block' ? result.name : 'DQL block draft', result, result.target === 'block' ? result.path : undefined)],
2484
+ summary: 'Prepared an ownerless, review-required DQL block draft. No file was changed.',
2485
+ answer: governedResult.answer,
2486
+ status: 'needs_review',
2487
+ trustState: 'review_required',
2488
+ stopReason: 'human_review_required',
2489
+ artifacts: [agentRunArtifact('dql_block_draft', result.name, result, undefined, 'review_required')],
2448
2490
  evaluations: [
2449
- agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to DQL block draft generation.'),
2450
- agentRunEvaluation('certification-boundary', 'Certification boundary', ready, 'warning', ready
2451
- ? 'The draft has no automatic certifier blockers, but certification still requires human review.'
2452
- : 'The draft has certifier blockers that must be resolved before certification review.', result),
2491
+ ...(governedResult.evaluations ?? []),
2492
+ agentRunEvaluation('block-commit-boundary', 'Explicit Block Studio commit', true, 'warning', 'Generation produced an ownerless transient DQL artifact. Add to Block Studio is the only action that writes the review draft.', result),
2453
2493
  ],
2454
2494
  nextActions: [
2455
- { id: 'open-review', label: 'Open review checklist', artifactKind: 'dql_block_draft' },
2456
- { id: 'build-app', label: 'Build app from block', route: 'app_build', artifactKind: 'app_draft' },
2495
+ { id: 'add-to-block-studio', label: 'Add to Block Studio', artifactKind: 'dql_block_draft' },
2457
2496
  ],
2458
2497
  };
2459
2498
  },
@@ -2566,7 +2605,7 @@ export async function startLocalServer(opts) {
2566
2605
  if (pending)
2567
2606
  return pending;
2568
2607
  const snapshot = projectSnapshot();
2569
- const requestedDomain = agentRunWorkspaceValue(request, 'domain');
2608
+ const requestedDomain = normalizeAgentRunDomain(agentRunWorkspaceValue(request, 'domain'));
2570
2609
  // CTX-003: resolve result entities/values before evidence retrieval. The
2571
2610
  // router, planner, and answer loop must rank the same typed follow-up; doing
2572
2611
  // this only inside the provider adapter allowed stale catalog matches to win
@@ -15177,7 +15216,7 @@ function composeSemanticBlockSql(source, semanticLayer, options) {
15177
15216
  diagnostics.push({
15178
15217
  severity: 'error',
15179
15218
  code: 'semantic_metric_missing',
15180
- message: 'Semantic block is missing a metric. Add metric = "metric_name" or metrics = ["metric_name"].',
15219
+ message: 'Semantic block is missing a metric. Add metrics = ["metric_name"].',
15181
15220
  });
15182
15221
  return { sql: null, diagnostics, semanticRefs };
15183
15222
  }
@@ -15203,12 +15242,9 @@ function composeSemanticBlockSql(source, semanticLayer, options) {
15203
15242
  if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) {
15204
15243
  return { sql: null, diagnostics, semanticRefs };
15205
15244
  }
15206
- // Import-adapter shape: a semantic block may carry a pre-compiled `query` (the
15207
- // governed metric already expanded to runnable SQL). When present, RUN THAT —
15208
- // the `metric` field is provenance/governance, not something to recompile. This
15209
- // keeps metric-bound blocks runnable offline (the full MetricFlow engine may be
15210
- // unavailable). We still validated the metric exists above. Returning the query
15211
- // as the composed SQL lets the normal {{ ref() }} resolution + execution apply.
15245
+ // Legacy import-adapter compatibility: older semantic blocks may carry a
15246
+ // pre-compiled `query`. Current writers keep compiled SQL outside semantic DQL,
15247
+ // but existing files remain executable while they are migrated by the formatter.
15212
15248
  const precompiledQuery = extractBlockStudioSql(source);
15213
15249
  if (precompiledQuery) {
15214
15250
  return { sql: precompiledQuery, diagnostics, semanticRefs };
@@ -15890,7 +15926,96 @@ function saveDqlGenerationDraftForProject(projectRoot, importId, candidate) {
15890
15926
  }
15891
15927
  /** AI may propose block logic, but ownership is assigned by a human at promotion. */
15892
15928
  export function sanitizeAgentBlockDraftSource(source) {
15893
- return source.replace(/^(\s*owner\s*=\s*).+$/mi, '$1""');
15929
+ return source.replace(/^\s*owner\s*=\s*.+(?:\r?\n|$)/gmi, '');
15930
+ }
15931
+ /**
15932
+ * UI-016 / CONTRACT-002 / AGT-021 — derive the transient Block AI artifact
15933
+ * from the exact governed answer artifact. Semantic drafts are re-rendered
15934
+ * through the one canonical metrics/dimensions writer; custom SQL drafts retain
15935
+ * their query body. Both become ownerless, review-required drafts and never
15936
+ * inherit a saved/certified identity from their answer source.
15937
+ */
15938
+ export function ownerlessReviewDqlArtifactFromAnswer(answer, question) {
15939
+ const artifact = normalizeDqlArtifactReference(answer.result?.dqlArtifact)
15940
+ ?? normalizeDqlArtifactReference(answer.dqlArtifact);
15941
+ if (!artifact?.source)
15942
+ return undefined;
15943
+ const parsed = parseSemanticBlockConfig(artifact.source);
15944
+ const metrics = Array.from(new Set([
15945
+ ...(artifact.metrics ?? []),
15946
+ ...parsed.metrics,
15947
+ ...(parsed.metric ? [parsed.metric] : []),
15948
+ ].map((metric) => metric.trim()).filter(Boolean)));
15949
+ const dimensions = Array.from(new Set([
15950
+ ...(artifact.dimensions ?? []),
15951
+ ...parsed.dimensions,
15952
+ ].map((dimension) => dimension.trim()).filter(Boolean)));
15953
+ const name = artifact.name
15954
+ ?? artifact.source.match(/\bblock\s+"([^"]+)"/i)?.[1]
15955
+ ?? deriveGeneratedDraftSlug(question);
15956
+ const compiledSql = artifact.compiledSql
15957
+ ?? answer.result?.sql
15958
+ ?? answer.proposedSql
15959
+ ?? answer.sql;
15960
+ if (artifact.kind === 'semantic_block' || parsed.blockType === 'semantic' || metrics.length > 0) {
15961
+ const source = renderSemanticBlockSource({
15962
+ name,
15963
+ status: 'draft',
15964
+ domain: parseBlockStudioStringField(artifact.source, 'domain'),
15965
+ description: question,
15966
+ tags: ['ai-generated', 'review-required'],
15967
+ metrics,
15968
+ dimensions,
15969
+ timeDimension: artifact.timeDimension ?? (parsed.timeDimension && parsed.granularity
15970
+ ? { name: parsed.timeDimension, granularity: parsed.granularity }
15971
+ : undefined),
15972
+ requestedFilters: (artifact.filters ?? parsed.filters).flatMap((filter) => filter.values.length > 0
15973
+ ? filter.values.map((value) => `${filter.dimension}=${value}`)
15974
+ : [filter.dimension]),
15975
+ orderBy: artifact.orderBy,
15976
+ limit: artifact.limit ?? parsed.limit,
15977
+ parameters: artifact.parameters?.map((parameter) => ({
15978
+ name: parameter.name,
15979
+ type: parameter.type,
15980
+ default: parameter.default,
15981
+ policy: parameter.policy,
15982
+ binding: parameter.binding?.kind === 'semantic_filter'
15983
+ ? parameter.binding.field
15984
+ : parameter.binding?.kind === 'limit'
15985
+ ? 'limit'
15986
+ : undefined,
15987
+ })),
15988
+ visualization: metrics.length > 1 ? { chart: 'table' } : undefined,
15989
+ });
15990
+ return {
15991
+ ...artifact,
15992
+ kind: 'semantic_block',
15993
+ name,
15994
+ source,
15995
+ sourcePath: undefined,
15996
+ metrics,
15997
+ dimensions,
15998
+ persistence: 'transient',
15999
+ trustState: 'review_required',
16000
+ ...(compiledSql ? { compiledSql } : {}),
16001
+ };
16002
+ }
16003
+ let source = sanitizeAgentBlockDraftSource(artifact.source);
16004
+ if (/\bstatus\s*=\s*"[^"]*"/i.test(source)) {
16005
+ source = source.replace(/\bstatus\s*=\s*"[^"]*"/i, 'status = "draft"');
16006
+ }
16007
+ else {
16008
+ source = source.replace(/(\bblock\s+"[^"]+"\s*\{)/i, '$1\n status = "draft"');
16009
+ }
16010
+ return {
16011
+ ...artifact,
16012
+ name,
16013
+ source,
16014
+ sourcePath: undefined,
16015
+ persistence: 'transient',
16016
+ trustState: 'review_required',
16017
+ ...(compiledSql ? { compiledSql } : {}),
16018
+ };
15894
16019
  }
15895
16020
  export function saveBlockStudioArtifacts(projectRoot, options) {
15896
16021
  const slug = options.name.toLowerCase().replace(/[^a-z0-9_]+/g, '-').replace(/^[-_]+|[-_]+$/g, '') || 'block';
@@ -16656,29 +16781,18 @@ function buildSemanticImportCandidateSource(candidate, patch, semanticLayer) {
16656
16781
  const allowedFilters = (patch.allowedFilters ?? [])
16657
16782
  .map((filter) => compatibleByName.get(normalizedTerm(filter))?.name)
16658
16783
  .filter((filter) => Boolean(filter));
16659
- const quote = (value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
16660
- const metricLine = metricNames.length === 1
16661
- ? ` metric = ${quote(metricNames[0])}`
16662
- : ` metrics = [${metricNames.map(quote).join(', ')}]`;
16663
- const lines = [
16664
- `block ${quote(candidate.name)} {`,
16665
- ' status = "draft"',
16666
- ` domain = ${quote(candidate.domain)}`,
16667
- ' type = "semantic"',
16668
- ` description = ${quote(candidate.description)}`,
16669
- ` owner = ${quote(candidate.owner)}`,
16670
- ` tags = [${candidate.tags.map(quote).join(', ')}]`,
16671
- metricLine,
16672
- dimensionNames.length > 0 ? ` dimensions = [${dimensionNames.map(quote).join(', ')}]` : '',
16673
- allowedFilters.length > 0 ? ` requested_filters = [${allowedFilters.map(quote).join(', ')}]` : '',
16674
- '',
16675
- ' visualization {',
16676
- ` chart = "${dimensionNames.length > 0 ? 'bar' : 'kpi'}"`,
16677
- ' }',
16678
- '}',
16679
- '',
16680
- ];
16681
- return lines.filter((line, index) => line !== '' || (index > 0 && lines[index - 1] !== '')).join('\n');
16784
+ return renderSemanticBlockSource({
16785
+ name: candidate.name,
16786
+ status: 'draft',
16787
+ domain: candidate.domain,
16788
+ description: candidate.description,
16789
+ owner: candidate.owner,
16790
+ tags: candidate.tags,
16791
+ metrics: metricNames,
16792
+ dimensions: dimensionNames,
16793
+ requestedFilters: allowedFilters,
16794
+ visualization: { chart: dimensionNames.length > 0 ? 'bar' : 'kpi' },
16795
+ });
16682
16796
  }
16683
16797
  function inferDqlGenerationTerms(evidence) {
16684
16798
  return Array.from(new Set(evidence
@@ -17950,44 +18064,18 @@ export function createSemanticBuilderBlock(projectRoot, options) {
17950
18064
  };
17951
18065
  }
17952
18066
  function buildSemanticBlockContent(options) {
17953
- const lines = [
17954
- `block "${options.name}" {`,
17955
- ` domain = "${options.domain ?? 'uncategorized'}"`,
17956
- ' type = "semantic"',
17957
- ];
17958
- if (options.description)
17959
- lines.push(` description = "${escapeDqlString(options.description)}"`);
17960
- if (options.owner)
17961
- lines.push(` owner = "${escapeDqlString(options.owner)}"`);
17962
- if (options.tags && options.tags.length > 0) {
17963
- lines.push(` tags = [${options.tags.map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`);
17964
- }
17965
- if (options.metrics.length === 0) {
17966
- lines.push(' metric = ""');
17967
- }
17968
- else if (options.metrics.length === 1) {
17969
- lines.push(` metric = "${escapeDqlString(options.metrics[0])}"`);
17970
- }
17971
- else {
17972
- lines.push(` metrics = [${options.metrics.map((metric) => `"${escapeDqlString(metric)}"`).join(', ')}]`);
17973
- }
17974
- if (options.dimensions.length > 0) {
17975
- lines.push(` dimensions = [${options.dimensions.map((dimension) => `"${escapeDqlString(dimension)}"`).join(', ')}]`);
17976
- }
17977
- else {
17978
- lines.push(' dimensions = []');
17979
- }
17980
- if (options.timeDimension) {
17981
- lines.push(` time_dimension = "${escapeDqlString(options.timeDimension.name)}"`);
17982
- lines.push(` granularity = "${escapeDqlString(options.timeDimension.granularity)}"`);
17983
- }
17984
- const visualization = buildVisualizationBlock(options.chart ?? 'table', options.dimensions, options.timeDimension, options.metrics);
17985
- if (visualization) {
17986
- lines.push('');
17987
- lines.push(...visualization);
17988
- }
17989
- lines.push('}');
17990
- return lines.join('\n') + '\n';
18067
+ return renderSemanticBlockSource({
18068
+ name: options.name,
18069
+ status: 'draft',
18070
+ domain: options.domain,
18071
+ description: options.description,
18072
+ owner: options.owner,
18073
+ tags: options.tags,
18074
+ metrics: options.metrics,
18075
+ dimensions: options.dimensions,
18076
+ timeDimension: options.timeDimension,
18077
+ visualization: semanticBlockVisualization(options.chart ?? 'table', options.dimensions, options.timeDimension, options.metrics),
18078
+ });
17991
18079
  }
17992
18080
  function buildCustomSemanticBlockContent(options) {
17993
18081
  const lines = [
@@ -18039,6 +18127,21 @@ function buildVisualizationBlock(chart, dimensions, timeDimension, metrics) {
18039
18127
  ' }',
18040
18128
  ];
18041
18129
  }
18130
+ function semanticBlockVisualization(chart, dimensions, timeDimension, metrics) {
18131
+ if (metrics.length > 1)
18132
+ return { chart: 'table' };
18133
+ const x = timeDimension ? `${timeDimension.name}_${timeDimension.granularity}` : dimensions[0];
18134
+ const y = metrics[0];
18135
+ if (chart === 'table')
18136
+ return { chart: 'table' };
18137
+ if (chart === 'kpi')
18138
+ return { chart: 'kpi', ...(y ? { y } : {}) };
18139
+ return {
18140
+ chart: x ? chart : 'table',
18141
+ ...(x ? { x } : {}),
18142
+ ...(x && y ? { y } : {}),
18143
+ };
18144
+ }
18042
18145
  function writeBlockCompanionFile(projectRoot, options) {
18043
18146
  const extractedRefs = extractSemanticReferenceNames(options.content);
18044
18147
  const semanticMetrics = Array.from(new Set([...(options.semanticMetrics ?? []), ...extractedRefs.metrics]));
@@ -18227,23 +18330,17 @@ function buildBlankBlockContent(options) {
18227
18330
  return lines.join('\n') + '\n';
18228
18331
  }
18229
18332
  function buildBlankSemanticBlockContent(options) {
18230
- const lines = [
18231
- `block "${escapeDqlString(options.name)}" {`,
18232
- ` domain = "${escapeDqlString(options.domain)}"`,
18233
- ' type = "semantic"',
18234
- ' status = "draft"',
18235
- ` description = "${escapeDqlString(options.description?.trim() || options.name)}"`,
18236
- ` owner = "${escapeDqlString(options.owner?.trim() ?? '')}"`,
18237
- ` tags = [${(options.tags ?? []).map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`,
18238
- ' metric = ""',
18239
- ' dimensions = []',
18240
- '',
18241
- ' visualization {',
18242
- ' chart = "table"',
18243
- ' }',
18244
- '}',
18245
- ];
18246
- return lines.join('\n') + '\n';
18333
+ return renderSemanticBlockSource({
18334
+ name: options.name,
18335
+ status: 'draft',
18336
+ domain: options.domain,
18337
+ description: options.description?.trim() || options.name,
18338
+ owner: options.owner,
18339
+ tags: options.tags,
18340
+ metrics: [],
18341
+ dimensions: [],
18342
+ visualization: { chart: 'table' },
18343
+ });
18247
18344
  }
18248
18345
  function parseYamlScalar(value) {
18249
18346
  const trimmed = value.trim();