@duckcodeailabs/dql-cli 1.6.27 → 1.6.29

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
@@ -70,6 +70,43 @@ export async function handleAppsApi(ctx) {
70
70
  }
71
71
  return true;
72
72
  }
73
+ // Two-phase AI build: propose (plan + confirmable content list, no files) …
74
+ if (req.method === 'POST' && path === '/api/apps/ai-builds/propose') {
75
+ try {
76
+ const body = await readJson(req);
77
+ const result = await proposeAppAiBuild(projectRoot, body, {
78
+ generateGovernedAnswer: ctx.generateGovernedAnswer,
79
+ });
80
+ if (result.status === 'error') {
81
+ sendJson(res, 400, { ok: false, session: result, error: result.error });
82
+ return true;
83
+ }
84
+ sendJson(res, 201, { ok: true, session: result });
85
+ }
86
+ catch (err) {
87
+ sendJson(res, 500, { ok: false, error: err.message });
88
+ }
89
+ return true;
90
+ }
91
+ // … then commit (user confirmed the selection → write the app files).
92
+ const commitMatch = path.match(/^\/api\/apps\/ai-builds\/([^/]+)\/commit$/);
93
+ if (commitMatch && req.method === 'POST') {
94
+ try {
95
+ const body = await readJson(req);
96
+ const result = await commitAppAiBuild(projectRoot, decodeURIComponent(commitMatch[1]), body, {
97
+ narrate: ctx.narrate,
98
+ });
99
+ if (!result.ok) {
100
+ sendJson(res, result.status ?? 400, { ok: false, error: result.error });
101
+ return true;
102
+ }
103
+ sendJson(res, 201, { ok: true, session: result.session, app: result.app, dashboardId: result.dashboardId });
104
+ }
105
+ catch (err) {
106
+ sendJson(res, 500, { ok: false, error: err.message });
107
+ }
108
+ return true;
109
+ }
73
110
  let m = path.match(/^\/api\/apps\/ai-builds\/([^/]+)$/);
74
111
  if (m && req.method === 'GET') {
75
112
  const session = getAppAiBuildSession(projectRoot, decodeURIComponent(m[1]));
@@ -1017,6 +1054,446 @@ export function getAppAiBuildSession(projectRoot, id) {
1017
1054
  return null;
1018
1055
  }
1019
1056
  }
1057
+ /** Derive the confirmable proposal list from a plan: certified tiles are selectable
1058
+ * (default on); uncovered questions are listed as gaps (Phase 2 fills them with
1059
+ * bounded AI-generated candidates). Narrative/placeholder tiles are structural and
1060
+ * not part of the confirm list. */
1061
+ function buildAppProposal(plan) {
1062
+ const tiles = [];
1063
+ for (const page of plan.pages) {
1064
+ for (const tile of page.tiles) {
1065
+ if (tile.kind !== 'certified_block')
1066
+ continue;
1067
+ tiles.push({
1068
+ id: tile.id,
1069
+ source: 'certified_block',
1070
+ title: tile.title,
1071
+ description: tile.description,
1072
+ blockId: tile.blockId,
1073
+ viz: tile.viz,
1074
+ certification: 'certified',
1075
+ selectedByDefault: true,
1076
+ });
1077
+ }
1078
+ }
1079
+ const gaps = [];
1080
+ const seenGap = new Set();
1081
+ for (const report of plan.scopedReports) {
1082
+ const question = cleanString(report.question) || report.title;
1083
+ if (!question || seenGap.has(question.toLowerCase()))
1084
+ continue;
1085
+ seenGap.add(question.toLowerCase());
1086
+ gaps.push({ id: report.id, question, reason: report.description || 'No certified block covers this question.' });
1087
+ }
1088
+ for (const evidence of plan.missingEvidence) {
1089
+ const question = cleanString(evidence);
1090
+ if (!question || seenGap.has(question.toLowerCase()))
1091
+ continue;
1092
+ seenGap.add(question.toLowerCase());
1093
+ gaps.push({ id: `gap_${gaps.length + 1}`, question, reason: 'No certified block covers this question.' });
1094
+ }
1095
+ return {
1096
+ tiles,
1097
+ gaps,
1098
+ followUps: gaps.map((gap) => gap.question).slice(0, 4),
1099
+ coverage: {
1100
+ certifiedTiles: tiles.filter((tile) => tile.certification === 'certified').length,
1101
+ generatedTiles: tiles.filter((tile) => tile.certification === 'ai_generated').length,
1102
+ gaps: gaps.length,
1103
+ },
1104
+ };
1105
+ }
1106
+ /** Bounded preview rows stored in the session file (the UI shows fewer). */
1107
+ const PROPOSAL_PREVIEW_ROW_CAP = 50;
1108
+ const GENERATED_TILE_DEFAULT = 3;
1109
+ const GENERATED_TILE_HARD_CAP = 5;
1110
+ const GENERATED_VIZ_TYPES = new Set([
1111
+ 'single_value', 'grouped_bar', 'stacked_bar', 'line', 'bar', 'area', 'pie', 'donut',
1112
+ 'scatter', 'heatmap', 'histogram', 'waterfall', 'gauge', 'table', 'pivot', 'map', 'funnel', 'kpi',
1113
+ ]);
1114
+ function normalizeGeneratedViz(viz) {
1115
+ const clean = typeof viz === 'string' ? viz.trim().toLowerCase() : '';
1116
+ return GENERATED_VIZ_TYPES.has(clean) ? clean : 'table';
1117
+ }
1118
+ function titleForGapQuestion(question) {
1119
+ const clean = question.trim().replace(/[?.!]+$/, '');
1120
+ const title = clean.charAt(0).toUpperCase() + clean.slice(1);
1121
+ return title.length > 72 ? `${title.slice(0, 69)}…` : title;
1122
+ }
1123
+ /** Normalize an executed agent result into the proposal's bounded preview shape.
1124
+ * Columns may arrive as strings or {name} objects; rows as objects or arrays. */
1125
+ function previewFromAgentResult(result) {
1126
+ if (!result || !Array.isArray(result.columns) || !Array.isArray(result.rows))
1127
+ return undefined;
1128
+ const columns = result.columns.map((column) => {
1129
+ if (typeof column === 'string')
1130
+ return column;
1131
+ const record = column;
1132
+ return typeof record?.name === 'string' ? record.name : String(column);
1133
+ });
1134
+ if (columns.length === 0)
1135
+ return undefined;
1136
+ const rows = result.rows.slice(0, PROPOSAL_PREVIEW_ROW_CAP).map((row) => {
1137
+ if (Array.isArray(row)) {
1138
+ const entry = {};
1139
+ columns.forEach((column, index) => { entry[column] = row[index]; });
1140
+ return entry;
1141
+ }
1142
+ return (row ?? {});
1143
+ });
1144
+ return { columns, rows, rowCount: typeof result.rowCount === 'number' ? result.rowCount : rows.length };
1145
+ }
1146
+ /**
1147
+ * Fill coverage gaps with bounded, review-required generated answers. Sequential
1148
+ * on purpose (provider rate limits); every failure is listed for transparency, not
1149
+ * thrown; and nothing here can certify — pass-through of a certified block match is
1150
+ * the only way a candidate keeps a 'certified' label.
1151
+ */
1152
+ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
1153
+ if (!hooks?.generateGovernedAnswer || proposal.gaps.length === 0)
1154
+ return;
1155
+ const cap = Math.min(Math.max(1, maxGeneratedTiles ?? GENERATED_TILE_DEFAULT), GENERATED_TILE_HARD_CAP);
1156
+ const toFill = proposal.gaps.slice(0, cap);
1157
+ const remaining = proposal.gaps.slice(cap);
1158
+ let providerUnavailable = false;
1159
+ for (const gap of toFill) {
1160
+ if (providerUnavailable) {
1161
+ remaining.push(gap);
1162
+ continue;
1163
+ }
1164
+ try {
1165
+ const answer = await hooks.generateGovernedAnswer(gap.question);
1166
+ const sql = cleanString(answer.sql ?? answer.proposedSql ?? answer.result?.sql ?? '');
1167
+ if (!sql) {
1168
+ remaining.push(gap);
1169
+ continue;
1170
+ }
1171
+ // Gap-fill tiles are AI-generated by definition (they answer questions the
1172
+ // certified blocks did NOT cover). They are ALWAYS ai_generated / review-
1173
+ // required — never auto-certified, even if the governed loop happened to
1174
+ // touch a certified block. The AI never certifies its own output.
1175
+ proposal.tiles.push({
1176
+ id: `gen_${gap.id}`,
1177
+ source: 'ai_generated',
1178
+ title: titleForGapQuestion(gap.question),
1179
+ question: gap.question,
1180
+ sql,
1181
+ answer: cleanString(answer.answer ?? answer.text ?? '') || undefined,
1182
+ viz: normalizeGeneratedViz(answer.suggestedViz),
1183
+ certification: 'ai_generated',
1184
+ preview: previewFromAgentResult(answer.result),
1185
+ selectedByDefault: true,
1186
+ });
1187
+ }
1188
+ catch (err) {
1189
+ const message = err instanceof Error ? err.message : String(err);
1190
+ // Offline degradation: without a configured provider, keep everything as
1191
+ // plain research gaps instead of listing one error tile per question.
1192
+ if (/no ai provider/i.test(message)) {
1193
+ providerUnavailable = true;
1194
+ remaining.push(gap);
1195
+ continue;
1196
+ }
1197
+ proposal.tiles.push({
1198
+ id: `gen_${gap.id}`,
1199
+ source: 'ai_generated',
1200
+ title: titleForGapQuestion(gap.question),
1201
+ question: gap.question,
1202
+ viz: 'table',
1203
+ certification: 'ai_generated',
1204
+ error: message,
1205
+ selectedByDefault: false,
1206
+ });
1207
+ }
1208
+ }
1209
+ proposal.gaps = remaining;
1210
+ proposal.followUps = remaining.map((gap) => gap.question).slice(0, 4);
1211
+ proposal.coverage = {
1212
+ certifiedTiles: proposal.tiles.filter((tile) => tile.certification === 'certified' && !tile.error).length,
1213
+ generatedTiles: proposal.tiles.filter((tile) => tile.certification === 'ai_generated' && !tile.error).length,
1214
+ gaps: remaining.length,
1215
+ };
1216
+ }
1217
+ /**
1218
+ * Phase 1 of the two-phase app build: plan + validate + build the confirmable
1219
+ * proposal, persist the session with status 'proposed'. Writes NO app files —
1220
+ * `commitAppAiBuild` does that after the user confirms the selection.
1221
+ */
1222
+ export async function proposeAppAiBuild(projectRoot, input, hooks) {
1223
+ const now = new Date().toISOString();
1224
+ const id = `app_build_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1225
+ const selectedBlockIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
1226
+ const base = {
1227
+ id,
1228
+ status: 'proposed',
1229
+ createdAt: now,
1230
+ updatedAt: now,
1231
+ prompt: cleanString(input.prompt),
1232
+ generatedPaths: [],
1233
+ warnings: [],
1234
+ reviewTasks: [],
1235
+ inputs: {
1236
+ domain: cleanString(input.domain) || undefined,
1237
+ owner: cleanString(input.owner) || undefined,
1238
+ audience: cleanString(input.audience) || undefined,
1239
+ notebookPath: cleanString(input.notebookPath) || undefined,
1240
+ existingAppId: cleanString(input.existingAppId) || undefined,
1241
+ selectedBlockIds,
1242
+ },
1243
+ };
1244
+ if (!base.prompt) {
1245
+ const session = { ...base, status: 'error', error: 'prompt is required' };
1246
+ writeAppAiBuildSession(projectRoot, session);
1247
+ return session;
1248
+ }
1249
+ const { KGStore, defaultKgPath, planAppFromPrompt, reindexProject, validateAppPlan, } = await import('@duckcodeailabs/dql-agent');
1250
+ const kgPath = defaultKgPath(projectRoot);
1251
+ await reindexProject(projectRoot, { kgPath });
1252
+ const kg = new KGStore(kgPath);
1253
+ try {
1254
+ const plan = planAppFromPrompt({
1255
+ prompt: base.prompt,
1256
+ kg,
1257
+ domain: base.inputs.domain,
1258
+ audience: base.inputs.audience,
1259
+ owner: base.inputs.owner,
1260
+ preferredBlockIds: selectedBlockIds,
1261
+ plannerMode: input.plannerMode === 'ai_assisted' ? 'ai_assisted' : 'deterministic',
1262
+ });
1263
+ const validation = validateAppPlan(plan, kg);
1264
+ const proposal = buildAppProposal(plan);
1265
+ // Fill uncovered questions with bounded, review-required generated SQL when the
1266
+ // runtime supplied a governed answer hook; offline the gaps stay listed as-is.
1267
+ await fillProposalGaps(proposal, hooks, input.maxGeneratedTiles);
1268
+ const session = {
1269
+ ...base,
1270
+ appId: plan.appId,
1271
+ dashboardId: plan.pages[0]?.id ?? null,
1272
+ plan,
1273
+ validation,
1274
+ proposal,
1275
+ warnings: appBuildWarnings(validation, plan),
1276
+ reviewTasks: reviewTasksFromPlan(plan),
1277
+ };
1278
+ writeAppAiBuildSession(projectRoot, session);
1279
+ return session;
1280
+ }
1281
+ catch (err) {
1282
+ const session = {
1283
+ ...base,
1284
+ status: 'error',
1285
+ error: err instanceof Error ? err.message : String(err),
1286
+ };
1287
+ writeAppAiBuildSession(projectRoot, session);
1288
+ return session;
1289
+ }
1290
+ finally {
1291
+ kg.close();
1292
+ }
1293
+ }
1294
+ /**
1295
+ * Persist selected AI-generated candidates as aiPins (certification preserved,
1296
+ * never upgraded; review-required) and append them as dashboard tiles below the
1297
+ * certified layout. The dashboard doc was just written by generateAppFromPlan, so
1298
+ * this rewrites it with the extra items — the renderer already badges aiPin tiles.
1299
+ */
1300
+ function attachGeneratedTiles(projectRoot, plan, generatedPaths, candidates) {
1301
+ const dashboardId = plan.pages[0]?.id ?? 'overview';
1302
+ const dashboardPath = generatedPaths.find((path) => path.endsWith(`${dashboardId}.dqld`))
1303
+ ?? generatedPaths.find((path) => path.endsWith('.dqld'));
1304
+ if (!dashboardPath)
1305
+ return;
1306
+ const absolutePath = join(projectRoot, dashboardPath);
1307
+ if (!existsSync(absolutePath))
1308
+ return;
1309
+ const doc = JSON.parse(readFileSync(absolutePath, 'utf-8'));
1310
+ // Story layout: generated tiles live in the review appendix. Classic grids
1311
+ // (no sections) just append at the bottom, untagged.
1312
+ const storyMode = Array.isArray(doc.sections) && doc.sections.length > 0;
1313
+ if (storyMode && !doc.sections.some((section) => section.kind === 'appendix')) {
1314
+ doc.sections.push({
1315
+ id: 'appendix',
1316
+ title: 'AI-generated analysis — needs review',
1317
+ kind: 'appendix',
1318
+ order: doc.sections.length,
1319
+ });
1320
+ }
1321
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
1322
+ try {
1323
+ let y = doc.layout.items.reduce((max, item) => Math.max(max, item.y + item.h), 0);
1324
+ let x = 0;
1325
+ for (const tile of candidates) {
1326
+ const certified = tile.certification === 'certified';
1327
+ const pin = storage.createAiPin({
1328
+ appId: plan.appId,
1329
+ dashboardId: doc.id,
1330
+ title: tile.title,
1331
+ answer: tile.answer ?? tile.question ?? tile.title,
1332
+ question: tile.question,
1333
+ sql: tile.sql,
1334
+ certification: certified ? 'certified' : 'ai_generated',
1335
+ reviewStatus: certified ? 'certified' : 'needs_review',
1336
+ refreshCadence: 'none',
1337
+ result: tile.preview,
1338
+ followUps: tile.followUps ?? [],
1339
+ });
1340
+ const item = {
1341
+ i: `aipin_${pin.id}`,
1342
+ x,
1343
+ y,
1344
+ w: 6,
1345
+ h: 4,
1346
+ aiPin: { id: pin.id },
1347
+ viz: { type: normalizeGeneratedViz(tile.viz) },
1348
+ title: tile.title,
1349
+ trustState: certified ? 'certified' : 'review_required',
1350
+ reviewStatus: certified ? 'certified' : 'review_required',
1351
+ ...(storyMode ? { sectionId: 'appendix' } : {}),
1352
+ };
1353
+ doc.layout.items.push(item);
1354
+ if (x === 0) {
1355
+ x = 6;
1356
+ }
1357
+ else {
1358
+ x = 0;
1359
+ y += 4;
1360
+ }
1361
+ }
1362
+ writeFileSync(absolutePath, JSON.stringify(doc, null, 2) + '\n', 'utf-8');
1363
+ }
1364
+ finally {
1365
+ storage.close();
1366
+ }
1367
+ }
1368
+ /**
1369
+ * Phase 2 of the two-phase app build: the user confirmed the proposal — filter the
1370
+ * plan to the selected tiles, re-validate (fail closed on drift), write the app
1371
+ * files, and mark the session ready.
1372
+ */
1373
+ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks) {
1374
+ const session = getAppAiBuildSession(projectRoot, sessionId);
1375
+ if (!session)
1376
+ return { ok: false, error: `AI build session "${sessionId}" not found`, status: 404 };
1377
+ if (session.status === 'ready')
1378
+ return { ok: false, error: 'This app build was already created.', status: 409 };
1379
+ if (session.status !== 'proposed' || !session.plan || !session.proposal) {
1380
+ return { ok: false, error: session.error ?? 'Session has no proposal to commit.', status: 400 };
1381
+ }
1382
+ const plan = session.plan;
1383
+ const proposal = session.proposal;
1384
+ const selectable = new Set(proposal.tiles.filter((tile) => !tile.error).map((tile) => tile.id));
1385
+ const selected = new Set((input.selectedTileIds ?? Array.from(selectable)).filter((tileId) => selectable.has(tileId)));
1386
+ if (selected.size === 0) {
1387
+ return { ok: false, error: 'Select at least one tile to create the app.', status: 400 };
1388
+ }
1389
+ // Filter the plan's certified tiles down to the confirmed selection; structural
1390
+ // tiles (narrative, draft placeholders) stay — they document the story + gaps.
1391
+ const committedPlan = {
1392
+ ...plan,
1393
+ pages: plan.pages.map((page) => ({
1394
+ ...page,
1395
+ tiles: page.tiles.filter((tile) => tile.kind !== 'certified_block' || selected.has(tile.id)),
1396
+ })),
1397
+ };
1398
+ const { KGStore, defaultKgPath, ensureMetadataCatalogFresh, generateAppFromPlan, narrateResult, validateAppPlan, } = await import('@duckcodeailabs/dql-agent');
1399
+ const kg = new KGStore(defaultKgPath(projectRoot));
1400
+ try {
1401
+ // Re-validate against the live catalog: blocks may have changed since propose.
1402
+ const validation = validateAppPlan(committedPlan, kg);
1403
+ if (validation.certifiedTiles === 0) {
1404
+ return { ok: false, error: appBuildBlockedMessage(validation, committedPlan), status: 409 };
1405
+ }
1406
+ // Narrate the story from the confirmed content (generated candidates carry
1407
+ // executed previews, so real numbers reach the prose). The LLM-backed hook is
1408
+ // optional — the deterministic narrator guarantees a story offline.
1409
+ const narrateInput = {
1410
+ question: committedPlan.prompt,
1411
+ items: [
1412
+ ...proposal.tiles
1413
+ .filter((tile) => tile.source === 'certified_block' && selected.has(tile.id))
1414
+ .map((tile) => ({ id: tile.id, title: tile.title })),
1415
+ ...proposal.tiles
1416
+ .filter((tile) => tile.source === 'ai_generated' && !tile.error && selected.has(tile.id))
1417
+ .map((tile) => ({
1418
+ id: tile.id,
1419
+ title: tile.title,
1420
+ result: tile.preview ? { columns: tile.preview.columns, rows: tile.preview.rows } : undefined,
1421
+ })),
1422
+ ],
1423
+ reviewRequired: proposal.tiles.some((tile) => tile.source === 'ai_generated' && !tile.error && selected.has(tile.id)),
1424
+ };
1425
+ let narration;
1426
+ try {
1427
+ narration = hooks?.narrate ? await hooks.narrate(narrateInput) : await narrateResult(narrateInput);
1428
+ }
1429
+ catch {
1430
+ try {
1431
+ narration = await narrateResult(narrateInput);
1432
+ }
1433
+ catch {
1434
+ narration = undefined; // Narration is enhancement, never a commit blocker.
1435
+ }
1436
+ }
1437
+ // Honesty caveat: when the story is built partly from AI-generated (review-
1438
+ // required) figures, say so in the executive summary — the headline must not
1439
+ // present uncertified numbers as trusted.
1440
+ if (narration && narrateInput.reviewRequired) {
1441
+ narration = {
1442
+ ...narration,
1443
+ summary: `${narration.summary}\n\n_Some figures draw on AI-generated analysis that is pending review — see the review appendix._`,
1444
+ };
1445
+ }
1446
+ // Copilot follow-ups the created app carries: still-uncovered gaps first, then
1447
+ // the questions behind any candidates the user chose to leave out.
1448
+ const copilotQuestions = unique([
1449
+ ...proposal.gaps.map((gap) => gap.question),
1450
+ ...proposal.tiles
1451
+ .filter((tile) => tile.source === 'ai_generated' && !selected.has(tile.id) && tile.question)
1452
+ .map((tile) => tile.question),
1453
+ ]).slice(0, 4);
1454
+ let generated;
1455
+ try {
1456
+ generated = generateAppFromPlan(projectRoot, committedPlan, kg, { overwrite: Boolean(input.force), narration, copilotQuestions });
1457
+ }
1458
+ catch (err) {
1459
+ const message = err instanceof Error ? err.message : String(err);
1460
+ return { ok: false, error: message, status: message.includes('already exists') ? 409 : 400 };
1461
+ }
1462
+ // Selected AI-generated candidates become aiPin tiles: stored in local app
1463
+ // storage with their review-required status, then appended to the dashboard.
1464
+ // The certified app is already valid on disk — attaching the extra pins must
1465
+ // NOT be able to fail the commit and orphan the app, so it is best-effort.
1466
+ const generatedCandidates = proposal.tiles.filter((tile) => tile.source === 'ai_generated' && !tile.error && selected.has(tile.id) && tile.sql);
1467
+ const attachWarnings = [];
1468
+ if (generatedCandidates.length > 0) {
1469
+ try {
1470
+ attachGeneratedTiles(projectRoot, committedPlan, generated.paths, generatedCandidates);
1471
+ }
1472
+ catch (err) {
1473
+ attachWarnings.push(`Some AI-generated tiles could not be attached: ${err instanceof Error ? err.message : String(err)}`);
1474
+ }
1475
+ }
1476
+ await ensureMetadataCatalogFresh(projectRoot, { force: true });
1477
+ const app = collectAppsList(projectRoot).find((entry) => entry.id === committedPlan.appId) ?? null;
1478
+ const next = {
1479
+ ...session,
1480
+ status: 'ready',
1481
+ updatedAt: new Date().toISOString(),
1482
+ appId: committedPlan.appId,
1483
+ dashboardId: committedPlan.pages[0]?.id ?? app?.dashboards[0]?.id ?? null,
1484
+ plan: committedPlan,
1485
+ validation,
1486
+ generatedPaths: generated.paths,
1487
+ committedTileIds: Array.from(selected),
1488
+ warnings: [...session.warnings, ...attachWarnings],
1489
+ };
1490
+ writeAppAiBuildSession(projectRoot, next);
1491
+ return { ok: true, session: next, app, dashboardId: next.dashboardId ?? null };
1492
+ }
1493
+ finally {
1494
+ kg.close();
1495
+ }
1496
+ }
1020
1497
  /**
1021
1498
  * Ask the App a question. Routes to the right lane (certified answer / investigation /
1022
1499
  * app change / metadata) and — when the runtime supplies a research planner — enriches
@@ -1024,8 +1501,16 @@ export function getAppAiBuildSession(projectRoot, id) {
1024
1501
  * panel can DECIDE and offer real next steps instead of generic strings.
1025
1502
  */
1026
1503
  async function askAppQuestion(ctx, appId, input) {
1027
- const result = await routeAppAskQuestion(ctx, appId, input);
1028
- if (!result.ok || !ctx.planResearch)
1504
+ const routed = await routeAppAskQuestion(ctx, appId, input);
1505
+ if (!routed.ok)
1506
+ return routed;
1507
+ // Blend in the app's own suggested questions (uncovered analysis gaps captured
1508
+ // at AI-build time) so follow-ups point at what the app can't answer yet.
1509
+ const appCopilotQuestions = loadAppById(ctx.projectRoot, appId)?.app.copilot?.suggestedQuestions ?? [];
1510
+ const result = appCopilotQuestions.length > 0
1511
+ ? { ...routed, followUps: unique([...routed.followUps, ...appCopilotQuestions]).slice(0, 5) }
1512
+ : routed;
1513
+ if (!ctx.planResearch)
1029
1514
  return result;
1030
1515
  try {
1031
1516
  const research = await ctx.planResearch({
@@ -1167,12 +1652,14 @@ async function routeAppAskQuestion(ctx, appId, input) {
1167
1652
  storage.close();
1168
1653
  }
1169
1654
  }
1170
- if (blockId) {
1655
+ // The focused tile narrates ONLY questions that are actually about it ("explain this",
1656
+ // "what does this chart show"). Everything else routes through the shared governed loop.
1657
+ const focusTileAnswer = () => {
1171
1658
  const selected = selectedBlockContext(input.context);
1172
1659
  const answer = buildCertifiedAppAnswer({
1173
1660
  app: loaded.app,
1174
1661
  dashboard: dashboard ?? null,
1175
- blockId,
1662
+ blockId: blockId,
1176
1663
  question,
1177
1664
  selected,
1178
1665
  variables: input.variables,
@@ -1185,14 +1672,22 @@ async function routeAppAskQuestion(ctx, appId, input) {
1185
1672
  reviewStatus: tile?.reviewStatus === 'certified' || tile?.display?.reviewStatus === 'certified' ? 'certified' : 'review_required',
1186
1673
  citations,
1187
1674
  followUps: ['Explain the visible result', 'Investigate drivers', 'Create block draft from this result'],
1188
- decision: buildAppAskDecision('certified_answer', {
1189
- question,
1190
- blockId,
1191
- selected,
1192
- appName: loaded.app.name,
1193
- }),
1675
+ decision: buildAppAskDecision('certified_answer', { question, blockId, selected, appName: loaded.app.name }),
1194
1676
  };
1195
- }
1677
+ };
1678
+ if (blockId && questionIsAboutFocusedTile(question)) {
1679
+ return focusTileAnswer();
1680
+ }
1681
+ // Route the QUESTION through the same governed answer loop the Notebook and Ask
1682
+ // surfaces use: question-first retrieval finds the best-matching certified block
1683
+ // across the app, generates review-required SQL, or digs deeper — instead of
1684
+ // narrating whichever tile happens to be focused.
1685
+ const governed = await tryGovernedAppAnswer(ctx, question, { blockId, appName: loaded.app.name, citations });
1686
+ if (governed)
1687
+ return governed;
1688
+ // Offline / no AI provider: keep the focused tile as a graceful fallback.
1689
+ if (blockId)
1690
+ return focusTileAnswer();
1196
1691
  return {
1197
1692
  ok: true,
1198
1693
  route: 'metadata_answer',
@@ -1223,6 +1718,16 @@ function buildAppAskDecision(route, input) {
1223
1718
  confidence: hasSelectedRows(selected) ? 0.9 : 0.74,
1224
1719
  };
1225
1720
  }
1721
+ if (route === 'generated_answer') {
1722
+ return {
1723
+ mode: 'answer',
1724
+ reason: 'Answered by routing the question through the governed answer loop (best-matching certified block, or review-required generated SQL).',
1725
+ nextAction: 'Review the result; promote to a certified block when it should be reused.',
1726
+ requiresContext: false,
1727
+ usesCertifiedResult: false,
1728
+ confidence: 0.7,
1729
+ };
1730
+ }
1226
1731
  if (route === 'investigation') {
1227
1732
  const intent = normalizeInvestigationIntent(undefined, input.question, selected);
1228
1733
  return {
@@ -1253,6 +1758,65 @@ function buildAppAskDecision(route, input) {
1253
1758
  confidence: 0.62,
1254
1759
  };
1255
1760
  }
1761
+ /**
1762
+ * True only when the question is genuinely ABOUT the focused tile ("explain this",
1763
+ * "what does this chart show") — deictic/explanatory and not asking for other data.
1764
+ * Any other question (rankings, other entities, drilldowns, "why") must route through
1765
+ * the shared governed loop instead of narrating the focused tile.
1766
+ */
1767
+ function questionIsAboutFocusedTile(question) {
1768
+ const lower = question.toLowerCase();
1769
+ if (!/\b(this|that|it|these|those|the (?:chart|graph|result|tile|number|value|visual|figure)|visible|current (?:tile|block|metric|result)|selected (?:tile|block|metric)|explain)\b/.test(lower)) {
1770
+ return false;
1771
+ }
1772
+ if (/\b(top|bottom|best|worst|highest|lowest|least|fewest|most|rank|ranking|customers?|products?|orders?|revenue|sales|spend|by\s+[a-z]|compare|versus|vs\.?|break\s*down|drill|why|driver|segment|cohort|trend|forecast|list|who|where|which)\b/.test(lower)) {
1773
+ return false;
1774
+ }
1775
+ return true;
1776
+ }
1777
+ /** Map a governed AgentAnswer into the App Copilot's response shape. */
1778
+ function mapGovernedToAppAsk(governed, input) {
1779
+ const isCertified = governed.certification === 'certified' || governed.kind === 'certified';
1780
+ const noAnswer = governed.kind === 'no_answer';
1781
+ const answer = cleanString(governed.answer) || cleanString(governed.text) || 'No governed answer was available for this question.';
1782
+ const route = noAnswer ? 'metadata_answer' : isCertified ? 'certified_answer' : 'generated_answer';
1783
+ const trustState = noAnswer ? 'draft_ready' : isCertified ? 'certified' : 'review_required';
1784
+ const govCitations = Array.isArray(governed.citations)
1785
+ ? governed.citations.map((c) => ({ kind: String(c.kind ?? 'source'), name: String(c.name ?? '') })).filter((c) => c.name)
1786
+ : [];
1787
+ const seen = new Set();
1788
+ const citations = [...input.citations, ...govCitations].filter((c) => {
1789
+ const key = `${c.kind}:${c.name}`;
1790
+ if (seen.has(key))
1791
+ return false;
1792
+ seen.add(key);
1793
+ return true;
1794
+ }).slice(0, 8);
1795
+ return {
1796
+ ok: true,
1797
+ route,
1798
+ answer,
1799
+ trustState,
1800
+ reviewStatus: trustState,
1801
+ citations,
1802
+ followUps: noAnswer
1803
+ ? ['Name the metric and grain', 'Pick a certified tile', 'Start driver analysis']
1804
+ : ['Investigate drivers', 'Break this down further', 'Create a draft DQL block'],
1805
+ decision: buildAppAskDecision(route, { question: input.question, blockId: input.blockId, appName: input.appName }),
1806
+ };
1807
+ }
1808
+ /** Route the question through the shared governed answer loop; null if unavailable. */
1809
+ async function tryGovernedAppAnswer(ctx, question, input) {
1810
+ if (!ctx.generateGovernedAnswer)
1811
+ return null;
1812
+ try {
1813
+ const governed = await ctx.generateGovernedAnswer(question);
1814
+ return governed ? mapGovernedToAppAsk(governed, { question, ...input }) : null;
1815
+ }
1816
+ catch {
1817
+ return null; // no provider / transient — caller falls back to tile or metadata
1818
+ }
1819
+ }
1256
1820
  function appAskInvestigationReason(intent) {
1257
1821
  if (intent === 'diagnose_change')
1258
1822
  return 'The question asks why something changed, which needs a comparison window or baseline beyond the visible certified result.';