@duckcodeailabs/dql-cli 1.6.20 → 1.6.21

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
@@ -55,6 +55,31 @@ export async function handleAppsApi(ctx) {
55
55
  }
56
56
  return true;
57
57
  }
58
+ if (req.method === 'POST' && path === '/api/apps/ai-builds') {
59
+ try {
60
+ const body = await readJson(req);
61
+ const result = await createAppAiBuildSession(projectRoot, body);
62
+ if (result.status === 'error') {
63
+ sendJson(res, 400, { ok: false, session: result, error: result.error });
64
+ return true;
65
+ }
66
+ sendJson(res, 201, { ok: true, session: result });
67
+ }
68
+ catch (err) {
69
+ sendJson(res, 500, { ok: false, error: err.message });
70
+ }
71
+ return true;
72
+ }
73
+ let m = path.match(/^\/api\/apps\/ai-builds\/([^/]+)$/);
74
+ if (m && req.method === 'GET') {
75
+ const session = getAppAiBuildSession(projectRoot, decodeURIComponent(m[1]));
76
+ if (!session) {
77
+ sendJson(res, 404, { ok: false, error: `AI build session "${decodeURIComponent(m[1])}" not found` });
78
+ return true;
79
+ }
80
+ sendJson(res, 200, { ok: true, session });
81
+ return true;
82
+ }
58
83
  if (req.method === 'POST' && path === '/api/apps') {
59
84
  try {
60
85
  const body = await readJson(req);
@@ -71,7 +96,42 @@ export async function handleAppsApi(ctx) {
71
96
  }
72
97
  return true;
73
98
  }
74
- let m = path.match(/^\/api\/apps\/([^/]+)\/editor\/catalog$/);
99
+ m = path.match(/^\/api\/apps\/([^/]+)\/ask$/);
100
+ if (m && req.method === 'POST') {
101
+ const appId = decodeURIComponent(m[1]);
102
+ try {
103
+ const body = await readJson(req);
104
+ const result = await askAppQuestion(ctx, appId, body);
105
+ if (!result.ok) {
106
+ sendJson(res, 400, result);
107
+ return true;
108
+ }
109
+ sendJson(res, 200, result);
110
+ }
111
+ catch (err) {
112
+ sendJson(res, 500, { ok: false, error: err.message });
113
+ }
114
+ return true;
115
+ }
116
+ m = path.match(/^\/api\/apps\/([^/]+)\/promote$/);
117
+ if (m && req.method === 'POST') {
118
+ const appId = decodeURIComponent(m[1]);
119
+ try {
120
+ const body = await readJson(req);
121
+ const result = promoteAppForStakeholders(projectRoot, appId, body);
122
+ if (!result.ok) {
123
+ sendJson(res, 400, result);
124
+ return true;
125
+ }
126
+ await refreshGeneratedMetadata(projectRoot);
127
+ sendJson(res, 200, result);
128
+ }
129
+ catch (err) {
130
+ sendJson(res, 500, { ok: false, error: err.message });
131
+ }
132
+ return true;
133
+ }
134
+ m = path.match(/^\/api\/apps\/([^/]+)\/editor\/catalog$/);
75
135
  if (m && req.method === 'GET') {
76
136
  const appId = decodeURIComponent(m[1]);
77
137
  const app = loadAppById(projectRoot, appId)?.app;
@@ -284,7 +344,7 @@ export async function handleAppsApi(ctx) {
284
344
  try {
285
345
  if (req.method === 'GET') {
286
346
  const dashboardId = ctx.url.searchParams.get('dashboardId') ?? undefined;
287
- sendJson(res, 200, { investigations: storage.listAppInvestigations(appId, dashboardId) });
347
+ sendJson(res, 200, { investigations: dedupeAppInvestigationsForDisplay(storage.listAppInvestigations(appId, dashboardId)) });
288
348
  return true;
289
349
  }
290
350
  if (req.method === 'POST') {
@@ -294,7 +354,7 @@ export async function handleAppsApi(ctx) {
294
354
  sendJson(res, 400, { error: 'question is required' });
295
355
  return true;
296
356
  }
297
- let investigation = storage.createAppInvestigation({
357
+ let investigation = createOrReuseAppInvestigation(storage, {
298
358
  appId,
299
359
  dashboardId: body.dashboardId,
300
360
  sourceTileId: body.sourceTileId ?? selectedContextString(body.context, 'tileId'),
@@ -306,7 +366,12 @@ export async function handleAppsApi(ctx) {
306
366
  generatedSql: body.generatedSql,
307
367
  });
308
368
  if (body.run !== false) {
309
- investigation = await runAppInvestigation(ctx, storage, investigation, body);
369
+ investigation = storage.updateAppInvestigation(investigation.id, {
370
+ status: 'running',
371
+ reviewStatus: 'needs_review',
372
+ error: '',
373
+ }) ?? investigation;
374
+ scheduleAppInvestigationRun(ctx, appId, investigation.id, body);
310
375
  }
311
376
  sendJson(res, 201, { ok: true, investigation });
312
377
  return true;
@@ -388,7 +453,7 @@ export async function handleAppsApi(ctx) {
388
453
  const created = createAiPinTile(projectRoot, appId, {
389
454
  dashboardId,
390
455
  title: cleanString(body.title) || investigation.title,
391
- answer: investigation.summary ?? investigation.recommendation ?? investigation.title,
456
+ answer: investigationNarrativeAnswer(investigation),
392
457
  question: investigation.question,
393
458
  sql: investigation.generatedSql,
394
459
  sourceTier: 'metadata_research',
@@ -522,6 +587,24 @@ export async function handleAppsApi(ctx) {
522
587
  }
523
588
  return true;
524
589
  }
590
+ m = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/tiles\/recommend$/);
591
+ if (m && req.method === 'POST') {
592
+ const appId = decodeURIComponent(m[1]);
593
+ const dashboardId = decodeURIComponent(m[2]);
594
+ try {
595
+ const body = await readJson(req);
596
+ const result = recommendDashboardTile(projectRoot, appId, dashboardId, body);
597
+ if (!result.ok) {
598
+ sendJson(res, 400, result);
599
+ return true;
600
+ }
601
+ sendJson(res, 200, result);
602
+ }
603
+ catch (err) {
604
+ sendJson(res, 500, { ok: false, error: err.message });
605
+ }
606
+ return true;
607
+ }
525
608
  // /api/apps/:id — single App with dashboards summary
526
609
  m = path.match(/^\/api\/apps\/([^/]+)$/);
527
610
  if (m && req.method === 'GET') {
@@ -680,9 +763,9 @@ function normalizeConversationContext(value) {
680
763
  export function recommendBlocks(projectRoot, input) {
681
764
  const domain = cleanString(input.domain).toLowerCase();
682
765
  const tags = normalizeTags(input.tags ?? []);
683
- const text = [input.purpose, input.audience, ...(input.tags ?? [])].map((v) => cleanString(v).toLowerCase()).filter(Boolean);
766
+ const terms = appRecommendationTerms([input.purpose, input.audience, ...(input.tags ?? [])]);
684
767
  const certifiedOnly = input.certifiedOnly !== false;
685
- const hasCriteria = Boolean(domain || tags.length > 0 || text.length > 0);
768
+ const hasCriteria = Boolean(domain || tags.length > 0 || terms.length > 0);
686
769
  return collectBlockCandidates(projectRoot)
687
770
  .map((block) => {
688
771
  let score = 0;
@@ -708,12 +791,16 @@ export function recommendBlocks(projectRoot, input) {
708
791
  const haystack = [block.name, block.description, block.owner ?? '', block.llmContext ?? '', ...block.tags]
709
792
  .join(' ')
710
793
  .toLowerCase();
711
- const textHits = text.filter((term) => term && haystack.includes(term));
794
+ const textHits = terms.filter((term) => term && haystack.includes(term));
712
795
  if (textHits.length > 0) {
713
796
  score += textHits.length * 6;
714
797
  criteriaScore += textHits.length * 6;
715
798
  reasons.push('context match');
716
799
  }
800
+ if (!hasCriteria && block.status === 'certified') {
801
+ criteriaScore += 1;
802
+ reasons.push('generic prompt fallback');
803
+ }
717
804
  if (score === 0 && !domain && tags.length === 0 && !certifiedOnly)
718
805
  score = 1;
719
806
  if (hasCriteria && criteriaScore === 0)
@@ -726,6 +813,18 @@ export function recommendBlocks(projectRoot, input) {
726
813
  .sort((a, b) => b.score - a.score || new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime())
727
814
  .slice(0, 50);
728
815
  }
816
+ function appRecommendationTerms(values) {
817
+ const stop = new Set([
818
+ 'a', 'an', 'and', 'app', 'apps', 'analytics', 'available', 'block', 'blocks', 'build', 'certified',
819
+ 'dashboard', 'dashboards', 'data', 'dql', 'from', 'for', 'governed', 'my', 'of', 'on', 'stakeholder',
820
+ 'stakeholders', 'table', 'tables', 'the', 'to', 'using', 'view', 'warehouse', 'with',
821
+ ]);
822
+ const terms = values
823
+ .flatMap((value) => cleanString(value).toLowerCase().match(/[a-z][a-z0-9_]{2,}/g) ?? [])
824
+ .filter((term) => !stop.has(term))
825
+ .slice(0, 40);
826
+ return unique(terms);
827
+ }
729
828
  export function recommendVisualization(projectRoot, input) {
730
829
  const blockRef = cleanString(input.blockRef);
731
830
  const prompt = cleanString(input.prompt).toLowerCase();
@@ -813,11 +912,21 @@ export async function generateAppPackage(projectRoot, input) {
813
912
  prompt,
814
913
  kg,
815
914
  domain: cleanString(input.domain) || undefined,
915
+ audience: cleanString(input.audience) || undefined,
816
916
  owner: cleanString(input.owner) || undefined,
817
917
  preferredBlockIds: selectedBlockIds,
818
918
  plannerMode: input.plannerMode === 'ai_assisted' ? 'ai_assisted' : 'deterministic',
819
919
  });
820
920
  const validation = validateAppPlan(plan, kg);
921
+ const certifiedTiles = typeof validation.certifiedTiles === 'number'
922
+ ? validation.certifiedTiles
923
+ : 0;
924
+ if (certifiedTiles === 0) {
925
+ return {
926
+ ok: false,
927
+ error: appBuildBlockedMessage(validation, plan),
928
+ };
929
+ }
821
930
  const generated = generateAppFromPlan(projectRoot, plan, kg, {
822
931
  overwrite: Boolean(input.force),
823
932
  });
@@ -839,6 +948,786 @@ export async function generateAppPackage(projectRoot, input) {
839
948
  kg.close();
840
949
  }
841
950
  }
951
+ export async function createAppAiBuildSession(projectRoot, input) {
952
+ const now = new Date().toISOString();
953
+ const id = `app_build_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
954
+ const selectedBlockIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
955
+ const base = {
956
+ id,
957
+ status: 'ready',
958
+ createdAt: now,
959
+ updatedAt: now,
960
+ prompt: cleanString(input.prompt),
961
+ generatedPaths: [],
962
+ warnings: [],
963
+ reviewTasks: [],
964
+ inputs: {
965
+ domain: cleanString(input.domain) || undefined,
966
+ owner: cleanString(input.owner) || undefined,
967
+ audience: cleanString(input.audience) || undefined,
968
+ notebookPath: cleanString(input.notebookPath) || undefined,
969
+ existingAppId: cleanString(input.existingAppId) || undefined,
970
+ selectedBlockIds,
971
+ },
972
+ };
973
+ if (!base.prompt) {
974
+ const session = { ...base, status: 'error', error: 'prompt is required' };
975
+ writeAppAiBuildSession(projectRoot, session);
976
+ return session;
977
+ }
978
+ const result = await generateAppPackage(projectRoot, {
979
+ ...input,
980
+ selectedBlockIds,
981
+ });
982
+ if (!result.ok) {
983
+ const session = {
984
+ ...base,
985
+ status: 'error',
986
+ error: result.error,
987
+ warnings: [result.error],
988
+ };
989
+ writeAppAiBuildSession(projectRoot, session);
990
+ return session;
991
+ }
992
+ const plan = result.plan;
993
+ const session = {
994
+ ...base,
995
+ appId: typeof plan.appId === 'string' ? plan.appId : result.app?.id,
996
+ dashboardId: result.dashboardId,
997
+ generatedPaths: result.generated.paths,
998
+ plan: result.plan,
999
+ validation: result.validation,
1000
+ warnings: appBuildWarnings(result.validation, plan),
1001
+ reviewTasks: reviewTasksFromPlan(plan),
1002
+ };
1003
+ writeAppAiBuildSession(projectRoot, session);
1004
+ return session;
1005
+ }
1006
+ export function getAppAiBuildSession(projectRoot, id) {
1007
+ const clean = cleanString(id);
1008
+ if (!clean || !/^[a-z0-9_:-]+$/i.test(clean))
1009
+ return null;
1010
+ const path = join(appAiBuildSessionDir(projectRoot), `${clean}.json`);
1011
+ if (!existsSync(path))
1012
+ return null;
1013
+ try {
1014
+ return JSON.parse(readFileSync(path, 'utf-8'));
1015
+ }
1016
+ catch {
1017
+ return null;
1018
+ }
1019
+ }
1020
+ async function askAppQuestion(ctx, appId, input) {
1021
+ const loaded = loadAppById(ctx.projectRoot, appId);
1022
+ if (!loaded)
1023
+ return { ok: false, error: `App "${appId}" not found` };
1024
+ const question = cleanString(input.question);
1025
+ if (!question)
1026
+ return { ok: false, error: 'question is required' };
1027
+ const dashboardId = cleanString(input.dashboardId)
1028
+ || (loaded.app.homepage?.type === 'dashboard' ? loaded.app.homepage.id : undefined)
1029
+ || loaded.dashboards[0]?.id;
1030
+ const dashboard = dashboardId ? loadDashboardForApp(ctx.projectRoot, appId, dashboardId)?.dashboard : null;
1031
+ const tile = dashboard?.layout.items.find((item) => item.i === input.tileId ||
1032
+ (input.blockId && item.block && 'blockId' in item.block && item.block.blockId === input.blockId));
1033
+ const blockId = cleanString(input.blockId) || (tile?.block && 'blockId' in tile.block ? tile.block.blockId : undefined);
1034
+ const citations = [
1035
+ { kind: 'app', name: loaded.app.name, path: loaded.appPath },
1036
+ ...(dashboard ? [{ kind: 'dashboard', name: dashboard.metadata.title, path: `${loaded.appPath}/dashboards/${dashboard.id}.dqld` }] : []),
1037
+ ...(blockId ? [{ kind: 'block', name: blockId }] : []),
1038
+ ];
1039
+ if (isAppChangeQuestion(question)) {
1040
+ const decision = buildAppAskDecision('app_change_proposal', {
1041
+ question,
1042
+ blockId,
1043
+ selected: selectedBlockContext(input.context),
1044
+ appName: loaded.app.name,
1045
+ });
1046
+ return {
1047
+ ok: true,
1048
+ route: 'app_change_proposal',
1049
+ answer: `I can update the app presentation, but this should remain a reviewed app change. Suggested action: ${appChangeSuggestion(question)}.`,
1050
+ trustState: 'review_required',
1051
+ reviewStatus: 'review_required',
1052
+ citations,
1053
+ followUps: ['Review this app change', 'Open Build mode', 'Create analysis before changing logic'],
1054
+ decision,
1055
+ proposal: {
1056
+ type: 'app_change',
1057
+ dashboardId,
1058
+ tileId: input.tileId,
1059
+ blockId,
1060
+ question,
1061
+ reviewRequired: true,
1062
+ },
1063
+ };
1064
+ }
1065
+ if (shouldRouteAppQuestionToInvestigation(question)) {
1066
+ const decision = buildAppAskDecision('investigation', {
1067
+ question,
1068
+ blockId,
1069
+ selected: selectedBlockContext(input.context),
1070
+ appName: loaded.app.name,
1071
+ });
1072
+ const investigationProposal = {
1073
+ type: 'research_investigation',
1074
+ dashboardId,
1075
+ tileId: cleanString(input.tileId) || undefined,
1076
+ blockId,
1077
+ question,
1078
+ intent: normalizeInvestigationIntent(undefined, question, input.context),
1079
+ title: titleFromInvestigation(question, selectedBlockContext(input.context)),
1080
+ requiredContext: true,
1081
+ reviewRequired: true,
1082
+ suggestedContext: {
1083
+ appId,
1084
+ appName: loaded.app.name,
1085
+ dashboardId,
1086
+ tileId: input.tileId,
1087
+ blockId,
1088
+ variables: input.variables,
1089
+ selectedTile: tile ? appAskTileContext(tile) : undefined,
1090
+ userContext: input.context,
1091
+ },
1092
+ };
1093
+ if (input.runInvestigation === false) {
1094
+ return {
1095
+ ok: true,
1096
+ route: 'investigation',
1097
+ answer: 'This needs a scoped analysis memo. Add the comparison, filters, timeframe, and decision context before DQL writes SQL or runs a preview.',
1098
+ trustState: 'draft_ready',
1099
+ reviewStatus: 'draft_ready',
1100
+ citations,
1101
+ followUps: ['Add analysis context', 'Check proof', 'Create block draft'],
1102
+ decision,
1103
+ proposal: investigationProposal,
1104
+ };
1105
+ }
1106
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(ctx.projectRoot));
1107
+ try {
1108
+ let investigation = createOrReuseAppInvestigation(storage, {
1109
+ appId,
1110
+ dashboardId,
1111
+ sourceTileId: cleanString(input.tileId) || undefined,
1112
+ sourceBlockId: blockId,
1113
+ title: titleFromInvestigation(question, selectedBlockContext(input.context)),
1114
+ question,
1115
+ intent: normalizeInvestigationIntent(undefined, question, input.context),
1116
+ context: {
1117
+ appId,
1118
+ appName: loaded.app.name,
1119
+ dashboardId,
1120
+ tileId: input.tileId,
1121
+ blockId,
1122
+ variables: input.variables,
1123
+ selectedTile: tile ? appAskTileContext(tile) : undefined,
1124
+ userContext: input.context,
1125
+ routeDecision: decision,
1126
+ },
1127
+ });
1128
+ investigation = await runAppInvestigation(ctx, storage, investigation, { context: investigation.context });
1129
+ return {
1130
+ ok: true,
1131
+ route: 'investigation',
1132
+ answer: 'I opened a review-required analysis memo for this follow-up. Review the numbers, caveats, SQL appendix, and source proof before adding it to the app or drafting reusable logic.',
1133
+ trustState: 'draft_ready',
1134
+ reviewStatus: 'draft_ready',
1135
+ citations,
1136
+ followUps: ['Review the memo', 'Add reviewed result to this app', 'Create a draft DQL block'],
1137
+ decision,
1138
+ investigation,
1139
+ };
1140
+ }
1141
+ finally {
1142
+ storage.close();
1143
+ }
1144
+ }
1145
+ if (blockId) {
1146
+ const selected = selectedBlockContext(input.context);
1147
+ const answer = buildCertifiedAppAnswer({
1148
+ app: loaded.app,
1149
+ dashboard: dashboard ?? null,
1150
+ blockId,
1151
+ question,
1152
+ selected,
1153
+ variables: input.variables,
1154
+ });
1155
+ return {
1156
+ ok: true,
1157
+ route: 'certified_answer',
1158
+ answer,
1159
+ trustState: tile?.trustState === 'certified' || tile?.display?.trustState === 'certified' ? 'certified' : 'review_required',
1160
+ reviewStatus: tile?.reviewStatus === 'certified' || tile?.display?.reviewStatus === 'certified' ? 'certified' : 'review_required',
1161
+ citations,
1162
+ followUps: ['Explain the visible result', 'Investigate drivers', 'Create block draft from this result'],
1163
+ decision: buildAppAskDecision('certified_answer', {
1164
+ question,
1165
+ blockId,
1166
+ selected,
1167
+ appName: loaded.app.name,
1168
+ }),
1169
+ };
1170
+ }
1171
+ return {
1172
+ ok: true,
1173
+ route: 'metadata_answer',
1174
+ answer: `${loaded.app.name} is a ${loaded.app.domain} app for ${loaded.app.audience ?? 'stakeholders'}. It has ${loaded.dashboards.length} dashboard page${loaded.dashboards.length === 1 ? '' : 's'}, ${loaded.aiPins.length} pinned insight${loaded.aiPins.length === 1 ? '' : 's'}, and ${loaded.investigations.length} analysis item${loaded.investigations.length === 1 ? '' : 's'}. Ask about a tile for a certified answer, or ask a why/change/drilldown question to create analysis.`,
1175
+ trustState: 'draft_ready',
1176
+ reviewStatus: 'draft_ready',
1177
+ citations,
1178
+ followUps: ['Ask about a specific tile', 'Find trust gaps', 'Start driver analysis'],
1179
+ decision: buildAppAskDecision('metadata_answer', {
1180
+ question,
1181
+ appName: loaded.app.name,
1182
+ }),
1183
+ };
1184
+ }
1185
+ function buildAppAskDecision(route, input) {
1186
+ const selected = input.selected ?? null;
1187
+ const sourceName = selectedString(selected, 'title')
1188
+ || cleanString(input.blockId)
1189
+ || cleanString(input.appName)
1190
+ || 'this app';
1191
+ if (route === 'certified_answer') {
1192
+ return {
1193
+ mode: 'answer',
1194
+ reason: `The question can be answered from the selected certified result: ${sourceName}.`,
1195
+ nextAction: 'Use the answer directly, or request deeper analysis when you need a new comparison, grain, or reusable logic.',
1196
+ requiresContext: false,
1197
+ usesCertifiedResult: true,
1198
+ confidence: hasSelectedRows(selected) ? 0.9 : 0.74,
1199
+ };
1200
+ }
1201
+ if (route === 'investigation') {
1202
+ const intent = normalizeInvestigationIntent(undefined, input.question, selected);
1203
+ return {
1204
+ mode: 'analysis',
1205
+ reason: appAskInvestigationReason(intent),
1206
+ nextAction: 'Add the exact comparison, filters, and proof focus before DQL writes SQL or opens the main-canvas analysis.',
1207
+ requiresContext: true,
1208
+ usesCertifiedResult: Boolean(input.blockId || input.selected),
1209
+ confidence: 0.82,
1210
+ };
1211
+ }
1212
+ if (route === 'app_change_proposal') {
1213
+ return {
1214
+ mode: 'app_change',
1215
+ reason: 'The question asks to change presentation or layout, not business logic.',
1216
+ nextAction: 'Review the proposed app change in Build mode and keep certified block logic unchanged.',
1217
+ requiresContext: false,
1218
+ usesCertifiedResult: Boolean(input.blockId || input.selected),
1219
+ confidence: 0.78,
1220
+ };
1221
+ }
1222
+ return {
1223
+ mode: 'metadata',
1224
+ reason: 'No specific certified tile was selected, so DQL answered from app metadata.',
1225
+ nextAction: 'Select a tile for a trusted answer, or ask for deeper analysis with a clear comparison and filter scope.',
1226
+ requiresContext: false,
1227
+ usesCertifiedResult: false,
1228
+ confidence: 0.62,
1229
+ };
1230
+ }
1231
+ function appAskInvestigationReason(intent) {
1232
+ if (intent === 'diagnose_change')
1233
+ return 'The question asks why something changed, which needs a comparison window or baseline beyond the visible certified result.';
1234
+ if (intent === 'segment_compare')
1235
+ return 'The question asks for a comparison across segments, cohorts, or groups, which needs scoped analysis before promotion.';
1236
+ if (intent === 'entity_drilldown')
1237
+ return 'The question asks for entity-level drilldown, which needs reviewed grain, joins, and identifiers.';
1238
+ if (intent === 'anomaly_investigation')
1239
+ return 'The question asks about an exception or outlier, which needs baseline proof before stakeholder use.';
1240
+ if (intent === 'trust_gap_review')
1241
+ return 'The question asks about proof, caveats, lineage, or trust gaps, which should be reviewed as an evidence brief.';
1242
+ return 'The question asks for drivers or decomposition, which needs scoped analysis before creating or changing reusable logic.';
1243
+ }
1244
+ function appAnalysisRouteDecisionFromContext(context, intent, selected, question) {
1245
+ const root = asRecord(context);
1246
+ const rawDecision = asRecord(root?.routeDecision) ?? asRecord(asRecord(root?.originatingAnswer)?.decision);
1247
+ if (rawDecision) {
1248
+ return normalizeAppAskDecision(rawDecision, intent, selected, question);
1249
+ }
1250
+ return buildAppAskDecision('investigation', {
1251
+ question,
1252
+ blockId: selectedString(selected, 'blockId'),
1253
+ selected,
1254
+ appName: cleanString(root?.appName),
1255
+ });
1256
+ }
1257
+ function normalizeAppAskDecision(value, intent, selected, question) {
1258
+ const mode = value.mode === 'answer' || value.mode === 'analysis' || value.mode === 'app_change' || value.mode === 'metadata'
1259
+ ? value.mode
1260
+ : 'analysis';
1261
+ const reason = cleanString(value.reason) || appAskInvestigationReason(intent);
1262
+ const nextAction = cleanString(value.nextAction)
1263
+ || 'Review the generated analysis, validate proof, then pin or promote only after approval.';
1264
+ const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence)
1265
+ ? Math.max(0, Math.min(1, value.confidence))
1266
+ : hasSelectedRows(selected) ? 0.82 : 0.68;
1267
+ return {
1268
+ mode,
1269
+ reason,
1270
+ nextAction,
1271
+ requiresContext: typeof value.requiresContext === 'boolean' ? value.requiresContext : mode === 'analysis',
1272
+ usesCertifiedResult: typeof value.usesCertifiedResult === 'boolean'
1273
+ ? value.usesCertifiedResult
1274
+ : Boolean(selectedString(selected, 'blockId') || selectedString(selected, 'certificationStatus')),
1275
+ confidence,
1276
+ };
1277
+ }
1278
+ function buildCertifiedAppAnswer(input) {
1279
+ const rows = selectedRows(input.selected);
1280
+ const columns = selectedColumns(input.selected, rows);
1281
+ const metrics = buildMetricSnapshot(input.selected);
1282
+ const currentValue = typeofNumber(metrics.currentValue);
1283
+ const baselineValue = typeofNumber(metrics.baselineValue);
1284
+ const deltaValue = typeofNumber(metrics.delta);
1285
+ const metricName = cleanString(metrics.metric) || preferredMeasureColumn(columns, rows) || 'selected metric';
1286
+ const firstRow = rows[0];
1287
+ const labelColumn = preferredLabelColumn(columns, rows);
1288
+ const label = labelColumn && firstRow ? cleanString(firstRow[labelColumn]) : '';
1289
+ const filterSummary = formatAppAskVariables(input.variables);
1290
+ const resultTitle = cleanString(input.selected?.title)
1291
+ || cleanString(input.dashboard?.metadata.title)
1292
+ || input.app.name;
1293
+ const currentLine = currentValue !== null
1294
+ ? `${label ? `${label} is the leading visible row and ` : 'The current visible result '}shows ${formatMetricValue(currentValue)} for ${formatBusinessColumn(metricName)}${baselineValue !== null ? ` versus ${formatMetricValue(baselineValue)} for the next comparison` : ''}${deltaValue !== null ? `, a gap of ${formatMetricValue(deltaValue)}` : ''}.`
1295
+ : rows.length > 0
1296
+ ? `The certified result is loaded with ${rows.length} sampled row${rows.length === 1 ? '' : 's'} across ${columns.length} field${columns.length === 1 ? '' : 's'}.`
1297
+ : 'The certified block is selected, but this request did not include result rows for a numeric summary.';
1298
+ const rowDetails = firstRow && columns.length
1299
+ ? columns
1300
+ .slice(0, 5)
1301
+ .map((column) => `${formatBusinessColumn(column)}: ${formatAskValue(firstRow[column])}`)
1302
+ .join('; ')
1303
+ : '';
1304
+ const nextStep = /\bwhy|driver|change|changed|compare|segment|drill|root cause|because\b/i.test(input.question)
1305
+ ? 'This question asks for explanation beyond the current certified result. Use the copilot analysis flow with the exact comparison, timeframe, and segment so DQL can create review-required analysis.'
1306
+ : 'Use deeper analysis only when you need a new grain, driver breakdown, segment comparison, or reusable block that this certified tile does not already cover.';
1307
+ return [
1308
+ `## Answer`,
1309
+ `For **${resultTitle}**${filterSummary ? ` with ${filterSummary}` : ''}, ${currentLine}`,
1310
+ rowDetails ? `Visible row detail: ${rowDetails}.` : '',
1311
+ `## Trusted source`,
1312
+ `This answer is grounded in certified DQL block **${input.blockId}**. The app can present this result directly; new SQL or new business logic still needs review before promotion.`,
1313
+ `## Next step`,
1314
+ nextStep,
1315
+ ].filter(Boolean).join('\n\n');
1316
+ }
1317
+ function preferredLabelColumn(columns, rows) {
1318
+ return columns.find((column) => /\b(name|player|customer|account|team|segment|category|label)\b/i.test(column) && rows.some((row) => cleanString(row[column])))
1319
+ ?? columns.find((column) => rows.some((row) => typeof row[column] === 'string' && cleanString(row[column])));
1320
+ }
1321
+ function preferredMeasureColumn(columns, rows) {
1322
+ return columns.find((column) => /\b(total|points?|revenue|score|amount|count|games?|orders?|value|delta|change)\b/i.test(column) && rows.some((row) => typeofNumber(row[column]) !== null))
1323
+ ?? columns.find((column) => rows.some((row) => typeofNumber(row[column]) !== null));
1324
+ }
1325
+ function formatAppAskVariables(variables) {
1326
+ if (!variables)
1327
+ return '';
1328
+ return Object.entries(variables)
1329
+ .filter(([key, value]) => key !== 'smartView' && value !== undefined && value !== null && String(value).trim() !== '')
1330
+ .slice(0, 6)
1331
+ .map(([key, value]) => `${formatBusinessColumn(key)} ${formatAskVariableValue(key, value)}`)
1332
+ .join(', ');
1333
+ }
1334
+ function formatBusinessColumn(value) {
1335
+ return value.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
1336
+ }
1337
+ function formatAskValue(value) {
1338
+ if (Array.isArray(value))
1339
+ return value.map(formatAskValue).join(', ');
1340
+ if (typeof value === 'number')
1341
+ return Number.isInteger(value) ? value.toLocaleString() : Number(value.toFixed(2)).toLocaleString();
1342
+ if (value === null || value === undefined)
1343
+ return 'not set';
1344
+ if (typeof value === 'object')
1345
+ return JSON.stringify(value);
1346
+ return String(value);
1347
+ }
1348
+ function formatAskVariableValue(key, value) {
1349
+ if (Array.isArray(value))
1350
+ return value.map((item) => formatAskVariableValue(key, item)).join(', ');
1351
+ const numeric = typeof value === 'number'
1352
+ ? value
1353
+ : typeof value === 'string' && value.trim() && Number.isFinite(Number(value))
1354
+ ? Number(value)
1355
+ : null;
1356
+ if (numeric !== null && /(season|year)/i.test(key) && Number.isInteger(numeric) && numeric >= 1900 && numeric <= 2200) {
1357
+ return String(numeric);
1358
+ }
1359
+ return formatAskValue(value);
1360
+ }
1361
+ export function recommendDashboardTile(projectRoot, appId, dashboardId, input) {
1362
+ const loaded = loadDashboardForApp(projectRoot, appId, dashboardId);
1363
+ if (!loaded)
1364
+ return { ok: false, error: `Dashboard "${dashboardId}" not found in app "${appId}"` };
1365
+ const recommendation = recommendVisualization(projectRoot, input);
1366
+ if (!recommendation.ok)
1367
+ return recommendation;
1368
+ const block = input.blockRef
1369
+ ? collectBlockCandidates(projectRoot).find((candidate) => candidate.id === input.blockRef ||
1370
+ candidate.name === input.blockRef ||
1371
+ candidate.path === input.blockRef ||
1372
+ candidate.path.endsWith(`/${input.blockRef}`))
1373
+ : undefined;
1374
+ const filterBindings = block ? filterBindingsForBlockSource(projectRoot, block) : [];
1375
+ const parameterBindings = filterBindings
1376
+ .filter((entry) => entry.mode === 'parameter' && entry.paramNames?.length)
1377
+ .flatMap((entry) => (entry.paramNames ?? []).map((param) => ({ param, source: 'dashboard_filter', filter: entry.filter, field: entry.binding })));
1378
+ const sourceEvidence = [
1379
+ ...recommendation.evidence.map((entry) => ({
1380
+ source: entry.source,
1381
+ reason: entry.reason,
1382
+ kind: entry.source === 'result_schema' ? 'result_schema' : 'metadata',
1383
+ trustState: recommendation.display.trustState,
1384
+ })),
1385
+ ...(block ? [{
1386
+ source: `block:${block.name}`,
1387
+ reason: block.status === 'certified' ? 'Certified block can back this app tile.' : 'Block is not certified; keep review-required.',
1388
+ kind: 'block',
1389
+ path: block.path,
1390
+ trustState: block.status === 'certified' ? 'certified' : 'review_required',
1391
+ }] : []),
1392
+ ];
1393
+ return {
1394
+ ok: true,
1395
+ display: recommendation.display,
1396
+ filterBindings,
1397
+ parameterBindings,
1398
+ sourceEvidence,
1399
+ trustState: recommendation.display.trustState,
1400
+ reviewStatus: recommendation.display.reviewStatus,
1401
+ evidence: recommendation.evidence,
1402
+ warnings: recommendation.warnings,
1403
+ };
1404
+ }
1405
+ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
1406
+ const loaded = loadAppById(projectRoot, appId);
1407
+ if (!loaded)
1408
+ return { ok: false, error: `App "${appId}" not found` };
1409
+ const lifecycle = input.lifecycle === 'certified' || input.lifecycle === 'deprecated' ? input.lifecycle : 'review';
1410
+ const app = {
1411
+ ...loaded.app,
1412
+ visibility: 'shared',
1413
+ lifecycle,
1414
+ };
1415
+ const appPath = join(loaded.appDir, 'dql.app.json');
1416
+ const parsedApp = parseAppDocument(JSON.stringify(app), appPath);
1417
+ if (!parsedApp.document)
1418
+ return { ok: false, error: parsedApp.errors.map((err) => err.message).join('; ') };
1419
+ writeFileSync(appPath, JSON.stringify(parsedApp.document, null, 2) + '\n', 'utf-8');
1420
+ let removedLocalTiles = 0;
1421
+ const paths = [relative(projectRoot, appPath)];
1422
+ for (const dashboardPath of findDashboardsForApp(loaded.appDir)) {
1423
+ const loadedDashboard = loadDashboardDocument(dashboardPath).document;
1424
+ if (!loadedDashboard)
1425
+ continue;
1426
+ const items = loadedDashboard.layout.items
1427
+ .filter((item) => {
1428
+ if (item.aiPin) {
1429
+ removedLocalTiles += 1;
1430
+ return false;
1431
+ }
1432
+ return true;
1433
+ })
1434
+ .map((item) => promoteSharedDashboardItem(item));
1435
+ const dashboard = {
1436
+ ...loadedDashboard,
1437
+ metadata: {
1438
+ ...loadedDashboard.metadata,
1439
+ visibility: 'shared',
1440
+ lifecycle,
1441
+ },
1442
+ layout: {
1443
+ ...loadedDashboard.layout,
1444
+ items,
1445
+ },
1446
+ };
1447
+ const parsed = parseDashboardDocument(JSON.stringify(dashboard), dashboardPath);
1448
+ if (!parsed.document)
1449
+ return { ok: false, error: parsed.errors.map((err) => err.message).join('; ') };
1450
+ writeFileSync(dashboardPath, JSON.stringify(parsed.document, null, 2) + '\n', 'utf-8');
1451
+ paths.push(relative(projectRoot, dashboardPath));
1452
+ }
1453
+ return { ok: true, app: parsedApp.document, paths, removedLocalTiles };
1454
+ }
1455
+ function appAiBuildSessionDir(projectRoot) {
1456
+ return join(projectRoot, '.dql', 'local', 'app-ai-builds');
1457
+ }
1458
+ function writeAppAiBuildSession(projectRoot, session) {
1459
+ const dir = appAiBuildSessionDir(projectRoot);
1460
+ mkdirSync(dir, { recursive: true });
1461
+ writeFileSync(join(dir, `${session.id}.json`), JSON.stringify(session, null, 2) + '\n', 'utf-8');
1462
+ }
1463
+ function appBuildWarnings(validation, plan) {
1464
+ const issues = Array.isArray(validation?.issues)
1465
+ ? validation.issues
1466
+ : [];
1467
+ const warnings = issues
1468
+ .filter((issue) => issue.level === 'warning')
1469
+ .map((issue) => cleanString(issue.message))
1470
+ .filter(Boolean);
1471
+ const missing = Array.isArray(plan.missingEvidence) ? plan.missingEvidence.map(cleanString).filter(Boolean) : [];
1472
+ return unique([...warnings, ...missing]);
1473
+ }
1474
+ function appBuildBlockedMessage(validation, plan) {
1475
+ const warnings = appBuildWarnings(validation, plan).slice(0, 4);
1476
+ const reviewTasks = reviewTasksFromPlan(plan).slice(0, 4);
1477
+ return [
1478
+ 'No certified DQL blocks matched strongly enough to create a stakeholder app.',
1479
+ 'DQL did not write an empty dashboard. Create or certify reusable blocks first, or select certified blocks explicitly.',
1480
+ warnings.length ? `Missing proof: ${warnings.join(' | ')}` : '',
1481
+ reviewTasks.length ? `Next review tasks: ${reviewTasks.join(' | ')}` : '',
1482
+ ].filter(Boolean).join(' ');
1483
+ }
1484
+ function reviewTasksFromPlan(plan) {
1485
+ const tasks = Array.isArray(plan.reviewTasks) ? plan.reviewTasks.map(cleanString).filter(Boolean) : [];
1486
+ const scopedReports = Array.isArray(plan.scopedReports) ? plan.scopedReports : [];
1487
+ const planning = plan.planning && typeof plan.planning === 'object' ? plan.planning : {};
1488
+ const planningReports = Array.isArray(planning.scopedReports) ? planning.scopedReports : [];
1489
+ const reportTasks = [...scopedReports, ...planningReports].flatMap((report) => {
1490
+ if (!report || typeof report !== 'object')
1491
+ return [];
1492
+ const record = report;
1493
+ const title = cleanString(record.title) || 'Scoped analysis';
1494
+ const question = cleanString(record.question);
1495
+ const evidence = Array.isArray(record.evidenceNeeded)
1496
+ ? record.evidenceNeeded.map(cleanString).filter(Boolean).join(', ')
1497
+ : '';
1498
+ return [
1499
+ `Scoped analysis "${title}": ${question || 'Review the analysis question before running it.'}`,
1500
+ evidence ? `Scoped analysis "${title}" evidence needed: ${evidence}` : '',
1501
+ ].filter(Boolean);
1502
+ });
1503
+ const pages = Array.isArray(plan.pages) ? plan.pages : [];
1504
+ const tileTasks = pages.flatMap((page) => {
1505
+ if (!page || typeof page !== 'object')
1506
+ return [];
1507
+ const tiles = Array.isArray(page.tiles) ? page.tiles : [];
1508
+ return tiles.flatMap((tile) => {
1509
+ if (!tile || typeof tile !== 'object')
1510
+ return [];
1511
+ const record = tile;
1512
+ const title = cleanString(record.title) || 'Tile';
1513
+ return Array.isArray(record.reviewTasks)
1514
+ ? record.reviewTasks.map((task) => `${title}: ${cleanString(task)}`).filter((task) => !task.endsWith(': '))
1515
+ : [];
1516
+ });
1517
+ });
1518
+ return unique([...tasks, ...reportTasks, ...tileTasks]).slice(0, 20);
1519
+ }
1520
+ function shouldRouteAppQuestionToInvestigation(question) {
1521
+ return /\b(why|changed|change|drop|decline|increase|decrease|driver|drill|break\s*down|segment|cohort|compare|versus| vs |anomal|outlier|spike|dip|root cause)\b/i.test(question);
1522
+ }
1523
+ function isAppChangeQuestion(question) {
1524
+ return /\b(add|remove|change|update|replace|resize|move|create|build|show|switch)\b.*\b(tile|chart|page|dashboard|app|layout|visual|view)\b/i.test(question);
1525
+ }
1526
+ function appChangeSuggestion(question) {
1527
+ if (/\b(chart|visual|bar|line|table|pivot|kpi)\b/i.test(question))
1528
+ return 'recommend a governed tile display change and save it in Build mode';
1529
+ if (/\b(page|dashboard)\b/i.test(question))
1530
+ return 'create or update a dashboard page as a reviewed app artifact';
1531
+ if (/\b(filter|parameter|param)\b/i.test(question))
1532
+ return 'bind the filter to compatible certified block parameters only';
1533
+ return 'prepare a reviewed app layout change';
1534
+ }
1535
+ function appAskTileContext(tile) {
1536
+ return {
1537
+ tileId: tile.i,
1538
+ title: tile.title,
1539
+ blockId: tile.block && 'blockId' in tile.block ? tile.block.blockId : undefined,
1540
+ viz: tile.viz.type,
1541
+ trustState: tile.trustState ?? tile.display?.trustState,
1542
+ reviewStatus: tile.reviewStatus ?? tile.display?.reviewStatus,
1543
+ filterBindings: tile.filterBindings,
1544
+ parameterBindings: tile.parameterBindings,
1545
+ sourceEvidence: tile.sourceEvidence,
1546
+ };
1547
+ }
1548
+ function filterBindingsForBlockSource(projectRoot, block) {
1549
+ const absPath = join(projectRoot, block.path);
1550
+ if (!existsSync(absPath))
1551
+ return [];
1552
+ const source = readFileSync(absPath, 'utf-8');
1553
+ const filterSection = sectionBody(source, 'filterBindings');
1554
+ const parameterSection = sectionBody(source, 'parameterPolicy');
1555
+ const bindings = Array.from(filterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"([^"]+)"/gm))
1556
+ .map((match) => ({ filter: match[1], binding: match[2], mode: 'predicate' }));
1557
+ const parameterNames = Array.from(parameterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"dynamic"/gm))
1558
+ .map((match) => match[1]);
1559
+ const parameterBindings = parameterNames.map((param) => ({
1560
+ filter: param,
1561
+ binding: param,
1562
+ mode: 'parameter',
1563
+ paramNames: [param],
1564
+ }));
1565
+ return uniqueFilterBindings([...bindings, ...parameterBindings]);
1566
+ }
1567
+ function sectionBody(source, sectionName) {
1568
+ const match = new RegExp(`${sectionName}\\s*\\{([\\s\\S]*?)\\n\\s*\\}`, 'm').exec(source);
1569
+ return match?.[1] ?? '';
1570
+ }
1571
+ function buildInvestigationSqlTemplateValues(projectRoot, context, selected, sourceBlockId) {
1572
+ const values = {};
1573
+ const block = resolveSelectedBlock(projectRoot, selected, sourceBlockId);
1574
+ if (block) {
1575
+ const absPath = join(projectRoot, block.path);
1576
+ if (existsSync(absPath)) {
1577
+ Object.assign(values, parseDqlParams(readFileSync(absPath, 'utf-8')));
1578
+ }
1579
+ }
1580
+ const root = asRecord(context);
1581
+ const selectedRecord = asRecord(selected);
1582
+ mergeTemplateRecord(values, root?.activeFilters);
1583
+ mergeTemplateRecord(values, root?.filters);
1584
+ mergeTemplateRecord(values, root?.variables);
1585
+ mergeTemplateRecord(values, root?.parameters);
1586
+ mergeTemplateRecord(values, root?.parameterValues);
1587
+ mergeTemplateRecord(values, selectedRecord?.activeFilters);
1588
+ mergeTemplateRecord(values, selectedRecord?.variables);
1589
+ mergeTemplateRecord(values, selectedRecord?.parameters);
1590
+ return values;
1591
+ }
1592
+ function mergeTemplateRecord(target, value) {
1593
+ const record = asRecord(value);
1594
+ if (!record)
1595
+ return;
1596
+ for (const [key, entry] of Object.entries(record)) {
1597
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
1598
+ continue;
1599
+ if (entry === undefined || entry === null || entry === '')
1600
+ continue;
1601
+ target[key] = entry;
1602
+ }
1603
+ }
1604
+ function parseDqlParams(source) {
1605
+ const body = sectionBody(source, 'params');
1606
+ const values = {};
1607
+ for (const match of body.matchAll(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$/gm)) {
1608
+ const name = match[1];
1609
+ const value = parseDqlLiteral(match[2]);
1610
+ if (value !== undefined)
1611
+ values[name] = value;
1612
+ }
1613
+ return values;
1614
+ }
1615
+ function parseDqlLiteral(raw) {
1616
+ const trimmed = raw
1617
+ .replace(/\s+#.*$/g, '')
1618
+ .replace(/\s+\/\/.*$/g, '')
1619
+ .replace(/,\s*$/g, '')
1620
+ .trim();
1621
+ if (!trimmed)
1622
+ return undefined;
1623
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed))
1624
+ return Number(trimmed);
1625
+ if (/^(true|false)$/i.test(trimmed))
1626
+ return /^true$/i.test(trimmed);
1627
+ const quoted = /^"((?:[^"\\]|\\.)*)"$/s.exec(trimmed) ?? /^'((?:[^'\\]|\\.)*)'$/s.exec(trimmed);
1628
+ if (quoted)
1629
+ return quoted[1].replace(/\\(["'\\])/g, '$1');
1630
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
1631
+ const items = splitDqlArrayValues(trimmed.slice(1, -1))
1632
+ .map(parseDqlLiteral)
1633
+ .filter((item) => item !== undefined);
1634
+ return items;
1635
+ }
1636
+ return trimmed;
1637
+ }
1638
+ function splitDqlArrayValues(value) {
1639
+ const out = [];
1640
+ let current = '';
1641
+ let quote = null;
1642
+ for (let index = 0; index < value.length; index += 1) {
1643
+ const char = value[index];
1644
+ if (quote) {
1645
+ current += char;
1646
+ if (char === quote && value[index - 1] !== '\\')
1647
+ quote = null;
1648
+ continue;
1649
+ }
1650
+ if (char === '"' || char === "'") {
1651
+ quote = char;
1652
+ current += char;
1653
+ continue;
1654
+ }
1655
+ if (char === ',') {
1656
+ if (current.trim())
1657
+ out.push(current.trim());
1658
+ current = '';
1659
+ continue;
1660
+ }
1661
+ current += char;
1662
+ }
1663
+ if (current.trim())
1664
+ out.push(current.trim());
1665
+ return out;
1666
+ }
1667
+ function renderSqlTemplateParams(sql, values) {
1668
+ const rendered = sql.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name) => {
1669
+ if (!Object.prototype.hasOwnProperty.call(values, name))
1670
+ return match;
1671
+ const literal = sqlTemplateLiteral(values[name]);
1672
+ return literal ?? match;
1673
+ });
1674
+ return { sql: rendered, unresolved: unresolvedSqlTemplateParams(rendered) };
1675
+ }
1676
+ function sqlTemplateLiteral(value) {
1677
+ if (value === undefined || value === null || value === '')
1678
+ return undefined;
1679
+ if (typeof value === 'number')
1680
+ return Number.isFinite(value) ? String(value) : undefined;
1681
+ if (typeof value === 'boolean')
1682
+ return value ? 'TRUE' : 'FALSE';
1683
+ if (Array.isArray(value)) {
1684
+ const rendered = value.map(sqlTemplateLiteral).filter((item) => Boolean(item));
1685
+ return rendered.length > 0 ? rendered.join(', ') : undefined;
1686
+ }
1687
+ const stringValue = String(value).trim();
1688
+ if (!stringValue)
1689
+ return undefined;
1690
+ if (/^-?\d+(?:\.\d+)?$/.test(stringValue))
1691
+ return stringValue;
1692
+ if (/^(true|false)$/i.test(stringValue))
1693
+ return /^true$/i.test(stringValue) ? 'TRUE' : 'FALSE';
1694
+ return sqlStringLiteral(stringValue);
1695
+ }
1696
+ function unresolvedSqlTemplateParams(sql) {
1697
+ const names = Array.from(sql.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g), (match) => match[1]);
1698
+ if (/\{\{[\s\S]*?\}\}/.test(sql))
1699
+ names.push('jinja_template');
1700
+ return Array.from(new Set(names)).sort((a, b) => a.localeCompare(b));
1701
+ }
1702
+ function uniqueFilterBindings(bindings) {
1703
+ const seen = new Set();
1704
+ const out = [];
1705
+ for (const binding of bindings) {
1706
+ const key = `${binding.filter}:${binding.binding ?? ''}:${binding.mode ?? ''}:${(binding.paramNames ?? []).join(',')}`;
1707
+ if (seen.has(key))
1708
+ continue;
1709
+ seen.add(key);
1710
+ out.push(binding);
1711
+ }
1712
+ return out;
1713
+ }
1714
+ function promoteSharedDashboardItem(item) {
1715
+ const trustState = item.trustState ?? item.display?.trustState ?? (item.block ? 'certified' : 'review_required');
1716
+ const reviewStatus = item.reviewStatus ?? item.display?.reviewStatus ?? (trustState === 'certified' ? 'certified' : 'review_required');
1717
+ const display = item.display
1718
+ ? {
1719
+ ...item.display,
1720
+ trustState,
1721
+ reviewStatus,
1722
+ }
1723
+ : undefined;
1724
+ return {
1725
+ ...item,
1726
+ ...(display ? { display } : {}),
1727
+ trustState,
1728
+ reviewStatus,
1729
+ };
1730
+ }
842
1731
  export function createAppPackage(projectRoot, input) {
843
1732
  const name = cleanString(input.name);
844
1733
  const domain = cleanString(input.domain);
@@ -1084,7 +1973,7 @@ function displayForAiPin(title, chartType, input) {
1084
1973
  defaultVisualization: chartType,
1085
1974
  allowedVisualizations: allowedVisualizationsForComponent(component, chartType),
1086
1975
  layoutIntent: layoutIntentForComponent(component),
1087
- rationale: 'AI-generated research pin saved as governed presentation metadata; promote the SQL to a draft block before treating it as reusable logic.',
1976
+ rationale: 'AI-generated analysis pin saved as governed presentation metadata; promote the SQL to a draft block before treating it as reusable logic.',
1088
1977
  trustState: certified ? 'certified' : 'review_required',
1089
1978
  reviewStatus: certified ? 'certified' : 'review_required',
1090
1979
  };
@@ -1340,8 +2229,12 @@ function normalizeConversationMessages(messages) {
1340
2229
  async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1341
2230
  const question = cleanString(input.question) || investigation.question;
1342
2231
  const context = input.context === undefined ? investigation.context : input.context;
1343
- const intent = normalizeInvestigationIntent(input.intent, question, context);
1344
- let generatedSql = cleanString(input.generatedSql) || investigation.generatedSql;
2232
+ const intent = normalizeInvestigationIntent('intent' in input ? input.intent : investigation.intent, question, context);
2233
+ const title = cleanString('title' in input ? input.title : undefined)
2234
+ || cleanString(investigation.title)
2235
+ || titleFromInvestigation(question, selectedBlockContext(context));
2236
+ const rebuildFromCertified = 'repairMode' in input && input.repairMode === 'rebuild_from_certified';
2237
+ let generatedSql = cleanString(input.generatedSql) || (rebuildFromCertified ? undefined : investigation.generatedSql);
1345
2238
  const lastRunAt = new Date().toISOString();
1346
2239
  storage.updateAppInvestigation(investigation.id, {
1347
2240
  question,
@@ -1361,6 +2254,11 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1361
2254
  const baselineGap = intent === 'diagnose_change' && hasSelectedRows(selected) && !hasComparableTimeBaseline(selected);
1362
2255
  const sourceTileId = investigation.sourceTileId ?? selectedContextString(context, 'tileId');
1363
2256
  const sourceBlockId = investigation.sourceBlockId ?? selectedContextString(context, 'blockId');
2257
+ const routeDecision = appAnalysisRouteDecisionFromContext(context, intent, selected, question);
2258
+ const sqlTemplateValues = buildInvestigationSqlTemplateValues(ctx.projectRoot, context, selected, sourceBlockId);
2259
+ if (generatedSql) {
2260
+ generatedSql = renderSqlTemplateParams(generatedSql, sqlTemplateValues).sql;
2261
+ }
1364
2262
  const deterministicGeneration = generatedSql || baselineGap
1365
2263
  ? undefined
1366
2264
  : buildDeterministicInvestigationSql(ctx.projectRoot, {
@@ -1368,11 +2266,12 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1368
2266
  intent,
1369
2267
  selected,
1370
2268
  sourceBlockId,
2269
+ context,
1371
2270
  });
1372
2271
  generatedSql = generatedSql || deterministicGeneration?.sql;
1373
- const agentGeneration = generatedSql || baselineGap
1374
- ? undefined
1375
- : await generateInvestigationSql(ctx, {
2272
+ let agentGeneration;
2273
+ if (!generatedSql && !baselineGap) {
2274
+ agentGeneration = await generateInvestigationSql(ctx, {
1376
2275
  appId: investigation.appId,
1377
2276
  dashboardId: investigation.dashboardId ?? selectedString(context, 'dashboardId'),
1378
2277
  sourceTileId,
@@ -1381,18 +2280,62 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1381
2280
  question,
1382
2281
  intent,
1383
2282
  context,
2283
+ mode: 'sql_and_memo',
1384
2284
  });
2285
+ }
1385
2286
  generatedSql = generatedSql || cleanString(agentGeneration?.sql);
1386
2287
  const generationError = cleanString(agentGeneration?.executionError);
1387
2288
  const sqlEvidence = agentGeneration?.result
1388
2289
  ? { preview: buildGeneratedSqlPreview(agentGeneration.result, generatedSql), error: generationError || undefined }
1389
2290
  : await runGeneratedSqlPreview(ctx, generatedSql);
1390
2291
  const sqlError = sqlEvidence.error ?? generationError;
2292
+ const sqlErrorKind = classifySqlPreviewError(sqlError);
1391
2293
  if (sqlEvidence.preview) {
1392
2294
  previews.unshift(sqlEvidence.preview);
1393
2295
  metricSnapshot = buildPreviewMetricSnapshot(sqlEvidence.preview, selectedString(selected, 'title'));
1394
2296
  driverCards = buildPreviewDriverCards(sqlEvidence.preview, intent);
1395
2297
  }
2298
+ const hasReportEvidence = previews.length > 0 || driverCards.length > 0 || Object.keys(metricSnapshot).length > 0;
2299
+ const reportSqlError = shouldSurfaceSqlPreviewIssue({
2300
+ sqlError,
2301
+ sqlErrorKind,
2302
+ generatedSql,
2303
+ hasReportEvidence,
2304
+ }) ? sqlError : undefined;
2305
+ const fallbackSummary = baselineGap
2306
+ ? buildMissingBaselineSummary(question, selected)
2307
+ : buildInvestigationSummary(intent, question, selected, metricSnapshot, driverCards);
2308
+ const recommendation = baselineGap
2309
+ ? buildMissingBaselineRecommendation(selected)
2310
+ : buildInvestigationRecommendation(intent, selected, reportSqlError, sqlErrorKind);
2311
+ const memoGeneration = !baselineGap && shouldRequestProviderMemo({
2312
+ ctx,
2313
+ generatedSql,
2314
+ agentGeneration,
2315
+ hasReportEvidence,
2316
+ })
2317
+ ? await generateInvestigationSql(ctx, {
2318
+ appId: investigation.appId,
2319
+ dashboardId: investigation.dashboardId ?? selectedString(context, 'dashboardId'),
2320
+ sourceTileId,
2321
+ sourceBlockId,
2322
+ title: investigation.title,
2323
+ question,
2324
+ intent,
2325
+ context,
2326
+ mode: 'memo_only',
2327
+ generatedSql,
2328
+ metrics: metricSnapshot,
2329
+ drivers: driverCards,
2330
+ resultPreviews: previews.slice(0, 4),
2331
+ summaryHint: fallbackSummary,
2332
+ recommendationHint: recommendation,
2333
+ sqlError: reportSqlError,
2334
+ sqlErrorKind,
2335
+ hasReportEvidence,
2336
+ })
2337
+ : undefined;
2338
+ const narrativeGeneration = cleanString(memoGeneration?.answer) ? memoGeneration : agentGeneration;
1396
2339
  const evidence = {
1397
2340
  trustStatus: buildInvestigationTrust(investigation, selected, sqlError),
1398
2341
  planner: {
@@ -1402,11 +2345,16 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1402
2345
  generatedSql: generatedSql || undefined,
1403
2346
  sqlExecuted: Boolean(sqlEvidence.preview),
1404
2347
  sqlError,
2348
+ sqlErrorKind,
1405
2349
  generationSource: baselineGap ? 'missing_baseline' : deterministicGeneration ? 'selected_block_metadata' : agentGeneration?.providerUsed ? 'ai_provider' : generatedSql ? 'provided_sql' : 'context_only',
2350
+ repairMode: rebuildFromCertified ? 'rebuild_from_certified' : undefined,
1406
2351
  baselineGap,
1407
2352
  sourceBlockPath: deterministicGeneration?.sourceBlockPath,
1408
2353
  sourceBlockName: deterministicGeneration?.sourceBlockName,
1409
2354
  providerUsed: agentGeneration?.providerUsed,
2355
+ memoProviderUsed: memoGeneration?.providerUsed,
2356
+ memoSource: cleanString(narrativeGeneration?.answer) ? 'ai_provider' : 'deterministic_template',
2357
+ routeDecision,
1410
2358
  },
1411
2359
  certifiedContext: {
1412
2360
  appId: investigation.appId,
@@ -1418,36 +2366,48 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1418
2366
  sourceBlockPath: deterministicGeneration?.sourceBlockPath ?? selectedString(selected, 'blockPath'),
1419
2367
  certificationStatus: selectedString(selected, 'certificationStatus'),
1420
2368
  },
2369
+ routeDecision,
1421
2370
  assumptions: [
1422
2371
  ...investigationAssumptions(intent, selected, generatedSql, sqlError),
1423
2372
  ...(baselineGap ? ['The selected tile sample does not include at least two comparable time values, so DQL did not invent a change query from an unrelated table.'] : []),
1424
2373
  ],
1425
2374
  context,
1426
- agentEvidence: agentGeneration?.evidence,
1427
- analysisPlan: agentGeneration?.analysisPlan,
1428
- citations: agentGeneration?.citations,
2375
+ agentEvidence: narrativeGeneration?.evidence ?? agentGeneration?.evidence,
2376
+ analysisPlan: narrativeGeneration?.analysisPlan ?? agentGeneration?.analysisPlan,
2377
+ citations: narrativeGeneration?.citations ?? agentGeneration?.citations,
1429
2378
  };
1430
- const summary = cleanString(agentGeneration?.answer) || (baselineGap
1431
- ? buildMissingBaselineSummary(question, selected)
1432
- : buildInvestigationSummary(intent, question, selected, metricSnapshot, driverCards));
1433
- const recommendation = baselineGap
1434
- ? buildMissingBaselineRecommendation(selected)
1435
- : buildInvestigationRecommendation(intent, selected, sqlError);
2379
+ const summary = cleanString(narrativeGeneration?.answer) || fallbackSummary;
2380
+ const reportSections = buildInvestigationReportSections({
2381
+ intent,
2382
+ question,
2383
+ context,
2384
+ selected,
2385
+ metrics: metricSnapshot,
2386
+ drivers: driverCards,
2387
+ summary,
2388
+ recommendation,
2389
+ agentAnswer: narrativeGeneration?.answer,
2390
+ hasReportEvidence,
2391
+ sqlError: reportSqlError,
2392
+ sqlErrorKind,
2393
+ baselineGap,
2394
+ });
1436
2395
  return storage.updateAppInvestigation(investigation.id, {
1437
- title: cleanString(input.question) ? titleFromInvestigation(question, selected) : investigation.title,
2396
+ title,
1438
2397
  question,
1439
2398
  intent,
1440
2399
  context,
1441
- status: sqlEvidence.fatal ? 'error' : 'ready',
2400
+ status: sqlEvidence.fatal && !hasReportEvidence ? 'error' : 'ready',
1442
2401
  summary,
1443
2402
  recommendation,
1444
2403
  metrics: metricSnapshot,
1445
2404
  driverCards,
1446
2405
  resultPreviews: previews,
1447
2406
  evidence,
2407
+ reportSections,
1448
2408
  generatedSql,
1449
2409
  reviewStatus: 'needs_review',
1450
- error: sqlError ?? '',
2410
+ error: reportSqlError ?? '',
1451
2411
  lastRunAt,
1452
2412
  }) ?? investigation;
1453
2413
  }
@@ -1460,6 +2420,94 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1460
2420
  }) ?? investigation;
1461
2421
  }
1462
2422
  }
2423
+ function scheduleAppInvestigationRun(ctx, appId, investigationId, input) {
2424
+ const runContext = { ...ctx };
2425
+ setTimeout(() => {
2426
+ void (async () => {
2427
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(runContext.projectRoot));
2428
+ try {
2429
+ const current = storage.getAppInvestigation(investigationId);
2430
+ if (!current || current.appId !== appId)
2431
+ return;
2432
+ await runAppInvestigation(runContext, storage, current, input);
2433
+ }
2434
+ finally {
2435
+ storage.close();
2436
+ }
2437
+ })();
2438
+ }, 0);
2439
+ }
2440
+ function createOrReuseAppInvestigation(storage, input) {
2441
+ if (!cleanString(input.generatedSql)) {
2442
+ const reusable = findReusableAppInvestigation(storage, {
2443
+ appId: input.appId,
2444
+ dashboardId: input.dashboardId,
2445
+ sourceTileId: input.sourceTileId,
2446
+ sourceBlockId: input.sourceBlockId,
2447
+ question: input.question,
2448
+ intent: input.intent,
2449
+ context: input.context,
2450
+ });
2451
+ if (reusable) {
2452
+ return storage.updateAppInvestigation(reusable.id, {
2453
+ title: input.title ?? reusable.title,
2454
+ question: input.question,
2455
+ intent: input.intent ?? reusable.intent,
2456
+ context: input.context,
2457
+ dashboardId: input.dashboardId,
2458
+ sourceTileId: input.sourceTileId,
2459
+ sourceBlockId: input.sourceBlockId,
2460
+ }) ?? reusable;
2461
+ }
2462
+ }
2463
+ return storage.createAppInvestigation(input);
2464
+ }
2465
+ function findReusableAppInvestigation(storage, input) {
2466
+ const storageWithMethod = storage;
2467
+ if (typeof storageWithMethod.findReusableAppInvestigation === 'function') {
2468
+ return storageWithMethod.findReusableAppInvestigation(input);
2469
+ }
2470
+ const target = appInvestigationReuseFingerprint(input);
2471
+ return storage.listAppInvestigations(input.appId, input.dashboardId).find((item) => {
2472
+ if (item.reviewStatus === 'rejected')
2473
+ return false;
2474
+ return appInvestigationReuseFingerprint(item) === target;
2475
+ }) ?? null;
2476
+ }
2477
+ function appInvestigationReuseFingerprint(input) {
2478
+ return [
2479
+ normalizeFingerprintString(input.appId),
2480
+ normalizeFingerprintString(input.dashboardId),
2481
+ normalizeFingerprintString(input.sourceTileId),
2482
+ normalizeFingerprintString(input.sourceBlockId),
2483
+ normalizeFingerprintString(input.question),
2484
+ normalizeFingerprintString(input.intent ?? 'driver_breakdown'),
2485
+ stableInvestigationFingerprintValue(input.context),
2486
+ ].join('|');
2487
+ }
2488
+ function normalizeFingerprintString(value) {
2489
+ return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').toLowerCase() : '';
2490
+ }
2491
+ function stableInvestigationFingerprintValue(value) {
2492
+ if (value === undefined || value === null)
2493
+ return '';
2494
+ return JSON.stringify(stableInvestigationFingerprintObject(value));
2495
+ }
2496
+ function stableInvestigationFingerprintObject(value) {
2497
+ if (Array.isArray(value))
2498
+ return value.map(stableInvestigationFingerprintObject);
2499
+ if (!value || typeof value !== 'object') {
2500
+ return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : value;
2501
+ }
2502
+ const record = value;
2503
+ const out = {};
2504
+ for (const key of Object.keys(record).sort()) {
2505
+ if (/^(nonce|timestamp|createdAt|updatedAt|lastRunAt)$/i.test(key))
2506
+ continue;
2507
+ out[key] = stableInvestigationFingerprintObject(record[key]);
2508
+ }
2509
+ return out;
2510
+ }
1463
2511
  function normalizeInvestigationIntent(value, question, context) {
1464
2512
  if (value === 'diagnose_change'
1465
2513
  || value === 'driver_breakdown'
@@ -1540,14 +2588,25 @@ function buildMetricSnapshot(selected) {
1540
2588
  context: 'Metric values were not available in the selected tile sample.',
1541
2589
  };
1542
2590
  }
1543
- const baselineValue = typeofNumber(rows[0]?.[metricColumn]);
1544
- const currentValue = typeofNumber(rows[rows.length - 1]?.[metricColumn]) ?? baselineValue;
2591
+ const topRow = rows.find((row) => typeofNumber(row[metricColumn]) !== null);
2592
+ const comparisonRow = rows.slice(1).find((row) => typeofNumber(row[metricColumn]) !== null);
2593
+ const dimensionColumn = columns.find((column) => column !== metricColumn && rows.some((row) => typeof row[column] === 'string'));
2594
+ const currentValue = typeofNumber(topRow?.[metricColumn]);
2595
+ const baselineValue = typeofNumber(comparisonRow?.[metricColumn]);
1545
2596
  const delta = currentValue !== null && baselineValue !== null ? currentValue - baselineValue : undefined;
2597
+ const topLabel = dimensionColumn && topRow ? cleanString(topRow[dimensionColumn]) : '';
2598
+ const comparisonLabel = dimensionColumn && comparisonRow ? cleanString(comparisonRow[dimensionColumn]) : '';
1546
2599
  return {
1547
2600
  metric: metricColumn,
1548
2601
  currentValue,
1549
2602
  baselineValue,
1550
2603
  delta,
2604
+ currentLabel: 'Top value',
2605
+ baselineLabel: 'Next comparison',
2606
+ deltaLabel: 'Top gap',
2607
+ currentDetail: topLabel ? `${topLabel} / ${metricColumn}` : 'highest ranked evidence row',
2608
+ baselineDetail: comparisonLabel ? `${comparisonLabel} / ${metricColumn}` : 'next comparable evidence row',
2609
+ deltaDetail: comparisonLabel ? `difference between ${topLabel || 'top row'} and ${comparisonLabel}` : 'difference to next comparison',
1551
2610
  rowsReviewed: rows.length,
1552
2611
  context: selectedString(selected, 'title') ?? 'Selected dashboard tile',
1553
2612
  };
@@ -1708,8 +2767,12 @@ function buildDeterministicInvestigationSql(projectRoot, input) {
1708
2767
  if (!block)
1709
2768
  return undefined;
1710
2769
  const source = readFileSync(join(projectRoot, block.path), 'utf-8');
1711
- const blockSql = extractDqlQuery(source);
1712
- if (!blockSql || /\{\{/.test(blockSql) || !isReadOnlySql(blockSql))
2770
+ const rawBlockSql = extractDqlQuery(source);
2771
+ if (!rawBlockSql)
2772
+ return undefined;
2773
+ const rendered = renderSqlTemplateParams(rawBlockSql, buildInvestigationSqlTemplateValues(projectRoot, input.context, input.selected, input.sourceBlockId));
2774
+ const blockSql = rendered.sql;
2775
+ if (rendered.unresolved.length > 0 || /\{\{/.test(blockSql) || !isReadOnlySql(blockSql))
1713
2776
  return undefined;
1714
2777
  const rows = selectedRows(input.selected);
1715
2778
  const columns = selectedColumns(input.selected, rows);
@@ -1928,7 +2991,14 @@ async function generateInvestigationSql(ctx, input) {
1928
2991
  if (!ctx.generateInvestigationSql)
1929
2992
  return undefined;
1930
2993
  try {
1931
- return await ctx.generateInvestigationSql(input);
2994
+ const timeoutMs = appResearchAiTimeoutMs();
2995
+ const result = await boundedPromise(ctx.generateInvestigationSql(input), timeoutMs);
2996
+ if (result.timedOut) {
2997
+ return {
2998
+ executionError: `AI SQL generation timed out after ${Math.round(timeoutMs / 1000)}s. DQL continued with deterministic app evidence and kept the analysis review-required.`,
2999
+ };
3000
+ }
3001
+ return result.value;
1932
3002
  }
1933
3003
  catch (err) {
1934
3004
  return {
@@ -1936,10 +3006,24 @@ async function generateInvestigationSql(ctx, input) {
1936
3006
  };
1937
3007
  }
1938
3008
  }
3009
+ function shouldRequestProviderMemo(input) {
3010
+ if (!input.ctx.generateInvestigationSql)
3011
+ return false;
3012
+ if (cleanString(input.agentGeneration?.answer))
3013
+ return false;
3014
+ return input.hasReportEvidence || Boolean(cleanString(input.generatedSql));
3015
+ }
1939
3016
  async function runGeneratedSqlPreview(ctx, generatedSql) {
1940
3017
  const sql = cleanString(generatedSql);
1941
3018
  if (!sql)
1942
3019
  return {};
3020
+ const unresolved = unresolvedSqlTemplateParams(sql);
3021
+ if (unresolved.length > 0) {
3022
+ return {
3023
+ error: `Generated SQL was not run because unresolved DQL parameters remain: ${unresolved.join(', ')}.`,
3024
+ fatal: false,
3025
+ };
3026
+ }
1943
3027
  if (!isReadOnlySql(sql)) {
1944
3028
  return {
1945
3029
  error: 'Generated SQL was not run because it was not a read-only SELECT or WITH query.',
@@ -1953,14 +3037,21 @@ async function runGeneratedSqlPreview(ctx, generatedSql) {
1953
3037
  };
1954
3038
  }
1955
3039
  try {
1956
- const result = await ctx.executeSql(boundedPreviewSql(sql));
3040
+ const timeoutMs = appResearchPreviewTimeoutMs();
3041
+ const result = await boundedPromise(ctx.executeSql(boundedPreviewSql(sql)), timeoutMs);
3042
+ if (result.timedOut) {
3043
+ return {
3044
+ error: `Generated SQL preview timed out after ${Math.round(timeoutMs / 1000)}s. The analysis was created from selected app evidence; review or simplify the SQL before promotion.`,
3045
+ fatal: false,
3046
+ };
3047
+ }
1957
3048
  return {
1958
3049
  preview: {
1959
3050
  id: 'generated-sql-preview',
1960
3051
  title: 'Generated SQL preview',
1961
3052
  kind: 'table',
1962
3053
  reviewRequired: true,
1963
- result,
3054
+ result: result.value,
1964
3055
  },
1965
3056
  };
1966
3057
  }
@@ -1971,6 +3062,33 @@ async function runGeneratedSqlPreview(ctx, generatedSql) {
1971
3062
  };
1972
3063
  }
1973
3064
  }
3065
+ async function boundedPromise(promise, timeoutMs) {
3066
+ let timer;
3067
+ try {
3068
+ return await Promise.race([
3069
+ promise.then((value) => ({ timedOut: false, value })),
3070
+ new Promise((resolve) => {
3071
+ timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
3072
+ }),
3073
+ ]);
3074
+ }
3075
+ finally {
3076
+ if (timer)
3077
+ clearTimeout(timer);
3078
+ }
3079
+ }
3080
+ function appResearchPreviewTimeoutMs() {
3081
+ const raw = Number(process.env.DQL_APP_RESEARCH_PREVIEW_TIMEOUT_MS);
3082
+ if (Number.isFinite(raw) && raw >= 500)
3083
+ return Math.min(raw, 120000);
3084
+ return 12000;
3085
+ }
3086
+ function appResearchAiTimeoutMs() {
3087
+ const raw = Number(process.env.DQL_APP_RESEARCH_AI_TIMEOUT_MS);
3088
+ if (Number.isFinite(raw) && raw >= 500)
3089
+ return Math.min(raw, 120000);
3090
+ return 12000;
3091
+ }
1974
3092
  function buildGeneratedSqlPreview(result, generatedSql) {
1975
3093
  const record = asRecord(result);
1976
3094
  const rawRows = Array.isArray(record?.rows) ? record.rows : [];
@@ -2000,6 +3118,36 @@ function buildGeneratedSqlPreview(result, generatedSql) {
2000
3118
  },
2001
3119
  };
2002
3120
  }
3121
+ function classifySqlPreviewError(error) {
3122
+ const value = cleanString(error);
3123
+ if (!value)
3124
+ return undefined;
3125
+ if (/\bAI SQL generation timed out\b/i.test(value))
3126
+ return 'ai_generation_timeout';
3127
+ if (/\b(warehouse|suspended|resume|authenticat|permission|privilege|network|connection|connect|host cannot execute|cannot execute|not configured)\b/i.test(value)) {
3128
+ return 'runtime_unavailable';
3129
+ }
3130
+ if (/\b(timed out|timeout)\b/i.test(value))
3131
+ return 'timeout';
3132
+ if (/\b(not a read-only|not read-only|insert|update|delete|drop|alter|create|merge|truncate|grant|revoke)\b/i.test(value)) {
3133
+ return 'safety';
3134
+ }
3135
+ if (/\b(unresolved|parameter|invalid identifier|syntax|compilation|parse|unknown column|does not exist|ambiguous|not found)\b/i.test(value)) {
3136
+ return 'sql_repair';
3137
+ }
3138
+ return 'unknown';
3139
+ }
3140
+ function shouldSurfaceSqlPreviewIssue(input) {
3141
+ const error = cleanString(input.sqlError);
3142
+ if (!error)
3143
+ return false;
3144
+ if (input.hasReportEvidence &&
3145
+ !cleanString(input.generatedSql) &&
3146
+ (input.sqlErrorKind === 'unknown' || /\bAI provider did not return a governed answer\b/i.test(error))) {
3147
+ return false;
3148
+ }
3149
+ return true;
3150
+ }
2003
3151
  function isReadOnlySql(sql) {
2004
3152
  const trimmed = stripLeadingSqlComments(sql).replace(/;+\s*$/g, '');
2005
3153
  if (!/^(select|with)\b/i.test(trimmed))
@@ -2026,20 +3174,20 @@ function boundedPreviewSql(sql) {
2026
3174
  }
2027
3175
  function buildInvestigationTrust(investigation, selected, sqlError) {
2028
3176
  return {
2029
- label: 'AI-generated research',
3177
+ label: 'AI-generated analysis',
2030
3178
  uncertified: true,
2031
3179
  reviewStatus: 'needs_review',
2032
3180
  certifiedContext: selectedString(selected, 'certificationStatus') === 'certified' ? 'selected tile is certified' : 'selected tile certification needs review',
2033
3181
  sourceBlockId: investigation.sourceBlockId ?? selectedString(selected, 'blockId'),
2034
3182
  sourceTileId: investigation.sourceTileId ?? selectedString(selected, 'tileId'),
2035
3183
  caveats: [
2036
- 'Investigation output is not certified until a reviewer promotes or certifies the generated block.',
3184
+ 'Analysis output is not certified until a reviewer promotes or certifies the generated block.',
2037
3185
  ...(sqlError ? [`SQL preview caveat: ${sqlError}`] : []),
2038
3186
  ],
2039
3187
  };
2040
3188
  }
2041
3189
  function investigationSteps(intent) {
2042
- const common = ['trust check', 'evidence capture'];
3190
+ const common = ['trust check', 'proof capture'];
2043
3191
  if (intent === 'trust_gap_review')
2044
3192
  return ['certification review', 'lineage review', 'owner and caveat check', ...common];
2045
3193
  if (intent === 'entity_drilldown')
@@ -2062,30 +3210,477 @@ function investigationAssumptions(intent, selected, generatedSql, sqlError) {
2062
3210
  }
2063
3211
  function buildInvestigationSummary(intent, question, selected, metrics, drivers) {
2064
3212
  const target = selectedString(selected, 'title') ?? 'this dashboard question';
2065
- const delta = typeof metrics.delta === 'number' ? ` Delta from the sampled baseline is ${formatContribution(metrics.delta)}.` : '';
3213
+ const cleanQuestion = cleanInvestigationQuestion(question);
3214
+ const metricName = cleanString(metrics.metric) || 'selected metric';
3215
+ const currentValue = typeofNumber(metrics.currentValue);
3216
+ const baselineValue = typeofNumber(metrics.baselineValue);
3217
+ const deltaValue = typeofNumber(metrics.delta);
3218
+ const currentDetail = cleanString(metrics.currentDetail);
3219
+ const baselineDetail = cleanString(metrics.baselineDetail);
3220
+ const currentLabel = currentDetail ? currentDetail.replace(/\s*\/\s*[^/]+$/, '') : cleanString(metrics.currentLabel) || 'Current result';
3221
+ const baselineLabel = baselineDetail ? baselineDetail.replace(/\s*\/\s*[^/]+$/, '') : cleanString(metrics.baselineLabel) || 'comparison';
3222
+ const valueSentence = currentValue !== null
3223
+ ? `${currentLabel} leads on ${metricName} with ${formatMetricValue(currentValue)}${baselineValue !== null ? ` versus ${formatMetricValue(baselineValue)} for ${baselineLabel}` : ''}${deltaValue !== null ? `, a gap of ${formatMetricValue(deltaValue)}` : ''}.`
3224
+ : '';
2066
3225
  if (intent === 'trust_gap_review') {
2067
- 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}`;
3226
+ return `This tile can be used as certified context only where its source block and lineage are certified. ${valueSentence || 'The deeper answer is AI-generated analysis and needs review before leaders rely on it.'}`;
3227
+ }
3228
+ const driverTitle = cleanString(drivers[0]?.title);
3229
+ const driver = driverTitle && driverTitle !== currentLabel
3230
+ ? ` ${driverTitle} is the strongest visible driver in the bounded preview.`
3231
+ : '';
3232
+ const reviewBoundary = ' This remains review-required until SQL, grain, filters, and lineage are confirmed.';
3233
+ if (valueSentence) {
3234
+ return `${valueSentence}${driver}${reviewBoundary}`.trim();
2068
3235
  }
2069
- const driver = drivers[0]?.title ? ` Top visible driver in the current evidence is ${drivers[0].title}.` : '';
2070
- return `DQL opened a review-required investigation for ${target}: ${question}.${delta}${driver}`;
3236
+ const fallbackSubject = cleanQuestion || target;
3237
+ return `This analysis investigates ${fallbackSubject}. The preview is bounded to the selected app context and needs analyst review before it becomes governed business logic.`;
3238
+ }
3239
+ function cleanInvestigationQuestion(value) {
3240
+ return cleanString(value)
3241
+ .replace(/^\/(ask|research|report|analy[sz]e|analysis|proof|evidence|validate|verify|add\s+block|create\s+block|draft\s+block|block)\b/i, '')
3242
+ .replace(/^(Analysis goal|Analysis question|Research question|Evidence question|Validation question|Reusable block goal|Business question|Question):\s*/i, '')
3243
+ .replace(/\bCurrent app filters:\s*[\s\S]*$/i, '')
3244
+ .replace(/\s+/g, ' ')
3245
+ .replace(/\s*[.?!]+$/, '')
3246
+ .trim();
2071
3247
  }
2072
3248
  function buildMissingBaselineSummary(question, selected) {
2073
3249
  const target = selectedString(selected, 'title') ?? 'the selected tile';
2074
- return `DQL opened a review-required investigation for ${target}: ${question}. The selected tile shows the current certified result, but its sample does not include a comparable prior period or historical snapshot, so DQL cannot calculate what changed without guessing.`;
3250
+ return `DQL opened review-required analysis for ${target}: ${question}. The selected tile shows the current certified result, but its sample does not include a comparable prior period or historical snapshot, so DQL cannot calculate what changed without guessing.`;
2075
3251
  }
2076
3252
  function buildMissingBaselineRecommendation(selected) {
2077
3253
  const target = selectedString(selected, 'title') ?? 'this tile';
2078
- return `Use ${target} as current-state evidence. To explain change, add or select a block with a time grain, snapshot date, or prior-period baseline, then rerun the investigation.`;
3254
+ return `Use ${target} as current-state evidence. To explain change, add or select a block with a time grain, snapshot date, or prior-period baseline, then rerun the analysis.`;
2079
3255
  }
2080
- function buildInvestigationRecommendation(intent, selected, sqlError) {
3256
+ function buildInvestigationRecommendation(intent, selected, sqlError, sqlErrorKind) {
3257
+ if (sqlErrorKind === 'runtime_unavailable')
3258
+ return 'Resume or choose an active warehouse, then refresh the analysis. Keep the generated SQL review-required until a preview runs successfully.';
3259
+ if (sqlErrorKind === 'ai_generation_timeout')
3260
+ return 'Use the certified app result as the current answer, then retry AI SQL generation or draft the scoped SQL manually before promotion.';
3261
+ if (sqlErrorKind === 'timeout')
3262
+ return 'Simplify the generated SQL or narrow the app filters, then refresh the bounded preview before promotion.';
3263
+ if (sqlErrorKind === 'safety')
3264
+ return 'Rewrite the SQL as a read-only SELECT/WITH query before any preview or promotion.';
2081
3265
  if (sqlError)
2082
3266
  return 'Review the generated SQL or add a certified drilldown block before promoting this result.';
2083
3267
  if (intent === 'trust_gap_review')
2084
- return 'Use the certified tile for reporting, and promote only the reviewed gaps into a draft block.';
3268
+ return 'Use the certified tile for stakeholder reporting, and promote only the reviewed gaps into a draft block.';
2085
3269
  if (!selected)
2086
3270
  return 'Select a dashboard tile or provide SQL so DQL can rank drivers with stronger evidence.';
2087
3271
  return 'Review the driver evidence, then pin the useful answer or promote the SQL path into a draft DQL block.';
2088
3272
  }
3273
+ function buildInvestigationReportSections(input) {
3274
+ const actionMode = cleanString(asRecord(input.context)?.actionMode);
3275
+ const sourceName = selectedString(input.selected, 'title') ?? selectedString(input.selected, 'blockId') ?? 'selected app result';
3276
+ const topNumber = reportMetricBullet(input.metrics, 'current');
3277
+ const comparison = reportMetricBullet(input.metrics, 'baseline');
3278
+ const delta = reportMetricBullet(input.metrics, 'delta');
3279
+ const keyBullets = [topNumber, comparison, delta].filter((item) => Boolean(item));
3280
+ const driver = cleanString(input.drivers[0]?.title);
3281
+ const driverValue = cleanString(input.drivers[0]?.contribution ?? input.drivers[0]?.value ?? input.drivers[0]?.metric);
3282
+ const nextDriver = cleanString(input.drivers[1]?.title);
3283
+ const interpretation = reportInterpretationForIntent({
3284
+ intent: input.intent,
3285
+ actionMode,
3286
+ sourceName,
3287
+ driver,
3288
+ driverValue,
3289
+ nextDriver,
3290
+ baselineGap: input.baselineGap,
3291
+ });
3292
+ const interpretationMeta = reportInterpretationMeta(input.intent, actionMode);
3293
+ const providerSections = extractProviderReportSections(input.agentAnswer);
3294
+ if (providerSections.length >= 2) {
3295
+ const sections = [...providerSections];
3296
+ if (keyBullets.length > 0 && !hasReportSection(sections, /\b(number|metric|kpi|value)\b/i)) {
3297
+ sections.push({
3298
+ id: 'key-numbers',
3299
+ kind: 'key_numbers',
3300
+ title: keyNumbersTitleForIntent(input.intent),
3301
+ body: keyNumbersBodyForIntent(input.intent),
3302
+ tone: 'insight',
3303
+ bullets: keyBullets,
3304
+ evidenceRefs: reportEvidenceRefs(input.selected),
3305
+ });
3306
+ }
3307
+ if (input.baselineGap && !hasReportSection(sections, /\b(missing|baseline|comparison)\b/i)) {
3308
+ sections.push({
3309
+ id: 'missing-comparison',
3310
+ kind: 'custom',
3311
+ title: 'Missing comparison',
3312
+ body: 'The selected dashboard sample does not contain a second comparable period or baseline. DQL preserved the current certified result and stopped short of inventing a change explanation from unrelated data.',
3313
+ tone: 'warning',
3314
+ evidenceRefs: reportEvidenceRefs(input.selected),
3315
+ });
3316
+ }
3317
+ const previewIssue = input.hasReportEvidence && input.sqlErrorKind === 'runtime_unavailable'
3318
+ ? undefined
3319
+ : reportPreviewIssue(input.sqlError, input.sqlErrorKind);
3320
+ if (previewIssue && !hasReportSection(sections, /\b(sql|preview|repair|warehouse|runtime)\b/i)) {
3321
+ sections.push({
3322
+ id: previewIssue.id,
3323
+ kind: 'custom',
3324
+ title: previewIssue.title,
3325
+ body: previewIssue.body,
3326
+ tone: previewIssue.tone,
3327
+ });
3328
+ }
3329
+ if (!hasReportSection(sections, /\b(next|recommend|action)\b/i)) {
3330
+ sections.push({
3331
+ id: 'recommended-next-step',
3332
+ kind: 'recommended_next_step',
3333
+ title: 'Recommended next step',
3334
+ body: input.recommendation,
3335
+ tone: input.sqlError ? 'warning' : 'neutral',
3336
+ });
3337
+ }
3338
+ if (!hasReportSection(sections, /\b(review boundary|review-required|governed boundary|trust boundary)\b/i)) {
3339
+ sections.push({
3340
+ id: 'review-boundary',
3341
+ kind: 'review_boundary',
3342
+ title: 'Review boundary',
3343
+ body: 'This analysis is AI-generated and review-required. Use it to guide decisions, then validate SQL, grain, filters, joins, and source proof before pinning it to the app or turning it into a reusable DQL block.',
3344
+ tone: 'review',
3345
+ });
3346
+ }
3347
+ return sections.filter((section) => cleanString(section.body));
3348
+ }
3349
+ const sections = [
3350
+ {
3351
+ id: 'executive-answer',
3352
+ kind: 'executive_answer',
3353
+ title: 'Executive answer',
3354
+ body: input.summary,
3355
+ tone: 'answer',
3356
+ evidenceRefs: reportEvidenceRefs(input.selected),
3357
+ },
3358
+ {
3359
+ id: interpretationMeta.id,
3360
+ kind: interpretationMeta.kind,
3361
+ title: interpretationMeta.title,
3362
+ body: interpretation,
3363
+ tone: interpretationMeta.tone,
3364
+ evidenceRefs: reportEvidenceRefs(input.selected),
3365
+ },
3366
+ ];
3367
+ if (keyBullets.length > 0) {
3368
+ sections.push({
3369
+ id: 'key-numbers',
3370
+ kind: 'key_numbers',
3371
+ title: keyNumbersTitleForIntent(input.intent),
3372
+ body: keyNumbersBodyForIntent(input.intent),
3373
+ tone: 'insight',
3374
+ bullets: keyBullets,
3375
+ evidenceRefs: reportEvidenceRefs(input.selected),
3376
+ });
3377
+ }
3378
+ if (input.baselineGap) {
3379
+ sections.push({
3380
+ id: 'missing-comparison',
3381
+ kind: 'custom',
3382
+ title: 'Missing comparison',
3383
+ body: 'The selected dashboard sample does not contain a second comparable period or baseline. DQL preserved the current certified result and stopped short of inventing a change explanation from unrelated data.',
3384
+ tone: 'warning',
3385
+ evidenceRefs: reportEvidenceRefs(input.selected),
3386
+ });
3387
+ }
3388
+ const previewIssue = input.hasReportEvidence && input.sqlErrorKind === 'runtime_unavailable'
3389
+ ? undefined
3390
+ : reportPreviewIssue(input.sqlError, input.sqlErrorKind);
3391
+ if (previewIssue) {
3392
+ sections.push({
3393
+ id: previewIssue.id,
3394
+ kind: 'custom',
3395
+ title: previewIssue.title,
3396
+ body: previewIssue.body,
3397
+ tone: previewIssue.tone,
3398
+ });
3399
+ }
3400
+ sections.push({
3401
+ id: 'recommended-next-step',
3402
+ kind: 'recommended_next_step',
3403
+ title: 'Recommended next step',
3404
+ body: input.recommendation,
3405
+ tone: input.sqlError ? 'warning' : 'neutral',
3406
+ }, {
3407
+ id: 'review-boundary',
3408
+ kind: 'review_boundary',
3409
+ title: 'Review boundary',
3410
+ body: 'This analysis is AI-generated and review-required. Use it to guide decisions, then validate SQL, grain, filters, joins, and source proof before pinning it to the app or turning it into a reusable DQL block.',
3411
+ tone: 'review',
3412
+ });
3413
+ return sections.filter((section) => cleanString(section.body));
3414
+ }
3415
+ function extractProviderReportSections(answer) {
3416
+ const raw = cleanString(answer);
3417
+ if (!raw)
3418
+ return [];
3419
+ const text = raw
3420
+ .replace(/```[\s\S]*?```/g, '')
3421
+ .replace(/^\s*(SQL|Query)\s*:.*$/gim, '')
3422
+ .trim();
3423
+ const matches = Array.from(text.matchAll(/^#{2,4}\s+(.+)$/gm));
3424
+ if (matches.length < 2)
3425
+ return [];
3426
+ const sections = [];
3427
+ const seen = new Set();
3428
+ for (let index = 0; index < matches.length; index += 1) {
3429
+ const match = matches[index];
3430
+ const title = cleanProviderReportTitle(match[1]);
3431
+ const next = matches[index + 1];
3432
+ const bodyText = text.slice((match.index ?? 0) + match[0].length, next?.index ?? text.length).trim();
3433
+ if (!title || !bodyText || isTraceOnlyProviderSection(title))
3434
+ continue;
3435
+ const id = slugify(title) || `section-${index + 1}`;
3436
+ if (seen.has(id))
3437
+ continue;
3438
+ seen.add(id);
3439
+ const parsed = parseProviderSectionBody(bodyText);
3440
+ if (!parsed.body)
3441
+ continue;
3442
+ sections.push({
3443
+ id,
3444
+ kind: providerReportKind(title),
3445
+ title,
3446
+ body: parsed.body,
3447
+ bullets: parsed.bullets,
3448
+ tone: providerReportTone(title),
3449
+ });
3450
+ }
3451
+ return sections.slice(0, 8);
3452
+ }
3453
+ function cleanProviderReportTitle(value) {
3454
+ return cleanString(value)
3455
+ .replace(/\*\*/g, '')
3456
+ .replace(/[::]\s*$/, '')
3457
+ .replace(/^\d+[.)]\s*/, '')
3458
+ .trim()
3459
+ .slice(0, 90);
3460
+ }
3461
+ function isTraceOnlyProviderSection(title) {
3462
+ return /\b(sql|query|raw trace|appendix|implementation detail)\b/i.test(title);
3463
+ }
3464
+ function parseProviderSectionBody(value) {
3465
+ const lines = value
3466
+ .split(/\r?\n/)
3467
+ .map((line) => line.trim())
3468
+ .filter(Boolean)
3469
+ .filter((line) => !/^#{1,6}\s+/.test(line));
3470
+ const bullets = [];
3471
+ const prose = [];
3472
+ for (const line of lines) {
3473
+ const bullet = /^[-*]\s+(.+)$/.exec(line)?.[1]?.trim();
3474
+ if (bullet) {
3475
+ bullets.push(bullet);
3476
+ continue;
3477
+ }
3478
+ prose.push(line);
3479
+ }
3480
+ const body = prose.join('\n\n') || bullets.slice(0, 3).join(' ');
3481
+ return {
3482
+ body: cleanString(body),
3483
+ bullets: bullets.length ? bullets.slice(0, 8) : undefined,
3484
+ };
3485
+ }
3486
+ function providerReportKind(title) {
3487
+ if (/\b(executive|answer|summary|decision)\b/i.test(title))
3488
+ return 'executive_answer';
3489
+ if (/\b(number|metric|kpi|value)\b/i.test(title))
3490
+ return 'key_numbers';
3491
+ if (/\b(driver|why|interpret|segment|movement|trend|entity|anomaly)\b/i.test(title))
3492
+ return 'business_interpretation';
3493
+ if (/\b(proof|validate|evidence|trust)\b/i.test(title))
3494
+ return 'validation';
3495
+ if (/\b(block|logic|reuse|contract)\b/i.test(title))
3496
+ return 'reusable_logic';
3497
+ if (/\b(next|recommend|action)\b/i.test(title))
3498
+ return 'recommended_next_step';
3499
+ if (/\b(caveat|review|boundary|risk|assumption)\b/i.test(title))
3500
+ return 'review_boundary';
3501
+ return 'custom';
3502
+ }
3503
+ function providerReportTone(title) {
3504
+ if (/\b(executive|answer|summary|decision)\b/i.test(title))
3505
+ return 'answer';
3506
+ if (/\b(driver|why|interpret|segment|movement|trend|number|metric|kpi|value)\b/i.test(title))
3507
+ return 'insight';
3508
+ if (/\b(caveat|warning|risk|missing|error|unavailable|anomaly)\b/i.test(title))
3509
+ return 'warning';
3510
+ if (/\b(proof|validate|evidence|trust|review|boundary|block|logic|reuse)\b/i.test(title))
3511
+ return 'review';
3512
+ return 'neutral';
3513
+ }
3514
+ function hasReportSection(sections, pattern) {
3515
+ return sections.some((section) => pattern.test(section.title) || pattern.test(section.kind));
3516
+ }
3517
+ function reportPreviewIssue(sqlError, sqlErrorKind) {
3518
+ if (!sqlError)
3519
+ return undefined;
3520
+ if (sqlErrorKind === 'runtime_unavailable') {
3521
+ return {
3522
+ id: 'preview-unavailable',
3523
+ title: 'Preview unavailable',
3524
+ body: 'The generated SQL was not proven because the warehouse or execution runtime is unavailable. Resume or choose an active warehouse, then refresh the analysis. Do not edit business logic just to fix an infrastructure issue.',
3525
+ tone: 'warning',
3526
+ };
3527
+ }
3528
+ if (sqlErrorKind === 'ai_generation_timeout') {
3529
+ return {
3530
+ id: 'ai-generation-timeout',
3531
+ title: 'AI SQL generation timed out',
3532
+ body: 'DQL did not receive generated SQL inside the bounded AI window. The memo uses certified app evidence only; retry AI generation, provide reviewed SQL, or draft reusable logic manually before promoting this analysis.',
3533
+ tone: 'warning',
3534
+ };
3535
+ }
3536
+ if (sqlErrorKind === 'timeout') {
3537
+ return {
3538
+ id: 'preview-timeout',
3539
+ title: 'Preview timed out',
3540
+ body: 'The generated SQL did not finish inside the bounded preview window. Narrow the filters, simplify the SQL, or rerun when the warehouse is responsive before promoting this analysis.',
3541
+ tone: 'warning',
3542
+ };
3543
+ }
3544
+ if (sqlErrorKind === 'safety') {
3545
+ return {
3546
+ id: 'sql-safety-check',
3547
+ title: 'SQL safety check',
3548
+ body: 'DQL blocked the preview because the SQL was not a read-only SELECT/WITH statement. Rewrite it as safe analytical SQL before running, pinning, or promoting.',
3549
+ tone: 'warning',
3550
+ };
3551
+ }
3552
+ return {
3553
+ id: 'sql-repair-path',
3554
+ title: 'SQL repair path',
3555
+ body: 'The generated SQL preview needs review before this analysis can be promoted. Open the trace appendix, edit the SQL against the selected block context, rerun the preview, then pin or draft reusable logic only after the row sample matches the business question.',
3556
+ tone: 'warning',
3557
+ };
3558
+ }
3559
+ function reportInterpretationMeta(intent, actionMode) {
3560
+ if (actionMode === 'block') {
3561
+ return { id: 'reusable-logic', kind: 'reusable_logic', title: 'Reusable logic decision', tone: 'review' };
3562
+ }
3563
+ if (actionMode === 'evidence' || intent === 'trust_gap_review') {
3564
+ return { id: 'validation-result', kind: 'validation', title: 'Validation result', tone: 'review' };
3565
+ }
3566
+ if (intent === 'diagnose_change') {
3567
+ return { id: 'change-explanation', kind: 'business_interpretation', title: 'Change explanation', tone: 'insight' };
3568
+ }
3569
+ if (intent === 'segment_compare') {
3570
+ return { id: 'segment-readout', kind: 'business_interpretation', title: 'Segment readout', tone: 'insight' };
3571
+ }
3572
+ if (intent === 'entity_drilldown') {
3573
+ return { id: 'entity-drilldown', kind: 'business_interpretation', title: 'Entity drilldown', tone: 'insight' };
3574
+ }
3575
+ if (intent === 'anomaly_investigation') {
3576
+ return { id: 'anomaly-readout', kind: 'business_interpretation', title: 'Anomaly readout', tone: 'warning' };
3577
+ }
3578
+ return { id: 'driver-readout', kind: 'business_interpretation', title: 'Driver readout', tone: 'insight' };
3579
+ }
3580
+ function reportInterpretationForIntent(input) {
3581
+ if (input.baselineGap) {
3582
+ return 'This is a current-state answer, not a completed change explanation. The analysis has enough proof to show the selected result, but it does not have a comparable prior period or baseline needed to explain movement responsibly.';
3583
+ }
3584
+ if (input.actionMode === 'block') {
3585
+ return 'This is a reusable-logic candidate. Preserve the business question, parameters, allowed filters, output grain, source proof, and review path before certification.';
3586
+ }
3587
+ if (input.actionMode === 'evidence' || input.intent === 'trust_gap_review') {
3588
+ return `This validation is bounded to ${input.sourceName}. Treat the claim as trusted only after source block, lineage, filters, and preview rows are reviewed.`;
3589
+ }
3590
+ if (input.driver) {
3591
+ const driverSentence = `${input.driver} is the strongest visible driver${input.driverValue ? ` (${input.driverValue})` : ''}.`;
3592
+ const comparisonSentence = input.nextDriver ? ` The next visible comparison is ${input.nextDriver}.` : '';
3593
+ const reviewSentence = ' Treat the interpretation as directional until a reviewer confirms SQL, grain, filters, joins, and lineage.';
3594
+ if (input.intent === 'segment_compare')
3595
+ return `${driverSentence}${comparisonSentence} Use this as a segment readout only after confirming the grouping field and filter bindings.${reviewSentence}`;
3596
+ if (input.intent === 'entity_drilldown')
3597
+ return `${driverSentence}${comparisonSentence} Use this as an entity drilldown only after confirming the entity identifier and output grain.${reviewSentence}`;
3598
+ if (input.intent === 'anomaly_investigation')
3599
+ return `${driverSentence}${comparisonSentence} Review the baseline, outlier rows, and time grain before treating this as an anomaly narrative.${reviewSentence}`;
3600
+ if (input.intent === 'diagnose_change')
3601
+ return `${driverSentence}${comparisonSentence} Confirm a comparable baseline or prior period before treating this as a causal change explanation.${reviewSentence}`;
3602
+ return `${driverSentence}${comparisonSentence}${reviewSentence}`;
3603
+ }
3604
+ if (input.intent === 'diagnose_change') {
3605
+ return 'The analysis does not yet have a comparable baseline or ranked drivers. Add a time grain, prior-period block, or segment field before treating the change explanation as complete.';
3606
+ }
3607
+ if (input.intent === 'segment_compare') {
3608
+ return 'The analysis does not yet have a clear segment grouping. Add a segment field or certified breakdown block before using this as a stakeholder segment comparison.';
3609
+ }
3610
+ if (input.intent === 'entity_drilldown') {
3611
+ return 'The analysis does not yet have a stable entity identifier. Add the entity key and output grain before using this as a stakeholder drilldown.';
3612
+ }
3613
+ return 'The analysis does not yet have ranked drivers. Add a clearer metric, time grain, or segment field before treating it as complete.';
3614
+ }
3615
+ function keyNumbersTitleForIntent(intent) {
3616
+ if (intent === 'diagnose_change')
3617
+ return 'Movement numbers';
3618
+ if (intent === 'segment_compare')
3619
+ return 'Segment numbers';
3620
+ if (intent === 'entity_drilldown')
3621
+ return 'Entity numbers';
3622
+ if (intent === 'anomaly_investigation')
3623
+ return 'Anomaly numbers';
3624
+ if (intent === 'trust_gap_review')
3625
+ return 'Validation numbers';
3626
+ return 'Key numbers';
3627
+ }
3628
+ function keyNumbersBodyForIntent(intent) {
3629
+ if (intent === 'diagnose_change')
3630
+ return 'The bounded preview includes the values DQL can prove for this movement question. Use them for stakeholder framing, but validate the baseline and time grain before promotion.';
3631
+ if (intent === 'segment_compare')
3632
+ return 'The bounded preview includes the segment values DQL can compare from the selected context. Confirm the grouping field and filters before promotion.';
3633
+ if (intent === 'entity_drilldown')
3634
+ return 'The bounded preview includes entity-level values from the selected context. Confirm the entity key and grain before promotion.';
3635
+ if (intent === 'anomaly_investigation')
3636
+ return 'The bounded preview includes values that may explain the exception. Confirm the baseline, threshold, and time grain before promotion.';
3637
+ if (intent === 'trust_gap_review')
3638
+ return 'The bounded preview includes values available for validation. Confirm source proof, lineage, and owner review before promotion.';
3639
+ return 'The bounded preview includes the following decision-relevant values. These are useful for stakeholder framing but still require source validation before promotion.';
3640
+ }
3641
+ function investigationNarrativeAnswer(investigation) {
3642
+ const sections = Array.isArray(investigation.reportSections)
3643
+ ? investigation.reportSections
3644
+ .filter((section) => cleanString(section?.title) && cleanString(section?.body))
3645
+ .slice(0, 8)
3646
+ : [];
3647
+ if (!sections.length) {
3648
+ return cleanString(investigation.summary)
3649
+ || cleanString(investigation.recommendation)
3650
+ || cleanString(investigation.title)
3651
+ || 'Review-required app analysis.';
3652
+ }
3653
+ const stakeholderSections = sections
3654
+ .filter((section) => {
3655
+ if (section.kind === 'review_boundary')
3656
+ return false;
3657
+ return !/\b(sql|query|appendix|technical|repair|preview error)\b/i.test(section.title);
3658
+ })
3659
+ .slice(0, 5);
3660
+ return (stakeholderSections.length ? stakeholderSections : sections.slice(0, 3)).map((section) => {
3661
+ const bullets = Array.isArray(section.bullets) && section.bullets.length
3662
+ ? `\n${section.bullets.filter(Boolean).slice(0, 8).map((bullet) => `- ${bullet}`).join('\n')}`
3663
+ : '';
3664
+ return `## ${section.title.trim()}\n${section.body.trim()}${bullets}`;
3665
+ }).join('\n\n');
3666
+ }
3667
+ function reportMetricBullet(metrics, role) {
3668
+ const prefix = role === 'current' ? 'current' : role === 'baseline' ? 'baseline' : 'delta';
3669
+ const label = cleanString(metrics[`${prefix}Label`])
3670
+ || (role === 'current' ? 'Top value' : role === 'baseline' ? 'Next comparison' : 'Gap');
3671
+ const value = typeofNumber(metrics[role === 'current' ? 'currentValue' : role === 'baseline' ? 'baselineValue' : 'delta']);
3672
+ if (value === null)
3673
+ return undefined;
3674
+ const detail = cleanString(metrics[`${prefix}Detail`]);
3675
+ return `${label}: ${formatMetricValue(value)}${detail ? ` (${detail})` : ''}`;
3676
+ }
3677
+ function reportEvidenceRefs(selected) {
3678
+ return [
3679
+ selectedString(selected, 'blockId') ? `block:${selectedString(selected, 'blockId')}` : '',
3680
+ selectedString(selected, 'tileId') ? `tile:${selectedString(selected, 'tileId')}` : '',
3681
+ selectedString(selected, 'blockPath') ?? '',
3682
+ ].filter(Boolean);
3683
+ }
2089
3684
  function investigationPreviewResult(investigation) {
2090
3685
  const previews = Array.isArray(investigation.resultPreviews) ? investigation.resultPreviews : [];
2091
3686
  const first = previews.find((preview) => asRecord(preview)?.result);
@@ -2142,6 +3737,12 @@ function formatContribution(value) {
2142
3737
  const rounded = Math.abs(value) >= 100 ? Math.round(value).toLocaleString() : Number(value.toFixed(2)).toLocaleString();
2143
3738
  return value >= 0 ? `+${rounded}` : `-${rounded.replace(/^-/, '')}`;
2144
3739
  }
3740
+ function formatMetricValue(value) {
3741
+ const absolute = Math.abs(value);
3742
+ if (absolute >= 100)
3743
+ return Math.round(value).toLocaleString();
3744
+ return Number(value.toFixed(2)).toLocaleString();
3745
+ }
2145
3746
  function asRecord(value) {
2146
3747
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
2147
3748
  }
@@ -2266,6 +3867,10 @@ function createAiPinTile(projectRoot, appId, input) {
2266
3867
  const tileId = cleanString(input.tileId) || nextTileId(loaded.dashboard, slugify(title) || 'ai-pin');
2267
3868
  const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
2268
3869
  try {
3870
+ const existing = findExistingAiPinTile(storage, loaded.dashboard, appId, dashboardId, title, input);
3871
+ if (existing) {
3872
+ return { ok: true, pin: existing.pin, dashboard: loaded.dashboard, tile: existing.tile, deduped: true };
3873
+ }
2269
3874
  const pin = storage.createAiPin({
2270
3875
  appId,
2271
3876
  dashboardId,
@@ -2311,6 +3916,60 @@ function createAiPinTile(projectRoot, appId, input) {
2311
3916
  storage.close();
2312
3917
  }
2313
3918
  }
3919
+ function findExistingAiPinTile(storage, dashboard, appId, dashboardId, title, input) {
3920
+ const requested = aiPinDedupeFingerprint(title, input.question, input.analysisPlan, input.result);
3921
+ if (!requested)
3922
+ return null;
3923
+ const pins = storage.listAiPins(appId, dashboardId);
3924
+ for (const pin of pins) {
3925
+ const existing = aiPinDedupeFingerprint(pin.title, pin.question, pin.analysisPlan, pin.result);
3926
+ if (!existing || existing !== requested)
3927
+ continue;
3928
+ const tile = dashboard.layout.items.find((item) => item.aiPin?.id === pin.id || (pin.tileId && item.i === pin.tileId));
3929
+ if (tile)
3930
+ return { pin, tile };
3931
+ }
3932
+ return null;
3933
+ }
3934
+ function aiPinDedupeFingerprint(title, question, analysisPlan, result) {
3935
+ const plan = asRecord(analysisPlan);
3936
+ const sourceBlock = cleanString(plan?.sourceBlockId);
3937
+ const sourceTile = cleanString(plan?.sourceTileId);
3938
+ const questionKey = normalizeAiPinDedupeText(question) || normalizeAiPinDedupeText(title);
3939
+ if (!questionKey && !sourceBlock && !sourceTile)
3940
+ return '';
3941
+ return [
3942
+ questionKey,
3943
+ sourceBlock,
3944
+ sourceTile,
3945
+ aiPinResultFingerprint(result),
3946
+ ].filter(Boolean).join('|');
3947
+ }
3948
+ function normalizeAiPinDedupeText(value) {
3949
+ return cleanString(value)
3950
+ .toLowerCase()
3951
+ .replace(/[^a-z0-9]+/g, ' ')
3952
+ .replace(/\s+/g, ' ')
3953
+ .trim();
3954
+ }
3955
+ function aiPinResultFingerprint(result) {
3956
+ const record = asRecord(result);
3957
+ if (!record)
3958
+ return '';
3959
+ const columns = Array.isArray(record.columns) ? record.columns.map((column) => String(column).toLowerCase()).join(',') : '';
3960
+ const rows = Array.isArray(record.rows) ? record.rows.slice(0, 8).map((row) => stableFingerprintValue(row)).join(';') : '';
3961
+ return columns || rows ? `${columns}:${rows}` : '';
3962
+ }
3963
+ function stableFingerprintValue(value) {
3964
+ if (value === null || value === undefined)
3965
+ return '';
3966
+ if (typeof value !== 'object')
3967
+ return String(value);
3968
+ if (Array.isArray(value))
3969
+ return `[${value.map(stableFingerprintValue).join(',')}]`;
3970
+ const record = value;
3971
+ return `{${Object.keys(record).sort().map((key) => `${key}:${stableFingerprintValue(record[key])}`).join(',')}}`;
3972
+ }
2314
3973
  function promoteAiPinToDraftBlock(projectRoot, appId, pinId) {
2315
3974
  const loaded = loadAppById(projectRoot, appId);
2316
3975
  if (!loaded)
@@ -2709,7 +4368,7 @@ function listAiPins(projectRoot, appId) {
2709
4368
  try {
2710
4369
  const storage = new LocalAppStorage(dbPath);
2711
4370
  try {
2712
- return storage.listAiPins(appId);
4371
+ return dedupeLocalAiPins(storage.listAiPins(appId));
2713
4372
  }
2714
4373
  finally {
2715
4374
  storage.close();
@@ -2721,6 +4380,18 @@ function listAiPins(projectRoot, appId) {
2721
4380
  return [];
2722
4381
  }
2723
4382
  }
4383
+ function dedupeLocalAiPins(pins) {
4384
+ const seen = new Set();
4385
+ const out = [];
4386
+ for (const pin of pins) {
4387
+ const fingerprint = aiPinDedupeFingerprint(pin.title, pin.question, pin.analysisPlan, pin.result) || pin.id;
4388
+ if (seen.has(fingerprint))
4389
+ continue;
4390
+ seen.add(fingerprint);
4391
+ out.push(pin);
4392
+ }
4393
+ return out;
4394
+ }
2724
4395
  function listAppInvestigations(projectRoot, appId) {
2725
4396
  const dbPath = defaultLocalAppsDbPath(projectRoot);
2726
4397
  if (!existsSync(dbPath))
@@ -2728,7 +4399,7 @@ function listAppInvestigations(projectRoot, appId) {
2728
4399
  try {
2729
4400
  const storage = new LocalAppStorage(dbPath);
2730
4401
  try {
2731
- return storage.listAppInvestigations(appId);
4402
+ return dedupeAppInvestigationsForDisplay(storage.listAppInvestigations(appId));
2732
4403
  }
2733
4404
  finally {
2734
4405
  storage.close();
@@ -2738,6 +4409,18 @@ function listAppInvestigations(projectRoot, appId) {
2738
4409
  return [];
2739
4410
  }
2740
4411
  }
4412
+ function dedupeAppInvestigationsForDisplay(investigations) {
4413
+ const seen = new Set();
4414
+ const out = [];
4415
+ for (const investigation of investigations) {
4416
+ const fingerprint = appInvestigationReuseFingerprint(investigation);
4417
+ if (seen.has(fingerprint))
4418
+ continue;
4419
+ seen.add(fingerprint);
4420
+ out.push(investigation);
4421
+ }
4422
+ return out;
4423
+ }
2741
4424
  function listDashboardsFor(projectRoot, id) {
2742
4425
  const result = loadAppById(projectRoot, id);
2743
4426
  return result?.dashboards ?? null;
@@ -2835,10 +4518,23 @@ async function readJson(req) {
2835
4518
  });
2836
4519
  }
2837
4520
  export const __test__ = {
4521
+ buildMetricSnapshot,
2838
4522
  buildPreviewDriverCards,
2839
4523
  buildPreviewMetricSnapshot,
2840
4524
  buildDeterministicInvestigationSql,
4525
+ buildInvestigationSummary,
4526
+ buildInvestigationReportSections,
4527
+ classifySqlPreviewError,
4528
+ investigationNarrativeAnswer,
4529
+ createAiPinTile,
4530
+ runGeneratedSqlPreview,
4531
+ renderSqlTemplateParams,
4532
+ unresolvedSqlTemplateParams,
2841
4533
  selectedBlockContext,
4534
+ runAppInvestigation,
4535
+ askAppQuestion,
4536
+ collectAppsList,
4537
+ loadAppById,
2842
4538
  };
2843
4539
  // reference unused parseAppDocument/readFileSync to keep import stable for forward use
2844
4540
  void parseAppDocument;