@duckcodeailabs/dql-cli 1.6.3 → 1.6.5

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.js CHANGED
@@ -255,6 +255,157 @@ export async function handleAppsApi(ctx) {
255
255
  storage.close();
256
256
  }
257
257
  }
258
+ m = path.match(/^\/api\/apps\/([^/]+)\/investigations$/);
259
+ if (m) {
260
+ const appId = decodeURIComponent(m[1]);
261
+ if (!loadAppById(projectRoot, appId)) {
262
+ sendJson(res, 404, { error: `App "${appId}" not found` });
263
+ return true;
264
+ }
265
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
266
+ try {
267
+ if (req.method === 'GET') {
268
+ const dashboardId = ctx.url.searchParams.get('dashboardId') ?? undefined;
269
+ sendJson(res, 200, { investigations: storage.listAppInvestigations(appId, dashboardId) });
270
+ return true;
271
+ }
272
+ if (req.method === 'POST') {
273
+ const body = await readJson(req);
274
+ const question = cleanString(body.question);
275
+ if (!question) {
276
+ sendJson(res, 400, { error: 'question is required' });
277
+ return true;
278
+ }
279
+ let investigation = storage.createAppInvestigation({
280
+ appId,
281
+ dashboardId: body.dashboardId,
282
+ sourceTileId: body.sourceTileId ?? selectedContextString(body.context, 'tileId'),
283
+ sourceBlockId: body.sourceBlockId ?? selectedContextString(body.context, 'blockId'),
284
+ title: body.title,
285
+ question,
286
+ intent: normalizeInvestigationIntent(body.intent, question, body.context),
287
+ context: body.context,
288
+ generatedSql: body.generatedSql,
289
+ });
290
+ if (body.run !== false) {
291
+ investigation = await runAppInvestigation(ctx, storage, investigation, body);
292
+ }
293
+ sendJson(res, 201, { ok: true, investigation });
294
+ return true;
295
+ }
296
+ }
297
+ catch (err) {
298
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
299
+ return true;
300
+ }
301
+ finally {
302
+ storage.close();
303
+ }
304
+ }
305
+ m = path.match(/^\/api\/apps\/([^/]+)\/investigations\/([^/]+)$/);
306
+ if (m) {
307
+ const appId = decodeURIComponent(m[1]);
308
+ const investigationId = decodeURIComponent(m[2]);
309
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
310
+ try {
311
+ const investigation = storage.getAppInvestigation(investigationId);
312
+ if (!investigation || investigation.appId !== appId) {
313
+ sendJson(res, 404, { error: `Investigation "${investigationId}" not found` });
314
+ return true;
315
+ }
316
+ if (req.method === 'GET') {
317
+ sendJson(res, 200, { investigation });
318
+ return true;
319
+ }
320
+ }
321
+ catch (err) {
322
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
323
+ return true;
324
+ }
325
+ finally {
326
+ storage.close();
327
+ }
328
+ }
329
+ m = path.match(/^\/api\/apps\/([^/]+)\/investigations\/([^/]+)\/run$/);
330
+ if (m && req.method === 'POST') {
331
+ const appId = decodeURIComponent(m[1]);
332
+ const investigationId = decodeURIComponent(m[2]);
333
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
334
+ try {
335
+ const investigation = storage.getAppInvestigation(investigationId);
336
+ if (!investigation || investigation.appId !== appId) {
337
+ sendJson(res, 404, { error: `Investigation "${investigationId}" not found` });
338
+ return true;
339
+ }
340
+ const body = await readJson(req);
341
+ const updated = await runAppInvestigation(ctx, storage, investigation, body);
342
+ sendJson(res, 200, { ok: true, investigation: updated });
343
+ }
344
+ catch (err) {
345
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
346
+ }
347
+ finally {
348
+ storage.close();
349
+ }
350
+ return true;
351
+ }
352
+ m = path.match(/^\/api\/apps\/([^/]+)\/investigations\/([^/]+)\/pin$/);
353
+ if (m && req.method === 'POST') {
354
+ const appId = decodeURIComponent(m[1]);
355
+ const investigationId = decodeURIComponent(m[2]);
356
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
357
+ try {
358
+ const investigation = storage.getAppInvestigation(investigationId);
359
+ if (!investigation || investigation.appId !== appId) {
360
+ sendJson(res, 404, { error: `Investigation "${investigationId}" not found` });
361
+ return true;
362
+ }
363
+ const body = await readJson(req);
364
+ const appInfo = loadAppById(projectRoot, appId);
365
+ const dashboardId = cleanString(body.dashboardId) || investigation.dashboardId || appInfo?.dashboards[0]?.id;
366
+ if (!dashboardId) {
367
+ sendJson(res, 400, { error: 'No dashboard is available for this investigation.' });
368
+ return true;
369
+ }
370
+ const created = createAiPinTile(projectRoot, appId, {
371
+ dashboardId,
372
+ title: cleanString(body.title) || investigation.title,
373
+ answer: investigation.summary ?? investigation.recommendation ?? investigation.title,
374
+ question: investigation.question,
375
+ sql: investigation.generatedSql,
376
+ sourceTier: 'metadata_research',
377
+ certification: 'ai_generated',
378
+ reviewStatus: 'needs_review',
379
+ refreshCadence: body.refreshCadence === 'daily' ? 'daily' : 'none',
380
+ chartConfig: { chart: 'table' },
381
+ result: investigationPreviewResult(investigation),
382
+ citations: investigationCitations(investigation),
383
+ analysisPlan: {
384
+ intent: investigation.intent,
385
+ reviewRequired: true,
386
+ uncertified: true,
387
+ sourceTileId: investigation.sourceTileId,
388
+ sourceBlockId: investigation.sourceBlockId,
389
+ },
390
+ evidence: investigation.evidence,
391
+ followUps: nextResearchFollowUps(investigation),
392
+ });
393
+ if (!created.ok) {
394
+ sendJson(res, 400, { error: created.error });
395
+ return true;
396
+ }
397
+ const pinId = typeof created.pin === 'object' && created.pin && 'id' in created.pin ? String(created.pin.id) : '';
398
+ const updated = pinId ? storage.markAppInvestigationPinned(investigationId, pinId) : investigation;
399
+ sendJson(res, 200, { ...created, investigation: updated });
400
+ }
401
+ catch (err) {
402
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
403
+ }
404
+ finally {
405
+ storage.close();
406
+ }
407
+ return true;
408
+ }
258
409
  m = path.match(/^\/api\/apps\/([^/]+)\/ai-pins$/);
259
410
  if (m) {
260
411
  const appId = decodeURIComponent(m[1]);
@@ -478,6 +629,7 @@ function collectAppsList(projectRoot) {
478
629
  notebooks: listAppNotebookRefs(projectRoot, document, appDir),
479
630
  drafts: listAppDrafts(projectRoot, appDir),
480
631
  aiPins: countAiPins(projectRoot, document.id),
632
+ investigations: countAppInvestigations(projectRoot, document.id),
481
633
  homepage: document.homepage,
482
634
  });
483
635
  }
@@ -890,6 +1042,779 @@ function normalizeConversationMessages(messages) {
890
1042
  }))
891
1043
  .filter((message) => message.content.length > 0);
892
1044
  }
1045
+ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1046
+ const question = cleanString(input.question) || investigation.question;
1047
+ const context = input.context === undefined ? investigation.context : input.context;
1048
+ const intent = normalizeInvestigationIntent(input.intent, question, context);
1049
+ let generatedSql = cleanString(input.generatedSql) || investigation.generatedSql;
1050
+ const lastRunAt = new Date().toISOString();
1051
+ storage.updateAppInvestigation(investigation.id, {
1052
+ question,
1053
+ intent,
1054
+ context,
1055
+ generatedSql,
1056
+ status: 'running',
1057
+ reviewStatus: 'needs_review',
1058
+ error: '',
1059
+ });
1060
+ try {
1061
+ const selected = selectedBlockContext(context);
1062
+ const appInfo = ctx.path.includes('/api/apps/') ? loadAppById(ctx.projectRoot, investigation.appId) : null;
1063
+ const previews = buildContextPreviews(selected);
1064
+ let metricSnapshot = buildMetricSnapshot(selected);
1065
+ let driverCards = buildDriverCards(selected, intent);
1066
+ const sourceTileId = investigation.sourceTileId ?? selectedContextString(context, 'tileId');
1067
+ const sourceBlockId = investigation.sourceBlockId ?? selectedContextString(context, 'blockId');
1068
+ const deterministicGeneration = generatedSql
1069
+ ? undefined
1070
+ : buildDeterministicInvestigationSql(ctx.projectRoot, {
1071
+ question,
1072
+ intent,
1073
+ selected,
1074
+ sourceBlockId,
1075
+ });
1076
+ generatedSql = generatedSql || deterministicGeneration?.sql;
1077
+ const agentGeneration = generatedSql
1078
+ ? undefined
1079
+ : await generateInvestigationSql(ctx, {
1080
+ appId: investigation.appId,
1081
+ dashboardId: investigation.dashboardId ?? selectedString(context, 'dashboardId'),
1082
+ sourceTileId,
1083
+ sourceBlockId,
1084
+ title: investigation.title,
1085
+ question,
1086
+ intent,
1087
+ context,
1088
+ });
1089
+ generatedSql = generatedSql || cleanString(agentGeneration?.sql);
1090
+ const generationError = cleanString(agentGeneration?.executionError);
1091
+ const sqlEvidence = agentGeneration?.result
1092
+ ? { preview: buildGeneratedSqlPreview(agentGeneration.result, generatedSql), error: generationError || undefined }
1093
+ : await runGeneratedSqlPreview(ctx, generatedSql);
1094
+ const sqlError = sqlEvidence.error ?? generationError;
1095
+ if (sqlEvidence.preview) {
1096
+ previews.unshift(sqlEvidence.preview);
1097
+ metricSnapshot = buildPreviewMetricSnapshot(sqlEvidence.preview, selectedString(selected, 'title'));
1098
+ driverCards = buildPreviewDriverCards(sqlEvidence.preview, intent);
1099
+ }
1100
+ const evidence = {
1101
+ trustStatus: buildInvestigationTrust(investigation, selected, sqlError),
1102
+ planner: {
1103
+ intent,
1104
+ steps: investigationSteps(intent),
1105
+ reviewRequired: true,
1106
+ generatedSql: generatedSql || undefined,
1107
+ sqlExecuted: Boolean(sqlEvidence.preview),
1108
+ sqlError,
1109
+ generationSource: deterministicGeneration ? 'selected_block_metadata' : agentGeneration?.providerUsed ? 'ai_provider' : generatedSql ? 'provided_sql' : 'context_only',
1110
+ sourceBlockPath: deterministicGeneration?.sourceBlockPath,
1111
+ sourceBlockName: deterministicGeneration?.sourceBlockName,
1112
+ providerUsed: agentGeneration?.providerUsed,
1113
+ },
1114
+ certifiedContext: {
1115
+ appId: investigation.appId,
1116
+ appName: appInfo?.app.name,
1117
+ dashboardId: investigation.dashboardId,
1118
+ dashboardTitle: selectedString(context, 'dashboardTitle'),
1119
+ sourceTileId,
1120
+ sourceBlockId,
1121
+ sourceBlockPath: deterministicGeneration?.sourceBlockPath ?? selectedString(selected, 'blockPath'),
1122
+ certificationStatus: selectedString(selected, 'certificationStatus'),
1123
+ },
1124
+ assumptions: investigationAssumptions(intent, selected, generatedSql, sqlError),
1125
+ context,
1126
+ agentEvidence: agentGeneration?.evidence,
1127
+ analysisPlan: agentGeneration?.analysisPlan,
1128
+ citations: agentGeneration?.citations,
1129
+ };
1130
+ const summary = cleanString(agentGeneration?.answer) || buildInvestigationSummary(intent, question, selected, metricSnapshot, driverCards);
1131
+ const recommendation = buildInvestigationRecommendation(intent, selected, sqlError);
1132
+ return storage.updateAppInvestigation(investigation.id, {
1133
+ title: cleanString(input.question) ? titleFromInvestigation(question, selected) : investigation.title,
1134
+ question,
1135
+ intent,
1136
+ context,
1137
+ status: sqlEvidence.fatal ? 'error' : 'ready',
1138
+ summary,
1139
+ recommendation,
1140
+ metrics: metricSnapshot,
1141
+ driverCards,
1142
+ resultPreviews: previews,
1143
+ evidence,
1144
+ generatedSql,
1145
+ reviewStatus: 'needs_review',
1146
+ error: sqlError ?? '',
1147
+ lastRunAt,
1148
+ }) ?? investigation;
1149
+ }
1150
+ catch (err) {
1151
+ return storage.updateAppInvestigation(investigation.id, {
1152
+ status: 'error',
1153
+ reviewStatus: 'needs_review',
1154
+ error: err instanceof Error ? err.message : String(err),
1155
+ lastRunAt,
1156
+ }) ?? investigation;
1157
+ }
1158
+ }
1159
+ function normalizeInvestigationIntent(value, question, context) {
1160
+ if (value === 'diagnose_change'
1161
+ || value === 'driver_breakdown'
1162
+ || value === 'segment_compare'
1163
+ || value === 'entity_drilldown'
1164
+ || value === 'anomaly_investigation'
1165
+ || value === 'trust_gap_review')
1166
+ return value;
1167
+ const text = `${question} ${JSON.stringify(safeIntentContext(context)).slice(0, 500)}`.toLowerCase();
1168
+ if (/\b(trust|rely|certif|lineage|owner|caveat|gap)\b/.test(text))
1169
+ return 'trust_gap_review';
1170
+ if (/\b(anomal|exception|outlier|spike|dip)\b/.test(text))
1171
+ return 'anomaly_investigation';
1172
+ if (/\b(compare|versus| vs |segment|cohort)\b/.test(text))
1173
+ return 'segment_compare';
1174
+ if (/\b(why|changed|change|drop|decline|increase|decrease|february|month|week|quarter)\b/.test(text))
1175
+ return 'diagnose_change';
1176
+ if (/\b(driver|drove|break down|breakdown|contribute|top mover|movers)\b/.test(text))
1177
+ return 'driver_breakdown';
1178
+ if (/\b(customer|account|user|client|merchant|product|sku|alice|johnson)\b/.test(text))
1179
+ return 'entity_drilldown';
1180
+ return 'driver_breakdown';
1181
+ }
1182
+ function safeIntentContext(context) {
1183
+ const root = asRecord(context);
1184
+ return {
1185
+ selectedBlock: root ? root.selectedBlock : undefined,
1186
+ dashboardTitle: root ? root.dashboardTitle : undefined,
1187
+ availableBlocks: root ? root.availableBlocks : undefined,
1188
+ };
1189
+ }
1190
+ function selectedBlockContext(context) {
1191
+ const root = asRecord(context);
1192
+ const selected = asRecord(root?.selectedBlock);
1193
+ if (selected)
1194
+ return selected;
1195
+ if (!root)
1196
+ return null;
1197
+ const hasSelectedTileContext = ['blockId', 'blockPath', 'tileId', 'certificationStatus', 'resultSample', 'rowCount']
1198
+ .some((key) => root[key] !== undefined && root[key] !== null);
1199
+ return hasSelectedTileContext ? root : null;
1200
+ }
1201
+ function selectedContextString(context, key) {
1202
+ return selectedString(selectedBlockContext(context), key);
1203
+ }
1204
+ function selectedString(context, key) {
1205
+ const record = asRecord(context);
1206
+ const value = record?.[key];
1207
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
1208
+ }
1209
+ function buildContextPreviews(selected) {
1210
+ const rows = selectedRows(selected);
1211
+ if (rows.length === 0)
1212
+ return [];
1213
+ const columns = selectedColumns(selected, rows);
1214
+ return [{
1215
+ id: 'selected-tile-sample',
1216
+ title: 'Selected tile evidence',
1217
+ kind: 'table',
1218
+ reviewRequired: true,
1219
+ result: {
1220
+ columns,
1221
+ rows,
1222
+ rowCount: typeof selected?.rowCount === 'number' ? selected.rowCount : rows.length,
1223
+ },
1224
+ }];
1225
+ }
1226
+ function buildMetricSnapshot(selected) {
1227
+ const rows = selectedRows(selected);
1228
+ const columns = selectedColumns(selected, rows);
1229
+ const numericColumns = columns.filter((column) => rows.some((row) => typeofNumber(row[column]) !== null));
1230
+ const metricColumn = numericColumns[0];
1231
+ if (!metricColumn || rows.length === 0) {
1232
+ return {
1233
+ currentValue: undefined,
1234
+ baselineValue: undefined,
1235
+ delta: undefined,
1236
+ context: 'Metric values were not available in the selected tile sample.',
1237
+ };
1238
+ }
1239
+ const baselineValue = typeofNumber(rows[0]?.[metricColumn]);
1240
+ const currentValue = typeofNumber(rows[rows.length - 1]?.[metricColumn]) ?? baselineValue;
1241
+ const delta = currentValue !== null && baselineValue !== null ? currentValue - baselineValue : undefined;
1242
+ return {
1243
+ metric: metricColumn,
1244
+ currentValue,
1245
+ baselineValue,
1246
+ delta,
1247
+ rowsReviewed: rows.length,
1248
+ context: selectedString(selected, 'title') ?? 'Selected dashboard tile',
1249
+ };
1250
+ }
1251
+ function buildDriverCards(selected, intent) {
1252
+ const rows = selectedRows(selected);
1253
+ const columns = selectedColumns(selected, rows);
1254
+ if (rows.length === 0) {
1255
+ return [{
1256
+ title: 'Runtime context needed',
1257
+ contribution: 'Needs SQL preview',
1258
+ explanation: 'The selected tile did not include sample rows, so DQL captured the question and review path for a deeper SQL run.',
1259
+ intent,
1260
+ }];
1261
+ }
1262
+ const numericColumn = columns.find((column) => rows.some((row) => typeofNumber(row[column]) !== null));
1263
+ const dimensionColumn = columns.find((column) => column !== numericColumn && rows.some((row) => typeof row[column] === 'string'));
1264
+ if (!numericColumn) {
1265
+ return rows.slice(0, 5).map((row, index) => ({
1266
+ title: String(row[dimensionColumn ?? columns[0]] ?? `Row ${index + 1}`),
1267
+ contribution: 'Context row',
1268
+ explanation: 'This row is part of the tile evidence for the investigation.',
1269
+ }));
1270
+ }
1271
+ return rows
1272
+ .map((row, index) => {
1273
+ const value = typeofNumber(row[numericColumn]) ?? 0;
1274
+ const label = String(row[dimensionColumn ?? columns[0]] ?? `Row ${index + 1}`);
1275
+ return {
1276
+ title: label,
1277
+ value,
1278
+ contribution: formatContribution(value),
1279
+ explanation: `${label} is one of the highest-signal rows in the selected tile sample for ${numericColumn}.`,
1280
+ evidenceLabel: numericColumn,
1281
+ };
1282
+ })
1283
+ .sort((a, b) => Math.abs(Number(b.value ?? 0)) - Math.abs(Number(a.value ?? 0)))
1284
+ .slice(0, 5);
1285
+ }
1286
+ function buildPreviewMetricSnapshot(preview, fallbackTitle) {
1287
+ const rows = previewResultRows(preview);
1288
+ const columns = previewResultColumns(preview, rows);
1289
+ const numericColumns = columns.filter((column) => rows.some((row) => typeofNumber(row[column]) !== null));
1290
+ if (rows.length === 0 || numericColumns.length === 0) {
1291
+ return {
1292
+ currentValue: undefined,
1293
+ baselineValue: undefined,
1294
+ delta: undefined,
1295
+ context: 'Generated SQL preview did not return numeric metric rows.',
1296
+ };
1297
+ }
1298
+ const currentColumn = pickColumn(numericColumns, [/^current_/i, /current.*(revenue|value|amount|total|orders?)/i])
1299
+ ?? pickColumn(numericColumns, [/^total_/i, /(revenue|value|amount|total|orders?|points?|goals?|assists?|rebounds?|score|games_played)$/i])
1300
+ ?? pickColumn(numericColumns, [/(count|row_count)$/i])
1301
+ ?? numericColumns[0];
1302
+ const baselineColumn = pickColumn(numericColumns, [/^baseline_/i, /baseline.*(revenue|value|amount|total|orders?)/i]);
1303
+ const deltaColumn = pickColumn(numericColumns, [/(delta|change|variance|diff|contribution)/i]);
1304
+ const currentValue = sumNumericRows(rows, currentColumn);
1305
+ const baselineValue = baselineColumn ? sumNumericRows(rows, baselineColumn) : typeofNumber(rows[0]?.[currentColumn]);
1306
+ const delta = deltaColumn
1307
+ ? sumNumericRows(rows, deltaColumn)
1308
+ : currentValue !== null && baselineValue !== null
1309
+ ? currentValue - baselineValue
1310
+ : undefined;
1311
+ return {
1312
+ metric: deltaColumn ?? currentColumn,
1313
+ currentValue,
1314
+ baselineValue,
1315
+ delta,
1316
+ rowsReviewed: rows.length,
1317
+ context: fallbackTitle ?? selectedString(preview, 'title') ?? 'Generated SQL preview',
1318
+ };
1319
+ }
1320
+ function buildPreviewDriverCards(preview, intent) {
1321
+ const rows = previewResultRows(preview);
1322
+ const columns = previewResultColumns(preview, rows);
1323
+ if (rows.length === 0) {
1324
+ return buildDriverCards(null, intent);
1325
+ }
1326
+ const numericColumns = columns.filter((column) => rows.some((row) => typeofNumber(row[column]) !== null));
1327
+ const contributionColumn = pickColumn(numericColumns, [/(delta|change|variance|diff|contribution)/i])
1328
+ ?? pickColumn(numericColumns, [/^current_/i, /^total_/i, /(revenue|value|amount|total|orders?|points?|goals?|assists?|rebounds?|score|games_played)$/i])
1329
+ ?? pickColumn(numericColumns, [/(count|row_count)$/i])
1330
+ ?? numericColumns[0];
1331
+ const dimensionColumn = columns.find((column) => column !== contributionColumn && rows.some((row) => typeof row[column] === 'string'));
1332
+ if (!contributionColumn) {
1333
+ return rows.slice(0, 5).map((row, index) => ({
1334
+ title: String(row[dimensionColumn ?? columns[0]] ?? `Row ${index + 1}`),
1335
+ contribution: 'Preview row',
1336
+ explanation: 'This row came from the generated SQL preview and needs analyst review.',
1337
+ intent,
1338
+ }));
1339
+ }
1340
+ return rows
1341
+ .map((row, index) => {
1342
+ const value = typeofNumber(row[contributionColumn]) ?? 0;
1343
+ const label = String(row[dimensionColumn ?? columns[0]] ?? `Row ${index + 1}`);
1344
+ return {
1345
+ title: label,
1346
+ value,
1347
+ contribution: formatContribution(value),
1348
+ explanation: `${label} is ranked by ${contributionColumn} from the generated SQL preview.`,
1349
+ evidenceLabel: contributionColumn,
1350
+ reviewRequired: true,
1351
+ intent,
1352
+ };
1353
+ })
1354
+ .sort((a, b) => Math.abs(Number(b.value ?? 0)) - Math.abs(Number(a.value ?? 0)))
1355
+ .slice(0, 5);
1356
+ }
1357
+ function previewResultRows(preview) {
1358
+ const result = asRecord(preview.result);
1359
+ const rows = Array.isArray(result?.rows) ? result.rows : [];
1360
+ return rows.map(asRecord).filter((row) => Boolean(row)).slice(0, 100);
1361
+ }
1362
+ function previewResultColumns(preview, rows) {
1363
+ const result = asRecord(preview.result);
1364
+ const columns = Array.isArray(result?.columns) ? result.columns.map(String).filter(Boolean) : [];
1365
+ return columns.length > 0 ? columns.slice(0, 20) : Object.keys(rows[0] ?? {}).slice(0, 20);
1366
+ }
1367
+ function pickColumn(columns, patterns) {
1368
+ return columns.find((column) => patterns.some((pattern) => pattern.test(column)));
1369
+ }
1370
+ function sumNumericRows(rows, column) {
1371
+ let total = 0;
1372
+ let found = false;
1373
+ for (const row of rows) {
1374
+ const value = typeofNumber(row[column]);
1375
+ if (value === null)
1376
+ continue;
1377
+ total += value;
1378
+ found = true;
1379
+ }
1380
+ return found ? total : null;
1381
+ }
1382
+ function buildDeterministicInvestigationSql(projectRoot, input) {
1383
+ if (input.intent === 'trust_gap_review')
1384
+ return undefined;
1385
+ const block = resolveSelectedBlock(projectRoot, input.selected, input.sourceBlockId);
1386
+ if (!block)
1387
+ return undefined;
1388
+ const source = readFileSync(join(projectRoot, block.path), 'utf-8');
1389
+ const blockSql = extractDqlQuery(source);
1390
+ if (!blockSql || /\{\{/.test(blockSql) || !isReadOnlySql(blockSql))
1391
+ return undefined;
1392
+ const rows = selectedRows(input.selected);
1393
+ const columns = selectedColumns(input.selected, rows);
1394
+ const sourceSql = stripTopLevelOrderAndLimit(blockSql);
1395
+ const profile = profileResultColumns(columns, rows);
1396
+ const measure = chooseMeasureColumn(profile);
1397
+ if (!measure)
1398
+ return undefined;
1399
+ const dimension = chooseDimensionColumn(input.question, profile, input.intent);
1400
+ const sourceCte = `WITH dql_source AS (\n${sourceSql}\n)`;
1401
+ if (input.intent === 'entity_drilldown') {
1402
+ const entity = inferEntityFilter(input.question, profile, rows);
1403
+ const orderBy = `ORDER BY ${quoteSqlIdentifier(measure.name)} DESC`;
1404
+ const where = entity ? `\nWHERE ${quoteSqlIdentifier(entity.column)} IS NOT NULL AND LOWER(CAST(${quoteSqlIdentifier(entity.column)} AS VARCHAR)) LIKE ${sqlStringLiteral(`%${entity.value.toLowerCase()}%`)}` : '';
1405
+ return {
1406
+ sql: `${sourceCte}\nSELECT *\nFROM dql_source${where}\n${orderBy}\nLIMIT 100`,
1407
+ sourceBlockPath: block.path,
1408
+ sourceBlockName: block.name,
1409
+ };
1410
+ }
1411
+ if (input.intent === 'anomaly_investigation') {
1412
+ const timeDimension = chooseTimeDimension(profile) ?? dimension;
1413
+ const rankExpr = `${measureAgg(measure)}(${quoteSqlIdentifier(measure.name)})`;
1414
+ if (timeDimension) {
1415
+ return {
1416
+ sql: [
1417
+ sourceCte,
1418
+ ', dql_trend AS (',
1419
+ ` SELECT ${quoteSqlIdentifier(timeDimension.name)} AS ${quoteSqlIdentifier(timeDimension.name)}, ${rankExpr} AS ${quoteSqlIdentifier(measure.name)}`,
1420
+ ' FROM dql_source',
1421
+ ` GROUP BY ${quoteSqlIdentifier(timeDimension.name)}`,
1422
+ '), dql_deltas AS (',
1423
+ ` SELECT ${quoteSqlIdentifier(timeDimension.name)}, ${quoteSqlIdentifier(measure.name)}, LAG(${quoteSqlIdentifier(measure.name)}) OVER (ORDER BY ${quoteSqlIdentifier(timeDimension.name)}) AS baseline_${safeAlias(measure.name)}`,
1424
+ ' FROM dql_trend',
1425
+ ')',
1426
+ `SELECT *, ${quoteSqlIdentifier(measure.name)} - baseline_${safeAlias(measure.name)} AS delta_${safeAlias(measure.name)}`,
1427
+ 'FROM dql_deltas',
1428
+ `ORDER BY ABS(COALESCE(delta_${safeAlias(measure.name)}, 0)) DESC`,
1429
+ 'LIMIT 20',
1430
+ ].join('\n'),
1431
+ sourceBlockPath: block.path,
1432
+ sourceBlockName: block.name,
1433
+ };
1434
+ }
1435
+ }
1436
+ if (!dimension)
1437
+ return undefined;
1438
+ const aggregate = `${measureAgg(measure)}(${quoteSqlIdentifier(measure.name)})`;
1439
+ const label = quoteSqlIdentifier(dimension.name);
1440
+ return {
1441
+ sql: [
1442
+ sourceCte,
1443
+ `SELECT ${label} AS ${label}, ${aggregate} AS ${quoteSqlIdentifier(measure.name)}, COUNT(*) AS ${quoteSqlIdentifier('row_count')}`,
1444
+ 'FROM dql_source',
1445
+ `GROUP BY ${label}`,
1446
+ `ORDER BY ABS(COALESCE(${quoteSqlIdentifier(measure.name)}, 0)) DESC`,
1447
+ 'LIMIT 20',
1448
+ ].join('\n'),
1449
+ sourceBlockPath: block.path,
1450
+ sourceBlockName: block.name,
1451
+ };
1452
+ }
1453
+ function resolveSelectedBlock(projectRoot, selected, sourceBlockId) {
1454
+ const selectedPath = selectedString(selected, 'blockPath');
1455
+ const candidates = collectBlockCandidates(projectRoot);
1456
+ if (selectedPath) {
1457
+ const normalizedPath = selectedPath.replace(/^\/+/, '');
1458
+ const found = candidates.find((block) => block.path === normalizedPath);
1459
+ if (found)
1460
+ return found;
1461
+ if (normalizedPath.startsWith('blocks/') && existsSync(join(projectRoot, normalizedPath))) {
1462
+ const source = readFileSync(join(projectRoot, normalizedPath), 'utf-8');
1463
+ const name = matchString(source, /block\s+"([^"]+)"/) ?? titleFromPath(normalizedPath);
1464
+ return {
1465
+ id: name,
1466
+ name,
1467
+ domain: matchString(source, /domain\s*=\s*"([^"]+)"/) ?? 'uncategorized',
1468
+ status: matchString(source, /status\s*=\s*"([^"]+)"/) ?? 'draft',
1469
+ owner: matchString(source, /owner\s*=\s*"([^"]+)"/),
1470
+ tags: matchArray(source, /tags\s*=\s*\[([^\]]*)\]/),
1471
+ path: normalizedPath,
1472
+ lastModified: statSyncSafe(join(projectRoot, normalizedPath))?.mtime.toISOString() ?? new Date(0).toISOString(),
1473
+ description: matchString(source, /description\s*=\s*"((?:[^"\\]|\\.)*)"/) ?? '',
1474
+ llmContext: matchString(source, /llmContext\s*=\s*"((?:[^"\\]|\\.)*)"/),
1475
+ chartType: matchString(source, /chart\s*=\s*"([^"]+)"/) ?? undefined,
1476
+ score: 0,
1477
+ reasons: [],
1478
+ };
1479
+ }
1480
+ }
1481
+ const id = cleanString(sourceBlockId) || selectedString(selected, 'blockId');
1482
+ if (!id)
1483
+ return undefined;
1484
+ return candidates.find((block) => block.id === id || block.name === id || block.path === id);
1485
+ }
1486
+ function profileResultColumns(columns, rows) {
1487
+ return columns.map((name) => {
1488
+ const lower = name.toLowerCase();
1489
+ const numeric = rows.length === 0 ? !isLikelyTextColumn(lower) : rows.some((row) => typeofNumber(row[name]) !== null);
1490
+ const text = rows.some((row) => typeof row[name] === 'string' && String(row[name]).trim().length > 0);
1491
+ const time = /\b(season|year|month|week|quarter|date|day)\b/i.test(lower);
1492
+ const identifier = /\b(id|key|uuid|number)\b/i.test(lower) && !time;
1493
+ const measureName = /\b(total|sum|amount|revenue|sales|points?|goals?|assists?|rebounds?|count|avg|average|rate|pct|percent|score|value|delta|change|variance)\b/i.test(lower);
1494
+ const dimensionName = /\b(name|type|segment|region|market|category|status|player|customer|account|team|season|year|month|week|quarter|date)\b/i.test(lower);
1495
+ const measure = numeric && measureName && !identifier && !time;
1496
+ const dimension = !measure && (text || time || dimensionName || !numeric);
1497
+ return { name, lower, numeric, text, dimension, measure, time };
1498
+ });
1499
+ }
1500
+ function chooseMeasureColumn(columns) {
1501
+ const candidates = columns.filter((column) => column.measure);
1502
+ return candidates.find((column) => /\b(delta|change|variance|contribution)\b/i.test(column.lower))
1503
+ ?? candidates.find((column) => /\b(total_points|total_revenue|total_amount|total|revenue|amount|sales|points|goals)\b/i.test(column.lower))
1504
+ ?? candidates.find((column) => /\b(count|value|score|avg|average|rate|pct|percent)\b/i.test(column.lower))
1505
+ ?? columns.find((column) => column.numeric && !column.dimension);
1506
+ }
1507
+ function chooseDimensionColumn(question, columns, intent) {
1508
+ const dimensions = columns.filter((column) => column.dimension);
1509
+ const questionTokens = new Set(question.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
1510
+ const mentioned = dimensions.find((column) => column.lower.split(/[^a-z0-9]+/).some((token) => questionTokens.has(token)));
1511
+ if (mentioned)
1512
+ return mentioned;
1513
+ const timeDimension = chooseTimeDimension(columns);
1514
+ if ((intent === 'diagnose_change' || intent === 'segment_compare' || intent === 'anomaly_investigation') && timeDimension)
1515
+ return timeDimension;
1516
+ return dimensions.find((column) => column.text && !column.time)
1517
+ ?? timeDimension
1518
+ ?? dimensions[0];
1519
+ }
1520
+ function chooseTimeDimension(columns) {
1521
+ return columns.find((column) => column.time);
1522
+ }
1523
+ function inferEntityFilter(question, columns, rows) {
1524
+ const textDimensions = columns.filter((column) => column.dimension && (column.text || /\b(name|player|customer|account|team)\b/i.test(column.lower)));
1525
+ const lowerQuestion = question.toLowerCase();
1526
+ for (const column of textDimensions) {
1527
+ for (const row of rows) {
1528
+ const value = cleanString(row[column.name]);
1529
+ if (value && lowerQuestion.includes(value.toLowerCase()))
1530
+ return { column: column.name, value };
1531
+ }
1532
+ }
1533
+ const named = question.match(/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/);
1534
+ const value = named?.[1]?.trim();
1535
+ const column = textDimensions[0];
1536
+ return value && column ? { column: column.name, value } : undefined;
1537
+ }
1538
+ function measureAgg(column) {
1539
+ return /\b(avg|average|rate|pct|percent|per_)\b/i.test(column.lower) ? 'AVG' : 'SUM';
1540
+ }
1541
+ function extractDqlQuery(source) {
1542
+ const tripleQuoteMatch = source.match(/query\s*=\s*"""([\s\S]*?)"""/i);
1543
+ if (tripleQuoteMatch)
1544
+ return tripleQuoteMatch[1].trim() || null;
1545
+ const singleQuoteMatch = source.match(/query\s*=\s*"((?:[^"\\]|\\.)*)"/i);
1546
+ if (singleQuoteMatch)
1547
+ return singleQuoteMatch[1].replace(/\\"/g, '"').trim() || null;
1548
+ return null;
1549
+ }
1550
+ function stripTopLevelOrderAndLimit(sql) {
1551
+ let next = sql.trim().replace(/;+\s*$/g, '');
1552
+ const limitIndex = findLastTopLevelKeyword(next, 'limit');
1553
+ if (limitIndex >= 0 && /^\s+limit\s+\d+\s*$/i.test(next.slice(limitIndex))) {
1554
+ next = next.slice(0, limitIndex).trim();
1555
+ }
1556
+ const orderIndex = findLastTopLevelKeyword(next, 'order by');
1557
+ if (orderIndex >= 0)
1558
+ next = next.slice(0, orderIndex).trim();
1559
+ return next;
1560
+ }
1561
+ function findLastTopLevelKeyword(sql, keyword) {
1562
+ const lower = sql.toLowerCase();
1563
+ const target = keyword.toLowerCase();
1564
+ let depth = 0;
1565
+ let quote = null;
1566
+ let last = -1;
1567
+ for (let i = 0; i < lower.length; i += 1) {
1568
+ const char = lower[i];
1569
+ if (quote) {
1570
+ if (char === quote && lower[i - 1] !== '\\')
1571
+ quote = null;
1572
+ continue;
1573
+ }
1574
+ if (char === '"' || char === "'" || char === '`') {
1575
+ quote = char;
1576
+ continue;
1577
+ }
1578
+ if (char === '(')
1579
+ depth += 1;
1580
+ if (char === ')')
1581
+ depth = Math.max(0, depth - 1);
1582
+ if (depth === 0 && lower.startsWith(target, i) && isKeywordBoundary(lower, i - 1) && isKeywordBoundary(lower, i + target.length)) {
1583
+ last = i;
1584
+ }
1585
+ }
1586
+ return last;
1587
+ }
1588
+ function isKeywordBoundary(value, index) {
1589
+ if (index < 0 || index >= value.length)
1590
+ return true;
1591
+ return /[^a-z0-9_]/i.test(value[index]);
1592
+ }
1593
+ function quoteSqlIdentifier(identifier) {
1594
+ return `"${identifier.replace(/"/g, '""')}"`;
1595
+ }
1596
+ function sqlStringLiteral(value) {
1597
+ return `'${value.replace(/'/g, "''")}'`;
1598
+ }
1599
+ function safeAlias(identifier) {
1600
+ return identifier.replace(/[^a-z0-9_]+/gi, '_').replace(/^_+|_+$/g, '') || 'value';
1601
+ }
1602
+ function isLikelyTextColumn(value) {
1603
+ return /\b(name|type|segment|region|market|category|status|player|customer|account|team)\b/i.test(value);
1604
+ }
1605
+ async function generateInvestigationSql(ctx, input) {
1606
+ if (!ctx.generateInvestigationSql)
1607
+ return undefined;
1608
+ try {
1609
+ return await ctx.generateInvestigationSql(input);
1610
+ }
1611
+ catch (err) {
1612
+ return {
1613
+ executionError: err instanceof Error ? err.message : String(err),
1614
+ };
1615
+ }
1616
+ }
1617
+ async function runGeneratedSqlPreview(ctx, generatedSql) {
1618
+ const sql = cleanString(generatedSql);
1619
+ if (!sql)
1620
+ return {};
1621
+ if (!isReadOnlySql(sql)) {
1622
+ return {
1623
+ error: 'Generated SQL was not run because it was not a read-only SELECT or WITH query.',
1624
+ fatal: true,
1625
+ };
1626
+ }
1627
+ if (!ctx.executeSql) {
1628
+ return {
1629
+ error: 'This host cannot execute investigation SQL.',
1630
+ fatal: false,
1631
+ };
1632
+ }
1633
+ try {
1634
+ const result = await ctx.executeSql(boundedPreviewSql(sql));
1635
+ return {
1636
+ preview: {
1637
+ id: 'generated-sql-preview',
1638
+ title: 'Generated SQL preview',
1639
+ kind: 'table',
1640
+ reviewRequired: true,
1641
+ result,
1642
+ },
1643
+ };
1644
+ }
1645
+ catch (err) {
1646
+ return {
1647
+ error: err instanceof Error ? err.message : String(err),
1648
+ fatal: true,
1649
+ };
1650
+ }
1651
+ }
1652
+ function buildGeneratedSqlPreview(result, generatedSql) {
1653
+ const record = asRecord(result);
1654
+ const rawRows = Array.isArray(record?.rows) ? record.rows : [];
1655
+ const rows = rawRows.map(asRecord).filter((row) => Boolean(row)).slice(0, 100);
1656
+ const rawColumns = Array.isArray(record?.columns) ? record.columns : [];
1657
+ const columns = rawColumns.length
1658
+ ? rawColumns.map((column) => {
1659
+ const columnRecord = asRecord(column);
1660
+ return typeof column === 'string'
1661
+ ? column
1662
+ : typeof columnRecord?.name === 'string'
1663
+ ? columnRecord.name
1664
+ : String(column);
1665
+ })
1666
+ : Object.keys(rows[0] ?? {});
1667
+ return {
1668
+ id: 'generated-sql-preview',
1669
+ title: 'Generated SQL preview',
1670
+ kind: 'table',
1671
+ reviewRequired: true,
1672
+ sql: generatedSql,
1673
+ result: {
1674
+ columns,
1675
+ rows,
1676
+ rowCount: typeof record?.rowCount === 'number' ? record.rowCount : rows.length,
1677
+ executionTime: typeof record?.executionTime === 'number' ? record.executionTime : undefined,
1678
+ },
1679
+ };
1680
+ }
1681
+ function isReadOnlySql(sql) {
1682
+ const trimmed = stripLeadingSqlComments(sql).replace(/;+\s*$/g, '');
1683
+ if (!/^(select|with)\b/i.test(trimmed))
1684
+ return false;
1685
+ if (/;\s*\S/.test(trimmed))
1686
+ return false;
1687
+ return !/\b(insert|update|delete|merge|drop|alter|create|truncate|copy|grant|revoke|call|execute|attach|detach)\b/i.test(trimmed);
1688
+ }
1689
+ function stripLeadingSqlComments(sql) {
1690
+ let next = sql.trim();
1691
+ while (next.startsWith('--') || next.startsWith('/*')) {
1692
+ if (next.startsWith('--')) {
1693
+ const lineEnd = next.indexOf('\n');
1694
+ next = lineEnd >= 0 ? next.slice(lineEnd + 1).trimStart() : '';
1695
+ continue;
1696
+ }
1697
+ const blockEnd = next.indexOf('*/');
1698
+ next = blockEnd >= 0 ? next.slice(blockEnd + 2).trimStart() : '';
1699
+ }
1700
+ return next;
1701
+ }
1702
+ function boundedPreviewSql(sql) {
1703
+ return `SELECT * FROM (${sql.trim().replace(/;+\s*$/g, '')}) AS dql_research_preview LIMIT 100`;
1704
+ }
1705
+ function buildInvestigationTrust(investigation, selected, sqlError) {
1706
+ return {
1707
+ label: 'AI-generated research',
1708
+ uncertified: true,
1709
+ reviewStatus: 'needs_review',
1710
+ certifiedContext: selectedString(selected, 'certificationStatus') === 'certified' ? 'selected tile is certified' : 'selected tile certification needs review',
1711
+ sourceBlockId: investigation.sourceBlockId ?? selectedString(selected, 'blockId'),
1712
+ sourceTileId: investigation.sourceTileId ?? selectedString(selected, 'tileId'),
1713
+ caveats: [
1714
+ 'Investigation output is not certified until a reviewer promotes or certifies the generated block.',
1715
+ ...(sqlError ? [`SQL preview caveat: ${sqlError}`] : []),
1716
+ ],
1717
+ };
1718
+ }
1719
+ function investigationSteps(intent) {
1720
+ const common = ['trust check', 'evidence capture'];
1721
+ if (intent === 'trust_gap_review')
1722
+ return ['certification review', 'lineage review', 'owner and caveat check', ...common];
1723
+ if (intent === 'entity_drilldown')
1724
+ return ['entity value match', 'metric trend', 'exception rows', ...common];
1725
+ if (intent === 'segment_compare')
1726
+ return ['segment grouping', 'baseline comparison', 'top movers', ...common];
1727
+ if (intent === 'anomaly_investigation')
1728
+ return ['baseline comparison', 'trend check', 'exception rows', 'top movers', ...common];
1729
+ if (intent === 'diagnose_change')
1730
+ return ['baseline comparison', 'trend check', 'top movers', 'segment contribution', ...common];
1731
+ return ['top movers', 'segment contribution', 'exception rows', ...common];
1732
+ }
1733
+ function investigationAssumptions(intent, selected, generatedSql, sqlError) {
1734
+ return [
1735
+ `Intent classified as ${intent}.`,
1736
+ selected ? 'The selected dashboard tile is the starting context.' : 'No selected tile context was provided.',
1737
+ generatedSql ? 'A generated SQL preview was requested and bounded to 100 rows.' : 'No generated SQL was supplied, so the first pass used dashboard result samples and metadata.',
1738
+ sqlError ? `SQL preview needs review: ${sqlError}` : 'The result remains uncertified until reviewed.',
1739
+ ];
1740
+ }
1741
+ function buildInvestigationSummary(intent, question, selected, metrics, drivers) {
1742
+ const target = selectedString(selected, 'title') ?? 'this dashboard question';
1743
+ const delta = typeof metrics.delta === 'number' ? ` Delta from the sampled baseline is ${formatContribution(metrics.delta)}.` : '';
1744
+ if (intent === 'trust_gap_review') {
1745
+ return `This tile can be used as certified context only where its source block and lineage are certified. The deeper answer is AI-generated research and needs review before leaders rely on it.${delta}`;
1746
+ }
1747
+ const driver = drivers[0]?.title ? ` Top visible driver in the current evidence is ${drivers[0].title}.` : '';
1748
+ return `DQL opened a review-required investigation for ${target}: ${question}.${delta}${driver}`;
1749
+ }
1750
+ function buildInvestigationRecommendation(intent, selected, sqlError) {
1751
+ if (sqlError)
1752
+ return 'Review the generated SQL or add a certified drilldown block before promoting this result.';
1753
+ if (intent === 'trust_gap_review')
1754
+ return 'Use the certified tile for reporting, and promote only the reviewed gaps into a draft block.';
1755
+ if (!selected)
1756
+ return 'Select a dashboard tile or provide SQL so DQL can rank drivers with stronger evidence.';
1757
+ return 'Review the driver evidence, then pin the useful answer or promote the SQL path into a draft DQL block.';
1758
+ }
1759
+ function investigationPreviewResult(investigation) {
1760
+ const previews = Array.isArray(investigation.resultPreviews) ? investigation.resultPreviews : [];
1761
+ const first = previews.find((preview) => asRecord(preview)?.result);
1762
+ return asRecord(first)?.result;
1763
+ }
1764
+ function investigationCitations(investigation) {
1765
+ return [{
1766
+ kind: 'app_investigation',
1767
+ name: investigation.title,
1768
+ reviewStatus: investigation.reviewStatus,
1769
+ uncertified: true,
1770
+ sourceBlockId: investigation.sourceBlockId,
1771
+ sourceTileId: investigation.sourceTileId,
1772
+ }];
1773
+ }
1774
+ function nextResearchFollowUps(investigation) {
1775
+ const target = investigation.sourceBlockId ?? investigation.sourceTileId ?? 'this result';
1776
+ return [
1777
+ `Break ${target} down by the strongest segment`,
1778
+ `Show exception rows for ${target}`,
1779
+ `What would need review before certifying this answer?`,
1780
+ ];
1781
+ }
1782
+ function titleFromInvestigation(question, selected) {
1783
+ const selectedTitle = selectedString(selected, 'title');
1784
+ const base = selectedTitle ? `${selectedTitle}: ${question}` : question;
1785
+ return base.replace(/\s+/g, ' ').slice(0, 90);
1786
+ }
1787
+ function selectedRows(selected) {
1788
+ const rows = Array.isArray(selected?.sampleRows)
1789
+ ? selected?.sampleRows
1790
+ : Array.isArray(selected?.resultSample)
1791
+ ? selected?.resultSample
1792
+ : selected?.rows;
1793
+ if (!Array.isArray(rows))
1794
+ return [];
1795
+ return rows.map(asRecord).filter((row) => Boolean(row)).slice(0, 100);
1796
+ }
1797
+ function selectedColumns(selected, rows) {
1798
+ const columns = selected?.columns;
1799
+ if (Array.isArray(columns) && columns.length > 0) {
1800
+ return columns.map(String).filter(Boolean).slice(0, 20);
1801
+ }
1802
+ return Object.keys(rows[0] ?? {}).slice(0, 20);
1803
+ }
1804
+ function typeofNumber(value) {
1805
+ if (typeof value === 'number' && Number.isFinite(value))
1806
+ return value;
1807
+ if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value)))
1808
+ return Number(value);
1809
+ return null;
1810
+ }
1811
+ function formatContribution(value) {
1812
+ const rounded = Math.abs(value) >= 100 ? Math.round(value).toLocaleString() : Number(value.toFixed(2)).toLocaleString();
1813
+ return value >= 0 ? `+${rounded}` : `-${rounded.replace(/^-/, '')}`;
1814
+ }
1815
+ function asRecord(value) {
1816
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
1817
+ }
893
1818
  function cleanString(value) {
894
1819
  return typeof value === 'string' ? value.trim() : '';
895
1820
  }
@@ -1145,6 +2070,7 @@ function loadAppById(projectRoot, id) {
1145
2070
  notebooks: listAppNotebookRefs(projectRoot, document, appDir),
1146
2071
  drafts: listAppDrafts(projectRoot, appDir),
1147
2072
  aiPins: listAiPins(projectRoot, document.id),
2073
+ investigations: listAppInvestigations(projectRoot, document.id),
1148
2074
  };
1149
2075
  }
1150
2076
  return null;
@@ -1422,6 +2348,9 @@ function scanFiles(root, extension) {
1422
2348
  function countAiPins(projectRoot, appId) {
1423
2349
  return listAiPins(projectRoot, appId).length;
1424
2350
  }
2351
+ function countAppInvestigations(projectRoot, appId) {
2352
+ return listAppInvestigations(projectRoot, appId).length;
2353
+ }
1425
2354
  function listAiPins(projectRoot, appId) {
1426
2355
  const dbPath = defaultLocalAppsDbPath(projectRoot);
1427
2356
  if (!existsSync(dbPath))
@@ -1441,6 +2370,23 @@ function listAiPins(projectRoot, appId) {
1441
2370
  return [];
1442
2371
  }
1443
2372
  }
2373
+ function listAppInvestigations(projectRoot, appId) {
2374
+ const dbPath = defaultLocalAppsDbPath(projectRoot);
2375
+ if (!existsSync(dbPath))
2376
+ return [];
2377
+ try {
2378
+ const storage = new LocalAppStorage(dbPath);
2379
+ try {
2380
+ return storage.listAppInvestigations(appId);
2381
+ }
2382
+ finally {
2383
+ storage.close();
2384
+ }
2385
+ }
2386
+ catch {
2387
+ return [];
2388
+ }
2389
+ }
1444
2390
  function listDashboardsFor(projectRoot, id) {
1445
2391
  const result = loadAppById(projectRoot, id);
1446
2392
  return result?.dashboards ?? null;
@@ -1527,6 +2473,12 @@ async function readJson(req) {
1527
2473
  req.on('error', reject);
1528
2474
  });
1529
2475
  }
2476
+ export const __test__ = {
2477
+ buildPreviewDriverCards,
2478
+ buildPreviewMetricSnapshot,
2479
+ buildDeterministicInvestigationSql,
2480
+ selectedBlockContext,
2481
+ };
1530
2482
  // reference unused parseAppDocument/readFileSync to keep import stable for forward use
1531
2483
  void parseAppDocument;
1532
2484
  void readFileSync;