@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.d.ts +93 -3
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +575 -11
- package/dist/apps-api.js.map +1 -1
- package/dist/assets/dql-notebook/assets/{index-A4ATUOrs.js → index-QJKCrLXe.js} +307 -307
- package/dist/assets/dql-notebook/index.html +1 -1
- package/dist/llm/index.d.ts.map +1 -1
- package/dist/llm/index.js +4 -0
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/providers/dql-agent-provider.d.ts +15 -2
- package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
- package/dist/llm/providers/dql-agent-provider.js +50 -1
- package/dist/llm/providers/dql-agent-provider.js.map +1 -1
- package/dist/llm/types.d.ts +1 -1
- package/dist/llm/types.d.ts.map +1 -1
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +384 -36
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- package/dist/providers/subscription-cli.d.ts +66 -0
- package/dist/providers/subscription-cli.d.ts.map +1 -0
- package/dist/providers/subscription-cli.js +314 -0
- package/dist/providers/subscription-cli.js.map +1 -0
- package/dist/settings/provider-settings.d.ts +13 -1
- package/dist/settings/provider-settings.d.ts.map +1 -1
- package/dist/settings/provider-settings.js +34 -6
- package/dist/settings/provider-settings.js.map +1 -1
- package/package.json +11 -11
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
|
|
1028
|
-
if (!
|
|
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
|
-
|
|
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.';
|