@duckcodeailabs/dql-cli 1.6.26 → 1.6.28
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 +92 -2
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +487 -2
- package/dist/apps-api.js.map +1 -1
- package/dist/assets/dql-notebook/assets/{index-D-wXlE3-.js → index-g0QVJ0wA.js} +298 -298
- package/dist/assets/dql-notebook/index.html +1 -1
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +46 -20
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- 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({
|