@duckcodeailabs/dql-cli 1.5.0 → 1.5.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 +71 -4
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +540 -19
- package/dist/apps-api.js.map +1 -1
- package/dist/apps-api.test.js +45 -2
- package/dist/apps-api.test.js.map +1 -1
- package/dist/assets/dql-notebook/assets/index-BZX1UCr2.js +863 -0
- package/dist/assets/dql-notebook/index.html +1 -1
- package/dist/block-studio-import.d.ts +53 -2
- package/dist/block-studio-import.d.ts.map +1 -1
- package/dist/block-studio-import.js +165 -22
- package/dist/block-studio-import.js.map +1 -1
- package/dist/block-studio-import.test.js +64 -2
- package/dist/block-studio-import.test.js.map +1 -1
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +6 -0
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +8 -5
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +84 -24
- package/dist/commands/init.test.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +587 -89
- package/dist/local-runtime.js.map +1 -1
- package/package.json +11 -11
- package/dist/assets/dql-notebook/assets/index-mlfOQ2me.js +0 -857
package/dist/local-runtime.js
CHANGED
|
@@ -12,8 +12,9 @@ import { handleAppsApi } from './apps-api.js';
|
|
|
12
12
|
import { getEffectiveProviderConfig, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
|
|
13
13
|
import { DQLAccessDeniedError, activePersonaAppId, assertAppAccess, loadRuntimeApp, runtimeVariables, } from './governance-runtime.js';
|
|
14
14
|
import { LocalAppStorage, defaultLocalAppsDbPath } from '@duckcodeailabs/dql-project';
|
|
15
|
+
import { Certifier } from '@duckcodeailabs/dql-governance';
|
|
15
16
|
import { buildSemanticObjectDetail, buildSemanticTree, computeSyncDiff, loadSemanticImportManifest, performSemanticImport, previewSemanticImport, syncSemanticImport, } from './semantic-import.js';
|
|
16
|
-
import { createBlockStudioImportSession, loadBlockStudioImportSession, readBlockStudioImportCandidate, updateBlockStudioImportCandidate, writeBlockStudioImportCandidate, } from './block-studio-import.js';
|
|
17
|
+
import { clearBlockStudioImportSessions, createBlockStudioImportSession, deleteBlockStudioImportSession, listBlockStudioImportSessions, loadBlockStudioImportSession, readBlockStudioImportCandidate, updateBlockStudioImportCandidate, writeBlockStudioImportSession, writeBlockStudioImportCandidate, } from './block-studio-import.js';
|
|
17
18
|
import { MetricFlowUnavailableError, compileMetricFlowQuery, hasDbtSemanticManifest, } from './metricflow.js';
|
|
18
19
|
export async function startLocalServer(opts) {
|
|
19
20
|
const { rootDir, executor, connection: rawConnection, preferredPort, projectRoot = process.cwd() } = opts;
|
|
@@ -81,6 +82,94 @@ export async function startLocalServer(opts) {
|
|
|
81
82
|
const result = await executor.executeQuery(prepared.sql, [], runtimeVariables({}), prepared.connection);
|
|
82
83
|
return normalizeQueryResult(result, semantic.semanticRefs);
|
|
83
84
|
};
|
|
85
|
+
const runNotebookForApp = async (appId, notebookPath) => {
|
|
86
|
+
const absPath = safeJoin(projectRoot, notebookPath);
|
|
87
|
+
if (!absPath || !existsSync(absPath) || statSync(absPath).isDirectory() || !absPath.endsWith('.dqlnb')) {
|
|
88
|
+
throw new Error(`Notebook not found: ${notebookPath}`);
|
|
89
|
+
}
|
|
90
|
+
const app = loadRuntimeApp(projectRoot, appId);
|
|
91
|
+
if (!app)
|
|
92
|
+
throw new Error(`App "${appId}" not found`);
|
|
93
|
+
const raw = readFileSync(absPath, 'utf-8');
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
const sourceCells = parsed.cells ?? [];
|
|
96
|
+
const resultByName = new Map();
|
|
97
|
+
const resultById = new Map();
|
|
98
|
+
const snapshotCells = [];
|
|
99
|
+
for (let index = 0; index < sourceCells.length; index++) {
|
|
100
|
+
const sourceCell = sourceCells[index];
|
|
101
|
+
const cellId = typeof sourceCell.id === 'string' ? sourceCell.id : `cell-${index + 1}`;
|
|
102
|
+
const type = typeof sourceCell.type === 'string' ? sourceCell.type : 'sql';
|
|
103
|
+
const title = typeof sourceCell.name === 'string'
|
|
104
|
+
? sourceCell.name
|
|
105
|
+
: typeof sourceCell.title === 'string'
|
|
106
|
+
? sourceCell.title
|
|
107
|
+
: undefined;
|
|
108
|
+
const executedAt = new Date().toISOString();
|
|
109
|
+
if (type === 'sql' || type === 'dql') {
|
|
110
|
+
try {
|
|
111
|
+
const cell = {
|
|
112
|
+
id: cellId,
|
|
113
|
+
type: type,
|
|
114
|
+
source: typeof sourceCell.content === 'string'
|
|
115
|
+
? sourceCell.content
|
|
116
|
+
: typeof sourceCell.source === 'string'
|
|
117
|
+
? sourceCell.source
|
|
118
|
+
: '',
|
|
119
|
+
title,
|
|
120
|
+
config: (sourceCell.chartConfig ?? sourceCell.config),
|
|
121
|
+
};
|
|
122
|
+
const resolved = resolveNotebookBlockReferenceCell(cell, projectRoot);
|
|
123
|
+
const plan = buildExecutionPlan(resolved.cell, { semanticLayer, driver: connection.driver });
|
|
124
|
+
if (!plan) {
|
|
125
|
+
snapshotCells.push({ cellId, status: 'idle', executionCount: 0, executedAt });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const prepared = prepareLocalExecution(plan.sql, connection, projectRoot, projectConfig);
|
|
129
|
+
assertAppAccess({ app, domain: resolved.domain ?? app.domain, level: 'execute' });
|
|
130
|
+
const rawResult = await executor.executeQuery(prepared.sql, plan.sqlParams, runtimeVariables(plan.variables), prepared.connection);
|
|
131
|
+
const result = normalizeQueryResult(rawResult);
|
|
132
|
+
snapshotCells.push({
|
|
133
|
+
cellId,
|
|
134
|
+
status: 'success',
|
|
135
|
+
result,
|
|
136
|
+
executionCount: 1,
|
|
137
|
+
executedAt,
|
|
138
|
+
});
|
|
139
|
+
resultById.set(cellId, result);
|
|
140
|
+
if (title)
|
|
141
|
+
resultByName.set(title, result);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
snapshotCells.push({
|
|
145
|
+
cellId,
|
|
146
|
+
status: 'error',
|
|
147
|
+
error: err instanceof Error ? err.message : String(err),
|
|
148
|
+
executionCount: 1,
|
|
149
|
+
executedAt,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const upstream = typeof sourceCell.upstream === 'string' ? sourceCell.upstream : undefined;
|
|
155
|
+
const upstreamResult = upstream ? resultByName.get(upstream) ?? resultById.get(upstream) : undefined;
|
|
156
|
+
if (upstreamResult && (type === 'chart' || type === 'table' || type === 'pivot' || type === 'single_value' || type === 'filter')) {
|
|
157
|
+
snapshotCells.push({
|
|
158
|
+
cellId,
|
|
159
|
+
status: 'success',
|
|
160
|
+
result: upstreamResult,
|
|
161
|
+
executionCount: 1,
|
|
162
|
+
executedAt,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
writeRunSnapshot(projectRoot, notebookPath, {
|
|
167
|
+
version: 1,
|
|
168
|
+
notebookPath,
|
|
169
|
+
capturedAt: new Date().toISOString(),
|
|
170
|
+
cells: snapshotCells,
|
|
171
|
+
});
|
|
172
|
+
};
|
|
84
173
|
const executeCertifiedBlockForAgent = async (node) => {
|
|
85
174
|
if (node.kind !== 'block') {
|
|
86
175
|
throw new Error(`Certified ${node.kind} "${node.name}" is a navigation artifact and cannot be executed as a block.`);
|
|
@@ -218,6 +307,122 @@ export async function startLocalServer(opts) {
|
|
|
218
307
|
chartConfig: plan?.chartConfig ?? validation.chartConfig ?? null,
|
|
219
308
|
};
|
|
220
309
|
};
|
|
310
|
+
const runBlockStudioTestSummary = async (source, targetConnection = connection) => {
|
|
311
|
+
const start = Date.now();
|
|
312
|
+
const plan = buildExecutionPlan({ id: 'block-studio-tests', type: 'dql', source, title: 'Block Studio' }, { semanticLayer, driver: targetConnection.driver });
|
|
313
|
+
const tests = plan?.tests ?? [];
|
|
314
|
+
if (!plan || !plan.sql) {
|
|
315
|
+
return {
|
|
316
|
+
passed: 0,
|
|
317
|
+
failed: Math.max(tests.length, 1),
|
|
318
|
+
skipped: 0,
|
|
319
|
+
duration: Date.now() - start,
|
|
320
|
+
assertions: [{
|
|
321
|
+
name: 'build execution plan',
|
|
322
|
+
passed: false,
|
|
323
|
+
error: 'Could not build an execution plan for this block.',
|
|
324
|
+
}],
|
|
325
|
+
runAt: new Date(),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (tests.length === 0) {
|
|
329
|
+
return { passed: 0, failed: 0, skipped: 0, duration: Date.now() - start, assertions: [], runAt: new Date() };
|
|
330
|
+
}
|
|
331
|
+
const prepared = prepareLocalExecution(plan.sql, targetConnection, projectRoot, projectConfig);
|
|
332
|
+
const rawResult = await executor.executeQuery(prepared.sql, plan.sqlParams ?? [], runtimeVariables(plan.variables ?? {}), prepared.connection);
|
|
333
|
+
const rows = Array.isArray(rawResult?.rows) ? rawResult.rows : [];
|
|
334
|
+
const columns = Array.isArray(rawResult?.columns)
|
|
335
|
+
? rawResult.columns.map((column) => typeof column === 'string' ? column : column?.name ?? String(column))
|
|
336
|
+
: [];
|
|
337
|
+
const assertions = [];
|
|
338
|
+
let passed = 0;
|
|
339
|
+
let failed = 0;
|
|
340
|
+
for (const test of tests) {
|
|
341
|
+
const name = `assert ${test.field} ${test.operator} ${formatBlockStudioExpected(test.expected)}`;
|
|
342
|
+
let actual;
|
|
343
|
+
if (test.field === 'row_count') {
|
|
344
|
+
actual = rows.length;
|
|
345
|
+
}
|
|
346
|
+
else if (!columns.includes(test.field)) {
|
|
347
|
+
assertions.push({ name, passed: false, expected: test.expected, error: `Column '${test.field}' not found in results` });
|
|
348
|
+
failed += 1;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
actual = rows[0]?.[test.field];
|
|
353
|
+
}
|
|
354
|
+
const ok = compareBlockStudioValues(actual, test.operator, test.expected);
|
|
355
|
+
if (ok) {
|
|
356
|
+
assertions.push({ name, passed: true, actual, expected: test.expected });
|
|
357
|
+
passed += 1;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
assertions.push({
|
|
361
|
+
name,
|
|
362
|
+
passed: false,
|
|
363
|
+
actual,
|
|
364
|
+
expected: test.expected,
|
|
365
|
+
error: `${String(actual)} ${test.operator} ${formatBlockStudioExpected(test.expected)} is false`,
|
|
366
|
+
});
|
|
367
|
+
failed += 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { passed, failed, skipped: 0, duration: Date.now() - start, assertions, runAt: new Date() };
|
|
371
|
+
};
|
|
372
|
+
const certifyBlockStudioSource = async (source, blockPath) => {
|
|
373
|
+
const validation = validateBlockStudioSource(source, semanticLayer);
|
|
374
|
+
let preview = null;
|
|
375
|
+
let testResults = null;
|
|
376
|
+
const blockers = [];
|
|
377
|
+
try {
|
|
378
|
+
preview = await runBlockStudioPreviewSource(source);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
blockers.push(error instanceof Error ? error.message : String(error));
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
testResults = await runBlockStudioTestSummary(source);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
testResults = {
|
|
388
|
+
passed: 0,
|
|
389
|
+
failed: 1,
|
|
390
|
+
skipped: 0,
|
|
391
|
+
duration: 0,
|
|
392
|
+
assertions: [{ name: 'run tests', passed: false, error: error instanceof Error ? error.message : String(error) }],
|
|
393
|
+
runAt: new Date(),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const parsed = parseBlockSourceMetadata(source);
|
|
397
|
+
const record = {
|
|
398
|
+
id: parsed.name || 'local',
|
|
399
|
+
name: parsed.name || 'unnamed',
|
|
400
|
+
domain: parsed.domain,
|
|
401
|
+
type: parsed.blockType || 'custom',
|
|
402
|
+
version: '0.0.0',
|
|
403
|
+
status: 'draft',
|
|
404
|
+
gitRepo: '',
|
|
405
|
+
gitPath: blockPath ?? '',
|
|
406
|
+
gitCommitSha: '',
|
|
407
|
+
description: parsed.description,
|
|
408
|
+
owner: parsed.owner,
|
|
409
|
+
tags: parsed.tags,
|
|
410
|
+
dependencies: [],
|
|
411
|
+
usedInCount: 0,
|
|
412
|
+
createdAt: new Date(),
|
|
413
|
+
updatedAt: new Date(),
|
|
414
|
+
};
|
|
415
|
+
const certification = new Certifier().evaluate(record, testResults ?? undefined);
|
|
416
|
+
const checklist = buildBlockStudioCertificationChecklist({
|
|
417
|
+
source,
|
|
418
|
+
validation,
|
|
419
|
+
previewSucceeded: Boolean(preview),
|
|
420
|
+
testResults,
|
|
421
|
+
certificationErrors: certification.errors,
|
|
422
|
+
extraBlockers: blockers,
|
|
423
|
+
});
|
|
424
|
+
return { certification, checklist, validation, preview, testResults };
|
|
425
|
+
};
|
|
221
426
|
const server = createServer(async (req, res) => {
|
|
222
427
|
const requestUrl = req.url || '/';
|
|
223
428
|
const url = new URL(requestUrl, 'http://127.0.0.1');
|
|
@@ -412,7 +617,7 @@ export async function startLocalServer(opts) {
|
|
|
412
617
|
tileType: 'aiPin',
|
|
413
618
|
title: item.title ?? pin.title,
|
|
414
619
|
viz: item.viz,
|
|
415
|
-
chartConfig: pin.chartConfig
|
|
620
|
+
chartConfig: mergeDashboardChartConfig(pin.chartConfig, item),
|
|
416
621
|
result: pin.result,
|
|
417
622
|
aiPin: pin,
|
|
418
623
|
citation: {
|
|
@@ -468,7 +673,7 @@ export async function startLocalServer(opts) {
|
|
|
468
673
|
certificationStatus: block.status ?? null,
|
|
469
674
|
title: item.title ?? block.name,
|
|
470
675
|
viz: item.viz,
|
|
471
|
-
chartConfig: plan?.chartConfig
|
|
676
|
+
chartConfig: mergeDashboardChartConfig(plan?.chartConfig, item),
|
|
472
677
|
result: normalizeQueryResult(result),
|
|
473
678
|
citation: {
|
|
474
679
|
kind: 'block',
|
|
@@ -521,6 +726,7 @@ export async function startLocalServer(opts) {
|
|
|
521
726
|
path,
|
|
522
727
|
projectRoot,
|
|
523
728
|
executeSql: executeLocalSqlForStoredResult,
|
|
729
|
+
runNotebook: (appId, notebookPath) => runNotebookForApp(appId, notebookPath),
|
|
524
730
|
});
|
|
525
731
|
if (handled)
|
|
526
732
|
return;
|
|
@@ -1052,22 +1258,12 @@ export async function startLocalServer(opts) {
|
|
|
1052
1258
|
res.end(serializeJSON({ error: `Status must be one of: ${validStatuses.join(', ')}` }));
|
|
1053
1259
|
return;
|
|
1054
1260
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
res.
|
|
1058
|
-
res.end(serializeJSON({ error: 'Block file not found' }));
|
|
1261
|
+
if (newStatus === 'certified') {
|
|
1262
|
+
res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1263
|
+
res.end(serializeJSON({ error: 'Use /api/block-studio/certify so validation, run, and tests gate certification.' }));
|
|
1059
1264
|
return;
|
|
1060
1265
|
}
|
|
1061
|
-
|
|
1062
|
-
// Update or insert status field
|
|
1063
|
-
if (/status\s*=\s*"[^"]*"/.test(source)) {
|
|
1064
|
-
source = source.replace(/status\s*=\s*"[^"]*"/, `status = "${newStatus}"`);
|
|
1065
|
-
}
|
|
1066
|
-
else {
|
|
1067
|
-
// Insert after first { in block declaration
|
|
1068
|
-
source = source.replace(/block\s+"[^"]*"\s*\{/, (match) => `${match}\n status = "${newStatus}"`);
|
|
1069
|
-
}
|
|
1070
|
-
writeFileSync(absPath, source, 'utf-8');
|
|
1266
|
+
setBlockStudioStatus(projectRoot, blockPath, newStatus);
|
|
1071
1267
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1072
1268
|
res.end(serializeJSON({ ok: true, status: newStatus }));
|
|
1073
1269
|
}
|
|
@@ -1158,80 +1354,65 @@ export async function startLocalServer(opts) {
|
|
|
1158
1354
|
res.end(serializeJSON({ error: 'source is required' }));
|
|
1159
1355
|
return;
|
|
1160
1356
|
}
|
|
1161
|
-
|
|
1162
|
-
const
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1357
|
+
const summary = await runBlockStudioTestSummary(source);
|
|
1358
|
+
const results = summary.assertions.map((assertion) => ({
|
|
1359
|
+
name: assertion.name,
|
|
1360
|
+
field: assertion.name.match(/^assert\s+(\S+)/)?.[1] ?? assertion.name,
|
|
1361
|
+
operator: assertion.name.match(/^assert\s+\S+\s+(\S+)/)?.[1] ?? '',
|
|
1362
|
+
expected: assertion.expected !== undefined ? String(assertion.expected) : assertion.name.replace(/^assert\s+\S+\s+\S+\s*/, ''),
|
|
1363
|
+
passed: assertion.passed,
|
|
1364
|
+
actual: assertion.error ?? (assertion.actual !== undefined ? String(assertion.actual) : undefined),
|
|
1365
|
+
}));
|
|
1366
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1367
|
+
res.end(serializeJSON({ assertions: results, passed: summary.passed, failed: summary.failed, duration: summary.duration }));
|
|
1368
|
+
}
|
|
1369
|
+
catch (error) {
|
|
1370
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1371
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1372
|
+
}
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (req.method === 'POST' && path === '/api/block-studio/certify') {
|
|
1376
|
+
try {
|
|
1377
|
+
const body = await readJSON(req);
|
|
1378
|
+
const source = typeof body.source === 'string' ? body.source : '';
|
|
1379
|
+
const blockPath = typeof body.path === 'string' ? body.path : null;
|
|
1380
|
+
if (!source.trim()) {
|
|
1166
1381
|
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1167
|
-
res.end(serializeJSON({ error: '
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
const testNodes = blockNode.tests ?? [];
|
|
1171
|
-
if (testNodes.length === 0) {
|
|
1172
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1173
|
-
res.end(serializeJSON({ assertions: [], passed: 0, failed: 0, duration: 0 }));
|
|
1382
|
+
res.end(serializeJSON({ error: 'source is required' }));
|
|
1174
1383
|
return;
|
|
1175
1384
|
}
|
|
1176
|
-
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1385
|
+
const result = await certifyBlockStudioSource(source, blockPath);
|
|
1386
|
+
const blockers = [
|
|
1387
|
+
...result.checklist.blockers,
|
|
1388
|
+
...result.certification.errors.map((error) => `${error.rule}: ${error.message}`),
|
|
1389
|
+
];
|
|
1390
|
+
if (!result.certification.certified || blockers.length > 0) {
|
|
1391
|
+
res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1392
|
+
res.end(serializeJSON({ ok: false, ...result, blockers }));
|
|
1181
1393
|
return;
|
|
1182
1394
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
const expectedNum = Number(expectedValue);
|
|
1203
|
-
let passed = false;
|
|
1204
|
-
switch (op) {
|
|
1205
|
-
case '>':
|
|
1206
|
-
passed = actualNum > expectedNum;
|
|
1207
|
-
break;
|
|
1208
|
-
case '<':
|
|
1209
|
-
passed = actualNum < expectedNum;
|
|
1210
|
-
break;
|
|
1211
|
-
case '>=':
|
|
1212
|
-
passed = actualNum >= expectedNum;
|
|
1213
|
-
break;
|
|
1214
|
-
case '<=':
|
|
1215
|
-
passed = actualNum <= expectedNum;
|
|
1216
|
-
break;
|
|
1217
|
-
case '==':
|
|
1218
|
-
passed = String(actual) === String(expectedValue);
|
|
1219
|
-
break;
|
|
1220
|
-
case '!=':
|
|
1221
|
-
passed = String(actual) !== String(expectedValue);
|
|
1222
|
-
break;
|
|
1223
|
-
default: passed = false;
|
|
1224
|
-
}
|
|
1225
|
-
results.push({ field, operator: op, expected: String(expectedValue), passed, actual: String(actual ?? '') });
|
|
1226
|
-
}
|
|
1227
|
-
catch (err) {
|
|
1228
|
-
results.push({ field, operator: op, expected: String(expectedValue), passed: false, actual: `Error: ${err instanceof Error ? err.message : String(err)}` });
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
const duration = Date.now() - start;
|
|
1232
|
-
const passed = results.filter((r) => r.passed).length;
|
|
1395
|
+
if (blockPath)
|
|
1396
|
+
setBlockStudioStatus(projectRoot, blockPath, 'certified');
|
|
1397
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1398
|
+
res.end(serializeJSON({ ok: true, status: 'certified', ...result }));
|
|
1399
|
+
}
|
|
1400
|
+
catch (error) {
|
|
1401
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1402
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1403
|
+
}
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (req.method === 'GET' && path === '/api/block-studio/imports') {
|
|
1407
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1408
|
+
res.end(serializeJSON({ sessions: listBlockStudioImportSessions(projectRoot) }));
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (req.method === 'DELETE' && path === '/api/block-studio/imports') {
|
|
1412
|
+
try {
|
|
1413
|
+
const removed = clearBlockStudioImportSessions(projectRoot);
|
|
1233
1414
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1234
|
-
res.end(serializeJSON({
|
|
1415
|
+
res.end(serializeJSON({ ok: true, removed }));
|
|
1235
1416
|
}
|
|
1236
1417
|
catch (error) {
|
|
1237
1418
|
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -1239,12 +1420,19 @@ export async function startLocalServer(opts) {
|
|
|
1239
1420
|
}
|
|
1240
1421
|
return;
|
|
1241
1422
|
}
|
|
1242
|
-
if (req.method === 'POST' && path === '/api/block-studio/import/preview') {
|
|
1423
|
+
if (req.method === 'POST' && (path === '/api/block-studio/import/preview' || path === '/api/block-studio/imports')) {
|
|
1243
1424
|
try {
|
|
1244
1425
|
const body = await readJSON(req);
|
|
1245
1426
|
const inputPath = typeof body.path === 'string' ? body.path : '';
|
|
1246
1427
|
const session = createBlockStudioImportSession(projectRoot, {
|
|
1247
1428
|
inputPath,
|
|
1429
|
+
inputMode: body.inputMode === 'paste' || body.inputMode === 'upload' || body.inputMode === 'path' ? body.inputMode : undefined,
|
|
1430
|
+
sources: Array.isArray(body.sources)
|
|
1431
|
+
? body.sources.map((source, index) => ({
|
|
1432
|
+
path: typeof source?.path === 'string' ? source.path : `source-${index + 1}.sql`,
|
|
1433
|
+
content: typeof source?.content === 'string' ? source.content : '',
|
|
1434
|
+
}))
|
|
1435
|
+
: undefined,
|
|
1248
1436
|
sourceKind: typeof body.sourceKind === 'string' ? body.sourceKind : 'raw-sql',
|
|
1249
1437
|
domain: typeof body.domain === 'string' ? body.domain : undefined,
|
|
1250
1438
|
owner: typeof body.owner === 'string' ? body.owner : undefined,
|
|
@@ -1264,12 +1452,65 @@ export async function startLocalServer(opts) {
|
|
|
1264
1452
|
}
|
|
1265
1453
|
return;
|
|
1266
1454
|
}
|
|
1267
|
-
const
|
|
1455
|
+
const importSaveAllMatch = path.match(/^\/api\/block-studio\/imports\/([^/]+)\/save-all$/);
|
|
1456
|
+
if (importSaveAllMatch && req.method === 'POST') {
|
|
1457
|
+
const importId = decodeURIComponent(importSaveAllMatch[1]);
|
|
1458
|
+
try {
|
|
1459
|
+
const session = loadBlockStudioImportSession(projectRoot, importId);
|
|
1460
|
+
const saved = [];
|
|
1461
|
+
const errors = [];
|
|
1462
|
+
const nextCandidates = [...session.candidates];
|
|
1463
|
+
for (let i = 0; i < nextCandidates.length; i += 1) {
|
|
1464
|
+
const candidate = nextCandidates[i];
|
|
1465
|
+
if (candidate.reviewStatus === 'saved' || candidate.reviewStatus === 'rejected' || candidate.validation?.valid === false)
|
|
1466
|
+
continue;
|
|
1467
|
+
try {
|
|
1468
|
+
const savedPath = saveBlockStudioArtifacts(projectRoot, {
|
|
1469
|
+
source: candidate.dqlSource,
|
|
1470
|
+
name: candidate.name,
|
|
1471
|
+
domain: candidate.domain,
|
|
1472
|
+
description: candidate.description,
|
|
1473
|
+
owner: candidate.owner,
|
|
1474
|
+
tags: candidate.tags,
|
|
1475
|
+
lineage: candidate.lineage.sourceTables,
|
|
1476
|
+
importMeta: {
|
|
1477
|
+
importId,
|
|
1478
|
+
candidateId: candidate.id,
|
|
1479
|
+
sourceKind: candidate.sourceKind,
|
|
1480
|
+
sourcePath: candidate.sourcePath,
|
|
1481
|
+
},
|
|
1482
|
+
});
|
|
1483
|
+
nextCandidates[i] = { ...candidate, reviewStatus: 'saved', savedPath };
|
|
1484
|
+
writeBlockStudioImportCandidate(projectRoot, importId, nextCandidates[i]);
|
|
1485
|
+
saved.push({ candidateId: candidate.id, path: savedPath });
|
|
1486
|
+
}
|
|
1487
|
+
catch (error) {
|
|
1488
|
+
errors.push({ candidateId: candidate.id, error: error instanceof Error ? error.message : String(error) });
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
const nextSession = { ...session, candidates: nextCandidates, updatedAt: new Date().toISOString() };
|
|
1492
|
+
writeBlockStudioImportSession(projectRoot, nextSession);
|
|
1493
|
+
res.writeHead(errors.length > 0 ? 207 : 200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1494
|
+
res.end(serializeJSON({ ok: errors.length === 0, session: nextSession, saved, errors }));
|
|
1495
|
+
}
|
|
1496
|
+
catch (error) {
|
|
1497
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1498
|
+
res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
|
|
1499
|
+
}
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const importPathMatch = path.match(/^\/api\/block-studio\/imports\/([^/]+)(?:\/candidates\/([^/]+)(?:\/(run|save|ai-assist))?)?$/);
|
|
1268
1503
|
if (importPathMatch) {
|
|
1269
1504
|
const importId = decodeURIComponent(importPathMatch[1]);
|
|
1270
1505
|
const candidateId = importPathMatch[2] ? decodeURIComponent(importPathMatch[2]) : null;
|
|
1271
1506
|
const action = importPathMatch[3] ?? null;
|
|
1272
1507
|
try {
|
|
1508
|
+
if (req.method === 'DELETE' && !candidateId) {
|
|
1509
|
+
deleteBlockStudioImportSession(projectRoot, importId);
|
|
1510
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1511
|
+
res.end(serializeJSON({ ok: true }));
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1273
1514
|
if (req.method === 'GET' && !candidateId) {
|
|
1274
1515
|
const session = loadBlockStudioImportSession(projectRoot, importId);
|
|
1275
1516
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -1278,7 +1519,7 @@ export async function startLocalServer(opts) {
|
|
|
1278
1519
|
}
|
|
1279
1520
|
if (req.method === 'PATCH' && candidateId && !action) {
|
|
1280
1521
|
const body = await readJSON(req);
|
|
1281
|
-
const reviewStatus = typeof body.reviewStatus === 'string' && ['draft', 'saved', 'rejected'].includes(body.reviewStatus)
|
|
1522
|
+
const reviewStatus = typeof body.reviewStatus === 'string' && ['draft', 'review', 'saved', 'rejected'].includes(body.reviewStatus)
|
|
1282
1523
|
? body.reviewStatus
|
|
1283
1524
|
: undefined;
|
|
1284
1525
|
const candidate = updateBlockStudioImportCandidate(projectRoot, importId, candidateId, {
|
|
@@ -1305,6 +1546,31 @@ export async function startLocalServer(opts) {
|
|
|
1305
1546
|
res.end(serializeJSON(next));
|
|
1306
1547
|
return;
|
|
1307
1548
|
}
|
|
1549
|
+
if (req.method === 'POST' && candidateId && action === 'ai-assist') {
|
|
1550
|
+
const body = await readJSON(req).catch(() => ({}));
|
|
1551
|
+
const candidate = readBlockStudioImportCandidate(projectRoot, importId, candidateId);
|
|
1552
|
+
const actionName = typeof body.action === 'string' ? body.action : 'explain';
|
|
1553
|
+
const validation = validateBlockStudioSource(candidate.dqlSource, semanticLayer);
|
|
1554
|
+
const assist = await buildBlockStudioAiAssistSummary(projectRoot, actionName, candidate, validation, isProviderSettingsId(body.provider) ? body.provider : undefined);
|
|
1555
|
+
const next = {
|
|
1556
|
+
...candidate,
|
|
1557
|
+
validation,
|
|
1558
|
+
aiAssistance: [
|
|
1559
|
+
...(candidate.aiAssistance ?? []),
|
|
1560
|
+
{
|
|
1561
|
+
action: actionName,
|
|
1562
|
+
summary: assist.summary,
|
|
1563
|
+
createdAt: new Date().toISOString(),
|
|
1564
|
+
status: 'suggested',
|
|
1565
|
+
provider: assist.provider,
|
|
1566
|
+
},
|
|
1567
|
+
],
|
|
1568
|
+
};
|
|
1569
|
+
writeBlockStudioImportCandidate(projectRoot, importId, next);
|
|
1570
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1571
|
+
res.end(serializeJSON(next));
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1308
1574
|
if (req.method === 'POST' && candidateId && action === 'save') {
|
|
1309
1575
|
const candidate = readBlockStudioImportCandidate(projectRoot, importId, candidateId);
|
|
1310
1576
|
const savedPath = saveBlockStudioArtifacts(projectRoot, {
|
|
@@ -2951,6 +3217,21 @@ function resolveDashboardItemBlock(item, manifest) {
|
|
|
2951
3217
|
const normalizedRef = normalize(item.block.ref).replaceAll('\\', '/');
|
|
2952
3218
|
return Object.values(manifest.blocks).find((b) => normalize(b.filePath).replaceAll('\\', '/') === normalizedRef) ?? null;
|
|
2953
3219
|
}
|
|
3220
|
+
function mergeDashboardChartConfig(base, item) {
|
|
3221
|
+
const options = item.viz.options ?? {};
|
|
3222
|
+
const baseChart = base?.chart;
|
|
3223
|
+
return {
|
|
3224
|
+
...(base ?? {}),
|
|
3225
|
+
...options,
|
|
3226
|
+
chart: dashboardVizToChart(String(options.chart ?? baseChart ?? item.viz.type)),
|
|
3227
|
+
};
|
|
3228
|
+
}
|
|
3229
|
+
function dashboardVizToChart(value) {
|
|
3230
|
+
const normalized = value.toLowerCase().replace(/_/g, '-');
|
|
3231
|
+
if (normalized === 'single-value')
|
|
3232
|
+
return 'kpi';
|
|
3233
|
+
return normalized;
|
|
3234
|
+
}
|
|
2954
3235
|
export function serializeJSON(value) {
|
|
2955
3236
|
return JSON.stringify(value, (_key, current) => {
|
|
2956
3237
|
if (typeof current === 'bigint') {
|
|
@@ -3423,7 +3704,7 @@ function openBlockStudioDocument(projectRoot, relativePath, semanticLayer) {
|
|
|
3423
3704
|
description: parsedMetadata.description || companion?.description || '',
|
|
3424
3705
|
owner: parsedMetadata.owner || companion?.owner || '',
|
|
3425
3706
|
tags: parsedMetadata.tags.length > 0 ? parsedMetadata.tags : companion?.tags ?? [],
|
|
3426
|
-
reviewStatus: companion?.reviewStatus,
|
|
3707
|
+
reviewStatus: parsedMetadata.status || companion?.reviewStatus || 'draft',
|
|
3427
3708
|
};
|
|
3428
3709
|
return {
|
|
3429
3710
|
path: normalizedPath,
|
|
@@ -3847,8 +4128,225 @@ function parseBlockSourceMetadata(source) {
|
|
|
3847
4128
|
description: extractString('description'),
|
|
3848
4129
|
owner: extractString('owner'),
|
|
3849
4130
|
tags: tags ? (tags[1].match(/"([^"]*)"/g) ?? []).map((value) => value.slice(1, -1)) : [],
|
|
4131
|
+
status: extractString('status') || 'draft',
|
|
4132
|
+
blockType: extractString('type') || 'custom',
|
|
4133
|
+
};
|
|
4134
|
+
}
|
|
4135
|
+
function compareBlockStudioValues(actual, operator, expected) {
|
|
4136
|
+
const expectedValue = normalizeBlockStudioExpected(expected);
|
|
4137
|
+
if (operator === '==' || operator === '=')
|
|
4138
|
+
return String(actual) === String(expectedValue);
|
|
4139
|
+
if (operator === '!=')
|
|
4140
|
+
return String(actual) !== String(expectedValue);
|
|
4141
|
+
const actualNumber = Number(actual);
|
|
4142
|
+
const expectedNumber = Number(expectedValue);
|
|
4143
|
+
switch (operator) {
|
|
4144
|
+
case '>': return actualNumber > expectedNumber;
|
|
4145
|
+
case '>=': return actualNumber >= expectedNumber;
|
|
4146
|
+
case '<': return actualNumber < expectedNumber;
|
|
4147
|
+
case '<=': return actualNumber <= expectedNumber;
|
|
4148
|
+
default: return false;
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
function normalizeBlockStudioExpected(expected) {
|
|
4152
|
+
if (expected && typeof expected === 'object' && Object.prototype.hasOwnProperty.call(expected, 'value')) {
|
|
4153
|
+
return expected.value;
|
|
4154
|
+
}
|
|
4155
|
+
if (expected && typeof expected === 'object' && Object.prototype.hasOwnProperty.call(expected, 'name')) {
|
|
4156
|
+
return expected.name;
|
|
4157
|
+
}
|
|
4158
|
+
return expected;
|
|
4159
|
+
}
|
|
4160
|
+
function formatBlockStudioExpected(expected) {
|
|
4161
|
+
const normalized = normalizeBlockStudioExpected(expected);
|
|
4162
|
+
if (normalized === null || normalized === undefined)
|
|
4163
|
+
return 'null';
|
|
4164
|
+
if (typeof normalized === 'string' || typeof normalized === 'number' || typeof normalized === 'boolean')
|
|
4165
|
+
return String(normalized);
|
|
4166
|
+
return JSON.stringify(normalized);
|
|
4167
|
+
}
|
|
4168
|
+
function buildBlockStudioCertificationChecklist(input) {
|
|
4169
|
+
const parsed = parseBlockSourceMetadata(input.source);
|
|
4170
|
+
const sql = extractBlockStudioSql(input.source) ?? '';
|
|
4171
|
+
const blockers = new Set();
|
|
4172
|
+
for (const diagnostic of input.validation.diagnostics) {
|
|
4173
|
+
if (diagnostic.severity === 'error')
|
|
4174
|
+
blockers.add(diagnostic.message);
|
|
4175
|
+
}
|
|
4176
|
+
for (const error of input.certificationErrors)
|
|
4177
|
+
blockers.add(`${error.rule}: ${error.message}`);
|
|
4178
|
+
for (const blocker of input.extraBlockers ?? [])
|
|
4179
|
+
blockers.add(blocker);
|
|
4180
|
+
if (!parsed.domain.trim())
|
|
4181
|
+
blockers.add('Missing domain');
|
|
4182
|
+
if (!parsed.owner.trim())
|
|
4183
|
+
blockers.add('Missing owner');
|
|
4184
|
+
if (!parsed.description.trim())
|
|
4185
|
+
blockers.add('Missing description');
|
|
4186
|
+
if (!input.previewSucceeded)
|
|
4187
|
+
blockers.add('Block has not run successfully');
|
|
4188
|
+
if (!input.testResults || input.testResults.failed > 0)
|
|
4189
|
+
blockers.add('Tests must pass before certification');
|
|
4190
|
+
if (!input.testResults || input.testResults.assertions.length === 0)
|
|
4191
|
+
blockers.add('At least one test assertion is required before certification');
|
|
4192
|
+
if (!input.validation.chartConfig?.chart)
|
|
4193
|
+
blockers.add('Visualization config is missing');
|
|
4194
|
+
return {
|
|
4195
|
+
metadata: Boolean(parsed.domain.trim() && parsed.owner.trim() && parsed.description.trim()),
|
|
4196
|
+
validation: input.validation.diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
|
|
4197
|
+
run: input.previewSucceeded,
|
|
4198
|
+
tests: Boolean(input.testResults && input.testResults.failed === 0 && input.testResults.assertions.length > 0),
|
|
4199
|
+
chart: Boolean(input.validation.chartConfig?.chart),
|
|
4200
|
+
lineage: extractSqlTablesLight(sql).length > 0 || input.validation.semanticRefs.metrics.length > 0,
|
|
4201
|
+
aiReviewed: true,
|
|
4202
|
+
blockers: Array.from(blockers),
|
|
4203
|
+
checkedAt: new Date().toISOString(),
|
|
3850
4204
|
};
|
|
3851
4205
|
}
|
|
4206
|
+
function extractSqlTablesLight(sql) {
|
|
4207
|
+
const tables = new Set();
|
|
4208
|
+
const cleaned = sql.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n\r]*/g, ' ');
|
|
4209
|
+
const regex = /\b(?:from|join|update|into)\s+([`"[]?[A-Za-z0-9_./:-]+(?:\.[A-Za-z0-9_./:-]+)*[`"\]]?)/gi;
|
|
4210
|
+
let match;
|
|
4211
|
+
while ((match = regex.exec(cleaned))) {
|
|
4212
|
+
const raw = match[1].replace(/^[`"[]|[`"\]]$/g, '');
|
|
4213
|
+
if (raw && !raw.startsWith('(') && !/^(select|values|unnest|lateral)$/i.test(raw))
|
|
4214
|
+
tables.add(raw);
|
|
4215
|
+
}
|
|
4216
|
+
return Array.from(tables);
|
|
4217
|
+
}
|
|
4218
|
+
function setBlockStudioStatus(projectRoot, blockPath, newStatus) {
|
|
4219
|
+
const normalizedPath = normalize(blockPath).replace(/^\/+/, '');
|
|
4220
|
+
if (!normalizedPath.startsWith('blocks/'))
|
|
4221
|
+
throw new Error('Invalid block path');
|
|
4222
|
+
const absPath = join(projectRoot, normalizedPath);
|
|
4223
|
+
if (!existsSync(absPath))
|
|
4224
|
+
throw new Error('Block file not found');
|
|
4225
|
+
let source = readFileSync(absPath, 'utf-8');
|
|
4226
|
+
if (/status\s*=\s*"[^"]*"/.test(source)) {
|
|
4227
|
+
source = source.replace(/status\s*=\s*"[^"]*"/, `status = "${newStatus}"`);
|
|
4228
|
+
}
|
|
4229
|
+
else {
|
|
4230
|
+
source = source.replace(/block\s+"[^"]*"\s*\{/, (match) => `${match}\n status = "${newStatus}"`);
|
|
4231
|
+
}
|
|
4232
|
+
writeFileSync(absPath, source, 'utf-8');
|
|
4233
|
+
const companionPath = blockCompanionRelativePath(normalizedPath);
|
|
4234
|
+
if (!companionPath)
|
|
4235
|
+
return;
|
|
4236
|
+
const absCompanionPath = join(projectRoot, companionPath);
|
|
4237
|
+
if (!existsSync(absCompanionPath))
|
|
4238
|
+
return;
|
|
4239
|
+
let companion = readFileSync(absCompanionPath, 'utf-8');
|
|
4240
|
+
if (/^reviewStatus:\s*.+$/m.test(companion)) {
|
|
4241
|
+
companion = companion.replace(/^reviewStatus:\s*.+$/m, `reviewStatus: ${newStatus}`);
|
|
4242
|
+
}
|
|
4243
|
+
else {
|
|
4244
|
+
companion = `${companion.trimEnd()}\nreviewStatus: ${newStatus}\n`;
|
|
4245
|
+
}
|
|
4246
|
+
writeFileSync(absCompanionPath, companion, 'utf-8');
|
|
4247
|
+
}
|
|
4248
|
+
async function buildBlockStudioAiAssistSummary(projectRoot, action, candidate, validation, requestedProvider) {
|
|
4249
|
+
const fallback = buildDeterministicAiAssistSummary(action, candidate, validation);
|
|
4250
|
+
const provider = await createBlockStudioAssistProvider(projectRoot, requestedProvider);
|
|
4251
|
+
if (!provider)
|
|
4252
|
+
return { summary: fallback, provider: 'review-gated-local' };
|
|
4253
|
+
const messages = [
|
|
4254
|
+
{
|
|
4255
|
+
role: 'system',
|
|
4256
|
+
content: [
|
|
4257
|
+
'You are DQL Block Studio AI Assist.',
|
|
4258
|
+
'Return concise review notes only. Do not claim the block is certified.',
|
|
4259
|
+
'Do not rewrite source unless the user explicitly applies a later patch.',
|
|
4260
|
+
'Focus on DQL custom block structure, metadata, tests, chart hints, and validation errors.',
|
|
4261
|
+
].join('\n'),
|
|
4262
|
+
},
|
|
4263
|
+
{
|
|
4264
|
+
role: 'user',
|
|
4265
|
+
content: JSON.stringify({
|
|
4266
|
+
action,
|
|
4267
|
+
candidate: {
|
|
4268
|
+
name: candidate.name,
|
|
4269
|
+
domain: candidate.domain,
|
|
4270
|
+
description: candidate.description,
|
|
4271
|
+
owner: candidate.owner,
|
|
4272
|
+
tags: candidate.tags,
|
|
4273
|
+
sourcePath: candidate.sourcePath,
|
|
4274
|
+
sql: candidate.sql,
|
|
4275
|
+
dqlSource: candidate.dqlSource,
|
|
4276
|
+
detectedTables: candidate.lineage.sourceTables,
|
|
4277
|
+
parameters: candidate.lineage.parameters,
|
|
4278
|
+
warnings: candidate.warnings ?? candidate.lineage.warnings,
|
|
4279
|
+
},
|
|
4280
|
+
validation: {
|
|
4281
|
+
valid: validation.valid,
|
|
4282
|
+
diagnostics: validation.diagnostics,
|
|
4283
|
+
chartConfig: validation.chartConfig,
|
|
4284
|
+
semanticRefs: validation.semanticRefs,
|
|
4285
|
+
},
|
|
4286
|
+
}, null, 2),
|
|
4287
|
+
},
|
|
4288
|
+
];
|
|
4289
|
+
try {
|
|
4290
|
+
const summary = await provider.generate(messages, { maxTokens: 700, temperature: 0.1 });
|
|
4291
|
+
return {
|
|
4292
|
+
summary: summary.trim() || fallback,
|
|
4293
|
+
provider: provider.name,
|
|
4294
|
+
};
|
|
4295
|
+
}
|
|
4296
|
+
catch (error) {
|
|
4297
|
+
return {
|
|
4298
|
+
summary: `${fallback}\n\nConfigured provider failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
4299
|
+
provider: provider.name,
|
|
4300
|
+
};
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
|
|
4304
|
+
const settings = listProviderSettings(projectRoot);
|
|
4305
|
+
const selected = requestedProvider
|
|
4306
|
+
? settings.find((provider) => provider.id === requestedProvider && provider.enabled && provider.hasApiKey)
|
|
4307
|
+
: settings.find((provider) => provider.enabled && provider.hasApiKey);
|
|
4308
|
+
if (!selected)
|
|
4309
|
+
return null;
|
|
4310
|
+
const config = getEffectiveProviderConfig(projectRoot, selected.id);
|
|
4311
|
+
let provider;
|
|
4312
|
+
switch (selected.id) {
|
|
4313
|
+
case 'anthropic':
|
|
4314
|
+
provider = new ClaudeProvider({ apiKey: config.apiKey, model: config.model });
|
|
4315
|
+
break;
|
|
4316
|
+
case 'openai':
|
|
4317
|
+
provider = new OpenAIProvider({ apiKey: config.apiKey, baseUrl: config.baseUrl, model: config.model });
|
|
4318
|
+
break;
|
|
4319
|
+
case 'gemini':
|
|
4320
|
+
provider = new GeminiProvider({ apiKey: config.apiKey, model: config.model });
|
|
4321
|
+
break;
|
|
4322
|
+
case 'ollama':
|
|
4323
|
+
provider = new OllamaProvider({ baseUrl: config.baseUrl, model: config.model });
|
|
4324
|
+
break;
|
|
4325
|
+
case 'custom-openai':
|
|
4326
|
+
provider = new OpenAIProvider({ apiKey: config.apiKey, baseUrl: config.baseUrl, model: config.model, allowNoApiKey: true });
|
|
4327
|
+
break;
|
|
4328
|
+
default:
|
|
4329
|
+
return null;
|
|
4330
|
+
}
|
|
4331
|
+
return await provider.available() ? provider : null;
|
|
4332
|
+
}
|
|
4333
|
+
function buildDeterministicAiAssistSummary(action, candidate, validation) {
|
|
4334
|
+
const tables = candidate.lineage.sourceTables.length > 0 ? candidate.lineage.sourceTables.join(', ') : 'no source tables detected';
|
|
4335
|
+
const params = candidate.lineage.parameters.length > 0 ? candidate.lineage.parameters.join(', ') : 'no parameters detected';
|
|
4336
|
+
const errors = validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error').map((diagnostic) => diagnostic.message);
|
|
4337
|
+
if (action === 'fix-validation') {
|
|
4338
|
+
return errors.length > 0
|
|
4339
|
+
? `Review-gated AI assist would focus on these validation errors: ${errors.join(' | ')}. No source was changed automatically.`
|
|
4340
|
+
: 'No validation errors were found. No source was changed automatically.';
|
|
4341
|
+
}
|
|
4342
|
+
if (action === 'infer-chart') {
|
|
4343
|
+
return `Candidate uses ${tables}. Keep table as the safe default, then choose a chart after previewing result columns. No chart was changed automatically.`;
|
|
4344
|
+
}
|
|
4345
|
+
if (action === 'propose-tests') {
|
|
4346
|
+
return `Default test is row_count > 0. Consider adding assertions for key measures after previewing this candidate. Parameters: ${params}.`;
|
|
4347
|
+
}
|
|
4348
|
+
return `This is a deterministic review note for ${candidate.name}. Tables: ${tables}. Parameters: ${params}. DQL wraps the SQL into a custom block and defaults visualization to table.`;
|
|
4349
|
+
}
|
|
3852
4350
|
function extractBlockStudioChartConfig(source) {
|
|
3853
4351
|
const vizMatch = source.match(/visualization\s*\{([^}]+)\}/is);
|
|
3854
4352
|
if (!vizMatch)
|