@duckcodeailabs/dql-cli 1.13.1 → 1.13.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.
- package/dist/apps-api.d.ts +225 -5
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +1950 -117
- package/dist/apps-api.js.map +1 -1
- package/dist/assets/dql-notebook/assets/{AgentLogPage-D6fgEAEy.js → AgentLogPage-BlUE3qsX.js} +1 -1
- package/dist/assets/dql-notebook/assets/{AiBuildDialog-8miXFPse.js → AiBuildDialog-O88iK6UW.js} +1 -1
- package/dist/assets/dql-notebook/assets/{AiBuildResult-DPJ_SI0R.js → AiBuildResult-D-6G4Sh3.js} +8 -8
- package/dist/assets/dql-notebook/assets/{AiSidePanel-NVA6lZnn.js → AiSidePanel-MRSGtQDV.js} +1 -1
- package/dist/assets/dql-notebook/assets/AnalyticsHome-CcRVO6zN.js +6 -0
- package/dist/assets/dql-notebook/assets/AppsView-BTkGC-wl.js +5588 -0
- package/dist/assets/dql-notebook/assets/{BlockStudio--jNw9XUI.js → BlockStudio-BrTNaDpb.js} +10 -15
- package/dist/assets/dql-notebook/assets/{BusinessArtifactView-DtRMiaF4.js → BusinessArtifactView-itEIUrYy.js} +1 -1
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-CbAqOpNc.js +22 -0
- package/dist/assets/dql-notebook/assets/{GitPage-CazC-eTR.js → GitPage-BxOqRYu2.js} +1 -1
- package/dist/assets/dql-notebook/assets/GlobalAiRail-Tfkx76CI.js +1 -0
- package/dist/assets/dql-notebook/assets/GovernedContextPage-CeaDbepP.js +7 -0
- package/dist/assets/dql-notebook/assets/{HelpDocsPage-DMHPsVqP.js → HelpDocsPage-D1lAryYv.js} +1 -1
- package/dist/assets/dql-notebook/assets/{HomePage-DcSI4dnU.js → HomePage-GB6se6xW.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDAG-BJ6xz06u.js → LineageDAG-zCyN-hdg.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDetailView-8WrQm-MF.js → LineageDetailView-Cg0TgG2Y.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineageDrawer-hnqCW_t-.js → LineageDrawer-DwMcenDf.js} +1 -1
- package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-B4LZTUsl.js → LineagePathBreadcrumb-DUZx3ksU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{MiniLineageGraph-CotnRuxa.js → MiniLineageGraph-DT_ycl6s.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewBlockModal-DHn-CEEW.js → NewBlockModal-qdhzeJPF.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NewNotebookModal-BPGp-8oq.js → NewNotebookModal-BnaDzs1A.js} +1 -1
- package/dist/assets/dql-notebook/assets/{NotebookEditor-B0OTHM6W.js → NotebookEditor-CouPFzdo.js} +5 -5
- package/dist/assets/dql-notebook/assets/{ReadinessPage-CoOIpxWq.js → ReadinessPage-DB_KbH9Y.js} +1 -1
- package/dist/assets/dql-notebook/assets/{SetupOnboarding-DxK_mFzf.js → SetupOnboarding-CamQq_N2.js} +1 -1
- package/dist/assets/dql-notebook/assets/SkillsPage--mYkOAK2.js +10 -0
- package/dist/assets/dql-notebook/assets/{TrustBadge-ZuD5N5Ww.js → TrustBadge-ClEYiYeA.js} +1 -1
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-DBfsmXes.js +88 -0
- package/dist/assets/dql-notebook/assets/{answer-to-notebook-WAVAYjBv.js → answer-to-notebook-Cg3UyGwh.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-left-L8odLbyN.js → arrow-left-DnYPGqlr.js} +1 -1
- package/dist/assets/dql-notebook/assets/{arrow-right-WIF1V9a9.js → arrow-right-Ct_YGrmU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{book-open-text-BpqWMa4m.js → book-open-text-DULlwbj6.js} +1 -1
- package/dist/assets/dql-notebook/assets/{circle-x-CQBOM0b0.js → circle-x-ByYxoiS9.js} +1 -1
- package/dist/assets/dql-notebook/assets/{dagre.esm-DlZi7NR3.js → dagre.esm-BjYSO53F.js} +1 -1
- package/dist/assets/dql-notebook/assets/{external-link-DYqdQIGp.js → external-link-DprWh9Yf.js} +1 -1
- package/dist/assets/dql-notebook/assets/{grip-vertical-HmUijbwN.js → grip-vertical-CrEEYkXN.js} +1 -1
- package/dist/assets/dql-notebook/assets/{index-Cpx47jCH.js → index-9w93YPF9.js} +127 -126
- package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +1 -0
- package/dist/assets/dql-notebook/assets/link-2-BkW-rkEC.js +6 -0
- package/dist/assets/dql-notebook/assets/{list-tree-DGKqCxmz.js → list-tree-BipBLmIE.js} +1 -1
- package/dist/assets/dql-notebook/assets/{minimize-2-C8dvqCxX.js → minimize-2-BhXsglrO.js} +1 -1
- package/dist/assets/dql-notebook/assets/{panel-right-open-yuVkIo5n.js → panel-right-open-BoCSilYB.js} +1 -1
- package/dist/assets/dql-notebook/assets/{bot-CLR1V6dO.js → play-mdoZawPu.js} +7 -2
- package/dist/assets/dql-notebook/assets/{rotate-ccw-Di0kRnrM.js → rotate-ccw-ELiDRUmj.js} +1 -1
- package/dist/assets/dql-notebook/assets/{semantic-fields-BMQV0li8.js → semantic-fields-DVNRLfeU.js} +1 -1
- package/dist/assets/dql-notebook/assets/{sliders-horizontal-alebptlJ.js → sliders-horizontal-GeUzpmKz.js} +1 -1
- package/dist/assets/dql-notebook/assets/{star-CgCQbnXc.js → star-D63O81Wp.js} +1 -1
- package/dist/assets/dql-notebook/assets/{triangle-alert-SyuSZ4ht.js → triangle-alert-BWrCxQVE.js} +1 -1
- package/dist/assets/dql-notebook/assets/upload-CI-ldXhC.js +6 -0
- package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Cv7Yq2hK.js +32 -0
- package/dist/assets/dql-notebook/assets/{user-round-J4PQHxo9.js → user-round-Do21aFGo.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wand-sparkles-c4WDXTCk.js → wand-sparkles-DT7u7F_x.js} +1 -1
- package/dist/assets/dql-notebook/assets/{workflow-NtRLaq7n.js → workflow-DHh2mMbn.js} +1 -1
- package/dist/assets/dql-notebook/assets/{wrench-CS1f1Zv4.js → wrench-7tYr_GKb.js} +1 -1
- package/dist/assets/dql-notebook/assets/{x-CbKEfe5m.js → x-CcYceFMz.js} +1 -1
- package/dist/assets/dql-notebook/index.html +2 -2
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +65 -133
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/model.js +21 -2
- package/dist/commands/model.js.map +1 -1
- package/dist/llm/providers/dql-agent-provider.d.ts +16 -0
- package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
- package/dist/llm/providers/dql-agent-provider.js +65 -2
- package/dist/llm/providers/dql-agent-provider.js.map +1 -1
- package/dist/local-runtime.d.ts +65 -0
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +1008 -125
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- package/dist/sql-result-filter.d.ts +9 -0
- package/dist/sql-result-filter.d.ts.map +1 -1
- package/dist/sql-result-filter.js +25 -0
- package/dist/sql-result-filter.js.map +1 -1
- package/package.json +10 -10
- package/dist/assets/dql-notebook/assets/AgentAnswerCard-c8QUo6NH.js +0 -5
- package/dist/assets/dql-notebook/assets/AnalyticsHome-DgwnRW71.js +0 -6
- package/dist/assets/dql-notebook/assets/AppsView-B6LYUpgm.js +0 -4578
- package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-DhBsLCYr.js +0 -22
- package/dist/assets/dql-notebook/assets/GlobalAiRail-1Tgweuu2.js +0 -1
- package/dist/assets/dql-notebook/assets/GovernedContextPage-C7qGrH3d.js +0 -16
- package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-duFXXNV_.js +0 -88
- package/dist/assets/dql-notebook/assets/index-DKu4CZt3.css +0 -1
- package/dist/assets/dql-notebook/assets/upload-BPhfea_E.js +0 -11
- package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Bnl9gWhj.js +0 -28
package/dist/local-runtime.js
CHANGED
|
@@ -15,7 +15,7 @@ import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, pla
|
|
|
15
15
|
import { resolveNpmInvocation } from './npm-runtime.js';
|
|
16
16
|
import { ensureDqlGitignore, isGovernedSourceFile, isLegacyBroadDqlIgnore, } from './git-contract.js';
|
|
17
17
|
import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
|
|
18
|
-
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, previewModelingChanges, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, } from '@duckcodeailabs/dql-core';
|
|
18
|
+
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, previewModelingChanges, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, collectDbtRelationshipTests, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, modelAreaLocalId, DEFAULT_MODEL_AREA_ID, } from '@duckcodeailabs/dql-core';
|
|
19
19
|
import { load as loadYaml } from 'js-yaml';
|
|
20
20
|
import { listBlockTemplates } from './block-templates.js';
|
|
21
21
|
import { getRunner as getLLMRunner } from './llm/index.js';
|
|
@@ -25,7 +25,7 @@ import { resolveRetrievalHealthStatus } from './retrieval-health.js';
|
|
|
25
25
|
import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
|
|
26
26
|
import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
|
|
27
27
|
import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, normalizeWarehouseSqlFailure, parseProposal, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, previewSkillChange, buildContextAuthoringProposal, contextAuthoringDependencyClosure, FileContextAuthoringProposalStore, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, resolveInternalRelationIds, analyticalError, tagAnalyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, } from '@duckcodeailabs/dql-agent';
|
|
28
|
-
import { addSqlResultFilter, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
|
|
28
|
+
import { addSqlResultFilter, dashboardFilterableResultColumns, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
|
|
29
29
|
import { gatherProposeEnrichment } from './propose-enrich.js';
|
|
30
30
|
import { handleAppsApi, proposeAppAiBuild, recommendVisualization, } from './apps-api.js';
|
|
31
31
|
import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
|
|
@@ -87,7 +87,16 @@ function hasDbtSemanticArtifacts(projectRoot, dbtProjectDir, configuredManifestP
|
|
|
87
87
|
}
|
|
88
88
|
return false;
|
|
89
89
|
}
|
|
90
|
-
|
|
90
|
+
// Every member of `AgentRunRequestedMode`. The `Record` (rather than a bare
|
|
91
|
+
// Set literal) is deliberate: a `Set<AgentRunRequestedMode>` happily accepts a
|
|
92
|
+
// subset, which is how 'modeling' and 'skill' went missing here — the parser
|
|
93
|
+
// dropped them to `undefined`, `selectRoute` never saw an explicit mode, and
|
|
94
|
+
// Modeling AI silently ran the governed answer loop instead of proposing a
|
|
95
|
+
// model. A Record makes an omission a compile error.
|
|
96
|
+
const AGENT_RUN_REQUESTED_MODE_MEMBERS = {
|
|
97
|
+
auto: true, ask: true, research: true, sql: true, block: true, app: true, modeling: true, skill: true,
|
|
98
|
+
};
|
|
99
|
+
const AGENT_RUN_REQUESTED_MODES = new Set(Object.keys(AGENT_RUN_REQUESTED_MODE_MEMBERS));
|
|
91
100
|
/** AGT-007 / AGT-022: App-specific context adapter into the uniform AgentRun. */
|
|
92
101
|
export function buildAppCopilotResearchAgentRequest(input) {
|
|
93
102
|
const memoOnly = input.mode === 'memo_only';
|
|
@@ -1503,59 +1512,6 @@ export async function startLocalServer(opts) {
|
|
|
1503
1512
|
});
|
|
1504
1513
|
return { domainContext, contextPack };
|
|
1505
1514
|
};
|
|
1506
|
-
const buildAgentPromptArtifact = async (request, target, repair) => {
|
|
1507
|
-
try {
|
|
1508
|
-
await reindexProject(projectRoot, { kgPath: defaultKgPath(projectRoot) });
|
|
1509
|
-
}
|
|
1510
|
-
catch {
|
|
1511
|
-
// Best-effort: buildFromPrompt can still use any existing KG/cache state.
|
|
1512
|
-
}
|
|
1513
|
-
const skills = loadSkills(projectRoot).skills;
|
|
1514
|
-
// On a repair re-run, target the prior failure and (for blocks) revise in place.
|
|
1515
|
-
const isRepair = (repair?.attempt ?? 0) > 0 && Boolean(repair?.repairHint);
|
|
1516
|
-
const mode = target === 'block' && (isRepair || agentRunWorkspaceValue(request, 'mode') === 'edit')
|
|
1517
|
-
? 'edit'
|
|
1518
|
-
: 'create';
|
|
1519
|
-
const prompt = isRepair
|
|
1520
|
-
? `${request.question}\n\nFix the previous attempt: ${repair?.repairHint}`
|
|
1521
|
-
: request.question;
|
|
1522
|
-
const domain = target === 'block' ? agentRunWorkspaceValue(request, 'domain') : undefined;
|
|
1523
|
-
const modelAreaId = agentRunWorkspaceValue(request, 'modelAreaId');
|
|
1524
|
-
const purpose = agentRunWorkspaceValue(request, 'purpose');
|
|
1525
|
-
const { domainContext, contextPack } = await buildDirectBuildContext({
|
|
1526
|
-
question: prompt,
|
|
1527
|
-
domain,
|
|
1528
|
-
purpose,
|
|
1529
|
-
modelAreaId,
|
|
1530
|
-
surface: target === 'cell' ? 'notebook' : 'block',
|
|
1531
|
-
selectedContext: { target, mode },
|
|
1532
|
-
});
|
|
1533
|
-
return buildFromPrompt({
|
|
1534
|
-
projectRoot,
|
|
1535
|
-
prompt,
|
|
1536
|
-
context: {
|
|
1537
|
-
cellSql: agentRunWorkspaceValue(request, 'cellSql'),
|
|
1538
|
-
selection: agentRunWorkspaceValue(request, 'selection'),
|
|
1539
|
-
},
|
|
1540
|
-
target,
|
|
1541
|
-
mode,
|
|
1542
|
-
blockPath: target === 'block'
|
|
1543
|
-
? agentRunWorkspaceValue(request, 'blockPath')
|
|
1544
|
-
?? (request.selectedObject?.kind === 'block' && request.selectedObject.path?.endsWith('.dql')
|
|
1545
|
-
? request.selectedObject.path
|
|
1546
|
-
: undefined)
|
|
1547
|
-
: undefined,
|
|
1548
|
-
owner: agentRunWorkspaceValue(request, 'owner'),
|
|
1549
|
-
domain,
|
|
1550
|
-
modelAreaId,
|
|
1551
|
-
contextPack,
|
|
1552
|
-
domainContext,
|
|
1553
|
-
userId: agentRunWorkspaceValue(request, 'userId'),
|
|
1554
|
-
skills,
|
|
1555
|
-
dbtManifestPath: resolveDbtManifestPath(projectRoot, projectConfig),
|
|
1556
|
-
executionProbe: target === 'block' ? runBlockReflectionProbe : undefined,
|
|
1557
|
-
});
|
|
1558
|
-
};
|
|
1559
1515
|
const agentRunEvaluation = (id, label, passed, severity, message, evidence) => ({ id, label, passed, severity, message, evidence });
|
|
1560
1516
|
const agentRunArtifact = (kind, title, payload, ref, trustState = 'review_required') => ({
|
|
1561
1517
|
id: `${kind}:${Date.now()}`,
|
|
@@ -2591,6 +2547,171 @@ export async function startLocalServer(opts) {
|
|
|
2591
2547
|
};
|
|
2592
2548
|
};
|
|
2593
2549
|
const contextProposalStore = new FileContextAuthoringProposalStore(projectRoot);
|
|
2550
|
+
/**
|
|
2551
|
+
* UI-019: authoring must never dead-end on a Domain/Area that does not exist
|
|
2552
|
+
* yet. Every modeling operation still belongs to exactly one Domain and Area
|
|
2553
|
+
* (`11-context-authoring-and-ai.md`); the difference is that DQL now creates
|
|
2554
|
+
* the missing scope as part of the same reviewable proposal instead of
|
|
2555
|
+
* blocking the author until they create it by hand. The synthesized
|
|
2556
|
+
* operations are ordinary reviewable patches, so nothing is written until the
|
|
2557
|
+
* proposal is explicitly committed.
|
|
2558
|
+
*/
|
|
2559
|
+
const ensureAuthoringScopeOperations = (operations, manifest) => {
|
|
2560
|
+
const modeling = manifest.modeling;
|
|
2561
|
+
if (!modeling)
|
|
2562
|
+
return operations;
|
|
2563
|
+
const existingDomains = new Set(Object.keys(modeling.packages ?? {}));
|
|
2564
|
+
const existingAreas = new Set(Object.values(modeling.areas ?? {}).flatMap((area) => [`${area.domain}::${area.localId}`, `${area.domain}::${area.qualifiedId}`]));
|
|
2565
|
+
// A scope created earlier in this same batch counts as existing.
|
|
2566
|
+
for (const operation of operations) {
|
|
2567
|
+
if (operation.kind !== 'modeling_change')
|
|
2568
|
+
continue;
|
|
2569
|
+
if (operation.change.operation === 'upsert_domain')
|
|
2570
|
+
existingDomains.add(operation.change.value.id);
|
|
2571
|
+
if (operation.change.operation === 'upsert_area')
|
|
2572
|
+
existingAreas.add(`${operation.change.value.domain}::${operation.change.value.id}`);
|
|
2573
|
+
}
|
|
2574
|
+
const created = [];
|
|
2575
|
+
const domainOperationId = new Map();
|
|
2576
|
+
const areaOperationId = new Map();
|
|
2577
|
+
const defaultAreaName = manifest.dbtProvenance?.projectName
|
|
2578
|
+
? titleCaseAuthoring(manifest.dbtProvenance.projectName)
|
|
2579
|
+
: 'Core models';
|
|
2580
|
+
const withScope = operations.map((operation) => {
|
|
2581
|
+
if (operation.kind !== 'modeling_change')
|
|
2582
|
+
return operation;
|
|
2583
|
+
const change = operation.change;
|
|
2584
|
+
if (change.operation !== 'upsert_entity' && change.operation !== 'upsert_relationship')
|
|
2585
|
+
return operation;
|
|
2586
|
+
const domain = change.value.domain;
|
|
2587
|
+
if (!domain)
|
|
2588
|
+
return operation;
|
|
2589
|
+
const dependsOn = [...(operation.dependsOn ?? [])];
|
|
2590
|
+
if (!existingDomains.has(domain)) {
|
|
2591
|
+
let id = domainOperationId.get(domain);
|
|
2592
|
+
if (!id) {
|
|
2593
|
+
id = `scope:domain:${domain}`;
|
|
2594
|
+
domainOperationId.set(domain, id);
|
|
2595
|
+
created.push({
|
|
2596
|
+
id,
|
|
2597
|
+
kind: 'modeling_change',
|
|
2598
|
+
evidence: ['created automatically so the requested modeling scope exists'],
|
|
2599
|
+
change: { operation: 'upsert_domain', value: { id: domain, name: titleCaseAuthoring(domain) } },
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
if (!dependsOn.includes(id))
|
|
2603
|
+
dependsOn.push(id);
|
|
2604
|
+
}
|
|
2605
|
+
const areaId = change.value.areaId;
|
|
2606
|
+
if (areaId && !existingAreas.has(`${domain}::${areaId}`)) {
|
|
2607
|
+
const key = `${domain}::${areaId}`;
|
|
2608
|
+
let id = areaOperationId.get(key);
|
|
2609
|
+
if (!id) {
|
|
2610
|
+
id = `scope:area:${domain}:${areaId}`;
|
|
2611
|
+
areaOperationId.set(key, id);
|
|
2612
|
+
created.push({
|
|
2613
|
+
id,
|
|
2614
|
+
kind: 'modeling_change',
|
|
2615
|
+
dependsOn: domainOperationId.has(domain) ? [domainOperationId.get(domain)] : [],
|
|
2616
|
+
evidence: ['created automatically so the requested modeling scope exists'],
|
|
2617
|
+
change: {
|
|
2618
|
+
operation: 'upsert_area',
|
|
2619
|
+
value: {
|
|
2620
|
+
id: areaId,
|
|
2621
|
+
domain,
|
|
2622
|
+
name: areaId === DEFAULT_MODEL_AREA_ID ? defaultAreaName : titleCaseAuthoring(areaId),
|
|
2623
|
+
description: 'Subject area created automatically when modeling started. Rename or split it at any time.',
|
|
2624
|
+
},
|
|
2625
|
+
},
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
if (!dependsOn.includes(id))
|
|
2629
|
+
dependsOn.push(id);
|
|
2630
|
+
}
|
|
2631
|
+
return dependsOn.length ? { ...operation, dependsOn } : operation;
|
|
2632
|
+
});
|
|
2633
|
+
return created.length ? [...created, ...withScope] : withScope;
|
|
2634
|
+
};
|
|
2635
|
+
/**
|
|
2636
|
+
* REL-004: when a batch binds dbt models, bring across the relationships dbt
|
|
2637
|
+
* already declares in its `relationships` tests. dbt states the exact key
|
|
2638
|
+
* pair and `dbt test` enforces it, so redrawing those edges by hand is pure
|
|
2639
|
+
* duplicated work.
|
|
2640
|
+
*
|
|
2641
|
+
* REL-001 is unchanged: a dbt test is evidence, not join authorization. Every
|
|
2642
|
+
* edge produced here is `status: 'draft'` with `fanout: 'unknown'` and no
|
|
2643
|
+
* validation receipt, so it can never prove an automatic join.
|
|
2644
|
+
*/
|
|
2645
|
+
const appendDbtDeclaredRelationships = (operations, manifest) => {
|
|
2646
|
+
const manifestPath = manifest.dbtProvenance?.manifestPath;
|
|
2647
|
+
if (!manifestPath || !existsSync(manifestPath))
|
|
2648
|
+
return operations;
|
|
2649
|
+
// Entities this batch creates, plus entities already bound in the project.
|
|
2650
|
+
const bound = new Map();
|
|
2651
|
+
for (const entity of Object.values(manifest.modeling?.entities ?? {})) {
|
|
2652
|
+
bound.set(entity.dbtUniqueId, { id: entity.localId, domain: entity.domain, areaId: modelAreaLocalId(entity.areaId) });
|
|
2653
|
+
}
|
|
2654
|
+
let batchBindings = 0;
|
|
2655
|
+
for (const operation of operations) {
|
|
2656
|
+
if (operation.kind !== 'modeling_change' || operation.change.operation !== 'upsert_entity')
|
|
2657
|
+
continue;
|
|
2658
|
+
const value = operation.change.value;
|
|
2659
|
+
bound.set(value.dbtModel, { id: value.id, domain: value.domain, areaId: value.areaId, operationId: operation.id });
|
|
2660
|
+
batchBindings += 1;
|
|
2661
|
+
}
|
|
2662
|
+
if (batchBindings === 0 || bound.size < 2)
|
|
2663
|
+
return operations;
|
|
2664
|
+
let rawManifest;
|
|
2665
|
+
try {
|
|
2666
|
+
rawManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
2667
|
+
}
|
|
2668
|
+
catch {
|
|
2669
|
+
return operations;
|
|
2670
|
+
}
|
|
2671
|
+
const existingRelationshipIds = new Set([
|
|
2672
|
+
...Object.values(manifest.modeling?.relationships ?? {}).map((relationship) => `${relationship.ownerDomain ?? ''}::${relationship.localId}`),
|
|
2673
|
+
...operations.flatMap((operation) => operation.kind === 'modeling_change' && operation.change.operation === 'upsert_relationship'
|
|
2674
|
+
? [`${operation.change.value.domain}::${operation.change.value.id}`]
|
|
2675
|
+
: []),
|
|
2676
|
+
]);
|
|
2677
|
+
const harvested = [];
|
|
2678
|
+
for (const edge of collectDbtRelationshipTests(rawManifest, new Set(bound.keys()))) {
|
|
2679
|
+
if (edge.fromColumn === 'unknown' || edge.toColumn === 'unknown')
|
|
2680
|
+
continue;
|
|
2681
|
+
const from = bound.get(edge.fromDbtUniqueId);
|
|
2682
|
+
const to = bound.get(edge.toDbtUniqueId);
|
|
2683
|
+
if (!from || !to || from.id === to.id)
|
|
2684
|
+
continue;
|
|
2685
|
+
// Only offer edges this batch actually introduces, and keep cross-domain
|
|
2686
|
+
// links on the explicit export/import path.
|
|
2687
|
+
if (!from.operationId && !to.operationId)
|
|
2688
|
+
continue;
|
|
2689
|
+
if (from.domain !== to.domain)
|
|
2690
|
+
continue;
|
|
2691
|
+
const id = `${from.id}_to_${to.id}`;
|
|
2692
|
+
const key = `${from.domain}::${id}`;
|
|
2693
|
+
if (existingRelationshipIds.has(key))
|
|
2694
|
+
continue;
|
|
2695
|
+
existingRelationshipIds.add(key);
|
|
2696
|
+
harvested.push({
|
|
2697
|
+
id: `dbt-test:${edge.testUniqueId}`,
|
|
2698
|
+
kind: 'modeling_change',
|
|
2699
|
+
dependsOn: [from.operationId, to.operationId].filter((value) => Boolean(value)),
|
|
2700
|
+
evidence: [edge.testUniqueId, ...(edge.sourcePath ? [edge.sourcePath] : [])],
|
|
2701
|
+
change: {
|
|
2702
|
+
operation: 'upsert_relationship',
|
|
2703
|
+
value: {
|
|
2704
|
+
id, domain: from.domain, areaId: from.areaId ?? to.areaId,
|
|
2705
|
+
from: from.id, to: to.id,
|
|
2706
|
+
keys: [{ from: edge.fromColumn, to: edge.toColumn }],
|
|
2707
|
+
cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
|
|
2708
|
+
rationale: `Declared by the dbt relationships test ${edge.testUniqueId}. Validate against the warehouse before certifying.`,
|
|
2709
|
+
},
|
|
2710
|
+
},
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
return harvested.length ? [...operations, ...harvested] : operations;
|
|
2714
|
+
};
|
|
2594
2715
|
const previewContextAuthoring = (input) => {
|
|
2595
2716
|
const snapshot = projectSnapshot();
|
|
2596
2717
|
if (input.expectedSnapshotId && input.expectedSnapshotId !== snapshot.snapshotId) {
|
|
@@ -2637,30 +2758,48 @@ export async function startLocalServer(opts) {
|
|
|
2637
2758
|
if (operation.change.operation === 'upsert_entity'
|
|
2638
2759
|
&& (input.origin === 'ai' || input.origin === 'yaml_import' || input.origin === 'dbt_discovery')
|
|
2639
2760
|
&& !operation.change.value.areaId) {
|
|
2640
|
-
|
|
2761
|
+
// Every model still belongs to exactly one Domain and subject area. An
|
|
2762
|
+
// omitted area is filled with the default rather than blocking the
|
|
2763
|
+
// author; `ensureAuthoringScopeOperations` creates it if it is new.
|
|
2764
|
+
diagnostics.push({ code: 'MODEL_AREA_DEFAULTED', severity: 'info', operationId: operation.id, message: `No subject area was selected, so these models were placed in "${DEFAULT_MODEL_AREA_ID}". You can rename or move them later.` });
|
|
2765
|
+
return { ...operation, change: { ...operation.change, value: { ...operation.change.value, areaId: DEFAULT_MODEL_AREA_ID } } };
|
|
2641
2766
|
}
|
|
2642
2767
|
return operation;
|
|
2643
2768
|
});
|
|
2644
|
-
|
|
2645
|
-
|
|
2769
|
+
// dbt discovery and Modeling AI bind models; give them the edges dbt already
|
|
2770
|
+
// declares. Manual authoring and YAML import bring their own relationships.
|
|
2771
|
+
const withDeclaredEdges = input.origin === 'dbt_discovery' || input.origin === 'ai'
|
|
2772
|
+
? appendDbtDeclaredRelationships(operations, snapshot.manifest)
|
|
2773
|
+
: operations;
|
|
2774
|
+
const scopedOperations = ensureAuthoringScopeOperations(withDeclaredEdges, snapshot.manifest);
|
|
2775
|
+
const operationIds = new Set(scopedOperations.map((operation) => operation.id));
|
|
2776
|
+
for (const operation of scopedOperations) {
|
|
2646
2777
|
for (const dependency of operation.dependsOn ?? []) {
|
|
2647
2778
|
if (!operationIds.has(dependency))
|
|
2648
2779
|
diagnostics.push({ code: 'DEPENDENCY_CONFLICT', severity: 'blocking', operationId: operation.id, message: `Required proposal operation is missing: ${dependency}` });
|
|
2649
2780
|
}
|
|
2650
2781
|
}
|
|
2651
2782
|
const patches = [];
|
|
2652
|
-
const modelingOperations =
|
|
2783
|
+
const modelingOperations = scopedOperations.filter((operation) => operation.kind === 'modeling_change');
|
|
2653
2784
|
if (modelingOperations.length > 0) {
|
|
2654
2785
|
const batch = previewModelingChanges(projectRoot, modelingOperations.map((operation) => operation.change));
|
|
2786
|
+
// The batch attributes each path while composing its shadow, so a change
|
|
2787
|
+
// that depends on an earlier one (an entity in a Domain this same batch
|
|
2788
|
+
// creates) is attributed correctly and without re-previewing per patch.
|
|
2789
|
+
const ownerByPath = new Map();
|
|
2790
|
+
batch.patchPathsByChange.forEach((paths, index) => {
|
|
2791
|
+
const operationId = modelingOperations[index]?.id;
|
|
2792
|
+
if (!operationId)
|
|
2793
|
+
return;
|
|
2794
|
+
for (const path of paths)
|
|
2795
|
+
if (!ownerByPath.has(path))
|
|
2796
|
+
ownerByPath.set(path, operationId);
|
|
2797
|
+
});
|
|
2655
2798
|
for (const patch of batch.patches) {
|
|
2656
|
-
|
|
2657
|
-
const single = previewModelingChange(projectRoot, operation.change);
|
|
2658
|
-
return single.patches.some((candidate) => candidate.path === patch.path);
|
|
2659
|
-
})?.id ?? modelingOperations[0].id;
|
|
2660
|
-
patches.push({ ...patch, owner: 'dql', operationId: owner });
|
|
2799
|
+
patches.push({ ...patch, owner: 'dql', operationId: ownerByPath.get(patch.path) ?? modelingOperations[0].id });
|
|
2661
2800
|
}
|
|
2662
2801
|
}
|
|
2663
|
-
for (const operation of
|
|
2802
|
+
for (const operation of scopedOperations) {
|
|
2664
2803
|
if (operation.kind === 'skill_change') {
|
|
2665
2804
|
const preview = previewSkillChange(projectRoot, operation.value, operation.targetQualifiedId, operation.expectedSourceHash);
|
|
2666
2805
|
for (const patch of preview.patches) {
|
|
@@ -2698,7 +2837,7 @@ export async function startLocalServer(opts) {
|
|
|
2698
2837
|
origin: input.origin,
|
|
2699
2838
|
baseSnapshotId: snapshot.snapshotId,
|
|
2700
2839
|
dependencyFingerprints,
|
|
2701
|
-
operations,
|
|
2840
|
+
operations: scopedOperations,
|
|
2702
2841
|
patches,
|
|
2703
2842
|
diagnostics,
|
|
2704
2843
|
sourceRunId: input.sourceRunId,
|
|
@@ -2710,34 +2849,75 @@ export async function startLocalServer(opts) {
|
|
|
2710
2849
|
};
|
|
2711
2850
|
const modelingAuthoringRunExecutor = async ({ runId, request }) => {
|
|
2712
2851
|
const snapshot = projectSnapshot();
|
|
2713
|
-
|
|
2852
|
+
// AGT-024: a configured provider designs the model; the deterministic
|
|
2853
|
+
// matcher is the documented provider-unavailable fallback, and the answer
|
|
2854
|
+
// says which one ran so an evidence-only suggestion is never mistaken for
|
|
2855
|
+
// an AI proposal.
|
|
2856
|
+
const provider = await createBlockStudioAssistProvider(projectRoot);
|
|
2857
|
+
let usedProvider = Boolean(provider);
|
|
2858
|
+
let operations;
|
|
2859
|
+
if (provider) {
|
|
2860
|
+
try {
|
|
2861
|
+
operations = await buildAiModelingOperations(projectRoot, snapshot.manifest, request, provider);
|
|
2862
|
+
}
|
|
2863
|
+
catch (error) {
|
|
2864
|
+
// Fall back only when the provider itself is unusable. A rejected
|
|
2865
|
+
// proposal (invented identifiers, nothing to propose) is a real result
|
|
2866
|
+
// and must surface, not be papered over by the template matcher.
|
|
2867
|
+
if (!/could not complete this modeling request/.test(error instanceof Error ? error.message : String(error)))
|
|
2868
|
+
throw error;
|
|
2869
|
+
usedProvider = false;
|
|
2870
|
+
operations = buildDeterministicModelingOperations(projectRoot, snapshot.manifest, request);
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
else {
|
|
2874
|
+
operations = buildDeterministicModelingOperations(projectRoot, snapshot.manifest, request);
|
|
2875
|
+
}
|
|
2714
2876
|
const proposal = previewContextAuthoring({
|
|
2715
2877
|
origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
|
|
2716
2878
|
operations,
|
|
2717
|
-
|
|
2879
|
+
// Base the draft on the snapshot it was just built from, not on the id the
|
|
2880
|
+
// client captured at page load. Gating construction on the client's id
|
|
2881
|
+
// rejected a proposal assembled from `snapshot.manifest` moments earlier,
|
|
2882
|
+
// so any unrelated project write (a block edit, a dbt reapply) made every
|
|
2883
|
+
// Modeling AI request fail with PROPOSAL_STALE until a manual refresh.
|
|
2884
|
+
// Drift is still caught where it matters: applying the proposal re-checks
|
|
2885
|
+
// its `baseSnapshotId` and `dependencyFingerprints` and fails SOURCE_CHANGED.
|
|
2886
|
+
expectedSnapshotId: snapshot.snapshotId,
|
|
2718
2887
|
sourceRunId: runId,
|
|
2719
2888
|
revision: Number(request.workspaceContext?.revision ?? 1),
|
|
2720
2889
|
});
|
|
2890
|
+
const blocked = proposal.diagnostics.some((item) => item.severity === 'blocking');
|
|
2721
2891
|
return {
|
|
2722
|
-
summary: `Prepared ${proposal.impact.modelingChanges}
|
|
2723
|
-
answer:
|
|
2892
|
+
summary: `Prepared ${proposal.impact.modelingChanges} modeling change${proposal.impact.modelingChanges === 1 ? '' : 's'} for review.`,
|
|
2893
|
+
answer: blocked
|
|
2724
2894
|
? 'I prepared a modeling proposal, but the blocking items must be resolved before it can be saved.'
|
|
2725
|
-
:
|
|
2895
|
+
: usedProvider
|
|
2896
|
+
? 'I designed a modeling proposal from your dbt metadata. Every model and column was checked against the current snapshot. Review the exact source diff before saving it as a draft.'
|
|
2897
|
+
: 'No AI provider is configured, so these are evidence-only suggestions matched from dbt metadata rather than an AI proposal. Review the exact source diff before saving it as a draft.',
|
|
2726
2898
|
status: 'needs_review',
|
|
2727
2899
|
trustState: 'review_required',
|
|
2728
2900
|
stopReason: 'human_review_required',
|
|
2729
|
-
artifacts: [{ id: `${runId}:modeling-proposal`, kind: 'modeling_change_proposal', title: 'Modeling change proposal', trustState: 'review_required', ref: proposal.id, payload: proposal }],
|
|
2901
|
+
artifacts: [{ id: `${runId}:modeling-proposal`, kind: 'modeling_change_proposal', title: usedProvider ? 'Modeling change proposal' : 'Evidence-only modeling suggestions', trustState: 'review_required', ref: proposal.id, payload: proposal }],
|
|
2730
2902
|
evaluations: proposal.diagnostics.map((diagnostic) => ({ id: `${proposal.id}:${diagnostic.code}`, label: diagnostic.code, passed: diagnostic.severity !== 'blocking', severity: diagnostic.severity, message: diagnostic.message })),
|
|
2731
2903
|
nextActions: [{ id: 'review-modeling-proposal', label: 'Review in Modeling', artifactKind: 'modeling_change_proposal' }],
|
|
2732
2904
|
};
|
|
2733
2905
|
};
|
|
2734
2906
|
const skillAuthoringRunExecutor = async ({ runId, request }) => {
|
|
2735
2907
|
const snapshot = projectSnapshot();
|
|
2736
|
-
|
|
2908
|
+
// SKILL-006: same contract as Modeling AI — a provider drafts the skill
|
|
2909
|
+
// body and vocabulary; the template is the labelled fallback.
|
|
2910
|
+
const provider = await createBlockStudioAssistProvider(projectRoot);
|
|
2911
|
+
const operations = provider
|
|
2912
|
+
? await buildAiSkillOperations(projectRoot, snapshot.manifest, request, provider)
|
|
2913
|
+
.catch(() => buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request))
|
|
2914
|
+
: buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request);
|
|
2737
2915
|
const proposal = previewContextAuthoring({
|
|
2738
2916
|
origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
|
|
2739
2917
|
operations,
|
|
2740
|
-
|
|
2918
|
+
// Same reasoning as the modeling executor above: base the draft on the
|
|
2919
|
+
// snapshot it was built from; apply-time re-validation catches real drift.
|
|
2920
|
+
expectedSnapshotId: snapshot.snapshotId,
|
|
2741
2921
|
sourceRunId: runId,
|
|
2742
2922
|
revision: Number(request.workspaceContext?.revision ?? 1),
|
|
2743
2923
|
});
|
|
@@ -4173,6 +4353,48 @@ export async function startLocalServer(opts) {
|
|
|
4173
4353
|
return [];
|
|
4174
4354
|
}
|
|
4175
4355
|
};
|
|
4356
|
+
/**
|
|
4357
|
+
* CTX-008: the project's authored relationships, as plain join facts for the
|
|
4358
|
+
* relations a bounded repair can actually see.
|
|
4359
|
+
*
|
|
4360
|
+
* A repair prompt previously carried only the failure, the failed SQL, and a
|
|
4361
|
+
* bare relation list, so a repair could silently replace a correct governed
|
|
4362
|
+
* join with a shared-column guess. This is grounding only — it never widens
|
|
4363
|
+
* the relation scope and never authorizes a join (`REL-002` still governs
|
|
4364
|
+
* execution); trust is labelled so a draft edge reads as a draft.
|
|
4365
|
+
*/
|
|
4366
|
+
const governedJoinFactsForRelations = (relations) => {
|
|
4367
|
+
const modeling = (() => {
|
|
4368
|
+
try {
|
|
4369
|
+
return projectSnapshot().manifest.modeling;
|
|
4370
|
+
}
|
|
4371
|
+
catch {
|
|
4372
|
+
return undefined;
|
|
4373
|
+
}
|
|
4374
|
+
})();
|
|
4375
|
+
if (!modeling)
|
|
4376
|
+
return [];
|
|
4377
|
+
const manifest = projectSnapshot().manifest;
|
|
4378
|
+
const normalize = (value) => value.toLowerCase().replace(/"/g, '').split('.').at(-1) ?? value.toLowerCase();
|
|
4379
|
+
const visible = new Set(relations.map(normalize));
|
|
4380
|
+
const relationFor = (entityKey) => {
|
|
4381
|
+
const entity = modeling.entities[entityKey];
|
|
4382
|
+
if (!entity)
|
|
4383
|
+
return undefined;
|
|
4384
|
+
const node = manifest.dbtProvenance?.nodes[entity.dbtUniqueId];
|
|
4385
|
+
return node?.relation ?? node?.name;
|
|
4386
|
+
};
|
|
4387
|
+
return Object.values(modeling.relationships).flatMap((relationship) => {
|
|
4388
|
+
const from = relationFor(relationship.from);
|
|
4389
|
+
const to = relationFor(relationship.to);
|
|
4390
|
+
if (!from || !to || !visible.has(normalize(from)) || !visible.has(normalize(to)))
|
|
4391
|
+
return [];
|
|
4392
|
+
const keys = relationship.keys.map((key) => `${from}.${key.from} = ${to}.${key.to}`).join(' AND ');
|
|
4393
|
+
const trust = relationship.automaticJoinAllowed ? 'certified' : `${relationship.status} (review required)`;
|
|
4394
|
+
const meaning = [relationship.verb, relationship.description].filter(Boolean).join(' — ');
|
|
4395
|
+
return [`${keys} (${relationship.cardinality}, fanout ${relationship.fanout}, ${trust}${meaning ? `; ${meaning}` : ''})`];
|
|
4396
|
+
}).slice(0, 12);
|
|
4397
|
+
};
|
|
4176
4398
|
const boundedRepairError = (message, status, warehouseFailure) => Object.assign(new Error(message), { status, ...(warehouseFailure ? { warehouseFailure } : {}) });
|
|
4177
4399
|
/**
|
|
4178
4400
|
* Shared one-attempt SQL repair for Ask and Notebook. It never creates a
|
|
@@ -4209,6 +4431,9 @@ export async function startLocalServer(opts) {
|
|
|
4209
4431
|
relation: table.relation,
|
|
4210
4432
|
columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
|
|
4211
4433
|
}));
|
|
4434
|
+
// CTX-008: a repair that re-derives joins from column names can undo a
|
|
4435
|
+
// join the original query got right. Give it the authored key pairs.
|
|
4436
|
+
const governedJoins = governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation));
|
|
4212
4437
|
let raw;
|
|
4213
4438
|
try {
|
|
4214
4439
|
raw = await provider.generate([
|
|
@@ -4222,6 +4447,7 @@ export async function startLocalServer(opts) {
|
|
|
4222
4447
|
? 'Preserve every positional parameter placeholder exactly; do not add, remove, or renumber placeholders.'
|
|
4223
4448
|
: '',
|
|
4224
4449
|
'Use only relations and columns present in the supplied schema context.',
|
|
4450
|
+
'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
|
|
4225
4451
|
'Never emit internal DQL relation identities such as source::, model::, seed::, or snapshot::.',
|
|
4226
4452
|
'Do not add writes, DDL, multiple statements, or commentary outside the JSON fence.',
|
|
4227
4453
|
].join(' '),
|
|
@@ -4238,6 +4464,7 @@ export async function startLocalServer(opts) {
|
|
|
4238
4464
|
placeholder: `$${parameter.position}`,
|
|
4239
4465
|
})),
|
|
4240
4466
|
schema: boundedSchema,
|
|
4467
|
+
...(governedJoins.length ? { governedJoins } : {}),
|
|
4241
4468
|
}),
|
|
4242
4469
|
},
|
|
4243
4470
|
], { maxTokens: 1200, temperature: 0 });
|
|
@@ -4307,6 +4534,8 @@ export async function startLocalServer(opts) {
|
|
|
4307
4534
|
relation: table.relation,
|
|
4308
4535
|
columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
|
|
4309
4536
|
}));
|
|
4537
|
+
// CTX-008: same as the SQL repair — keep authored joins out of the guesser.
|
|
4538
|
+
const governedJoins = governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation));
|
|
4310
4539
|
let raw;
|
|
4311
4540
|
try {
|
|
4312
4541
|
raw = await provider.generate([
|
|
@@ -4319,6 +4548,7 @@ export async function startLocalServer(opts) {
|
|
|
4319
4548
|
'Preserve the block name, business fields, visualization, tests, requested grain, filters, output columns, and aggregation semantics.',
|
|
4320
4549
|
'Preserve every declared parameter and every ${parameter} interpolation exactly. Do not add or remove parameters.',
|
|
4321
4550
|
'Use only relations and columns present in the supplied schema context.',
|
|
4551
|
+
'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
|
|
4322
4552
|
'Never emit internal relation identities such as source::, model::, seed::, or snapshot::.',
|
|
4323
4553
|
'Return exactly one custom block. Never certify it, bind a saved block, add writes, DDL, or multiple SQL statements.',
|
|
4324
4554
|
].join(' '),
|
|
@@ -4332,6 +4562,7 @@ export async function startLocalServer(opts) {
|
|
|
4332
4562
|
dql: input.source.slice(0, 40_000),
|
|
4333
4563
|
parameters: input.parameters ?? {},
|
|
4334
4564
|
schema: boundedSchema,
|
|
4565
|
+
...(governedJoins.length ? { governedJoins } : {}),
|
|
4335
4566
|
}),
|
|
4336
4567
|
},
|
|
4337
4568
|
], { maxTokens: 2400, temperature: 0 });
|
|
@@ -6621,10 +6852,13 @@ export async function startLocalServer(opts) {
|
|
|
6621
6852
|
const selectedCandidateIds = Array.isArray(body.selectedCandidateIds)
|
|
6622
6853
|
? body.selectedCandidateIds.filter((value) => typeof value === 'string')
|
|
6623
6854
|
: session.candidates.filter((candidate) => candidate.dialect !== 'unsupported' && candidate.action !== 'open_existing').map((candidate) => candidate.id);
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6855
|
+
// An unspecified scope is defaulted, not rejected: the proposal carries
|
|
6856
|
+
// the `upsert_domain`/`upsert_area` patches so the author reviews the
|
|
6857
|
+
// scope alongside the models it will hold.
|
|
6858
|
+
const domain = typeof body.domain === 'string' && body.domain.trim()
|
|
6859
|
+
? body.domain.trim()
|
|
6860
|
+
: defaultAuthoringDomainId(snapshot.manifest);
|
|
6861
|
+
const areaId = typeof body.areaId === 'string' && body.areaId.trim() ? body.areaId.trim() : DEFAULT_MODEL_AREA_ID;
|
|
6628
6862
|
const planned = modelingImportOperations(session, selectedCandidateIds, domain, areaId, snapshot.manifest);
|
|
6629
6863
|
const proposal = previewContextAuthoring({
|
|
6630
6864
|
origin: 'yaml_import',
|
|
@@ -9008,20 +9242,23 @@ export async function startLocalServer(opts) {
|
|
|
9008
9242
|
}));
|
|
9009
9243
|
return;
|
|
9010
9244
|
}
|
|
9011
|
-
const appDashRun = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/run$/);
|
|
9245
|
+
const appDashRun = path.match(/^\/api\/(apps|app-builds)\/([^/]+)\/dashboards\/([^/]+)\/run$/);
|
|
9012
9246
|
if (req.method === 'POST' && appDashRun) {
|
|
9013
9247
|
try {
|
|
9014
|
-
const
|
|
9015
|
-
const
|
|
9248
|
+
const runSurface = appDashRun[1];
|
|
9249
|
+
const appId = decodeURIComponent(appDashRun[2]);
|
|
9250
|
+
const dashboardId = decodeURIComponent(appDashRun[3]);
|
|
9016
9251
|
const body = await readJSON(req).catch(() => ({}));
|
|
9017
|
-
const loaded =
|
|
9252
|
+
const loaded = runSurface === 'app-builds'
|
|
9253
|
+
? loadAppBuildDraftDashboard(projectRoot, appId, dashboardId)
|
|
9254
|
+
: loadAppDashboard(projectRoot, appId, dashboardId);
|
|
9018
9255
|
if (!loaded) {
|
|
9019
9256
|
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9020
|
-
res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in app "${appId}"` }));
|
|
9257
|
+
res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in ${runSurface === 'app-builds' ? 'local App draft' : 'App'} "${appId}"` }));
|
|
9021
9258
|
return;
|
|
9022
9259
|
}
|
|
9023
9260
|
const activeApp = activePersonaAppId();
|
|
9024
|
-
if (activeApp && activeApp !== appId) {
|
|
9261
|
+
if (runSurface === 'apps' && activeApp && activeApp !== appId) {
|
|
9025
9262
|
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
9026
9263
|
res.end(serializeJSON({ error: `The active persona belongs to App "${activeApp}", not "${appId}".` }));
|
|
9027
9264
|
return;
|
|
@@ -9152,8 +9389,14 @@ export async function startLocalServer(opts) {
|
|
|
9152
9389
|
let draftTargetConnection;
|
|
9153
9390
|
let draftRepairPlan;
|
|
9154
9391
|
try {
|
|
9155
|
-
const
|
|
9156
|
-
|
|
9392
|
+
const localDraftPath = loaded.draftArtifactsDir
|
|
9393
|
+
? join(loaded.draftArtifactsDir, item.draftAnalysis.ref)
|
|
9394
|
+
: undefined;
|
|
9395
|
+
const draftRoot = localDraftPath && existsSync(localDraftPath)
|
|
9396
|
+
? loaded.draftArtifactsDir
|
|
9397
|
+
: loaded.appDir;
|
|
9398
|
+
const draftPath = join(draftRoot, item.draftAnalysis.ref);
|
|
9399
|
+
draftAppRelativePath = relative(draftRoot, draftPath);
|
|
9157
9400
|
if (draftAppRelativePath.startsWith('..') || draftAppRelativePath.startsWith('/')) {
|
|
9158
9401
|
throw new Error('App-scoped analysis reference escapes the App directory.');
|
|
9159
9402
|
}
|
|
@@ -9328,9 +9571,7 @@ export async function startLocalServer(opts) {
|
|
|
9328
9571
|
throw new Error(`Semantic definition changed; missing model(s): ${missingModels.join(', ')}`);
|
|
9329
9572
|
const targetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
|
|
9330
9573
|
const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer, projectRoot);
|
|
9331
|
-
const activeFilters =
|
|
9332
|
-
.filter(([field, value]) => item.semantic.dimensions?.includes(field) && value !== undefined && value !== null && value !== '')
|
|
9333
|
-
.map(([dimension, value]) => ({ dimension, operator: 'equals', values: Array.isArray(value) ? value.map(String) : [String(value)] }));
|
|
9574
|
+
const activeFilters = dashboardSemanticFiltersForTile(loaded.dashboard, item, dashboardVariables);
|
|
9334
9575
|
const staticFilters = (item.semantic.filters ?? []).map((filter) => ({
|
|
9335
9576
|
dimension: filter.field,
|
|
9336
9577
|
operator: filter.operator,
|
|
@@ -9516,6 +9757,8 @@ export async function startLocalServer(opts) {
|
|
|
9516
9757
|
variables: { ...(blockPlan?.variables ?? {}), ...dashboardVariables },
|
|
9517
9758
|
block,
|
|
9518
9759
|
dashboard: loaded.dashboard,
|
|
9760
|
+
tileId: item.i,
|
|
9761
|
+
tileFilterBindings: item.filterBindings,
|
|
9519
9762
|
});
|
|
9520
9763
|
const prepared = prepareLocalExecution(blockFilterApplication.sql, blockTargetConnection, projectRoot, projectConfig);
|
|
9521
9764
|
const result = await executor.executeQuery(prepared.sql, blockFilterApplication.sqlParams, runtimeVariables({ ...blockFilterApplication.variables, ...blockInvocation.values }), prepared.connection);
|
|
@@ -9656,6 +9899,36 @@ export async function startLocalServer(opts) {
|
|
|
9656
9899
|
}
|
|
9657
9900
|
}
|
|
9658
9901
|
localApps?.close();
|
|
9902
|
+
// Which of each tile's result columns a viewer may safely filter on.
|
|
9903
|
+
// Decided here, from a real dialect parse of the SQL that actually ran,
|
|
9904
|
+
// for the same reason Ask does it server-side: an aggregate output needs
|
|
9905
|
+
// HAVING, and picking such a column off the table would quietly change
|
|
9906
|
+
// the measure. The browser must never re-derive this.
|
|
9907
|
+
const runDialect = (() => {
|
|
9908
|
+
try {
|
|
9909
|
+
return requireActiveConnection().driver || 'duckdb';
|
|
9910
|
+
}
|
|
9911
|
+
catch {
|
|
9912
|
+
return 'duckdb';
|
|
9913
|
+
}
|
|
9914
|
+
})();
|
|
9915
|
+
const tilesWithFilters = tiles.map((tile) => {
|
|
9916
|
+
const executedSql = tile.artifact?.sql;
|
|
9917
|
+
const columns = tile.result?.columns ?? [];
|
|
9918
|
+
if (typeof executedSql !== 'string' || columns.length === 0)
|
|
9919
|
+
return tile;
|
|
9920
|
+
try {
|
|
9921
|
+
const dashboardItem = loaded.dashboard.layout.items.find((item) => item.i === tile.tileId);
|
|
9922
|
+
const block = dashboardItem ? resolveDashboardItemBlock(dashboardItem, manifest) : null;
|
|
9923
|
+
const declaredDimensions = block?.status === 'certified' ? block.dimensions ?? [] : [];
|
|
9924
|
+
const filterable = dashboardFilterableResultColumns(executedSql, columns, declaredDimensions, runDialect);
|
|
9925
|
+
return filterable.length > 0 ? { ...tile, filterableColumns: filterable } : tile;
|
|
9926
|
+
}
|
|
9927
|
+
catch {
|
|
9928
|
+
return tile; // Unparseable SQL simply offers no filter candidates.
|
|
9929
|
+
}
|
|
9930
|
+
});
|
|
9931
|
+
const filterOptions = collectDashboardFilterOptions(loaded.dashboard, tilesWithFilters);
|
|
9659
9932
|
const snapshot = projectSnapshot();
|
|
9660
9933
|
const filterFingerprint = createHash('sha256').update(JSON.stringify(dashboardVariables)).digest('hex');
|
|
9661
9934
|
const resultFingerprint = createHash('sha256').update(JSON.stringify(tiles.map((tile) => ({ tileId: tile.tileId, status: tile.status, result: tile.result })))).digest('hex');
|
|
@@ -9706,7 +9979,8 @@ export async function startLocalServer(opts) {
|
|
|
9706
9979
|
filterFingerprint,
|
|
9707
9980
|
resultFingerprint,
|
|
9708
9981
|
personaFingerprint,
|
|
9709
|
-
|
|
9982
|
+
filterOptions,
|
|
9983
|
+
tiles: tilesWithFilters,
|
|
9710
9984
|
facts: storyResult.facts,
|
|
9711
9985
|
story: storyResult.story,
|
|
9712
9986
|
}));
|
|
@@ -9718,7 +9992,11 @@ export async function startLocalServer(opts) {
|
|
|
9718
9992
|
return;
|
|
9719
9993
|
}
|
|
9720
9994
|
// Apps, dashboards, persona — see apps-api.ts. Returns true if handled.
|
|
9721
|
-
if (path.startsWith('/api/apps')
|
|
9995
|
+
if (path.startsWith('/api/apps')
|
|
9996
|
+
|| path.startsWith('/api/app-builds')
|
|
9997
|
+
|| path.startsWith('/api/app-recoveries')
|
|
9998
|
+
|| path.startsWith('/api/visualizations')
|
|
9999
|
+
|| path === '/api/persona') {
|
|
9722
10000
|
try {
|
|
9723
10001
|
const handled = await handleAppsApi({
|
|
9724
10002
|
req,
|
|
@@ -9764,6 +10042,18 @@ export async function startLocalServer(opts) {
|
|
|
9764
10042
|
}
|
|
9765
10043
|
return { ok: true, snapshotId: evidence.snapshotId, resultFingerprint: evidence.resultFingerprint };
|
|
9766
10044
|
},
|
|
10045
|
+
verifyAppBuildPreview: ({ runId, draftId, dashboardId, tileIds }) => {
|
|
10046
|
+
const evidence = dashboardRunEvidence.get(runId);
|
|
10047
|
+
if (!evidence || evidence.expiresAt < Date.now())
|
|
10048
|
+
return { ok: false, error: 'The App preview receipt expired. Run the local draft again.' };
|
|
10049
|
+
if (evidence.appId !== draftId || evidence.dashboardId !== dashboardId)
|
|
10050
|
+
return { ok: false, error: 'The App preview receipt does not match this local draft page.' };
|
|
10051
|
+
const successful = new Set(evidence.successfulTileIds);
|
|
10052
|
+
const failed = tileIds.filter((tileId) => !successful.has(tileId));
|
|
10053
|
+
if (failed.length > 0)
|
|
10054
|
+
return { ok: false, error: `These App components did not complete successfully: ${failed.join(', ')}` };
|
|
10055
|
+
return { ok: true, snapshotId: evidence.snapshotId, resultFingerprint: evidence.resultFingerprint };
|
|
10056
|
+
},
|
|
9767
10057
|
// Story narration for commit — LLM-backed with deterministic fallback.
|
|
9768
10058
|
narrate: narrateForAgentRun,
|
|
9769
10059
|
});
|
|
@@ -15888,6 +16178,61 @@ function loadAppDashboard(projectRoot, appId, dashboardId) {
|
|
|
15888
16178
|
}
|
|
15889
16179
|
return null;
|
|
15890
16180
|
}
|
|
16181
|
+
/**
|
|
16182
|
+
* Resolves an App Studio draft directly from local SQLite for read-only preview.
|
|
16183
|
+
* The draft is never copied into the Git-owned `apps/` tree. This gives manual
|
|
16184
|
+
* and AI authoring the same execution path and the same filter/story receipts
|
|
16185
|
+
* as a published App while keeping publication explicit.
|
|
16186
|
+
*/
|
|
16187
|
+
function loadAppBuildDraftDashboard(projectRoot, draftId, dashboardId) {
|
|
16188
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
16189
|
+
try {
|
|
16190
|
+
const draft = storage.getAppBuildDraft(draftId);
|
|
16191
|
+
if (!draft)
|
|
16192
|
+
return null;
|
|
16193
|
+
const dashboard = draft.pages.find((page) => page.id === dashboardId);
|
|
16194
|
+
if (!dashboard)
|
|
16195
|
+
return null;
|
|
16196
|
+
const base = draft.baseApp ? findAppById(projectRoot, draft.baseApp.appId) : null;
|
|
16197
|
+
const app = base?.app ?? {
|
|
16198
|
+
version: 1,
|
|
16199
|
+
id: draft.appId,
|
|
16200
|
+
name: draft.name,
|
|
16201
|
+
description: draft.frame.goal,
|
|
16202
|
+
visibility: 'private',
|
|
16203
|
+
publicationIntent: 'personal',
|
|
16204
|
+
domain: dashboard.metadata.domain ?? 'general',
|
|
16205
|
+
usesDomains: [],
|
|
16206
|
+
requiredExports: [],
|
|
16207
|
+
audience: draft.frame.audience || 'stakeholders',
|
|
16208
|
+
lifecycle: 'draft',
|
|
16209
|
+
owners: ['local-author'],
|
|
16210
|
+
tags: ['app-studio', 'local-draft'],
|
|
16211
|
+
members: [],
|
|
16212
|
+
roles: [],
|
|
16213
|
+
policies: [],
|
|
16214
|
+
homepage: { type: 'dashboard', id: dashboard.id },
|
|
16215
|
+
};
|
|
16216
|
+
return {
|
|
16217
|
+
app: { ...app, id: draft.appId, name: draft.name, visibility: 'private', lifecycle: 'draft' },
|
|
16218
|
+
dashboard,
|
|
16219
|
+
appDir: base?.appDir ?? join(projectRoot, '.dql', 'local', 'app-builds', draft.id),
|
|
16220
|
+
draftArtifactsDir: join(projectRoot, '.dql', 'local', 'app-builds', draft.id),
|
|
16221
|
+
};
|
|
16222
|
+
}
|
|
16223
|
+
finally {
|
|
16224
|
+
storage.close();
|
|
16225
|
+
}
|
|
16226
|
+
}
|
|
16227
|
+
function findAppById(projectRoot, appId) {
|
|
16228
|
+
for (const path of findAppDocuments(projectRoot)) {
|
|
16229
|
+
const { document: app } = loadAppDocument(path);
|
|
16230
|
+
if (app?.id !== appId)
|
|
16231
|
+
continue;
|
|
16232
|
+
return { app, appDir: path.slice(0, -'/dql.app.json'.length) };
|
|
16233
|
+
}
|
|
16234
|
+
return null;
|
|
16235
|
+
}
|
|
15891
16236
|
function resolveDashboardItemBlock(item, manifest) {
|
|
15892
16237
|
if (!item.block)
|
|
15893
16238
|
return null;
|
|
@@ -16224,6 +16569,8 @@ export function parseDomainInput(raw, fallbackId) {
|
|
|
16224
16569
|
sourcePath: typeof domain.sourcePath === 'string' ? domain.sourcePath : undefined,
|
|
16225
16570
|
};
|
|
16226
16571
|
}
|
|
16572
|
+
/** Bounded discovery: an import scan reads at most this many YAML files. */
|
|
16573
|
+
const MODELING_IMPORT_FILE_LIMIT = 100;
|
|
16227
16574
|
function modelingImportDirectory(projectRoot) {
|
|
16228
16575
|
return join(projectRoot, '.dql', 'local', 'modeling-imports');
|
|
16229
16576
|
}
|
|
@@ -16260,6 +16607,7 @@ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
|
|
|
16260
16607
|
if (mode !== 'current_project' && mode !== 'path' && mode !== 'upload' && mode !== 'paste')
|
|
16261
16608
|
throw Object.assign(new Error('Choose current_project, path, upload, or paste.'), { code: 'INVALID_REQUEST' });
|
|
16262
16609
|
const files = [];
|
|
16610
|
+
const truncationWarnings = [];
|
|
16263
16611
|
if (mode === 'paste' || mode === 'upload') {
|
|
16264
16612
|
const content = typeof source.content === 'string' ? source.content : '';
|
|
16265
16613
|
if (!content.trim() || Buffer.byteLength(content) > 1_000_000)
|
|
@@ -16278,11 +16626,17 @@ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
|
|
|
16278
16626
|
const requested = realpathSync(requestedInput);
|
|
16279
16627
|
if (requested !== allowedRoot && !requested.startsWith(`${allowedRoot}${sep}`))
|
|
16280
16628
|
throw Object.assign(new Error('Import path must stay inside the current Git workspace.'), { code: 'PATH_ACCESS_FORBIDDEN' });
|
|
16281
|
-
const paths = collectModelingYamlPaths(requested,
|
|
16629
|
+
const paths = collectModelingYamlPaths(requested, MODELING_IMPORT_FILE_LIMIT);
|
|
16630
|
+
// Truncation used to be silent, which reads as "DQL did not find my file".
|
|
16631
|
+
if (paths.length >= MODELING_IMPORT_FILE_LIMIT) {
|
|
16632
|
+
truncationWarnings.push(`Only the first ${MODELING_IMPORT_FILE_LIMIT} YAML files in this location were scanned. Point the import at a narrower folder to see the rest.`);
|
|
16633
|
+
}
|
|
16282
16634
|
for (const path of paths) {
|
|
16283
16635
|
const size = statSync(path).size;
|
|
16284
|
-
if (size > 1_000_000)
|
|
16636
|
+
if (size > 1_000_000) {
|
|
16637
|
+
truncationWarnings.push(`Skipped ${relative(requested, path).replace(/\\/g, '/')}: larger than the 1 MB import limit.`);
|
|
16285
16638
|
continue;
|
|
16639
|
+
}
|
|
16286
16640
|
const resolvedPath = realpathSync(path);
|
|
16287
16641
|
if (resolvedPath !== allowedRoot && !resolvedPath.startsWith(`${allowedRoot}${sep}`))
|
|
16288
16642
|
continue;
|
|
@@ -16301,6 +16655,7 @@ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
|
|
|
16301
16655
|
updatedAt: now,
|
|
16302
16656
|
source: { mode, label: mode === 'path' ? String(source.path ?? '') : mode === 'current_project' ? 'Current project' : files[0].name },
|
|
16303
16657
|
candidates,
|
|
16658
|
+
...(truncationWarnings.length ? { warnings: truncationWarnings } : {}),
|
|
16304
16659
|
};
|
|
16305
16660
|
writeModelingImportSession(projectRoot, session);
|
|
16306
16661
|
return session;
|
|
@@ -16368,10 +16723,12 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
|
|
|
16368
16723
|
throw Object.assign(new Error('Select at least one supported YAML candidate.'), { code: 'MODELING_IMPORT_EMPTY' });
|
|
16369
16724
|
const packageExists = Boolean(manifest.modeling?.packages?.[domain]);
|
|
16370
16725
|
const area = Object.values(manifest.modeling?.areas ?? {}).find((item) => item.domain === domain && (item.localId === areaId || item.qualifiedId === areaId));
|
|
16726
|
+
// A missing Domain/subject area is created by `ensureAuthoringScopeOperations`
|
|
16727
|
+
// as part of this same proposal, so it is reported, not blocked.
|
|
16371
16728
|
if (!packageExists)
|
|
16372
|
-
diagnostics.push({ code: '
|
|
16729
|
+
diagnostics.push({ code: 'DOMAIN_CREATED', severity: 'info', message: `Domain "${domain}" does not exist yet and will be created with these models.` });
|
|
16373
16730
|
if (!area)
|
|
16374
|
-
diagnostics.push({ code: '
|
|
16731
|
+
diagnostics.push({ code: 'MODEL_AREA_CREATED', severity: 'info', message: `Subject area "${areaId}" does not exist yet in ${domain} and will be created with these models.` });
|
|
16375
16732
|
const nodes = Object.values(manifest.dbtProvenance?.nodes ?? {});
|
|
16376
16733
|
const resolveDbt = (reference, operationId) => {
|
|
16377
16734
|
const cleaned = reference.replace(/^ref\(['"]|['"]\)$/g, '').replace(/^model\./, '');
|
|
@@ -16381,6 +16738,15 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
|
|
|
16381
16738
|
return matches.length === 1 ? matches[0].uniqueId : undefined;
|
|
16382
16739
|
};
|
|
16383
16740
|
const used = new Set(Object.values(manifest.modeling?.entities ?? {}).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
|
|
16741
|
+
/**
|
|
16742
|
+
* REL-004: dbt already declares its relationships. Every model this import
|
|
16743
|
+
* binds is recorded here so the dbt `relationships` tests and MetricFlow
|
|
16744
|
+
* `entities:` blocks covering those models become draft edges instead of
|
|
16745
|
+
* leaving the author to redraw connections they already wrote down.
|
|
16746
|
+
*/
|
|
16747
|
+
const boundEntityByDbtId = new Map();
|
|
16748
|
+
const semanticPrimaryByName = new Map();
|
|
16749
|
+
const semanticForeignRefs = [];
|
|
16384
16750
|
const allocate = (value) => {
|
|
16385
16751
|
const base = authoringSlug(value).replace(/-/g, '_') || 'model';
|
|
16386
16752
|
let id = base;
|
|
@@ -16410,6 +16776,7 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
|
|
|
16410
16776
|
continue;
|
|
16411
16777
|
const sourceId = String(entity.id ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`);
|
|
16412
16778
|
const id = allocate(sourceId);
|
|
16779
|
+
boundEntityByDbtId.set(dbtModel, { id, operationId });
|
|
16413
16780
|
importedEntities.set(sourceId, { id, operationId });
|
|
16414
16781
|
importedEntities.set(String(entity.qualified_id ?? entity.qualifiedId ?? ''), { id, operationId });
|
|
16415
16782
|
importedEntities.set(dbtModel, { id, operationId });
|
|
@@ -16460,7 +16827,91 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
|
|
|
16460
16827
|
continue;
|
|
16461
16828
|
}
|
|
16462
16829
|
const id = existing?.localId ?? allocate(String(model.name ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`));
|
|
16463
|
-
|
|
16830
|
+
boundEntityByDbtId.set(dbtModel, { id, operationId });
|
|
16831
|
+
// MetricFlow states its own join graph in `entities:`; keep the primary
|
|
16832
|
+
// key so the semantic branch can pair it with foreign references below.
|
|
16833
|
+
const semanticEntities = contextArray(model.entities).map(contextRecord);
|
|
16834
|
+
const primary = semanticEntities.find((entry) => entry.type === 'primary');
|
|
16835
|
+
if (primary?.name)
|
|
16836
|
+
semanticPrimaryByName.set(String(primary.name), { dbtModel, column: String(primary.expr ?? primary.name) });
|
|
16837
|
+
for (const entry of semanticEntities) {
|
|
16838
|
+
if (entry.type !== 'foreign' || !entry.name)
|
|
16839
|
+
continue;
|
|
16840
|
+
semanticForeignRefs.push({ name: String(entry.name), column: String(entry.expr ?? entry.name), dbtModel, operationId });
|
|
16841
|
+
}
|
|
16842
|
+
const aggTimeDimension = contextRecord(model.defaults).agg_time_dimension;
|
|
16843
|
+
operations.push({ id: operationId, kind: 'modeling_change', evidence: [candidate.path ?? candidate.name, dbtModel], change: { operation: 'upsert_entity', value: { id, domain, areaId: area?.localId ?? areaId, dbtModel, businessName: typeof model.name === 'string' ? titleCaseAuthoring(model.name) : undefined, businessContext: typeof model.description === 'string' ? model.description : undefined, status: 'draft', analyticalRole: 'unknown', ...(typeof aggTimeDimension === 'string' ? { grain: aggTimeDimension } : {}), ...(primary?.name ? { keys: [String(primary.expr ?? primary.name)] } : {}) } } });
|
|
16844
|
+
}
|
|
16845
|
+
}
|
|
16846
|
+
}
|
|
16847
|
+
// REL-004: pair MetricFlow foreign references against the primary entity of
|
|
16848
|
+
// the same name. This is dbt's own declared join graph, so it needs no
|
|
16849
|
+
// guessing — but it is still only a draft candidate (REL-001).
|
|
16850
|
+
for (const reference of semanticForeignRefs) {
|
|
16851
|
+
const primary = semanticPrimaryByName.get(reference.name);
|
|
16852
|
+
if (!primary || primary.dbtModel === reference.dbtModel)
|
|
16853
|
+
continue;
|
|
16854
|
+
const from = boundEntityByDbtId.get(reference.dbtModel);
|
|
16855
|
+
const to = boundEntityByDbtId.get(primary.dbtModel);
|
|
16856
|
+
if (!from || !to)
|
|
16857
|
+
continue;
|
|
16858
|
+
operations.push({
|
|
16859
|
+
id: `semantic-entity:${from.id}:${to.id}:${reference.name}`,
|
|
16860
|
+
kind: 'modeling_change',
|
|
16861
|
+
dependsOn: [...new Set([from.operationId, to.operationId])],
|
|
16862
|
+
evidence: [`MetricFlow entity "${reference.name}" declared foreign in ${reference.dbtModel} and primary in ${primary.dbtModel}`],
|
|
16863
|
+
change: {
|
|
16864
|
+
operation: 'upsert_relationship',
|
|
16865
|
+
value: {
|
|
16866
|
+
id: `${from.id}_to_${to.id}`, domain, areaId: area?.localId ?? areaId,
|
|
16867
|
+
from: from.id, to: to.id,
|
|
16868
|
+
keys: [{ from: reference.column, to: primary.column }],
|
|
16869
|
+
cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
|
|
16870
|
+
verb: reference.name,
|
|
16871
|
+
rationale: 'Declared by the MetricFlow semantic entity graph. Validate against the warehouse before certifying.',
|
|
16872
|
+
},
|
|
16873
|
+
},
|
|
16874
|
+
});
|
|
16875
|
+
}
|
|
16876
|
+
// REL-004: dbt `relationships` tests state an exact key pair and are already
|
|
16877
|
+
// enforced by `dbt test`, which makes them the strongest available candidate.
|
|
16878
|
+
// They remain drafts: a dbt test is evidence, never join authorization.
|
|
16879
|
+
const manifestPath = manifest.dbtProvenance?.manifestPath;
|
|
16880
|
+
if (manifestPath && boundEntityByDbtId.size > 1) {
|
|
16881
|
+
let rawManifest;
|
|
16882
|
+
try {
|
|
16883
|
+
rawManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
16884
|
+
}
|
|
16885
|
+
catch {
|
|
16886
|
+
rawManifest = undefined;
|
|
16887
|
+
}
|
|
16888
|
+
if (rawManifest) {
|
|
16889
|
+
for (const edge of collectDbtRelationshipTests(rawManifest, new Set(boundEntityByDbtId.keys()))) {
|
|
16890
|
+
if (edge.fromColumn === 'unknown' || edge.toColumn === 'unknown')
|
|
16891
|
+
continue;
|
|
16892
|
+
const from = boundEntityByDbtId.get(edge.fromDbtUniqueId);
|
|
16893
|
+
const to = boundEntityByDbtId.get(edge.toDbtUniqueId);
|
|
16894
|
+
if (!from || !to || from.id === to.id)
|
|
16895
|
+
continue;
|
|
16896
|
+
const id = `${from.id}_to_${to.id}`;
|
|
16897
|
+
if (operations.some((operation) => operation.kind === 'modeling_change' && operation.change.operation === 'upsert_relationship' && operation.change.value.id === id))
|
|
16898
|
+
continue;
|
|
16899
|
+
operations.push({
|
|
16900
|
+
id: `dbt-test:${edge.testUniqueId}`,
|
|
16901
|
+
kind: 'modeling_change',
|
|
16902
|
+
dependsOn: [...new Set([from.operationId, to.operationId])],
|
|
16903
|
+
evidence: [edge.testUniqueId, ...(edge.sourcePath ? [edge.sourcePath] : [])],
|
|
16904
|
+
change: {
|
|
16905
|
+
operation: 'upsert_relationship',
|
|
16906
|
+
value: {
|
|
16907
|
+
id, domain, areaId: area?.localId ?? areaId,
|
|
16908
|
+
from: from.id, to: to.id,
|
|
16909
|
+
keys: [{ from: edge.fromColumn, to: edge.toColumn }],
|
|
16910
|
+
cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
|
|
16911
|
+
rationale: `Declared by the dbt relationships test ${edge.testUniqueId}. Validate against the warehouse before certifying.`,
|
|
16912
|
+
},
|
|
16913
|
+
},
|
|
16914
|
+
});
|
|
16464
16915
|
}
|
|
16465
16916
|
}
|
|
16466
16917
|
}
|
|
@@ -16515,7 +16966,7 @@ function parseContextAuthoringOperations(value) {
|
|
|
16515
16966
|
const evidence = Array.isArray(operation.evidence) ? operation.evidence.filter((item) => typeof item === 'string').slice(0, 32) : undefined;
|
|
16516
16967
|
if (operation.kind === 'modeling_change') {
|
|
16517
16968
|
const change = operation.change;
|
|
16518
|
-
const allowed = new Set(['upsert_domain', 'upsert_area', 'upsert_entity', 'upsert_relationship', 'upsert_contract', 'upsert_export', 'upsert_import', 'remove_entity', 'remove_relationship', 'remove_area']);
|
|
16969
|
+
const allowed = new Set(['upsert_domain', 'upsert_area', 'upsert_entity', 'upsert_relationship', 'upsert_contract', 'upsert_export', 'upsert_import', 'remove_entity', 'remove_relationship', 'remove_area', 'remove_contract', 'remove_export', 'remove_import']);
|
|
16519
16970
|
if (!change || typeof change !== 'object' || !allowed.has(change.operation) || !('value' in change) || !change.value || typeof change.value !== 'object')
|
|
16520
16971
|
throw Object.assign(new Error(`Modeling operation ${id} is invalid.`), { code: 'INVALID_REQUEST' });
|
|
16521
16972
|
return { id, kind: 'modeling_change', change, dependsOn, evidence };
|
|
@@ -16553,7 +17004,225 @@ function resolveContextPatchPath(projectRoot, gitRoot, relativePath) {
|
|
|
16553
17004
|
}
|
|
16554
17005
|
return absolute;
|
|
16555
17006
|
}
|
|
16556
|
-
|
|
17007
|
+
/**
|
|
17008
|
+
* Modeling AI: propose modeling changes from repository metadata.
|
|
17009
|
+
*
|
|
17010
|
+
* `AGT-024` — the model receives manifest identifiers, columns, lineage, tests,
|
|
17011
|
+
* current modeling, and source locations, and never query rows. It proposes;
|
|
17012
|
+
* this function then validates every identifier against the live snapshot and
|
|
17013
|
+
* drops anything invented, so a hallucinated model or column cannot enter a
|
|
17014
|
+
* proposal. `previewContextAuthoring` independently forces `status: 'draft'`,
|
|
17015
|
+
* strips certification and validation, and blocks deletes and cross-domain
|
|
17016
|
+
* relationships, so nothing here can certify or authorize a join.
|
|
17017
|
+
*/
|
|
17018
|
+
async function buildAiModelingOperations(projectRoot, manifest, request, provider) {
|
|
17019
|
+
const modeling = manifest.modeling;
|
|
17020
|
+
if (!modeling || !manifest.dbtProvenance)
|
|
17021
|
+
throw new Error('Modeling AI requires a compiled dbt-first project snapshot.');
|
|
17022
|
+
const context = request.workspaceContext ?? {};
|
|
17023
|
+
const requestedDomain = typeof context.domain === 'string'
|
|
17024
|
+
? context.domain
|
|
17025
|
+
: request.selectedObject?.kind === 'domain' ? request.selectedObject.id : undefined;
|
|
17026
|
+
const domain = requestedDomain ?? Object.keys(modeling.packages)[0] ?? defaultAuthoringDomainId(manifest);
|
|
17027
|
+
const requestedArea = typeof context.modelAreaId === 'string' ? context.modelAreaId : typeof context.areaId === 'string' ? context.areaId : undefined;
|
|
17028
|
+
const area = Object.values(modeling.areas).find((candidate) => candidate.domain === domain
|
|
17029
|
+
&& (!requestedArea || candidate.qualifiedId === requestedArea || candidate.localId === requestedArea));
|
|
17030
|
+
const areaId = area?.localId ?? modelAreaLocalId(requestedArea) ?? DEFAULT_MODEL_AREA_ID;
|
|
17031
|
+
const bound = new Set(Object.values(modeling.entities).map((entity) => entity.dbtUniqueId));
|
|
17032
|
+
const manifestPath = resolveDbtManifestPath(projectRoot);
|
|
17033
|
+
const describe = (uniqueId) => {
|
|
17034
|
+
const detail = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, uniqueId) : undefined;
|
|
17035
|
+
return {
|
|
17036
|
+
dbtUniqueId: uniqueId,
|
|
17037
|
+
name: manifest.dbtProvenance.nodes[uniqueId]?.name,
|
|
17038
|
+
relation: manifest.dbtProvenance.nodes[uniqueId]?.relation,
|
|
17039
|
+
description: detail?.description,
|
|
17040
|
+
columns: (detail?.columns ?? []).slice(0, 60).map((column) => ({ name: column.name, type: column.type, description: column.description })),
|
|
17041
|
+
tests: (detail?.tests ?? []).slice(0, 40),
|
|
17042
|
+
};
|
|
17043
|
+
};
|
|
17044
|
+
// Bound as candidates so a large project still fits a prompt.
|
|
17045
|
+
const unbound = Object.values(manifest.dbtProvenance.nodes).filter((node) => !bound.has(node.uniqueId));
|
|
17046
|
+
const tokens = authoringTokens(request.question);
|
|
17047
|
+
const ranked = [
|
|
17048
|
+
...unbound.filter((node) => tokens.some((token) => `${node.name} ${node.uniqueId} ${node.sourcePath ?? ''}`.toLowerCase().includes(token))),
|
|
17049
|
+
...unbound,
|
|
17050
|
+
].filter((node, index, values) => values.findIndex((other) => other.uniqueId === node.uniqueId) === index).slice(0, 24);
|
|
17051
|
+
const payload = {
|
|
17052
|
+
request: request.question.trim(),
|
|
17053
|
+
targetDomain: domain,
|
|
17054
|
+
targetAreaId: areaId,
|
|
17055
|
+
currentModels: Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => ({
|
|
17056
|
+
id: entity.localId, dbtUniqueId: entity.dbtUniqueId, businessName: entity.businessName,
|
|
17057
|
+
businessContext: entity.businessContext, grain: entity.grain, keys: entity.keys, analyticalRole: entity.analyticalRole,
|
|
17058
|
+
})),
|
|
17059
|
+
currentRelationships: Object.values(modeling.relationships).filter((relationship) => relationship.ownerDomain === domain).map((relationship) => ({
|
|
17060
|
+
id: relationship.localId, from: modeling.entities[relationship.from]?.localId, to: modeling.entities[relationship.to]?.localId,
|
|
17061
|
+
keys: relationship.keys, cardinality: relationship.cardinality, fanout: relationship.fanout, status: relationship.status,
|
|
17062
|
+
})),
|
|
17063
|
+
availableDbtModels: ranked.map((node) => describe(node.uniqueId)),
|
|
17064
|
+
focusedModel: request.selectedObject?.kind === 'model'
|
|
17065
|
+
? Object.values(modeling.entities).find((entity) => entity.qualifiedId === request.selectedObject.id)
|
|
17066
|
+
: undefined,
|
|
17067
|
+
};
|
|
17068
|
+
let raw;
|
|
17069
|
+
try {
|
|
17070
|
+
raw = await provider.generate([
|
|
17071
|
+
{
|
|
17072
|
+
role: 'system',
|
|
17073
|
+
content: [
|
|
17074
|
+
'You design analytical data models. Propose modeling changes for review; you are never applying them.',
|
|
17075
|
+
'Return exactly one JSON object in a ```json fence: {"operations":[...]}. No prose outside the fence.',
|
|
17076
|
+
'Each operation is {"kind":"upsert_entity","id":"<snake_case>","dbtModel":"<exact dbt unique id>","businessName":"...","businessContext":"...","grain":"...","keys":["..."],"analyticalRole":"event|dimension|snapshot|bridge|unknown"}',
|
|
17077
|
+
'or {"kind":"upsert_relationship","id":"<snake_case>","from":"<entity id>","to":"<entity id>","keys":[{"from":"<column>","to":"<column>"}],"cardinality":"one_to_one|one_to_many|many_to_one|many_to_many|unknown","fanout":"safe|dedupe_required|attribution_required|forbidden|unknown","verb":"...","description":"...","rationale":"..."}.',
|
|
17078
|
+
'Use ONLY dbt unique ids and column names present in the supplied metadata. Never invent an identifier.',
|
|
17079
|
+
'businessContext states what one row means in business terms. Never restate the request back as business context.',
|
|
17080
|
+
'Set cardinality and fanout from the evidence; use "unknown" when the metadata does not establish them. Never claim a relationship is certified or validated.',
|
|
17081
|
+
'Do not propose deletions, cross-domain relationships, or changes to dbt-owned SQL, columns, tests, or descriptions.',
|
|
17082
|
+
].join(' '),
|
|
17083
|
+
},
|
|
17084
|
+
{ role: 'user', content: JSON.stringify(payload) },
|
|
17085
|
+
], { maxTokens: 3000, temperature: 0 });
|
|
17086
|
+
}
|
|
17087
|
+
catch (error) {
|
|
17088
|
+
throw new Error(`The AI provider could not complete this modeling request: ${error instanceof Error ? error.message : String(error)}`);
|
|
17089
|
+
}
|
|
17090
|
+
const parsed = parseAiModelingOperations(raw);
|
|
17091
|
+
if (parsed.length === 0)
|
|
17092
|
+
throw new Error('Modeling AI did not return any usable modeling changes. Try naming the models or the relationship you want.');
|
|
17093
|
+
// Validate every identifier against the live snapshot. An operation that
|
|
17094
|
+
// references something the project does not have is dropped, not repaired.
|
|
17095
|
+
const knownDbtIds = new Set(Object.keys(manifest.dbtProvenance.nodes));
|
|
17096
|
+
const columnsFor = new Map();
|
|
17097
|
+
const proposedEntityIds = new Set(Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
|
|
17098
|
+
const operations = [];
|
|
17099
|
+
for (const candidate of parsed) {
|
|
17100
|
+
if (candidate.kind === 'upsert_entity') {
|
|
17101
|
+
if (!candidate.dbtModel || !knownDbtIds.has(candidate.dbtModel))
|
|
17102
|
+
continue;
|
|
17103
|
+
const id = authoringSlug(candidate.id || candidate.dbtModel.split('.').at(-1) || 'model').replace(/-/g, '_');
|
|
17104
|
+
if (!id)
|
|
17105
|
+
continue;
|
|
17106
|
+
proposedEntityIds.add(id);
|
|
17107
|
+
const detail = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, candidate.dbtModel) : undefined;
|
|
17108
|
+
const columns = new Set((detail?.columns ?? []).map((column) => column.name.toLowerCase()));
|
|
17109
|
+
columnsFor.set(id, columns);
|
|
17110
|
+
operations.push({
|
|
17111
|
+
id: `entity:${domain}:${id}`,
|
|
17112
|
+
kind: 'modeling_change',
|
|
17113
|
+
evidence: [candidate.dbtModel, ...(detail?.sourcePath ? [detail.sourcePath] : [])],
|
|
17114
|
+
change: {
|
|
17115
|
+
operation: 'upsert_entity',
|
|
17116
|
+
value: {
|
|
17117
|
+
id, domain, areaId, dbtModel: candidate.dbtModel,
|
|
17118
|
+
businessName: candidate.businessName?.trim() || titleCaseAuthoring(id),
|
|
17119
|
+
businessContext: candidate.businessContext?.trim() || undefined,
|
|
17120
|
+
grain: candidate.grain && columns.has(candidate.grain.toLowerCase()) ? candidate.grain : undefined,
|
|
17121
|
+
keys: (candidate.keys ?? []).filter((key) => columns.has(key.toLowerCase())),
|
|
17122
|
+
analyticalRole: parseImportedAnalyticalRole(candidate.analyticalRole) ?? 'unknown',
|
|
17123
|
+
status: 'draft',
|
|
17124
|
+
},
|
|
17125
|
+
},
|
|
17126
|
+
});
|
|
17127
|
+
continue;
|
|
17128
|
+
}
|
|
17129
|
+
const from = authoringSlug(candidate.from ?? '').replace(/-/g, '_');
|
|
17130
|
+
const to = authoringSlug(candidate.to ?? '').replace(/-/g, '_');
|
|
17131
|
+
if (!proposedEntityIds.has(from) || !proposedEntityIds.has(to) || from === to)
|
|
17132
|
+
continue;
|
|
17133
|
+
const knownColumns = (entityId) => {
|
|
17134
|
+
const cached = columnsFor.get(entityId);
|
|
17135
|
+
if (cached)
|
|
17136
|
+
return cached;
|
|
17137
|
+
const entity = Object.values(modeling.entities).find((item) => item.domain === domain && item.localId === entityId);
|
|
17138
|
+
const detail = entity && manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, entity.dbtUniqueId) : undefined;
|
|
17139
|
+
const columns = new Set((detail?.columns ?? []).map((column) => column.name.toLowerCase()));
|
|
17140
|
+
columnsFor.set(entityId, columns);
|
|
17141
|
+
return columns;
|
|
17142
|
+
};
|
|
17143
|
+
const fromColumns = knownColumns(from);
|
|
17144
|
+
const toColumns = knownColumns(to);
|
|
17145
|
+
const keys = (candidate.keys ?? []).filter((key) => key?.from && key?.to && fromColumns.has(key.from.toLowerCase()) && toColumns.has(key.to.toLowerCase()));
|
|
17146
|
+
if (keys.length === 0)
|
|
17147
|
+
continue;
|
|
17148
|
+
const id = authoringSlug(candidate.id || `${from}_to_${to}`).replace(/-/g, '_');
|
|
17149
|
+
operations.push({
|
|
17150
|
+
id: `relationship:${domain}:${id}`,
|
|
17151
|
+
kind: 'modeling_change',
|
|
17152
|
+
dependsOn: [`entity:${domain}:${from}`, `entity:${domain}:${to}`].filter((dependency) => operations.some((operation) => operation.id === dependency)),
|
|
17153
|
+
evidence: ['Modeling AI proposal from dbt metadata; requires warehouse validation before certification'],
|
|
17154
|
+
change: {
|
|
17155
|
+
operation: 'upsert_relationship',
|
|
17156
|
+
value: {
|
|
17157
|
+
id, domain, areaId, from, to, keys,
|
|
17158
|
+
cardinality: parseImportedCardinality(candidate.cardinality),
|
|
17159
|
+
fanout: parseImportedFanout(candidate.fanout),
|
|
17160
|
+
status: 'draft',
|
|
17161
|
+
verb: candidate.verb?.trim() || undefined,
|
|
17162
|
+
description: candidate.description?.trim() || undefined,
|
|
17163
|
+
rationale: candidate.rationale?.trim() || undefined,
|
|
17164
|
+
},
|
|
17165
|
+
},
|
|
17166
|
+
});
|
|
17167
|
+
}
|
|
17168
|
+
if (operations.length === 0)
|
|
17169
|
+
throw new Error('Modeling AI referenced models or columns that are not in the current dbt snapshot, so nothing was proposed.');
|
|
17170
|
+
return operations;
|
|
17171
|
+
}
|
|
17172
|
+
/** Tolerant JSON extraction; a malformed provider reply yields no operations. */
|
|
17173
|
+
export function parseAiModelingOperations(raw) {
|
|
17174
|
+
const fenced = raw.match(/```json\s*([\s\S]*?)```/i)?.[1] ?? raw.match(/```\s*([\s\S]*?)```/)?.[1] ?? raw;
|
|
17175
|
+
let parsed;
|
|
17176
|
+
try {
|
|
17177
|
+
parsed = JSON.parse(fenced.trim());
|
|
17178
|
+
}
|
|
17179
|
+
catch {
|
|
17180
|
+
return [];
|
|
17181
|
+
}
|
|
17182
|
+
const text = (value) => (typeof value === 'string' ? value : undefined);
|
|
17183
|
+
return contextArray(contextRecord(parsed).operations).flatMap((value) => {
|
|
17184
|
+
const record = contextRecord(value);
|
|
17185
|
+
if (record.kind === 'upsert_entity') {
|
|
17186
|
+
return [{
|
|
17187
|
+
kind: 'upsert_entity',
|
|
17188
|
+
id: text(record.id),
|
|
17189
|
+
dbtModel: text(record.dbtModel),
|
|
17190
|
+
businessName: text(record.businessName),
|
|
17191
|
+
businessContext: text(record.businessContext),
|
|
17192
|
+
grain: text(record.grain),
|
|
17193
|
+
analyticalRole: record.analyticalRole,
|
|
17194
|
+
keys: contextStrings(record.keys),
|
|
17195
|
+
}];
|
|
17196
|
+
}
|
|
17197
|
+
if (record.kind === 'upsert_relationship') {
|
|
17198
|
+
return [{
|
|
17199
|
+
kind: 'upsert_relationship',
|
|
17200
|
+
id: text(record.id),
|
|
17201
|
+
from: text(record.from),
|
|
17202
|
+
to: text(record.to),
|
|
17203
|
+
verb: text(record.verb),
|
|
17204
|
+
description: text(record.description),
|
|
17205
|
+
rationale: text(record.rationale),
|
|
17206
|
+
cardinality: record.cardinality,
|
|
17207
|
+
fanout: record.fanout,
|
|
17208
|
+
keys: contextArray(record.keys).flatMap((entry) => {
|
|
17209
|
+
const key = contextRecord(entry);
|
|
17210
|
+
return typeof key.from === 'string' && typeof key.to === 'string' ? [{ from: key.from, to: key.to }] : [];
|
|
17211
|
+
}),
|
|
17212
|
+
}];
|
|
17213
|
+
}
|
|
17214
|
+
return [];
|
|
17215
|
+
});
|
|
17216
|
+
}
|
|
17217
|
+
/**
|
|
17218
|
+
* Evidence-only modeling suggestions: deterministic, no provider required.
|
|
17219
|
+
*
|
|
17220
|
+
* This is the `AGT-024` provider-unavailable fallback, not "Modeling AI". It
|
|
17221
|
+
* matches dbt model names against the request and pairs shared `*_id` columns.
|
|
17222
|
+
* Callers must label its output honestly so a user is never told a template
|
|
17223
|
+
* expansion was an AI proposal.
|
|
17224
|
+
*/
|
|
17225
|
+
function buildDeterministicModelingOperations(projectRoot, manifest, request) {
|
|
16557
17226
|
const modeling = manifest.modeling;
|
|
16558
17227
|
if (!modeling || !manifest.dbtProvenance)
|
|
16559
17228
|
throw new Error('Modeling AI requires a compiled dbt-first project snapshot.');
|
|
@@ -16589,7 +17258,7 @@ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
|
|
|
16589
17258
|
value: {
|
|
16590
17259
|
id: selectedRelationship.localId,
|
|
16591
17260
|
domain: selectedRelationship.ownerDomain ?? domain,
|
|
16592
|
-
areaId: selectedRelationship.areaId
|
|
17261
|
+
areaId: modelAreaLocalId(selectedRelationship.areaId),
|
|
16593
17262
|
from: modeling.entities[selectedRelationship.from]?.localId ?? selectedRelationship.from,
|
|
16594
17263
|
to: modeling.entities[selectedRelationship.to]?.localId ?? selectedRelationship.to,
|
|
16595
17264
|
keys: selectedRelationship.keys,
|
|
@@ -16617,33 +17286,15 @@ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
|
|
|
16617
17286
|
? Object.values(modeling.entities).find((item) => item.qualifiedId === selectedIdentity || item.id === selectedIdentity || item.localId === selectedIdentity)
|
|
16618
17287
|
: undefined;
|
|
16619
17288
|
if (selectedEntity) {
|
|
16620
|
-
|
|
16621
|
-
|
|
16622
|
-
|
|
16623
|
-
|
|
16624
|
-
change: {
|
|
16625
|
-
operation: 'upsert_entity',
|
|
16626
|
-
value: {
|
|
16627
|
-
id: selectedEntity.localId,
|
|
16628
|
-
domain: selectedEntity.domain,
|
|
16629
|
-
areaId: selectedEntity.areaId?.split('::area::').at(-1),
|
|
16630
|
-
dbtModel: selectedEntity.dbtUniqueId,
|
|
16631
|
-
businessName: selectedEntity.businessName,
|
|
16632
|
-
businessContext: request.question.trim(),
|
|
16633
|
-
conceptRefs: selectedEntity.conceptRefs,
|
|
16634
|
-
analyticalRole: selectedEntity.analyticalRole,
|
|
16635
|
-
status: 'draft',
|
|
16636
|
-
owner: selectedEntity.owner,
|
|
16637
|
-
grain: selectedEntity.grain,
|
|
16638
|
-
keys: selectedEntity.keys,
|
|
16639
|
-
},
|
|
16640
|
-
},
|
|
16641
|
-
}];
|
|
17289
|
+
// Without a provider there is nothing evidence-backed to propose here.
|
|
17290
|
+
// Copying the raw request into `businessContext` would present the user's
|
|
17291
|
+
// own prompt back to them as authored business meaning.
|
|
17292
|
+
throw new Error('Writing business context needs an AI provider. Configure one in Settings, or edit this model directly in Modeling.');
|
|
16642
17293
|
}
|
|
16643
17294
|
const requestedArea = typeof context.modelAreaId === 'string' ? context.modelAreaId : typeof context.areaId === 'string' ? context.areaId : undefined;
|
|
16644
17295
|
const existingArea = Object.values(modeling.areas).find((area) => area.domain === domain
|
|
16645
17296
|
&& (!requestedArea || area.qualifiedId === requestedArea || area.localId === requestedArea));
|
|
16646
|
-
const areaId = existingArea?.localId ?? requestedArea
|
|
17297
|
+
const areaId = existingArea?.localId ?? modelAreaLocalId(requestedArea) ?? `${authoringSlug(request.question).slice(0, 36) || 'metadata'}_models`;
|
|
16647
17298
|
const operations = [];
|
|
16648
17299
|
if (!existingArea) {
|
|
16649
17300
|
operations.push({
|
|
@@ -16701,6 +17352,81 @@ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
|
|
|
16701
17352
|
}
|
|
16702
17353
|
return operations;
|
|
16703
17354
|
}
|
|
17355
|
+
/**
|
|
17356
|
+
* Skills AI: draft a domain skill's purpose, guardrails, and vocabulary.
|
|
17357
|
+
*
|
|
17358
|
+
* `SKILL-006` — the model sees qualified modeling/metric references, the
|
|
17359
|
+
* current skill, and the Domain/Area scope. It writes prose and vocabulary
|
|
17360
|
+
* only; every governed reference is taken from the deterministic base built
|
|
17361
|
+
* below, so it cannot invent a metric, block, or model. `previewContextAuthoring`
|
|
17362
|
+
* independently forces `status: 'draft'`, so a drafted skill never guides an
|
|
17363
|
+
* agent until a human activates it.
|
|
17364
|
+
*/
|
|
17365
|
+
async function buildAiSkillOperations(projectRoot, manifest, request, provider) {
|
|
17366
|
+
const base = buildMetadataBoundSkillOperations(projectRoot, manifest, request);
|
|
17367
|
+
const operation = base[0];
|
|
17368
|
+
if (!operation || operation.kind !== 'skill_change')
|
|
17369
|
+
return base;
|
|
17370
|
+
const raw = await provider.generate([
|
|
17371
|
+
{
|
|
17372
|
+
role: 'system',
|
|
17373
|
+
content: [
|
|
17374
|
+
'You write governed analytics guidance for one business domain.',
|
|
17375
|
+
'Return exactly one JSON object in a ```json fence: {"description":"...","body":"...","triggers":["..."],"clarifyWhen":["..."],"vocabulary":{"term":"meaning"}}. No prose outside the fence.',
|
|
17376
|
+
'body is markdown with a "## Purpose" section, a "## Guardrails" section, and a "## Clarify" section.',
|
|
17377
|
+
'Write guidance an analyst would follow. Never restate the request back as the guidance.',
|
|
17378
|
+
'Never name a metric, block, model, or relationship that is not in the supplied governed references.',
|
|
17379
|
+
'Do not grant tools, authorize joins or data access, activate the skill, or write executable code.',
|
|
17380
|
+
].join(' '),
|
|
17381
|
+
},
|
|
17382
|
+
{
|
|
17383
|
+
role: 'user',
|
|
17384
|
+
content: JSON.stringify({
|
|
17385
|
+
request: request.question.trim(),
|
|
17386
|
+
domain: operation.value.domain,
|
|
17387
|
+
modelAreaRefs: operation.value.modelAreaRefs,
|
|
17388
|
+
currentSkill: { id: operation.value.id, description: operation.value.description, body: operation.value.body },
|
|
17389
|
+
governedReferences: {
|
|
17390
|
+
metrics: operation.value.preferredMetrics,
|
|
17391
|
+
blocks: operation.value.preferredBlocks,
|
|
17392
|
+
modelingRefs: operation.value.sourceRefs,
|
|
17393
|
+
},
|
|
17394
|
+
}),
|
|
17395
|
+
},
|
|
17396
|
+
], { maxTokens: 2000, temperature: 0.2 });
|
|
17397
|
+
const fenced = raw.match(/```json\s*([\s\S]*?)```/i)?.[1] ?? raw.match(/```\s*([\s\S]*?)```/)?.[1] ?? raw;
|
|
17398
|
+
let parsed;
|
|
17399
|
+
try {
|
|
17400
|
+
parsed = contextRecord(JSON.parse(fenced.trim()));
|
|
17401
|
+
}
|
|
17402
|
+
catch {
|
|
17403
|
+
return base;
|
|
17404
|
+
}
|
|
17405
|
+
const body = typeof parsed.body === 'string' ? parsed.body.trim() : '';
|
|
17406
|
+
if (!body)
|
|
17407
|
+
return base;
|
|
17408
|
+
const vocabulary = contextRecord(parsed.vocabulary);
|
|
17409
|
+
return [{
|
|
17410
|
+
...operation,
|
|
17411
|
+
evidence: [...(operation.evidence ?? []), 'drafted by Skills AI from governed references only'],
|
|
17412
|
+
value: {
|
|
17413
|
+
...operation.value,
|
|
17414
|
+
description: typeof parsed.description === 'string' && parsed.description.trim() ? parsed.description.trim() : operation.value.description,
|
|
17415
|
+
body,
|
|
17416
|
+
triggers: contextStrings(parsed.triggers).slice(0, 12).length ? contextStrings(parsed.triggers).slice(0, 12) : operation.value.triggers,
|
|
17417
|
+
clarifyWhen: contextStrings(parsed.clarifyWhen).slice(0, 8).length ? contextStrings(parsed.clarifyWhen).slice(0, 8) : operation.value.clarifyWhen,
|
|
17418
|
+
vocabulary: Object.fromEntries(Object.entries(vocabulary)
|
|
17419
|
+
.filter(([, value]) => typeof value === 'string')
|
|
17420
|
+
.slice(0, 24)),
|
|
17421
|
+
// Governed references always come from the deterministic base; the model
|
|
17422
|
+
// never gets to add one.
|
|
17423
|
+
preferredMetrics: operation.value.preferredMetrics,
|
|
17424
|
+
preferredBlocks: operation.value.preferredBlocks,
|
|
17425
|
+
sourceRefs: operation.value.sourceRefs,
|
|
17426
|
+
status: 'draft',
|
|
17427
|
+
},
|
|
17428
|
+
}];
|
|
17429
|
+
}
|
|
16704
17430
|
function buildMetadataBoundSkillOperations(projectRoot, manifest, request) {
|
|
16705
17431
|
const context = request.workspaceContext ?? {};
|
|
16706
17432
|
const selectedIdentity = request.selectedObject?.kind === 'skill' ? request.selectedObject.id : undefined;
|
|
@@ -16761,6 +17487,18 @@ function buildMetadataBoundSkillOperations(projectRoot, manifest, request) {
|
|
|
16761
17487
|
value,
|
|
16762
17488
|
}];
|
|
16763
17489
|
}
|
|
17490
|
+
/**
|
|
17491
|
+
* The Domain an author lands in when none is selected: the single existing
|
|
17492
|
+
* Domain if there is exactly one, otherwise a new one named after the dbt
|
|
17493
|
+
* project. Callers keep full control — this is only the prefilled default.
|
|
17494
|
+
*/
|
|
17495
|
+
function defaultAuthoringDomainId(manifest) {
|
|
17496
|
+
const packages = Object.keys(manifest.modeling?.packages ?? {});
|
|
17497
|
+
if (packages.length === 1)
|
|
17498
|
+
return packages[0];
|
|
17499
|
+
const projectName = manifest.dbtProvenance?.projectName;
|
|
17500
|
+
return projectName ? authoringSlug(projectName).replace(/-/g, '_') || 'analytics' : 'analytics';
|
|
17501
|
+
}
|
|
16764
17502
|
function authoringTokens(value) {
|
|
16765
17503
|
const stop = new Set(['the', 'and', 'for', 'with', 'from', 'this', 'that', 'build', 'create', 'model', 'models', 'skill', 'please', 'into', 'about']);
|
|
16766
17504
|
return [...new Set(value.toLowerCase().split(/[^a-z0-9_]+/).filter((token) => token.length > 2 && !stop.has(token)))];
|
|
@@ -18184,6 +18922,132 @@ export function dashboardRuntimeVariables(dashboard, overrides = {}) {
|
|
|
18184
18922
|
}
|
|
18185
18923
|
return { ...variables, ...overrides };
|
|
18186
18924
|
}
|
|
18925
|
+
/** Resolve semantic filters only through the component's explicit mapping. */
|
|
18926
|
+
export function dashboardSemanticFiltersForTile(dashboard, item, dashboardValues) {
|
|
18927
|
+
if (!item.semantic)
|
|
18928
|
+
return [];
|
|
18929
|
+
return (dashboard.filters ?? []).flatMap((filter) => {
|
|
18930
|
+
if (filter.scope?.tileIds && !filter.scope.tileIds.includes(item.i))
|
|
18931
|
+
return [];
|
|
18932
|
+
const binding = item.filterBindings?.find((candidate) => candidate.filter === filter.id
|
|
18933
|
+
&& candidate.capability !== 'unsupported'
|
|
18934
|
+
&& !candidate.unsupportedReason);
|
|
18935
|
+
const dimension = binding?.binding;
|
|
18936
|
+
const value = dashboardValues[filter.id];
|
|
18937
|
+
if (!dimension || !item.semantic?.dimensions?.includes(dimension) || isEmptyDashboardFilterValue(value))
|
|
18938
|
+
return [];
|
|
18939
|
+
const values = Array.isArray(value) ? value.map(String) : [String(value)];
|
|
18940
|
+
return [{ dimension, operator: values.length > 1 ? 'in' : 'equals', values }];
|
|
18941
|
+
});
|
|
18942
|
+
}
|
|
18943
|
+
/**
|
|
18944
|
+
* Return bounded, run-scoped filter choices only from columns the server has
|
|
18945
|
+
* already proven safe for predicate filtering. These sampled labels are sent
|
|
18946
|
+
* to the browser for a searchable control and are never written to `.dqld`.
|
|
18947
|
+
*/
|
|
18948
|
+
export function collectDashboardFilterOptions(dashboard, tiles, defaultLimit = 100) {
|
|
18949
|
+
const items = new Map(dashboard.layout.items.map((item) => [item.i, item]));
|
|
18950
|
+
const optionSets = [];
|
|
18951
|
+
for (const filter of dashboard.filters ?? []) {
|
|
18952
|
+
if (filter.type === 'boolean' || filter.type === 'relative_date' || filter.type === 'number_range')
|
|
18953
|
+
continue;
|
|
18954
|
+
const dateControl = filter.type === 'daterange' || filter.type === 'date';
|
|
18955
|
+
const limit = Math.max(1, Math.min(100, filter.optionSource?.limit ?? defaultLimit));
|
|
18956
|
+
const values = new Set();
|
|
18957
|
+
const sourceTileIds = [];
|
|
18958
|
+
let truncated = false;
|
|
18959
|
+
for (const tile of tiles) {
|
|
18960
|
+
const result = dashboardFilterOptionResult(tile.result);
|
|
18961
|
+
if (tile.status !== 'ok' || !result?.rows.length)
|
|
18962
|
+
continue;
|
|
18963
|
+
const item = items.get(tile.tileId);
|
|
18964
|
+
const binding = item?.filterBindings?.find((candidate) => candidate.filter === filter.id && candidate.capability !== 'unsupported');
|
|
18965
|
+
if (!binding)
|
|
18966
|
+
continue;
|
|
18967
|
+
const requestedColumn = dashboardOutputColumn(binding?.binding ?? filter.field?.name ?? filter.bindsTo ?? filter.id);
|
|
18968
|
+
if (!requestedColumn)
|
|
18969
|
+
continue;
|
|
18970
|
+
const safeColumn = tile.filterableColumns?.find((candidate) => normalizeDashboardOptionColumn(candidate.column) === normalizeDashboardOptionColumn(requestedColumn));
|
|
18971
|
+
if (!safeColumn)
|
|
18972
|
+
continue;
|
|
18973
|
+
const resultColumn = result.columns.find((column) => normalizeDashboardOptionColumn(column) === normalizeDashboardOptionColumn(safeColumn.column));
|
|
18974
|
+
if (!resultColumn)
|
|
18975
|
+
continue;
|
|
18976
|
+
sourceTileIds.push(tile.tileId);
|
|
18977
|
+
for (const row of result.rows) {
|
|
18978
|
+
const value = dashboardFilterOptionValue(row[resultColumn]);
|
|
18979
|
+
if (value !== null)
|
|
18980
|
+
values.add(value);
|
|
18981
|
+
}
|
|
18982
|
+
if (result.truncated || (result.rowCount ?? result.rows.length) > result.rows.length)
|
|
18983
|
+
truncated = true;
|
|
18984
|
+
}
|
|
18985
|
+
if (values.size === 0) {
|
|
18986
|
+
if (dateControl) {
|
|
18987
|
+
optionSets.push({
|
|
18988
|
+
filterId: filter.id,
|
|
18989
|
+
values: [],
|
|
18990
|
+
truncated,
|
|
18991
|
+
sourceTileIds: Array.from(new Set(sourceTileIds)),
|
|
18992
|
+
valueCount: 0,
|
|
18993
|
+
});
|
|
18994
|
+
}
|
|
18995
|
+
continue;
|
|
18996
|
+
}
|
|
18997
|
+
const sorted = Array.from(values).sort((left, right) => left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }));
|
|
18998
|
+
const dateValues = dateControl
|
|
18999
|
+
? sorted
|
|
19000
|
+
.map((value) => ({ value, timestamp: Date.parse(value) }))
|
|
19001
|
+
.filter((item) => Number.isFinite(item.timestamp))
|
|
19002
|
+
.sort((left, right) => left.timestamp - right.timestamp)
|
|
19003
|
+
: [];
|
|
19004
|
+
optionSets.push({
|
|
19005
|
+
filterId: filter.id,
|
|
19006
|
+
values: dateControl ? [] : sorted.slice(0, limit),
|
|
19007
|
+
truncated: dateControl ? truncated : truncated || sorted.length > limit,
|
|
19008
|
+
sourceTileIds: Array.from(new Set(sourceTileIds)),
|
|
19009
|
+
...(dateControl ? {
|
|
19010
|
+
valueCount: dateValues.length,
|
|
19011
|
+
...(dateValues.length ? {
|
|
19012
|
+
dateRange: {
|
|
19013
|
+
min: new Date(dateValues[0].timestamp).toISOString().slice(0, 10),
|
|
19014
|
+
max: new Date(dateValues[dateValues.length - 1].timestamp).toISOString().slice(0, 10),
|
|
19015
|
+
},
|
|
19016
|
+
} : {}),
|
|
19017
|
+
} : {}),
|
|
19018
|
+
});
|
|
19019
|
+
}
|
|
19020
|
+
return optionSets;
|
|
19021
|
+
}
|
|
19022
|
+
function dashboardFilterOptionResult(value) {
|
|
19023
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
19024
|
+
return null;
|
|
19025
|
+
const record = value;
|
|
19026
|
+
if (!Array.isArray(record.columns) || !record.columns.every((column) => typeof column === 'string'))
|
|
19027
|
+
return null;
|
|
19028
|
+
if (!Array.isArray(record.rows) || !record.rows.every((row) => Boolean(row) && typeof row === 'object' && !Array.isArray(row)))
|
|
19029
|
+
return null;
|
|
19030
|
+
return {
|
|
19031
|
+
columns: record.columns,
|
|
19032
|
+
rows: record.rows,
|
|
19033
|
+
...(typeof record.rowCount === 'number' ? { rowCount: record.rowCount } : {}),
|
|
19034
|
+
...(typeof record.truncated === 'boolean' ? { truncated: record.truncated } : {}),
|
|
19035
|
+
};
|
|
19036
|
+
}
|
|
19037
|
+
function normalizeDashboardOptionColumn(value) {
|
|
19038
|
+
return value.replace(/[`"[\]]/g, '').trim().split('.').filter(Boolean).at(-1)?.toLowerCase() ?? '';
|
|
19039
|
+
}
|
|
19040
|
+
function dashboardFilterOptionValue(value) {
|
|
19041
|
+
if (typeof value === 'string')
|
|
19042
|
+
return value.trim() ? value : null;
|
|
19043
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
19044
|
+
return String(value);
|
|
19045
|
+
if (typeof value === 'boolean')
|
|
19046
|
+
return String(value);
|
|
19047
|
+
if (value instanceof Date && Number.isFinite(value.getTime()))
|
|
19048
|
+
return value.toISOString();
|
|
19049
|
+
return null;
|
|
19050
|
+
}
|
|
18187
19051
|
/**
|
|
18188
19052
|
* Resolve only declared tile parameter bindings. Dashboard filters remain
|
|
18189
19053
|
* values; they never become SQL identifiers or an implicit name-based binding.
|
|
@@ -18224,6 +19088,10 @@ export function applyDashboardFiltersToBlockExecution(input) {
|
|
|
18224
19088
|
const clauses = [];
|
|
18225
19089
|
let nextPosition = sqlParams.reduce((max, param) => Math.max(max, param.position), 0);
|
|
18226
19090
|
for (const filter of input.dashboard.filters ?? []) {
|
|
19091
|
+
if (filter.scope?.tileIds && input.tileId && !filter.scope.tileIds.includes(input.tileId)) {
|
|
19092
|
+
skippedFilters.push({ filter: filter.id, reason: 'component is outside this filter scope' });
|
|
19093
|
+
continue;
|
|
19094
|
+
}
|
|
18227
19095
|
const value = dashboardFilterValue(filter, variables);
|
|
18228
19096
|
if (isEmptyDashboardFilterValue(value)) {
|
|
18229
19097
|
skippedFilters.push({ filter: filter.id, reason: 'no value supplied' });
|
|
@@ -18238,7 +19106,7 @@ export function applyDashboardFiltersToBlockExecution(input) {
|
|
|
18238
19106
|
});
|
|
18239
19107
|
continue;
|
|
18240
19108
|
}
|
|
18241
|
-
const binding = resolveDashboardFilterBinding(filter, input.block);
|
|
19109
|
+
const binding = resolveDashboardFilterBinding(filter, input.block, input.tileFilterBindings);
|
|
18242
19110
|
if (!binding) {
|
|
18243
19111
|
skippedFilters.push({ filter: filter.id, reason: `block "${input.block.name}" does not declare a compatible filter binding` });
|
|
18244
19112
|
continue;
|
|
@@ -18360,8 +19228,16 @@ function bindDashboardFilterToExistingParams(filter, value, block, sqlParams, va
|
|
|
18360
19228
|
}
|
|
18361
19229
|
return [];
|
|
18362
19230
|
}
|
|
18363
|
-
function resolveDashboardFilterBinding(filter, block) {
|
|
19231
|
+
function resolveDashboardFilterBinding(filter, block, tileFilterBindings = []) {
|
|
18364
19232
|
const candidates = uniqueDashboardStrings([filter.id, filter.bindsTo ?? '']).map(normalizeDashboardFilterName);
|
|
19233
|
+
for (const entry of tileFilterBindings ?? []) {
|
|
19234
|
+
const mode = entry.mode ?? (entry.binding ? 'predicate' : undefined);
|
|
19235
|
+
if (mode === 'predicate'
|
|
19236
|
+
&& entry.binding
|
|
19237
|
+
&& !entry.unsupportedReason
|
|
19238
|
+
&& candidates.includes(normalizeDashboardFilterName(entry.filter)))
|
|
19239
|
+
return entry.binding;
|
|
19240
|
+
}
|
|
18365
19241
|
for (const entry of block.filterBindings ?? []) {
|
|
18366
19242
|
if (candidates.includes(normalizeDashboardFilterName(entry.filter)))
|
|
18367
19243
|
return entry.binding;
|
|
@@ -18405,6 +19281,13 @@ function buildDashboardFilterPredicate(input) {
|
|
|
18405
19281
|
const values = Array.isArray(input.value) ? input.value.filter((item) => !isEmptyDashboardFilterValue(item)) : [input.value];
|
|
18406
19282
|
if (values.length === 0)
|
|
18407
19283
|
return null;
|
|
19284
|
+
if (input.filterType === 'search' && values.length === 1) {
|
|
19285
|
+
const search = String(values[0] ?? '').trim();
|
|
19286
|
+
if (!search)
|
|
19287
|
+
return null;
|
|
19288
|
+
const param = addDashboardFilterParam(input, 'contains', `%${search}%`);
|
|
19289
|
+
return `LOWER(${input.expression}) LIKE LOWER($${param.position})`;
|
|
19290
|
+
}
|
|
18408
19291
|
if (values.length === 1) {
|
|
18409
19292
|
const param = addDashboardFilterParam(input, 'value', values[0]);
|
|
18410
19293
|
return `${input.expression} = $${param.position}`;
|