@duckcodeailabs/dql-cli 1.8.1 → 1.8.3
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/assets/dql-notebook/assets/{index-siDRLswd.js → index-DGbXmDU6.js} +325 -324
- package/dist/assets/dql-notebook/index.html +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +62 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/llm/cancellation.d.ts +13 -0
- package/dist/llm/cancellation.d.ts.map +1 -0
- package/dist/llm/cancellation.js +19 -0
- package/dist/llm/cancellation.js.map +1 -0
- package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
- package/dist/llm/providers/dql-agent-provider.js +38 -7
- package/dist/llm/providers/dql-agent-provider.js.map +1 -1
- package/dist/local-runtime.d.ts +35 -2
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +321 -56
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- package/dist/providers/subscription-cli.d.ts.map +1 -1
- package/dist/providers/subscription-cli.js +16 -0
- package/dist/providers/subscription-cli.js.map +1 -1
- package/dist/retrieval-health.d.ts +29 -0
- package/dist/retrieval-health.d.ts.map +1 -0
- package/dist/retrieval-health.js +88 -0
- package/dist/retrieval-health.js.map +1 -0
- package/dist/version-status.d.ts +28 -0
- package/dist/version-status.d.ts.map +1 -0
- package/dist/version-status.js +118 -0
- package/dist/version-status.js.map +1 -0
- package/package.json +10 -10
package/dist/local-runtime.js
CHANGED
|
@@ -14,9 +14,12 @@ import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetri
|
|
|
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';
|
|
17
|
+
import { rethrowIfCancelled } from './llm/cancellation.js';
|
|
18
|
+
import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from './version-status.js';
|
|
19
|
+
import { resolveRetrievalHealthStatus } from './retrieval-health.js';
|
|
17
20
|
import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
|
|
18
21
|
import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
|
|
19
|
-
import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, buildLocalContextPack, toAgentRetrievalEvidence, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine,
|
|
22
|
+
import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, buildLocalContextPack, toAgentRetrievalEvidence, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, } from '@duckcodeailabs/dql-agent';
|
|
20
23
|
import { gatherProposeEnrichment } from './propose-enrich.js';
|
|
21
24
|
import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
|
|
22
25
|
import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
|
|
@@ -145,6 +148,7 @@ export function parseAgentRunRequestBody(body) {
|
|
|
145
148
|
return {
|
|
146
149
|
request: {
|
|
147
150
|
question,
|
|
151
|
+
selectedEvidenceId: agentRunString(record.selectedEvidenceId),
|
|
148
152
|
requestedMode,
|
|
149
153
|
audience,
|
|
150
154
|
intent: agentRunString(record.intent),
|
|
@@ -163,17 +167,26 @@ export function parseAgentRunRequestBody(body) {
|
|
|
163
167
|
}
|
|
164
168
|
const AGENT_LOOKUP_DEADLINE_MS = 45_000;
|
|
165
169
|
const AGENT_RESEARCH_DEADLINE_MS = 120_000;
|
|
170
|
+
/** Env-tunable run deadline (e.g. slow subscription-CLI providers), clamped to sane bounds. */
|
|
171
|
+
function resolveAgentDeadlineMs(envKey, fallback, env = process.env) {
|
|
172
|
+
const configured = Number(env[envKey]);
|
|
173
|
+
if (!Number.isFinite(configured) || configured <= 0)
|
|
174
|
+
return fallback;
|
|
175
|
+
return Math.max(15_000, Math.min(600_000, Math.floor(configured)));
|
|
176
|
+
}
|
|
166
177
|
/**
|
|
167
178
|
* PERF-002: one wall-clock budget follows the request through routing, provider
|
|
168
179
|
* calls, repair, and execution. Ordinary Ask never inherits Research's budget
|
|
169
180
|
* merely because it spans two tables; explicit/deep investigation does.
|
|
170
181
|
*/
|
|
171
|
-
export function agentRunDeadlineMs(request) {
|
|
182
|
+
export function agentRunDeadlineMs(request, env = process.env) {
|
|
183
|
+
const lookupDeadline = resolveAgentDeadlineMs('DQL_AGENT_LOOKUP_DEADLINE_MS', AGENT_LOOKUP_DEADLINE_MS, env);
|
|
184
|
+
const researchDeadline = resolveAgentDeadlineMs('DQL_AGENT_RESEARCH_DEADLINE_MS', AGENT_RESEARCH_DEADLINE_MS, env);
|
|
172
185
|
if (request.requestedMode === 'research' || request.analysisDepth === 'deep') {
|
|
173
|
-
return
|
|
186
|
+
return researchDeadline;
|
|
174
187
|
}
|
|
175
188
|
const plan = buildAnalysisQuestionPlan(request.question);
|
|
176
|
-
return plan.needsResearchWorkspace ?
|
|
189
|
+
return plan.needsResearchWorkspace ? researchDeadline : lookupDeadline;
|
|
177
190
|
}
|
|
178
191
|
export function shouldSynthesizeAgentRunAnswer(governedAnswer) {
|
|
179
192
|
if (governedAnswer.kind === 'no_answer')
|
|
@@ -197,6 +210,10 @@ export function shouldSynthesizeAgentRunAnswer(governedAnswer) {
|
|
|
197
210
|
return false;
|
|
198
211
|
return true;
|
|
199
212
|
}
|
|
213
|
+
export function agentAnswerHasExecutionFailure(governedAnswer) {
|
|
214
|
+
return typeof governedAnswer.executionError === 'string'
|
|
215
|
+
&& governedAnswer.executionError.trim().length > 0;
|
|
216
|
+
}
|
|
200
217
|
function businessNarrativeGaps(warnings) {
|
|
201
218
|
const businessRelevant = (warnings ?? []).filter((warning) => !(/missing requested output column/i.test(warning)
|
|
202
219
|
|| /\bquery plan\b/i.test(warning)
|
|
@@ -210,7 +227,11 @@ function businessNarrativeGaps(warnings) {
|
|
|
210
227
|
// source of prior turns (survives refresh); the client-built context remains
|
|
211
228
|
// the fallback for embedders that never send a threadId.
|
|
212
229
|
async function conversationContextFromThread(store, threadId, clientContext, question) {
|
|
213
|
-
|
|
230
|
+
// Token-budget backstop: six verbatim turns with per-field caps. Older turns
|
|
231
|
+
// remain reachable through the rolling summary + semantic recall below —
|
|
232
|
+
// carrying more raw prose mostly slows every provider call on follow-ups.
|
|
233
|
+
const clampTurnText = (value) => value && value.length > 1_200 ? `${value.slice(0, 1_200)}…` : value;
|
|
234
|
+
const turns = store.recentTurns(threadId, 6).map((turn) => {
|
|
214
235
|
const contract = turn.contract ?? {};
|
|
215
236
|
const topN = contract.topN;
|
|
216
237
|
const topNValue = typeof topN === 'number'
|
|
@@ -220,8 +241,8 @@ async function conversationContextFromThread(store, threadId, clientContext, que
|
|
|
220
241
|
: undefined;
|
|
221
242
|
return compactConversationRecord({
|
|
222
243
|
id: turn.id,
|
|
223
|
-
question: turn.question,
|
|
224
|
-
answerSummary: turn.answerSummary,
|
|
244
|
+
question: clampTurnText(turn.question),
|
|
245
|
+
answerSummary: clampTurnText(turn.answerSummary),
|
|
225
246
|
sourceCertifiedBlock: turn.sourceCertifiedBlock,
|
|
226
247
|
route: turn.route,
|
|
227
248
|
trustLabel: turn.trustLabel,
|
|
@@ -280,16 +301,20 @@ function recordConversationTurn(store, threadId, run) {
|
|
|
280
301
|
// Conversation persistence is additive; a failed write must not fail the run.
|
|
281
302
|
}
|
|
282
303
|
}
|
|
283
|
-
function conversationTurnInputFromRun(run) {
|
|
304
|
+
export function conversationTurnInputFromRun(run) {
|
|
284
305
|
const artifact = run.artifacts.find((candidate) => candidate.kind === 'answer')
|
|
285
306
|
?? run.artifacts.find((candidate) => candidate.kind === 'research_run')
|
|
286
307
|
?? run.artifacts[0];
|
|
287
308
|
const payload = agentRunRecord(artifact?.payload);
|
|
288
309
|
const result = agentRunRecord(payload?.result);
|
|
289
310
|
const columns = conversationResultColumns(result?.columns);
|
|
290
|
-
|
|
291
|
-
|
|
311
|
+
// The visual preview stays tiny, but member resolution needs a wider bounded
|
|
312
|
+
// value window. Deriving dimensions from only the eight preview rows caused a
|
|
313
|
+
// valid row 9/10 member to disappear before the next turn (AGT-012/E2E-010).
|
|
314
|
+
const memberRows = Array.isArray(result?.rows)
|
|
315
|
+
? result.rows.filter((row) => Boolean(row && typeof row === 'object' && !Array.isArray(row))).slice(0, 24)
|
|
292
316
|
: [];
|
|
317
|
+
const rows = memberRows.slice(0, 8);
|
|
293
318
|
const rowsSample = rows.map((row) => columns.map((column) => row[column]));
|
|
294
319
|
const contextPack = agentRunRecord(payload?.contextPack);
|
|
295
320
|
const questionPlan = agentRunRecord(contextPack?.questionPlan);
|
|
@@ -313,7 +338,7 @@ function conversationTurnInputFromRun(run) {
|
|
|
313
338
|
? {
|
|
314
339
|
columns,
|
|
315
340
|
rowsSample,
|
|
316
|
-
dimensionValues: conversationDimensionValues(columns,
|
|
341
|
+
dimensionValues: conversationDimensionValues(columns, memberRows),
|
|
317
342
|
measureColumns,
|
|
318
343
|
rowCount: typeof rowCountRaw === 'number' ? rowCountRaw : rows.length || undefined,
|
|
319
344
|
}
|
|
@@ -416,6 +441,8 @@ export async function startLocalServer(opts) {
|
|
|
416
441
|
const loopback = bindHost === '127.0.0.1' || bindHost === 'localhost' || bindHost === '::1';
|
|
417
442
|
const authToken = opts.authToken ?? process.env.DQL_SERVER_TOKEN;
|
|
418
443
|
const runtimeVersion = readDqlRuntimeVersion();
|
|
444
|
+
// Warm the latest-version cache in the background (2s cap, 24h cache; offline → unknown).
|
|
445
|
+
void fetchLatestPublishedDqlVersion();
|
|
419
446
|
const allowedOrigins = new Set((opts.allowedOrigins ?? (process.env.DQL_ALLOWED_ORIGINS ?? '').split(','))
|
|
420
447
|
.map((value) => value.trim().replace(/\/$/, ''))
|
|
421
448
|
.filter(Boolean));
|
|
@@ -1084,7 +1111,12 @@ export async function startLocalServer(opts) {
|
|
|
1084
1111
|
await runner.run({
|
|
1085
1112
|
provider: resolvedProvider,
|
|
1086
1113
|
messages: [
|
|
1087
|
-
|
|
1114
|
+
// No-thread fallback path: bound the raw client history so follow-ups
|
|
1115
|
+
// cannot inflate every provider call (8 turns × 4k chars).
|
|
1116
|
+
...(request.history ?? []).slice(-8).map((message) => ({
|
|
1117
|
+
role: message.role,
|
|
1118
|
+
content: message.text.length > 4_000 ? `${message.text.slice(0, 4_000)}…` : message.text,
|
|
1119
|
+
})),
|
|
1088
1120
|
...(isRepair
|
|
1089
1121
|
? [{ role: 'assistant', content: `The prior attempt needs repair without changing the original question or requested output grain: ${repair?.repairHint}` }]
|
|
1090
1122
|
: []),
|
|
@@ -1420,6 +1452,9 @@ export async function startLocalServer(opts) {
|
|
|
1420
1452
|
applySmartVisualization(governedAnswer, request.question);
|
|
1421
1453
|
}
|
|
1422
1454
|
catch (error) {
|
|
1455
|
+
// Deadline/cancellation propagates to the engine, which renders the
|
|
1456
|
+
// graceful "bounded execution deadline" outcome — never a provider error.
|
|
1457
|
+
rethrowIfCancelled(error, request.signal);
|
|
1423
1458
|
const message = formatAgentRunInfrastructureError(error, 'AI answer provider');
|
|
1424
1459
|
return {
|
|
1425
1460
|
summary: message,
|
|
@@ -1445,8 +1480,9 @@ export async function startLocalServer(opts) {
|
|
|
1445
1480
|
|| governedAnswer.sourceCertifiedBlock
|
|
1446
1481
|
|| governedAnswer.dqlArtifact?.kind === 'certified_block'));
|
|
1447
1482
|
const isExploratory = Boolean(governedAnswer.exploratoryCandidate);
|
|
1483
|
+
const isExecutionFailure = agentAnswerHasExecutionFailure(governedAnswer);
|
|
1448
1484
|
const isGroundingGap = governedAnswer.kind === 'no_answer'
|
|
1449
|
-
&& governedAnswer.refusalCode === 'grounding_gap'
|
|
1485
|
+
&& (governedAnswer.refusalCode === 'grounding_gap' || governedAnswer.refusalCode === 'modeling_gap')
|
|
1450
1486
|
&& !isExploratory;
|
|
1451
1487
|
const isProviderError = governedAnswer.kind === 'no_answer' && governedAnswer.refusalCode === 'provider_error';
|
|
1452
1488
|
// AGT-004: a rejected attribution/export/proof policy is a deliberate
|
|
@@ -1465,7 +1501,7 @@ export async function startLocalServer(opts) {
|
|
|
1465
1501
|
const needsClarification = governedAnswer.kind === 'no_answer'
|
|
1466
1502
|
&& !isGroundingGap && !isProviderError && !isModelDeclined && !isPolicyBlocked;
|
|
1467
1503
|
const sql = governedAnswer.proposedSql ?? governedAnswer.sql;
|
|
1468
|
-
const runnableSql = governedAnswer.kind === 'no_answer' || (isExploratory && !governedAnswer.result)
|
|
1504
|
+
const runnableSql = governedAnswer.kind === 'no_answer' || isExecutionFailure || (isExploratory && !governedAnswer.result)
|
|
1469
1505
|
? undefined
|
|
1470
1506
|
: sql;
|
|
1471
1507
|
// Render executed rows deterministically for ordinary lookups. A second LLM
|
|
@@ -1504,14 +1540,14 @@ export async function startLocalServer(opts) {
|
|
|
1504
1540
|
synthesizedAnswer = undefined;
|
|
1505
1541
|
}
|
|
1506
1542
|
}
|
|
1507
|
-
const status = isProviderError
|
|
1543
|
+
const status = isProviderError || isExecutionFailure
|
|
1508
1544
|
? 'blocked'
|
|
1509
1545
|
: needsClarification
|
|
1510
1546
|
? 'needs_clarification'
|
|
1511
1547
|
: isCertified || isSemantic
|
|
1512
1548
|
? 'completed'
|
|
1513
1549
|
: 'needs_review';
|
|
1514
|
-
const trustState = isProviderError
|
|
1550
|
+
const trustState = isProviderError || isExecutionFailure
|
|
1515
1551
|
? 'blocked'
|
|
1516
1552
|
: needsClarification
|
|
1517
1553
|
? 'not_applicable'
|
|
@@ -1520,7 +1556,7 @@ export async function startLocalServer(opts) {
|
|
|
1520
1556
|
: isSemantic
|
|
1521
1557
|
? 'governed'
|
|
1522
1558
|
: 'review_required';
|
|
1523
|
-
const stopReason = isProviderError
|
|
1559
|
+
const stopReason = isProviderError || isExecutionFailure
|
|
1524
1560
|
? 'blocked'
|
|
1525
1561
|
: needsClarification
|
|
1526
1562
|
? 'needs_clarification'
|
|
@@ -1531,33 +1567,41 @@ export async function startLocalServer(opts) {
|
|
|
1531
1567
|
: 'human_review_required';
|
|
1532
1568
|
const nextActions = needsClarification
|
|
1533
1569
|
? [{ id: 'clarify', label: 'Clarify question', route: 'generated_answer' }]
|
|
1534
|
-
:
|
|
1535
|
-
{ id: '
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
label: governedAnswer.dqlArtifact ? '
|
|
1540
|
-
route: '
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1570
|
+
: isExecutionFailure
|
|
1571
|
+
? [{ id: 'retry-after-connection', label: 'Retry after fixing the database connection', route: 'generated_answer' }]
|
|
1572
|
+
: isGroundingGap
|
|
1573
|
+
? [{ id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' }]
|
|
1574
|
+
: [
|
|
1575
|
+
{ id: 'create-block', label: governedAnswer.dqlArtifact ? 'Review DQL draft' : 'Create DQL draft', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
|
|
1576
|
+
{ id: 'research-gap', label: 'Research deeper', route: 'research' },
|
|
1577
|
+
...(runnableSql ? [{
|
|
1578
|
+
id: 'insert-sql',
|
|
1579
|
+
label: governedAnswer.dqlArtifact ? 'Insert as DQL cell' : 'Insert SQL preview',
|
|
1580
|
+
route: 'sql_cell',
|
|
1581
|
+
artifactKind: 'sql_cell',
|
|
1582
|
+
}] : []),
|
|
1583
|
+
];
|
|
1544
1584
|
return {
|
|
1545
1585
|
resolvedRoute,
|
|
1546
1586
|
answerRefusalCode: governedAnswer.kind === 'no_answer' ? governedAnswer.refusalCode : undefined,
|
|
1547
1587
|
answerTier: governedAnswer.route?.tier,
|
|
1548
|
-
summary:
|
|
1588
|
+
summary: isExecutionFailure
|
|
1589
|
+
? 'The governed query could not be executed.'
|
|
1590
|
+
: governedAnswer.route?.label ?? (isCertified ? 'Answered from certified DQL context.' : isExploratory ? 'Exploratory DBT-grounded analysis requires review.' : 'Answered with review-required generated analysis.'),
|
|
1549
1591
|
answer: synthesizedAnswer ?? governedAnswer.answer ?? governedAnswer.text,
|
|
1550
1592
|
status,
|
|
1551
1593
|
trustState,
|
|
1552
1594
|
stopReason,
|
|
1553
|
-
artifacts:
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1595
|
+
artifacts: isExecutionFailure
|
|
1596
|
+
? []
|
|
1597
|
+
: governedAnswer.kind === 'no_answer'
|
|
1598
|
+
// A refusal still keeps the DQL draft the answer loop produced (when any),
|
|
1599
|
+
// so the "Review DQL draft" next-action isn't a dead link and the user can
|
|
1600
|
+
// see the SQL that was about to run. Provider outages carry no draft.
|
|
1601
|
+
? (governedAnswer.dqlArtifact && !isProviderError && !isGroundingGap && !isModelDeclined && !isPolicyBlocked
|
|
1602
|
+
? [agentRunArtifact('dql_block_draft', 'DQL draft (review required)', governedAnswer.dqlArtifact, undefined, 'review_required')]
|
|
1603
|
+
: [])
|
|
1604
|
+
: [agentRunArtifact('answer', isCertified ? 'Certified answer' : isSemantic ? 'Governed semantic answer' : isExploratory ? 'Exploratory DBT-grounded answer' : 'Review-required answer', governedAnswer, governedAnswer.sourceCertifiedBlock ?? governedAnswer.block?.name, isCertified ? 'certified' : isSemantic ? 'governed' : 'review_required')],
|
|
1561
1605
|
evaluations: [
|
|
1562
1606
|
agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to governed answer.', {
|
|
1563
1607
|
plannedRoute: route,
|
|
@@ -1586,6 +1630,7 @@ export async function startLocalServer(opts) {
|
|
|
1586
1630
|
: isExploratory
|
|
1587
1631
|
? 'The answer used bounded DBT/schema evidence because no certified modeled relationship path covered the request. It is review-required and was not certified.'
|
|
1588
1632
|
: 'The answer is generated or semantic-layer backed and remains review-required.', governedAnswer.route),
|
|
1633
|
+
...(isExecutionFailure ? [agentRunEvaluation('query-execution', 'Query execution', false, 'blocking', `The governed query failed before it produced a result: ${governedAnswer.executionError}`)] : []),
|
|
1589
1634
|
...(isGroundingGap ? [
|
|
1590
1635
|
{
|
|
1591
1636
|
...agentRunEvaluation('grounding-gap', 'Metadata grounding', false, 'warning', 'The bounded lookup could not prove the required metadata grounding. No automatic retry or Research escalation was started.', {
|
|
@@ -1767,6 +1812,7 @@ export async function startLocalServer(opts) {
|
|
|
1767
1812
|
}
|
|
1768
1813
|
}
|
|
1769
1814
|
catch (error) {
|
|
1815
|
+
rethrowIfCancelled(error, request.signal);
|
|
1770
1816
|
researchWorkspaceError = formatNotebookResearchStorageError(error);
|
|
1771
1817
|
}
|
|
1772
1818
|
}
|
|
@@ -2171,6 +2217,7 @@ export async function startLocalServer(opts) {
|
|
|
2171
2217
|
: undefined;
|
|
2172
2218
|
const task = buildLocalContextPack(projectRoot, {
|
|
2173
2219
|
question: request.question,
|
|
2220
|
+
focusObjectKey: request.selectedEvidenceId,
|
|
2174
2221
|
followUp,
|
|
2175
2222
|
priorContextPackId: agentRunString(request.conversationContext?.contextPackId),
|
|
2176
2223
|
conversationTopicRelation: topicRelation === 'continuation'
|
|
@@ -2246,9 +2293,9 @@ export async function startLocalServer(opts) {
|
|
|
2246
2293
|
};
|
|
2247
2294
|
}
|
|
2248
2295
|
if ((candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
|
|
2249
|
-
&& semanticEvidence.has(candidate.id)
|
|
2250
|
-
&&
|
|
2251
|
-
|
|
2296
|
+
&& (semanticEvidence.has(candidate.id) || request.selectedEvidenceId === candidate.id)
|
|
2297
|
+
&& (request.selectedEvidenceId === candidate.id
|
|
2298
|
+
|| (pack.routeDecision.route !== 'clarify' && pack.routeDecision.route !== 'conflict'))) {
|
|
2252
2299
|
const requestedDimensions = pack.questionPlan.requestedShape.dimensions.map((dimension) => dimension.toLowerCase());
|
|
2253
2300
|
const availableDimensions = (candidate.dimensions ?? []).map((dimension) => dimension.toLowerCase());
|
|
2254
2301
|
const dimensionsFit = requestedDimensions.length === 0 || requestedDimensions.every((requested) => availableDimensions.some((available) => available === requested || available.endsWith(`.${requested}`)));
|
|
@@ -2335,7 +2382,13 @@ export async function startLocalServer(opts) {
|
|
|
2335
2382
|
getEvidence: buildAgentRunEvidence,
|
|
2336
2383
|
getCatalogContext: buildRankedAgentRunCatalogContext,
|
|
2337
2384
|
});
|
|
2338
|
-
|
|
2385
|
+
// P0: one row per run with retention + old-run compaction. The legacy JSON
|
|
2386
|
+
// store rewrote the entire file (123 MB observed) twice per answered question;
|
|
2387
|
+
// existing history is imported once and the JSON renamed to *.migrated.
|
|
2388
|
+
const agentRunStore = new SqliteAgentRunStore({
|
|
2389
|
+
path: defaultAgentRunSqlitePath(projectRoot),
|
|
2390
|
+
legacyJsonPath: defaultAgentRunStorePath(projectRoot),
|
|
2391
|
+
});
|
|
2339
2392
|
// A run may outlive its streaming browser connection, so cancellation is
|
|
2340
2393
|
// server-owned and keyed by run id rather than relying on fetch abort alone.
|
|
2341
2394
|
const activeAgentRunControllers = new Map();
|
|
@@ -2557,7 +2610,7 @@ export async function startLocalServer(opts) {
|
|
|
2557
2610
|
const preflight = repairExploratorySqlBeforeExecution(candidate.sql, schemaContext, question);
|
|
2558
2611
|
const boundedSql = applyRequestedTopNToExploratorySql(preflight.sql, requestedTopN);
|
|
2559
2612
|
const repairs = boundedSql === preflight.sql
|
|
2560
|
-
? preflight.repairs
|
|
2613
|
+
? [...preflight.repairs]
|
|
2561
2614
|
: [...preflight.repairs, `Applied the requested overall top-${requestedTopN} bound before exploratory execution.`];
|
|
2562
2615
|
if (preflight.blockedReason) {
|
|
2563
2616
|
return {
|
|
@@ -2571,19 +2624,62 @@ export async function startLocalServer(opts) {
|
|
|
2571
2624
|
if (!analysis.parsed) {
|
|
2572
2625
|
return { proofs: [], sql: boundedSql, repairs, error: 'DQL could not parse the exploratory SQL to validate its join predicates.' };
|
|
2573
2626
|
}
|
|
2627
|
+
const probeableJoins = probeableExploratoryJoins(analysis.joins, analysis.ctes);
|
|
2574
2628
|
// A normal customer → order → order-item → product question needs three
|
|
2575
2629
|
// joins. Keep the lane bounded, but do not reject this common dbt-star path
|
|
2576
|
-
// solely because the old two-join demo limit was too small.
|
|
2577
|
-
|
|
2630
|
+
// solely because the old two-join demo limit was too small. CTE-internal
|
|
2631
|
+
// joins are counted physically (they appear in analysis.joins with their
|
|
2632
|
+
// physical endpoints); joins ON a CTE are restructuring, not new paths.
|
|
2633
|
+
if (probeableJoins.length > 4) {
|
|
2578
2634
|
return { proofs: [], sql: boundedSql, repairs, error: 'This exploratory query has more than four joins and requires an analyst to review the relationship path.' };
|
|
2579
2635
|
}
|
|
2580
|
-
if (
|
|
2636
|
+
if (probeableJoins.some((join) => !join.leftRelation || !join.rightRelation)) {
|
|
2581
2637
|
return { proofs: [], sql: boundedSql, repairs, error: 'This exploratory query has a join endpoint DQL could not resolve for bounded validation.' };
|
|
2582
2638
|
}
|
|
2639
|
+
// Structural binding: when the candidate carries a declared draft join path,
|
|
2640
|
+
// every SQL join must ride one of its edges on the declared key pair. A join
|
|
2641
|
+
// outside the declared path (or on different keys) is not executed — it is a
|
|
2642
|
+
// review-required modeling question, not a runtime guess.
|
|
2643
|
+
const declaredEdges = candidate.exploratoryPath?.edges ?? [];
|
|
2644
|
+
const joinEdges = new Map();
|
|
2645
|
+
if (declaredEdges.length > 0) {
|
|
2646
|
+
for (const [index, join] of probeableJoins.entries()) {
|
|
2647
|
+
const edge = declaredEdges.find((value) => declaredExploratoryRelationMatches(join.leftRelation, value.fromRelation) && declaredExploratoryRelationMatches(join.rightRelation, value.toRelation)
|
|
2648
|
+
|| declaredExploratoryRelationMatches(join.leftRelation, value.toRelation) && declaredExploratoryRelationMatches(join.rightRelation, value.fromRelation));
|
|
2649
|
+
if (!edge) {
|
|
2650
|
+
return {
|
|
2651
|
+
proofs: [],
|
|
2652
|
+
sql: boundedSql,
|
|
2653
|
+
repairs,
|
|
2654
|
+
error: `The join between ${join.leftRelation} and ${join.rightRelation} is not part of the declared relationship path, so DQL did not execute it. Declare the relationship in the DQL model to enable it.`,
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
const keyMatches = edge.keys.some((key) => exploratoryColumnsMatch(join.leftColumn, key.from) && exploratoryColumnsMatch(join.rightColumn, key.to)
|
|
2658
|
+
|| exploratoryColumnsMatch(join.leftColumn, key.to) && exploratoryColumnsMatch(join.rightColumn, key.from));
|
|
2659
|
+
if (!keyMatches) {
|
|
2660
|
+
return {
|
|
2661
|
+
proofs: [],
|
|
2662
|
+
sql: boundedSql,
|
|
2663
|
+
repairs,
|
|
2664
|
+
error: `The join ${join.leftColumn} = ${join.rightColumn} uses different keys than relationship "${edge.relationshipId}" declares (${edge.keys.map((key) => `${key.from}=${key.to}`).join(', ')}). Review the declared keys before executing.`,
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
joinEdges.set(index, edge);
|
|
2668
|
+
}
|
|
2669
|
+
const exercised = new Set([...joinEdges.values()].map((edge) => edge.relationshipId));
|
|
2670
|
+
for (const edge of declaredEdges) {
|
|
2671
|
+
if (!exercised.has(edge.relationshipId)) {
|
|
2672
|
+
repairs.push(`Declared relationship "${edge.relationshipId}" was not exercised by this query.`);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
if (probeableJoins.length < analysis.joins.length) {
|
|
2677
|
+
repairs.push(`Skipped join probes for ${analysis.joins.length - probeableJoins.length} join(s) on derived (CTE) relations; the final query execution validates them.`);
|
|
2678
|
+
}
|
|
2583
2679
|
const activeConnection = requireActiveConnection();
|
|
2584
2680
|
const proofs = [];
|
|
2585
2681
|
try {
|
|
2586
|
-
for (const join of
|
|
2682
|
+
for (const [index, join] of probeableJoins.entries()) {
|
|
2587
2683
|
const proofSql = buildExploratoryJoinProbeSql({
|
|
2588
2684
|
leftRelation: join.leftRelation,
|
|
2589
2685
|
leftColumn: join.leftColumn,
|
|
@@ -2593,6 +2689,14 @@ export async function startLocalServer(opts) {
|
|
|
2593
2689
|
const prepared = prepareLocalExecution(proofSql, activeConnection, projectRoot, projectConfig);
|
|
2594
2690
|
const raw = await executor.executeQuery(prepared.sql, [], runtimeVariables({}), prepared.connection);
|
|
2595
2691
|
proofs.push({ summary: summarizeExploratoryJoinProbe(raw.rows, join.leftRelation, join.leftColumn, join.rightRelation, join.rightColumn) });
|
|
2692
|
+
// Gating (not observational): a structural contradiction between the
|
|
2693
|
+
// UNFILTERED key overlap and the declared relationship stops automatic
|
|
2694
|
+
// execution. A legitimately empty *filtered* business result is still an
|
|
2695
|
+
// answer — these gates only inspect the raw key samples.
|
|
2696
|
+
const contradiction = exploratoryProbeContradiction(raw.rows, join, joinEdges.get(index));
|
|
2697
|
+
if (contradiction) {
|
|
2698
|
+
return { proofs, sql: boundedSql, repairs, error: contradiction };
|
|
2699
|
+
}
|
|
2596
2700
|
}
|
|
2597
2701
|
const result = await executeGeneratedSqlForAgent(boundedSql);
|
|
2598
2702
|
return { result, proofs, sql: boundedSql, repairs };
|
|
@@ -3713,8 +3817,19 @@ export async function startLocalServer(opts) {
|
|
|
3713
3817
|
}
|
|
3714
3818
|
}
|
|
3715
3819
|
if (req.method === 'GET' && path === '/api/health') {
|
|
3820
|
+
// REL-002: expose runtime/build identity so a server started before a
|
|
3821
|
+
// rebuild or running a different version than the project pin is visibly
|
|
3822
|
+
// stale. Uses only the cached latest-version lookup — never blocks.
|
|
3823
|
+
const versionStatus = resolveDqlRuntimeVersionStatus({ projectRoot, runningVersion: runtimeVersion });
|
|
3824
|
+
void fetchLatestPublishedDqlVersion();
|
|
3825
|
+
const healthValueGrounding = resolveAgentRuntimeValueGrounding(projectConfig);
|
|
3826
|
+
const retrievalHealth = resolveRetrievalHealthStatus({
|
|
3827
|
+
projectRoot,
|
|
3828
|
+
valueGroundingMode: healthValueGrounding.mode,
|
|
3829
|
+
searchSafeColumnCount: healthValueGrounding.searchSafeColumns.size,
|
|
3830
|
+
});
|
|
3716
3831
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
3717
|
-
res.end(serializeJSON({ status: 'ok', version: runtimeVersion }));
|
|
3832
|
+
res.end(serializeJSON({ status: 'ok', version: runtimeVersion, versionStatus, retrievalHealth }));
|
|
3718
3833
|
return;
|
|
3719
3834
|
}
|
|
3720
3835
|
if (req.method === 'GET' && path === '/api/onboarding/launch') {
|
|
@@ -4523,6 +4638,11 @@ export async function startLocalServer(opts) {
|
|
|
4523
4638
|
const conversationStore = parsed.request.threadId ? getConversationStore() : null;
|
|
4524
4639
|
if (conversationStore && parsed.request.threadId && conversationStore.getThread(parsed.request.threadId)) {
|
|
4525
4640
|
parsed.request.conversationContext = await conversationContextFromThread(conversationStore, parsed.request.threadId, parsed.request.conversationContext, parsed.request.question);
|
|
4641
|
+
// The persisted thread is authoritative for prior turns. Raw client
|
|
4642
|
+
// history would duplicate the same conversation into the prompt a
|
|
4643
|
+
// second time — dropping it keeps follow-up prompts bounded.
|
|
4644
|
+
if (parsed.request.history?.length)
|
|
4645
|
+
parsed.request.history = [];
|
|
4526
4646
|
}
|
|
4527
4647
|
const wantsStream = url.searchParams.get('stream') === '1' || url.searchParams.get('stream') === 'true';
|
|
4528
4648
|
const runId = parsed.request.runId;
|
|
@@ -8722,6 +8842,12 @@ export async function startLocalServer(opts) {
|
|
|
8722
8842
|
}));
|
|
8723
8843
|
return;
|
|
8724
8844
|
}
|
|
8845
|
+
const provider = semanticConfig?.provider ?? semanticDetectedProvider ?? 'dql';
|
|
8846
|
+
const dbtManifestReady = provider === 'dbt'
|
|
8847
|
+
? hasDbtSemanticManifest(projectRoot, semanticConfig?.projectPath)
|
|
8848
|
+
: false;
|
|
8849
|
+
const metricFlowReady = provider === 'dbt' ? hasMetricFlowCli() : false;
|
|
8850
|
+
const dbtExecutionReady = dbtManifestReady && metricFlowReady;
|
|
8725
8851
|
const metrics = semanticLayer.listMetrics().map((m) => ({
|
|
8726
8852
|
name: m.name,
|
|
8727
8853
|
label: m.label,
|
|
@@ -8736,6 +8862,7 @@ export async function startLocalServer(opts) {
|
|
|
8736
8862
|
typeParams: m.typeParams ?? null,
|
|
8737
8863
|
filter: m.filter ?? null,
|
|
8738
8864
|
source: m.source ?? null,
|
|
8865
|
+
execution: semanticMetricExecutionCapability(m.name, semanticLayer, provider, metricFlowReady, connection?.driver),
|
|
8739
8866
|
}));
|
|
8740
8867
|
const measures = semanticLayer.listMeasures().map((m) => ({
|
|
8741
8868
|
name: m.name,
|
|
@@ -8833,12 +8960,6 @@ export async function startLocalServer(opts) {
|
|
|
8833
8960
|
owner: q.owner ?? null,
|
|
8834
8961
|
source: q.source ?? null,
|
|
8835
8962
|
}));
|
|
8836
|
-
const provider = semanticConfig?.provider ?? semanticDetectedProvider ?? 'dql';
|
|
8837
|
-
const dbtManifestReady = provider === 'dbt'
|
|
8838
|
-
? hasDbtSemanticManifest(projectRoot, semanticConfig?.projectPath)
|
|
8839
|
-
: false;
|
|
8840
|
-
const metricFlowReady = provider === 'dbt' ? hasMetricFlowCli() : false;
|
|
8841
|
-
const dbtExecutionReady = dbtManifestReady && metricFlowReady;
|
|
8842
8963
|
const dbtExecutionSetup = dbtExecutionReady
|
|
8843
8964
|
? null
|
|
8844
8965
|
: !dbtManifestReady && !metricFlowReady
|
|
@@ -9753,8 +9874,20 @@ export async function startLocalServer(opts) {
|
|
|
9753
9874
|
tableMapping,
|
|
9754
9875
|
});
|
|
9755
9876
|
if (!composed) {
|
|
9877
|
+
const provider = semanticConfig?.provider ?? semanticDetectedProvider ?? 'dql';
|
|
9878
|
+
const metricFlowReady = provider === 'dbt' && hasMetricFlowCli();
|
|
9879
|
+
const blocked = metrics.map((metricName) => ({
|
|
9880
|
+
metric: metricName,
|
|
9881
|
+
...semanticMetricExecutionCapability(metricName, semanticLayer, provider, metricFlowReady, driver),
|
|
9882
|
+
})).filter((capability) => capability.status !== 'ready');
|
|
9756
9883
|
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9757
|
-
res.end(serializeJSON({
|
|
9884
|
+
res.end(serializeJSON({
|
|
9885
|
+
error: blocked.length > 0
|
|
9886
|
+
? blocked.map((capability) => `${capability.metric}: ${capability.reason}`).join(' ')
|
|
9887
|
+
: 'The selected dimensions do not share a governed join path with every selected metric.',
|
|
9888
|
+
code: blocked.length > 0 ? 'SEMANTIC_RUNTIME_REQUIRED' : 'SEMANTIC_FIELDS_INCOMPATIBLE',
|
|
9889
|
+
details: { metrics, dimensions, blocked },
|
|
9890
|
+
}));
|
|
9758
9891
|
return;
|
|
9759
9892
|
}
|
|
9760
9893
|
// Execute the composed SQL against the resolved connection
|
|
@@ -9839,8 +9972,20 @@ export async function startLocalServer(opts) {
|
|
|
9839
9972
|
tableMapping,
|
|
9840
9973
|
});
|
|
9841
9974
|
if (!composed) {
|
|
9975
|
+
const provider = semanticConfig?.provider ?? semanticDetectedProvider ?? 'dql';
|
|
9976
|
+
const metricFlowReady = provider === 'dbt' && hasMetricFlowCli();
|
|
9977
|
+
const blocked = metrics.map((metricName) => ({
|
|
9978
|
+
metric: metricName,
|
|
9979
|
+
...semanticMetricExecutionCapability(metricName, semanticLayer, provider, metricFlowReady, driver),
|
|
9980
|
+
})).filter((capability) => capability.status !== 'ready');
|
|
9842
9981
|
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9843
|
-
res.end(serializeJSON({
|
|
9982
|
+
res.end(serializeJSON({
|
|
9983
|
+
error: blocked.length > 0
|
|
9984
|
+
? blocked.map((capability) => `${capability.metric}: ${capability.reason}`).join(' ')
|
|
9985
|
+
: 'The selected dimensions do not share a governed join path with every selected metric.',
|
|
9986
|
+
code: blocked.length > 0 ? 'SEMANTIC_RUNTIME_REQUIRED' : 'SEMANTIC_FIELDS_INCOMPATIBLE',
|
|
9987
|
+
details: { metrics, dimensions, blocked },
|
|
9988
|
+
}));
|
|
9844
9989
|
return;
|
|
9845
9990
|
}
|
|
9846
9991
|
const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
|
|
@@ -13063,6 +13208,16 @@ export function buildExploratoryJoinProbeSql(input) {
|
|
|
13063
13208
|
SELECT right_key, COUNT(*) AS match_count
|
|
13064
13209
|
FROM matches
|
|
13065
13210
|
GROUP BY right_key
|
|
13211
|
+
),
|
|
13212
|
+
left_key_counts AS (
|
|
13213
|
+
SELECT join_key, COUNT(*) AS key_count
|
|
13214
|
+
FROM left_sample
|
|
13215
|
+
GROUP BY join_key
|
|
13216
|
+
),
|
|
13217
|
+
right_key_counts AS (
|
|
13218
|
+
SELECT join_key, COUNT(*) AS key_count
|
|
13219
|
+
FROM right_sample
|
|
13220
|
+
GROUP BY join_key
|
|
13066
13221
|
)
|
|
13067
13222
|
SELECT
|
|
13068
13223
|
(SELECT COUNT(*) FROM left_sample) AS left_sample_rows,
|
|
@@ -13071,7 +13226,87 @@ SELECT
|
|
|
13071
13226
|
(SELECT COUNT(*) FROM left_sample AS l WHERE NOT EXISTS (SELECT 1 FROM right_sample AS r WHERE r.join_key = l.join_key)) AS unmatched_left_sample_rows,
|
|
13072
13227
|
(SELECT COUNT(*) FROM right_sample AS r WHERE NOT EXISTS (SELECT 1 FROM left_sample AS l WHERE l.join_key = r.join_key)) AS unmatched_right_sample_rows,
|
|
13073
13228
|
(SELECT COALESCE(MAX(match_count), 0) FROM left_counts) AS max_matches_per_left_key,
|
|
13074
|
-
(SELECT COALESCE(MAX(match_count), 0) FROM right_counts) AS max_matches_per_right_key
|
|
13229
|
+
(SELECT COALESCE(MAX(match_count), 0) FROM right_counts) AS max_matches_per_right_key,
|
|
13230
|
+
(SELECT COALESCE(MAX(key_count), 0) FROM left_key_counts) AS max_left_rows_per_key,
|
|
13231
|
+
(SELECT COALESCE(MAX(key_count), 0) FROM right_key_counts) AS max_right_rows_per_key`;
|
|
13232
|
+
}
|
|
13233
|
+
/**
|
|
13234
|
+
* CTE names are query-internal derived relations, not warehouse tables. A join
|
|
13235
|
+
* whose endpoint is a CTE (e.g. `WITH joy_items AS (…) … JOIN joy_items`) must
|
|
13236
|
+
* be excluded from declared-path enforcement and from join probes — probing
|
|
13237
|
+
* `FROM "joy_items"` throws a DuckDB catalog error for a table that was never
|
|
13238
|
+
* supposed to exist. The physical joins INSIDE the CTE are still present in
|
|
13239
|
+
* the parsed join list and get validated/probed on their own.
|
|
13240
|
+
*/
|
|
13241
|
+
export function probeableExploratoryJoins(joins, ctes) {
|
|
13242
|
+
const cteNames = new Set(ctes.map((name) => name.split('.').at(-1)?.toLowerCase() ?? name.toLowerCase()));
|
|
13243
|
+
if (cteNames.size === 0)
|
|
13244
|
+
return joins;
|
|
13245
|
+
return joins.filter((join) => ![join.leftRelation, join.rightRelation].some((relation) => {
|
|
13246
|
+
const bare = relation?.replace(/["`\[\]]/g, '').split('.').at(-1)?.toLowerCase();
|
|
13247
|
+
return Boolean(bare && cteNames.has(bare));
|
|
13248
|
+
}));
|
|
13249
|
+
}
|
|
13250
|
+
function declaredExploratoryRelationMatches(sqlRelation, declaredRelation) {
|
|
13251
|
+
if (!declaredRelation)
|
|
13252
|
+
return false;
|
|
13253
|
+
const normalize = (value) => value.replace(/["`\[\]]/g, '').trim();
|
|
13254
|
+
return exploratoryRelationsMatch(normalize(sqlRelation), normalize(declaredRelation));
|
|
13255
|
+
}
|
|
13256
|
+
function exploratoryColumnsMatch(sqlColumn, declaredColumn) {
|
|
13257
|
+
const normalize = (value) => value.replace(/["`\[\]]/g, '').split('.').at(-1)?.trim().toLowerCase() ?? value.toLowerCase();
|
|
13258
|
+
return normalize(sqlColumn) === normalize(declaredColumn);
|
|
13259
|
+
}
|
|
13260
|
+
/**
|
|
13261
|
+
* Gate a join probe against the declared relationship. Returns an error string
|
|
13262
|
+
* when the UNFILTERED key samples structurally contradict the declaration:
|
|
13263
|
+
* - zero key overlap while both sides have rows (wrong/mistyped key), or
|
|
13264
|
+
* - duplicate keys on the declared "one" side of a *_to_one / one_to_* edge.
|
|
13265
|
+
* Sampling caveat: gates only fire on definitive contradictions, never on low
|
|
13266
|
+
* match rates, so a sparse but real relationship still executes.
|
|
13267
|
+
*/
|
|
13268
|
+
export function exploratoryProbeContradiction(rows, join, edge) {
|
|
13269
|
+
const row = Array.isArray(rows) && rows[0] && typeof rows[0] === 'object'
|
|
13270
|
+
? rows[0]
|
|
13271
|
+
: {};
|
|
13272
|
+
const number = (key) => {
|
|
13273
|
+
const value = Number(row[key]);
|
|
13274
|
+
return Number.isFinite(value) ? value : 0;
|
|
13275
|
+
};
|
|
13276
|
+
const leftRows = number('left_sample_rows');
|
|
13277
|
+
const rightRows = number('right_sample_rows');
|
|
13278
|
+
const joined = number('joined_rows');
|
|
13279
|
+
if (leftRows > 0 && rightRows > 0 && joined === 0) {
|
|
13280
|
+
return `The join key ${join.leftColumn} = ${join.rightColumn} produced no matching rows between ${join.leftRelation} and ${join.rightRelation} (unfiltered sample). The declared key pair is likely wrong — review the relationship before executing this analysis.`;
|
|
13281
|
+
}
|
|
13282
|
+
if (!edge)
|
|
13283
|
+
return undefined;
|
|
13284
|
+
// Orient the declared cardinality onto the parsed join: which physical side is
|
|
13285
|
+
// the declared "one" side? Duplicated keys there contradict the declaration.
|
|
13286
|
+
const leftIsFrom = Boolean(join.leftRelation && declaredExploratoryRelationMatches(join.leftRelation, edge.fromRelation));
|
|
13287
|
+
const rightIsFrom = Boolean(join.rightRelation && declaredExploratoryRelationMatches(join.rightRelation, edge.fromRelation));
|
|
13288
|
+
if (leftIsFrom === rightIsFrom)
|
|
13289
|
+
return undefined; // orientation unresolved — do not guess
|
|
13290
|
+
const oneSide = edge.cardinality === 'many_to_one' ? 'to' : edge.cardinality === 'one_to_many' ? 'from' : edge.cardinality === 'one_to_one' ? 'both' : undefined;
|
|
13291
|
+
if (!oneSide)
|
|
13292
|
+
return undefined;
|
|
13293
|
+
// Per-side key duplication measured WITHIN each sample (max rows sharing one
|
|
13294
|
+
// key value). Duplicates inside a sample are definitive — a subset of a table
|
|
13295
|
+
// cannot show duplicates the full table does not have. The pairwise
|
|
13296
|
+
// max_matches_* columns are NOT used here: they inflate whenever the
|
|
13297
|
+
// legitimate "many" side repeats a key value.
|
|
13298
|
+
const leftDuplicated = number('max_left_rows_per_key') > 1;
|
|
13299
|
+
const rightDuplicated = number('max_right_rows_per_key') > 1;
|
|
13300
|
+
const fromSideIsLeft = leftIsFrom;
|
|
13301
|
+
const duplicatedSides = [
|
|
13302
|
+
...(leftDuplicated ? [fromSideIsLeft ? 'from' : 'to'] : []),
|
|
13303
|
+
...(rightDuplicated ? [fromSideIsLeft ? 'to' : 'from'] : []),
|
|
13304
|
+
];
|
|
13305
|
+
const violated = oneSide === 'both' ? duplicatedSides.length > 0 : duplicatedSides.includes(oneSide);
|
|
13306
|
+
if (violated) {
|
|
13307
|
+
return `Relationship "${edge.relationshipId}" declares ${edge.cardinality}, but the sampled join keys show duplicates on the declared unique side. Executing would duplicate rows — validate and correct the relationship first.`;
|
|
13308
|
+
}
|
|
13309
|
+
return undefined;
|
|
13075
13310
|
}
|
|
13076
13311
|
function summarizeExploratoryJoinProbe(rows, leftRelation, leftColumn, rightRelation, rightColumn) {
|
|
13077
13312
|
const row = Array.isArray(rows) && rows[0] && typeof rows[0] === 'object'
|
|
@@ -13671,6 +13906,28 @@ export function openBlockStudioDocument(projectRoot, relativePath, semanticLayer
|
|
|
13671
13906
|
validation: validateBlockStudioSource(source, semanticLayer),
|
|
13672
13907
|
};
|
|
13673
13908
|
}
|
|
13909
|
+
function semanticMetricExecutionCapability(metricName, semanticLayer, provider, metricFlowReady, driver) {
|
|
13910
|
+
if (provider === 'dbt' && metricFlowReady) {
|
|
13911
|
+
return { status: 'ready', engine: 'metricflow', reason: null };
|
|
13912
|
+
}
|
|
13913
|
+
const native = semanticLayer.composeQuery({ metrics: [metricName], dimensions: [], driver });
|
|
13914
|
+
if (native)
|
|
13915
|
+
return { status: 'ready', engine: 'native', reason: null };
|
|
13916
|
+
const metric = semanticLayer.getMetric(metricName);
|
|
13917
|
+
if (provider === 'dbt') {
|
|
13918
|
+
const kind = metric?.metricType || metric?.aggregation || metric?.type || 'metric';
|
|
13919
|
+
return {
|
|
13920
|
+
status: 'requires_setup',
|
|
13921
|
+
engine: null,
|
|
13922
|
+
reason: `${kind} metric requires MetricFlow execution. Install/configure the MetricFlow CLI, then refresh the semantic layer.`,
|
|
13923
|
+
};
|
|
13924
|
+
}
|
|
13925
|
+
return {
|
|
13926
|
+
status: 'unsupported',
|
|
13927
|
+
engine: null,
|
|
13928
|
+
reason: 'The metric does not have enough composable measure and relation metadata.',
|
|
13929
|
+
};
|
|
13930
|
+
}
|
|
13674
13931
|
function parseBlockStudioArrayField(source, key) {
|
|
13675
13932
|
const match = source.match(new RegExp(`\\b${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`, 'i'));
|
|
13676
13933
|
if (!match)
|
|
@@ -13905,10 +14162,18 @@ function composeSemanticBlockSql(source, semanticLayer, options) {
|
|
|
13905
14162
|
return { sql: null, diagnostics, semanticRefs };
|
|
13906
14163
|
}
|
|
13907
14164
|
if (!composed) {
|
|
14165
|
+
const provider = options?.projectConfig && isDbtSemanticRuntime(options.projectConfig, options.detectedProvider, semanticLayer) ? 'dbt' : (options?.detectedProvider ?? 'dql');
|
|
14166
|
+
const metricFlowReady = provider === 'dbt' && hasMetricFlowCli();
|
|
14167
|
+
const reasons = metrics.map((metricName) => {
|
|
14168
|
+
const capability = semanticMetricExecutionCapability(metricName, semanticLayer, provider, metricFlowReady, options?.driver);
|
|
14169
|
+
return capability.status === 'ready' ? null : `${metricName}: ${capability.reason}`;
|
|
14170
|
+
}).filter((reason) => Boolean(reason));
|
|
13908
14171
|
diagnostics.push({
|
|
13909
14172
|
severity: 'error',
|
|
13910
14173
|
code: 'semantic_compose_failed',
|
|
13911
|
-
message:
|
|
14174
|
+
message: reasons.length > 0
|
|
14175
|
+
? `Could not compose SQL for semantic block metrics. ${reasons.join(' ')}`
|
|
14176
|
+
: `Could not compose SQL for semantic block metrics: [${metrics.join(', ')}]. Check that the selected dimensions share a governed join path.`,
|
|
13912
14177
|
});
|
|
13913
14178
|
return { sql: null, diagnostics, semanticRefs };
|
|
13914
14179
|
}
|