@duckcodeailabs/dql-cli 1.6.20 → 1.6.22

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.
Files changed (71) hide show
  1. package/dist/apps-api.d.ts +216 -2
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +1767 -46
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/args.d.ts +10 -0
  6. package/dist/args.d.ts.map +1 -1
  7. package/dist/args.js +19 -0
  8. package/dist/args.js.map +1 -1
  9. package/dist/assets/dql-notebook/assets/{codemirror-DJYUkPr1.js → codemirror-Co0dCEDr.js} +1 -1
  10. package/dist/assets/dql-notebook/assets/index-D9Owd4EN.css +1 -0
  11. package/dist/assets/dql-notebook/assets/index-nDZHVgfg.js +5636 -0
  12. package/dist/assets/dql-notebook/assets/{react-CRB3T2We.js → react-BRY8hXab.js} +2 -2
  13. package/dist/assets/dql-notebook/index.html +4 -4
  14. package/dist/commands/agent.d.ts +1 -1
  15. package/dist/commands/agent.d.ts.map +1 -1
  16. package/dist/commands/agent.js +50 -10
  17. package/dist/commands/agent.js.map +1 -1
  18. package/dist/commands/app.d.ts +4 -4
  19. package/dist/commands/app.d.ts.map +1 -1
  20. package/dist/commands/app.js +22 -19
  21. package/dist/commands/app.js.map +1 -1
  22. package/dist/commands/certify.d.ts.map +1 -1
  23. package/dist/commands/certify.js +70 -25
  24. package/dist/commands/certify.js.map +1 -1
  25. package/dist/commands/diff.d.ts.map +1 -1
  26. package/dist/commands/diff.js +108 -5
  27. package/dist/commands/diff.js.map +1 -1
  28. package/dist/commands/doctor.d.ts +1 -1
  29. package/dist/commands/doctor.d.ts.map +1 -1
  30. package/dist/commands/doctor.js +68 -7
  31. package/dist/commands/doctor.js.map +1 -1
  32. package/dist/commands/eval.d.ts +121 -0
  33. package/dist/commands/eval.d.ts.map +1 -0
  34. package/dist/commands/eval.js +350 -0
  35. package/dist/commands/eval.js.map +1 -0
  36. package/dist/commands/init.d.ts.map +1 -1
  37. package/dist/commands/init.js +50 -10
  38. package/dist/commands/init.js.map +1 -1
  39. package/dist/commands/notebook.d.ts +15 -0
  40. package/dist/commands/notebook.d.ts.map +1 -1
  41. package/dist/commands/notebook.js +32 -11
  42. package/dist/commands/notebook.js.map +1 -1
  43. package/dist/commands/propose.d.ts +23 -0
  44. package/dist/commands/propose.d.ts.map +1 -0
  45. package/dist/commands/propose.js +184 -0
  46. package/dist/commands/propose.js.map +1 -0
  47. package/dist/index.js +64 -11
  48. package/dist/index.js.map +1 -1
  49. package/dist/llm/analytics-tools.d.ts.map +1 -1
  50. package/dist/llm/analytics-tools.js +9 -4
  51. package/dist/llm/analytics-tools.js.map +1 -1
  52. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  53. package/dist/llm/providers/dql-agent-provider.js +21 -32
  54. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  55. package/dist/llm/providers/native-sdk-provider.d.ts.map +1 -1
  56. package/dist/llm/providers/native-sdk-provider.js +5 -2
  57. package/dist/llm/providers/native-sdk-provider.js.map +1 -1
  58. package/dist/local-runtime.d.ts +132 -4
  59. package/dist/local-runtime.d.ts.map +1 -1
  60. package/dist/local-runtime.js +2120 -46
  61. package/dist/local-runtime.js.map +1 -1
  62. package/dist/package.json +10 -10
  63. package/dist/propose-enrich.d.ts +13 -0
  64. package/dist/propose-enrich.d.ts.map +1 -0
  65. package/dist/propose-enrich.js +34 -0
  66. package/dist/propose-enrich.js.map +1 -0
  67. package/dist/settings/provider-settings.js +2 -2
  68. package/dist/settings/provider-settings.js.map +1 -1
  69. package/package.json +11 -11
  70. package/dist/assets/dql-notebook/assets/index-Dgqvyn5s.js +0 -3886
  71. package/dist/assets/dql-notebook/assets/index-RaDW1A5g.css +0 -1
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,811 @@ 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
+ /**
1021
+ * Ask the App a question. Routes to the right lane (certified answer / investigation /
1022
+ * app change / metadata) and — when the runtime supplies a research planner — enriches
1023
+ * the result with a grounded ReAct plan + smart follow-up options (the P4 loop), so the
1024
+ * panel can DECIDE and offer real next steps instead of generic strings.
1025
+ */
1026
+ async function askAppQuestion(ctx, appId, input) {
1027
+ const result = await routeAppAskQuestion(ctx, appId, input);
1028
+ if (!result.ok || !ctx.planResearch)
1029
+ return result;
1030
+ try {
1031
+ const research = await ctx.planResearch({
1032
+ question: cleanString(input.question),
1033
+ isFollowUp: result.route === 'investigation' || result.route === 'app_change_proposal',
1034
+ });
1035
+ // A clarify decision means "ask before researching" — surface the real options.
1036
+ const followUps = research.decision === 'clarify' && research.followUp
1037
+ ? [research.followUp.question, ...research.followUp.options].slice(0, 5)
1038
+ : result.followUps;
1039
+ return { ...result, researchPlan: research, followUps };
1040
+ }
1041
+ catch {
1042
+ return result; // best-effort: research enrichment never blocks the answer
1043
+ }
1044
+ }
1045
+ async function routeAppAskQuestion(ctx, appId, input) {
1046
+ const loaded = loadAppById(ctx.projectRoot, appId);
1047
+ if (!loaded)
1048
+ return { ok: false, error: `App "${appId}" not found` };
1049
+ const question = cleanString(input.question);
1050
+ if (!question)
1051
+ return { ok: false, error: 'question is required' };
1052
+ const dashboardId = cleanString(input.dashboardId)
1053
+ || (loaded.app.homepage?.type === 'dashboard' ? loaded.app.homepage.id : undefined)
1054
+ || loaded.dashboards[0]?.id;
1055
+ const dashboard = dashboardId ? loadDashboardForApp(ctx.projectRoot, appId, dashboardId)?.dashboard : null;
1056
+ const tile = dashboard?.layout.items.find((item) => item.i === input.tileId ||
1057
+ (input.blockId && item.block && 'blockId' in item.block && item.block.blockId === input.blockId));
1058
+ const blockId = cleanString(input.blockId) || (tile?.block && 'blockId' in tile.block ? tile.block.blockId : undefined);
1059
+ const citations = [
1060
+ { kind: 'app', name: loaded.app.name, path: loaded.appPath },
1061
+ ...(dashboard ? [{ kind: 'dashboard', name: dashboard.metadata.title, path: `${loaded.appPath}/dashboards/${dashboard.id}.dqld` }] : []),
1062
+ ...(blockId ? [{ kind: 'block', name: blockId }] : []),
1063
+ ];
1064
+ if (isAppChangeQuestion(question)) {
1065
+ const decision = buildAppAskDecision('app_change_proposal', {
1066
+ question,
1067
+ blockId,
1068
+ selected: selectedBlockContext(input.context),
1069
+ appName: loaded.app.name,
1070
+ });
1071
+ return {
1072
+ ok: true,
1073
+ route: 'app_change_proposal',
1074
+ answer: `I can update the app presentation, but this should remain a reviewed app change. Suggested action: ${appChangeSuggestion(question)}.`,
1075
+ trustState: 'review_required',
1076
+ reviewStatus: 'review_required',
1077
+ citations,
1078
+ followUps: ['Review this app change', 'Open Build mode', 'Create analysis before changing logic'],
1079
+ decision,
1080
+ proposal: {
1081
+ type: 'app_change',
1082
+ dashboardId,
1083
+ tileId: input.tileId,
1084
+ blockId,
1085
+ question,
1086
+ reviewRequired: true,
1087
+ },
1088
+ };
1089
+ }
1090
+ if (shouldRouteAppQuestionToInvestigation(question)) {
1091
+ const decision = buildAppAskDecision('investigation', {
1092
+ question,
1093
+ blockId,
1094
+ selected: selectedBlockContext(input.context),
1095
+ appName: loaded.app.name,
1096
+ });
1097
+ const investigationProposal = {
1098
+ type: 'research_investigation',
1099
+ dashboardId,
1100
+ tileId: cleanString(input.tileId) || undefined,
1101
+ blockId,
1102
+ question,
1103
+ intent: normalizeInvestigationIntent(undefined, question, input.context),
1104
+ title: titleFromInvestigation(question, selectedBlockContext(input.context)),
1105
+ requiredContext: true,
1106
+ reviewRequired: true,
1107
+ suggestedContext: {
1108
+ appId,
1109
+ appName: loaded.app.name,
1110
+ dashboardId,
1111
+ tileId: input.tileId,
1112
+ blockId,
1113
+ variables: input.variables,
1114
+ selectedTile: tile ? appAskTileContext(tile) : undefined,
1115
+ userContext: input.context,
1116
+ },
1117
+ };
1118
+ if (input.runInvestigation === false) {
1119
+ return {
1120
+ ok: true,
1121
+ route: 'investigation',
1122
+ answer: 'This needs a scoped analysis memo. Add the comparison, filters, timeframe, and decision context before DQL writes SQL or runs a preview.',
1123
+ trustState: 'draft_ready',
1124
+ reviewStatus: 'draft_ready',
1125
+ citations,
1126
+ followUps: ['Add analysis context', 'Check proof', 'Create block draft'],
1127
+ decision,
1128
+ proposal: investigationProposal,
1129
+ };
1130
+ }
1131
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(ctx.projectRoot));
1132
+ try {
1133
+ let investigation = createOrReuseAppInvestigation(storage, {
1134
+ appId,
1135
+ dashboardId,
1136
+ sourceTileId: cleanString(input.tileId) || undefined,
1137
+ sourceBlockId: blockId,
1138
+ title: titleFromInvestigation(question, selectedBlockContext(input.context)),
1139
+ question,
1140
+ intent: normalizeInvestigationIntent(undefined, question, input.context),
1141
+ context: {
1142
+ appId,
1143
+ appName: loaded.app.name,
1144
+ dashboardId,
1145
+ tileId: input.tileId,
1146
+ blockId,
1147
+ variables: input.variables,
1148
+ selectedTile: tile ? appAskTileContext(tile) : undefined,
1149
+ userContext: input.context,
1150
+ routeDecision: decision,
1151
+ },
1152
+ });
1153
+ investigation = await runAppInvestigation(ctx, storage, investigation, { context: investigation.context });
1154
+ return {
1155
+ ok: true,
1156
+ route: 'investigation',
1157
+ 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.',
1158
+ trustState: 'draft_ready',
1159
+ reviewStatus: 'draft_ready',
1160
+ citations,
1161
+ followUps: ['Review the memo', 'Add reviewed result to this app', 'Create a draft DQL block'],
1162
+ decision,
1163
+ investigation,
1164
+ };
1165
+ }
1166
+ finally {
1167
+ storage.close();
1168
+ }
1169
+ }
1170
+ if (blockId) {
1171
+ const selected = selectedBlockContext(input.context);
1172
+ const answer = buildCertifiedAppAnswer({
1173
+ app: loaded.app,
1174
+ dashboard: dashboard ?? null,
1175
+ blockId,
1176
+ question,
1177
+ selected,
1178
+ variables: input.variables,
1179
+ });
1180
+ return {
1181
+ ok: true,
1182
+ route: 'certified_answer',
1183
+ answer,
1184
+ trustState: tile?.trustState === 'certified' || tile?.display?.trustState === 'certified' ? 'certified' : 'review_required',
1185
+ reviewStatus: tile?.reviewStatus === 'certified' || tile?.display?.reviewStatus === 'certified' ? 'certified' : 'review_required',
1186
+ citations,
1187
+ followUps: ['Explain the visible result', 'Investigate drivers', 'Create block draft from this result'],
1188
+ decision: buildAppAskDecision('certified_answer', {
1189
+ question,
1190
+ blockId,
1191
+ selected,
1192
+ appName: loaded.app.name,
1193
+ }),
1194
+ };
1195
+ }
1196
+ return {
1197
+ ok: true,
1198
+ route: 'metadata_answer',
1199
+ 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.`,
1200
+ trustState: 'draft_ready',
1201
+ reviewStatus: 'draft_ready',
1202
+ citations,
1203
+ followUps: ['Ask about a specific tile', 'Find trust gaps', 'Start driver analysis'],
1204
+ decision: buildAppAskDecision('metadata_answer', {
1205
+ question,
1206
+ appName: loaded.app.name,
1207
+ }),
1208
+ };
1209
+ }
1210
+ function buildAppAskDecision(route, input) {
1211
+ const selected = input.selected ?? null;
1212
+ const sourceName = selectedString(selected, 'title')
1213
+ || cleanString(input.blockId)
1214
+ || cleanString(input.appName)
1215
+ || 'this app';
1216
+ if (route === 'certified_answer') {
1217
+ return {
1218
+ mode: 'answer',
1219
+ reason: `The question can be answered from the selected certified result: ${sourceName}.`,
1220
+ nextAction: 'Use the answer directly, or request deeper analysis when you need a new comparison, grain, or reusable logic.',
1221
+ requiresContext: false,
1222
+ usesCertifiedResult: true,
1223
+ confidence: hasSelectedRows(selected) ? 0.9 : 0.74,
1224
+ };
1225
+ }
1226
+ if (route === 'investigation') {
1227
+ const intent = normalizeInvestigationIntent(undefined, input.question, selected);
1228
+ return {
1229
+ mode: 'analysis',
1230
+ reason: appAskInvestigationReason(intent),
1231
+ nextAction: 'Add the exact comparison, filters, and proof focus before DQL writes SQL or opens the main-canvas analysis.',
1232
+ requiresContext: true,
1233
+ usesCertifiedResult: Boolean(input.blockId || input.selected),
1234
+ confidence: 0.82,
1235
+ };
1236
+ }
1237
+ if (route === 'app_change_proposal') {
1238
+ return {
1239
+ mode: 'app_change',
1240
+ reason: 'The question asks to change presentation or layout, not business logic.',
1241
+ nextAction: 'Review the proposed app change in Build mode and keep certified block logic unchanged.',
1242
+ requiresContext: false,
1243
+ usesCertifiedResult: Boolean(input.blockId || input.selected),
1244
+ confidence: 0.78,
1245
+ };
1246
+ }
1247
+ return {
1248
+ mode: 'metadata',
1249
+ reason: 'No specific certified tile was selected, so DQL answered from app metadata.',
1250
+ nextAction: 'Select a tile for a trusted answer, or ask for deeper analysis with a clear comparison and filter scope.',
1251
+ requiresContext: false,
1252
+ usesCertifiedResult: false,
1253
+ confidence: 0.62,
1254
+ };
1255
+ }
1256
+ function appAskInvestigationReason(intent) {
1257
+ if (intent === 'diagnose_change')
1258
+ return 'The question asks why something changed, which needs a comparison window or baseline beyond the visible certified result.';
1259
+ if (intent === 'segment_compare')
1260
+ return 'The question asks for a comparison across segments, cohorts, or groups, which needs scoped analysis before promotion.';
1261
+ if (intent === 'entity_drilldown')
1262
+ return 'The question asks for entity-level drilldown, which needs reviewed grain, joins, and identifiers.';
1263
+ if (intent === 'anomaly_investigation')
1264
+ return 'The question asks about an exception or outlier, which needs baseline proof before stakeholder use.';
1265
+ if (intent === 'trust_gap_review')
1266
+ return 'The question asks about proof, caveats, lineage, or trust gaps, which should be reviewed as an evidence brief.';
1267
+ return 'The question asks for drivers or decomposition, which needs scoped analysis before creating or changing reusable logic.';
1268
+ }
1269
+ function appAnalysisRouteDecisionFromContext(context, intent, selected, question) {
1270
+ const root = asRecord(context);
1271
+ const rawDecision = asRecord(root?.routeDecision) ?? asRecord(asRecord(root?.originatingAnswer)?.decision);
1272
+ if (rawDecision) {
1273
+ return normalizeAppAskDecision(rawDecision, intent, selected, question);
1274
+ }
1275
+ return buildAppAskDecision('investigation', {
1276
+ question,
1277
+ blockId: selectedString(selected, 'blockId'),
1278
+ selected,
1279
+ appName: cleanString(root?.appName),
1280
+ });
1281
+ }
1282
+ function normalizeAppAskDecision(value, intent, selected, question) {
1283
+ const mode = value.mode === 'answer' || value.mode === 'analysis' || value.mode === 'app_change' || value.mode === 'metadata'
1284
+ ? value.mode
1285
+ : 'analysis';
1286
+ const reason = cleanString(value.reason) || appAskInvestigationReason(intent);
1287
+ const nextAction = cleanString(value.nextAction)
1288
+ || 'Review the generated analysis, validate proof, then pin or promote only after approval.';
1289
+ const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence)
1290
+ ? Math.max(0, Math.min(1, value.confidence))
1291
+ : hasSelectedRows(selected) ? 0.82 : 0.68;
1292
+ return {
1293
+ mode,
1294
+ reason,
1295
+ nextAction,
1296
+ requiresContext: typeof value.requiresContext === 'boolean' ? value.requiresContext : mode === 'analysis',
1297
+ usesCertifiedResult: typeof value.usesCertifiedResult === 'boolean'
1298
+ ? value.usesCertifiedResult
1299
+ : Boolean(selectedString(selected, 'blockId') || selectedString(selected, 'certificationStatus')),
1300
+ confidence,
1301
+ };
1302
+ }
1303
+ function buildCertifiedAppAnswer(input) {
1304
+ const rows = selectedRows(input.selected);
1305
+ const columns = selectedColumns(input.selected, rows);
1306
+ const metrics = buildMetricSnapshot(input.selected);
1307
+ const currentValue = typeofNumber(metrics.currentValue);
1308
+ const baselineValue = typeofNumber(metrics.baselineValue);
1309
+ const deltaValue = typeofNumber(metrics.delta);
1310
+ const metricName = cleanString(metrics.metric) || preferredMeasureColumn(columns, rows) || 'selected metric';
1311
+ const firstRow = rows[0];
1312
+ const labelColumn = preferredLabelColumn(columns, rows);
1313
+ const label = labelColumn && firstRow ? cleanString(firstRow[labelColumn]) : '';
1314
+ const filterSummary = formatAppAskVariables(input.variables);
1315
+ const resultTitle = cleanString(input.selected?.title)
1316
+ || cleanString(input.dashboard?.metadata.title)
1317
+ || input.app.name;
1318
+ const currentLine = currentValue !== null
1319
+ ? `${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)}` : ''}.`
1320
+ : rows.length > 0
1321
+ ? `The certified result is loaded with ${rows.length} sampled row${rows.length === 1 ? '' : 's'} across ${columns.length} field${columns.length === 1 ? '' : 's'}.`
1322
+ : 'The certified block is selected, but this request did not include result rows for a numeric summary.';
1323
+ const rowDetails = firstRow && columns.length
1324
+ ? columns
1325
+ .slice(0, 5)
1326
+ .map((column) => `${formatBusinessColumn(column)}: ${formatAskValue(firstRow[column])}`)
1327
+ .join('; ')
1328
+ : '';
1329
+ const nextStep = /\bwhy|driver|change|changed|compare|segment|drill|root cause|because\b/i.test(input.question)
1330
+ ? '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.'
1331
+ : '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.';
1332
+ return [
1333
+ `## Answer`,
1334
+ `For **${resultTitle}**${filterSummary ? ` with ${filterSummary}` : ''}, ${currentLine}`,
1335
+ rowDetails ? `Visible row detail: ${rowDetails}.` : '',
1336
+ `## Trusted source`,
1337
+ `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.`,
1338
+ `## Next step`,
1339
+ nextStep,
1340
+ ].filter(Boolean).join('\n\n');
1341
+ }
1342
+ function preferredLabelColumn(columns, rows) {
1343
+ return columns.find((column) => /\b(name|player|customer|account|team|segment|category|label)\b/i.test(column) && rows.some((row) => cleanString(row[column])))
1344
+ ?? columns.find((column) => rows.some((row) => typeof row[column] === 'string' && cleanString(row[column])));
1345
+ }
1346
+ function preferredMeasureColumn(columns, rows) {
1347
+ 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))
1348
+ ?? columns.find((column) => rows.some((row) => typeofNumber(row[column]) !== null));
1349
+ }
1350
+ function formatAppAskVariables(variables) {
1351
+ if (!variables)
1352
+ return '';
1353
+ return Object.entries(variables)
1354
+ .filter(([key, value]) => key !== 'smartView' && value !== undefined && value !== null && String(value).trim() !== '')
1355
+ .slice(0, 6)
1356
+ .map(([key, value]) => `${formatBusinessColumn(key)} ${formatAskVariableValue(key, value)}`)
1357
+ .join(', ');
1358
+ }
1359
+ function formatBusinessColumn(value) {
1360
+ return value.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
1361
+ }
1362
+ function formatAskValue(value) {
1363
+ if (Array.isArray(value))
1364
+ return value.map(formatAskValue).join(', ');
1365
+ if (typeof value === 'number')
1366
+ return Number.isInteger(value) ? value.toLocaleString() : Number(value.toFixed(2)).toLocaleString();
1367
+ if (value === null || value === undefined)
1368
+ return 'not set';
1369
+ if (typeof value === 'object')
1370
+ return JSON.stringify(value);
1371
+ return String(value);
1372
+ }
1373
+ function formatAskVariableValue(key, value) {
1374
+ if (Array.isArray(value))
1375
+ return value.map((item) => formatAskVariableValue(key, item)).join(', ');
1376
+ const numeric = typeof value === 'number'
1377
+ ? value
1378
+ : typeof value === 'string' && value.trim() && Number.isFinite(Number(value))
1379
+ ? Number(value)
1380
+ : null;
1381
+ if (numeric !== null && /(season|year)/i.test(key) && Number.isInteger(numeric) && numeric >= 1900 && numeric <= 2200) {
1382
+ return String(numeric);
1383
+ }
1384
+ return formatAskValue(value);
1385
+ }
1386
+ export function recommendDashboardTile(projectRoot, appId, dashboardId, input) {
1387
+ const loaded = loadDashboardForApp(projectRoot, appId, dashboardId);
1388
+ if (!loaded)
1389
+ return { ok: false, error: `Dashboard "${dashboardId}" not found in app "${appId}"` };
1390
+ const recommendation = recommendVisualization(projectRoot, input);
1391
+ if (!recommendation.ok)
1392
+ return recommendation;
1393
+ const block = input.blockRef
1394
+ ? collectBlockCandidates(projectRoot).find((candidate) => candidate.id === input.blockRef ||
1395
+ candidate.name === input.blockRef ||
1396
+ candidate.path === input.blockRef ||
1397
+ candidate.path.endsWith(`/${input.blockRef}`))
1398
+ : undefined;
1399
+ const filterBindings = block ? filterBindingsForBlockSource(projectRoot, block) : [];
1400
+ const parameterBindings = filterBindings
1401
+ .filter((entry) => entry.mode === 'parameter' && entry.paramNames?.length)
1402
+ .flatMap((entry) => (entry.paramNames ?? []).map((param) => ({ param, source: 'dashboard_filter', filter: entry.filter, field: entry.binding })));
1403
+ const sourceEvidence = [
1404
+ ...recommendation.evidence.map((entry) => ({
1405
+ source: entry.source,
1406
+ reason: entry.reason,
1407
+ kind: entry.source === 'result_schema' ? 'result_schema' : 'metadata',
1408
+ trustState: recommendation.display.trustState,
1409
+ })),
1410
+ ...(block ? [{
1411
+ source: `block:${block.name}`,
1412
+ reason: block.status === 'certified' ? 'Certified block can back this app tile.' : 'Block is not certified; keep review-required.',
1413
+ kind: 'block',
1414
+ path: block.path,
1415
+ trustState: block.status === 'certified' ? 'certified' : 'review_required',
1416
+ }] : []),
1417
+ ];
1418
+ return {
1419
+ ok: true,
1420
+ display: recommendation.display,
1421
+ filterBindings,
1422
+ parameterBindings,
1423
+ sourceEvidence,
1424
+ trustState: recommendation.display.trustState,
1425
+ reviewStatus: recommendation.display.reviewStatus,
1426
+ evidence: recommendation.evidence,
1427
+ warnings: recommendation.warnings,
1428
+ };
1429
+ }
1430
+ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
1431
+ const loaded = loadAppById(projectRoot, appId);
1432
+ if (!loaded)
1433
+ return { ok: false, error: `App "${appId}" not found` };
1434
+ const lifecycle = input.lifecycle === 'certified' || input.lifecycle === 'deprecated' ? input.lifecycle : 'review';
1435
+ const app = {
1436
+ ...loaded.app,
1437
+ visibility: 'shared',
1438
+ lifecycle,
1439
+ };
1440
+ const appPath = join(loaded.appDir, 'dql.app.json');
1441
+ const parsedApp = parseAppDocument(JSON.stringify(app), appPath);
1442
+ if (!parsedApp.document)
1443
+ return { ok: false, error: parsedApp.errors.map((err) => err.message).join('; ') };
1444
+ writeFileSync(appPath, JSON.stringify(parsedApp.document, null, 2) + '\n', 'utf-8');
1445
+ let removedLocalTiles = 0;
1446
+ const paths = [relative(projectRoot, appPath)];
1447
+ for (const dashboardPath of findDashboardsForApp(loaded.appDir)) {
1448
+ const loadedDashboard = loadDashboardDocument(dashboardPath).document;
1449
+ if (!loadedDashboard)
1450
+ continue;
1451
+ const items = loadedDashboard.layout.items
1452
+ .filter((item) => {
1453
+ if (item.aiPin) {
1454
+ removedLocalTiles += 1;
1455
+ return false;
1456
+ }
1457
+ return true;
1458
+ })
1459
+ .map((item) => promoteSharedDashboardItem(item));
1460
+ const dashboard = {
1461
+ ...loadedDashboard,
1462
+ metadata: {
1463
+ ...loadedDashboard.metadata,
1464
+ visibility: 'shared',
1465
+ lifecycle,
1466
+ },
1467
+ layout: {
1468
+ ...loadedDashboard.layout,
1469
+ items,
1470
+ },
1471
+ };
1472
+ const parsed = parseDashboardDocument(JSON.stringify(dashboard), dashboardPath);
1473
+ if (!parsed.document)
1474
+ return { ok: false, error: parsed.errors.map((err) => err.message).join('; ') };
1475
+ writeFileSync(dashboardPath, JSON.stringify(parsed.document, null, 2) + '\n', 'utf-8');
1476
+ paths.push(relative(projectRoot, dashboardPath));
1477
+ }
1478
+ return { ok: true, app: parsedApp.document, paths, removedLocalTiles };
1479
+ }
1480
+ function appAiBuildSessionDir(projectRoot) {
1481
+ return join(projectRoot, '.dql', 'local', 'app-ai-builds');
1482
+ }
1483
+ function writeAppAiBuildSession(projectRoot, session) {
1484
+ const dir = appAiBuildSessionDir(projectRoot);
1485
+ mkdirSync(dir, { recursive: true });
1486
+ writeFileSync(join(dir, `${session.id}.json`), JSON.stringify(session, null, 2) + '\n', 'utf-8');
1487
+ }
1488
+ function appBuildWarnings(validation, plan) {
1489
+ const issues = Array.isArray(validation?.issues)
1490
+ ? validation.issues
1491
+ : [];
1492
+ const warnings = issues
1493
+ .filter((issue) => issue.level === 'warning')
1494
+ .map((issue) => cleanString(issue.message))
1495
+ .filter(Boolean);
1496
+ const missing = Array.isArray(plan.missingEvidence) ? plan.missingEvidence.map(cleanString).filter(Boolean) : [];
1497
+ return unique([...warnings, ...missing]);
1498
+ }
1499
+ function appBuildBlockedMessage(validation, plan) {
1500
+ const warnings = appBuildWarnings(validation, plan).slice(0, 4);
1501
+ const reviewTasks = reviewTasksFromPlan(plan).slice(0, 4);
1502
+ return [
1503
+ 'No certified DQL blocks matched strongly enough to create a stakeholder app.',
1504
+ 'DQL did not write an empty dashboard. Create or certify reusable blocks first, or select certified blocks explicitly.',
1505
+ warnings.length ? `Missing proof: ${warnings.join(' | ')}` : '',
1506
+ reviewTasks.length ? `Next review tasks: ${reviewTasks.join(' | ')}` : '',
1507
+ ].filter(Boolean).join(' ');
1508
+ }
1509
+ function reviewTasksFromPlan(plan) {
1510
+ const tasks = Array.isArray(plan.reviewTasks) ? plan.reviewTasks.map(cleanString).filter(Boolean) : [];
1511
+ const scopedReports = Array.isArray(plan.scopedReports) ? plan.scopedReports : [];
1512
+ const planning = plan.planning && typeof plan.planning === 'object' ? plan.planning : {};
1513
+ const planningReports = Array.isArray(planning.scopedReports) ? planning.scopedReports : [];
1514
+ const reportTasks = [...scopedReports, ...planningReports].flatMap((report) => {
1515
+ if (!report || typeof report !== 'object')
1516
+ return [];
1517
+ const record = report;
1518
+ const title = cleanString(record.title) || 'Scoped analysis';
1519
+ const question = cleanString(record.question);
1520
+ const evidence = Array.isArray(record.evidenceNeeded)
1521
+ ? record.evidenceNeeded.map(cleanString).filter(Boolean).join(', ')
1522
+ : '';
1523
+ return [
1524
+ `Scoped analysis "${title}": ${question || 'Review the analysis question before running it.'}`,
1525
+ evidence ? `Scoped analysis "${title}" evidence needed: ${evidence}` : '',
1526
+ ].filter(Boolean);
1527
+ });
1528
+ const pages = Array.isArray(plan.pages) ? plan.pages : [];
1529
+ const tileTasks = pages.flatMap((page) => {
1530
+ if (!page || typeof page !== 'object')
1531
+ return [];
1532
+ const tiles = Array.isArray(page.tiles) ? page.tiles : [];
1533
+ return tiles.flatMap((tile) => {
1534
+ if (!tile || typeof tile !== 'object')
1535
+ return [];
1536
+ const record = tile;
1537
+ const title = cleanString(record.title) || 'Tile';
1538
+ return Array.isArray(record.reviewTasks)
1539
+ ? record.reviewTasks.map((task) => `${title}: ${cleanString(task)}`).filter((task) => !task.endsWith(': '))
1540
+ : [];
1541
+ });
1542
+ });
1543
+ return unique([...tasks, ...reportTasks, ...tileTasks]).slice(0, 20);
1544
+ }
1545
+ function shouldRouteAppQuestionToInvestigation(question) {
1546
+ 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);
1547
+ }
1548
+ function isAppChangeQuestion(question) {
1549
+ 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);
1550
+ }
1551
+ function appChangeSuggestion(question) {
1552
+ if (/\b(chart|visual|bar|line|table|pivot|kpi)\b/i.test(question))
1553
+ return 'recommend a governed tile display change and save it in Build mode';
1554
+ if (/\b(page|dashboard)\b/i.test(question))
1555
+ return 'create or update a dashboard page as a reviewed app artifact';
1556
+ if (/\b(filter|parameter|param)\b/i.test(question))
1557
+ return 'bind the filter to compatible certified block parameters only';
1558
+ return 'prepare a reviewed app layout change';
1559
+ }
1560
+ function appAskTileContext(tile) {
1561
+ return {
1562
+ tileId: tile.i,
1563
+ title: tile.title,
1564
+ blockId: tile.block && 'blockId' in tile.block ? tile.block.blockId : undefined,
1565
+ viz: tile.viz.type,
1566
+ trustState: tile.trustState ?? tile.display?.trustState,
1567
+ reviewStatus: tile.reviewStatus ?? tile.display?.reviewStatus,
1568
+ filterBindings: tile.filterBindings,
1569
+ parameterBindings: tile.parameterBindings,
1570
+ sourceEvidence: tile.sourceEvidence,
1571
+ };
1572
+ }
1573
+ function filterBindingsForBlockSource(projectRoot, block) {
1574
+ const absPath = join(projectRoot, block.path);
1575
+ if (!existsSync(absPath))
1576
+ return [];
1577
+ const source = readFileSync(absPath, 'utf-8');
1578
+ const filterSection = sectionBody(source, 'filterBindings');
1579
+ const parameterSection = sectionBody(source, 'parameterPolicy');
1580
+ const bindings = Array.from(filterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"([^"]+)"/gm))
1581
+ .map((match) => ({ filter: match[1], binding: match[2], mode: 'predicate' }));
1582
+ const parameterNames = Array.from(parameterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"dynamic"/gm))
1583
+ .map((match) => match[1]);
1584
+ const parameterBindings = parameterNames.map((param) => ({
1585
+ filter: param,
1586
+ binding: param,
1587
+ mode: 'parameter',
1588
+ paramNames: [param],
1589
+ }));
1590
+ return uniqueFilterBindings([...bindings, ...parameterBindings]);
1591
+ }
1592
+ function sectionBody(source, sectionName) {
1593
+ const match = new RegExp(`${sectionName}\\s*\\{([\\s\\S]*?)\\n\\s*\\}`, 'm').exec(source);
1594
+ return match?.[1] ?? '';
1595
+ }
1596
+ function buildInvestigationSqlTemplateValues(projectRoot, context, selected, sourceBlockId) {
1597
+ const values = {};
1598
+ const block = resolveSelectedBlock(projectRoot, selected, sourceBlockId);
1599
+ if (block) {
1600
+ const absPath = join(projectRoot, block.path);
1601
+ if (existsSync(absPath)) {
1602
+ Object.assign(values, parseDqlParams(readFileSync(absPath, 'utf-8')));
1603
+ }
1604
+ }
1605
+ const root = asRecord(context);
1606
+ const selectedRecord = asRecord(selected);
1607
+ mergeTemplateRecord(values, root?.activeFilters);
1608
+ mergeTemplateRecord(values, root?.filters);
1609
+ mergeTemplateRecord(values, root?.variables);
1610
+ mergeTemplateRecord(values, root?.parameters);
1611
+ mergeTemplateRecord(values, root?.parameterValues);
1612
+ mergeTemplateRecord(values, selectedRecord?.activeFilters);
1613
+ mergeTemplateRecord(values, selectedRecord?.variables);
1614
+ mergeTemplateRecord(values, selectedRecord?.parameters);
1615
+ return values;
1616
+ }
1617
+ function mergeTemplateRecord(target, value) {
1618
+ const record = asRecord(value);
1619
+ if (!record)
1620
+ return;
1621
+ for (const [key, entry] of Object.entries(record)) {
1622
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
1623
+ continue;
1624
+ if (entry === undefined || entry === null || entry === '')
1625
+ continue;
1626
+ target[key] = entry;
1627
+ }
1628
+ }
1629
+ function parseDqlParams(source) {
1630
+ const body = sectionBody(source, 'params');
1631
+ const values = {};
1632
+ for (const match of body.matchAll(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$/gm)) {
1633
+ const name = match[1];
1634
+ const value = parseDqlLiteral(match[2]);
1635
+ if (value !== undefined)
1636
+ values[name] = value;
1637
+ }
1638
+ return values;
1639
+ }
1640
+ function parseDqlLiteral(raw) {
1641
+ const trimmed = raw
1642
+ .replace(/\s+#.*$/g, '')
1643
+ .replace(/\s+\/\/.*$/g, '')
1644
+ .replace(/,\s*$/g, '')
1645
+ .trim();
1646
+ if (!trimmed)
1647
+ return undefined;
1648
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed))
1649
+ return Number(trimmed);
1650
+ if (/^(true|false)$/i.test(trimmed))
1651
+ return /^true$/i.test(trimmed);
1652
+ const quoted = /^"((?:[^"\\]|\\.)*)"$/s.exec(trimmed) ?? /^'((?:[^'\\]|\\.)*)'$/s.exec(trimmed);
1653
+ if (quoted)
1654
+ return quoted[1].replace(/\\(["'\\])/g, '$1');
1655
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
1656
+ const items = splitDqlArrayValues(trimmed.slice(1, -1))
1657
+ .map(parseDqlLiteral)
1658
+ .filter((item) => item !== undefined);
1659
+ return items;
1660
+ }
1661
+ return trimmed;
1662
+ }
1663
+ function splitDqlArrayValues(value) {
1664
+ const out = [];
1665
+ let current = '';
1666
+ let quote = null;
1667
+ for (let index = 0; index < value.length; index += 1) {
1668
+ const char = value[index];
1669
+ if (quote) {
1670
+ current += char;
1671
+ if (char === quote && value[index - 1] !== '\\')
1672
+ quote = null;
1673
+ continue;
1674
+ }
1675
+ if (char === '"' || char === "'") {
1676
+ quote = char;
1677
+ current += char;
1678
+ continue;
1679
+ }
1680
+ if (char === ',') {
1681
+ if (current.trim())
1682
+ out.push(current.trim());
1683
+ current = '';
1684
+ continue;
1685
+ }
1686
+ current += char;
1687
+ }
1688
+ if (current.trim())
1689
+ out.push(current.trim());
1690
+ return out;
1691
+ }
1692
+ function renderSqlTemplateParams(sql, values) {
1693
+ const rendered = sql.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name) => {
1694
+ if (!Object.prototype.hasOwnProperty.call(values, name))
1695
+ return match;
1696
+ const literal = sqlTemplateLiteral(values[name]);
1697
+ return literal ?? match;
1698
+ });
1699
+ return { sql: rendered, unresolved: unresolvedSqlTemplateParams(rendered) };
1700
+ }
1701
+ function sqlTemplateLiteral(value) {
1702
+ if (value === undefined || value === null || value === '')
1703
+ return undefined;
1704
+ if (typeof value === 'number')
1705
+ return Number.isFinite(value) ? String(value) : undefined;
1706
+ if (typeof value === 'boolean')
1707
+ return value ? 'TRUE' : 'FALSE';
1708
+ if (Array.isArray(value)) {
1709
+ const rendered = value.map(sqlTemplateLiteral).filter((item) => Boolean(item));
1710
+ return rendered.length > 0 ? rendered.join(', ') : undefined;
1711
+ }
1712
+ const stringValue = String(value).trim();
1713
+ if (!stringValue)
1714
+ return undefined;
1715
+ if (/^-?\d+(?:\.\d+)?$/.test(stringValue))
1716
+ return stringValue;
1717
+ if (/^(true|false)$/i.test(stringValue))
1718
+ return /^true$/i.test(stringValue) ? 'TRUE' : 'FALSE';
1719
+ return sqlStringLiteral(stringValue);
1720
+ }
1721
+ function unresolvedSqlTemplateParams(sql) {
1722
+ const names = Array.from(sql.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g), (match) => match[1]);
1723
+ if (/\{\{[\s\S]*?\}\}/.test(sql))
1724
+ names.push('jinja_template');
1725
+ return Array.from(new Set(names)).sort((a, b) => a.localeCompare(b));
1726
+ }
1727
+ function uniqueFilterBindings(bindings) {
1728
+ const seen = new Set();
1729
+ const out = [];
1730
+ for (const binding of bindings) {
1731
+ const key = `${binding.filter}:${binding.binding ?? ''}:${binding.mode ?? ''}:${(binding.paramNames ?? []).join(',')}`;
1732
+ if (seen.has(key))
1733
+ continue;
1734
+ seen.add(key);
1735
+ out.push(binding);
1736
+ }
1737
+ return out;
1738
+ }
1739
+ function promoteSharedDashboardItem(item) {
1740
+ const trustState = item.trustState ?? item.display?.trustState ?? (item.block ? 'certified' : 'review_required');
1741
+ const reviewStatus = item.reviewStatus ?? item.display?.reviewStatus ?? (trustState === 'certified' ? 'certified' : 'review_required');
1742
+ const display = item.display
1743
+ ? {
1744
+ ...item.display,
1745
+ trustState,
1746
+ reviewStatus,
1747
+ }
1748
+ : undefined;
1749
+ return {
1750
+ ...item,
1751
+ ...(display ? { display } : {}),
1752
+ trustState,
1753
+ reviewStatus,
1754
+ };
1755
+ }
842
1756
  export function createAppPackage(projectRoot, input) {
843
1757
  const name = cleanString(input.name);
844
1758
  const domain = cleanString(input.domain);
@@ -1084,7 +1998,7 @@ function displayForAiPin(title, chartType, input) {
1084
1998
  defaultVisualization: chartType,
1085
1999
  allowedVisualizations: allowedVisualizationsForComponent(component, chartType),
1086
2000
  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.',
2001
+ rationale: 'AI-generated analysis pin saved as governed presentation metadata; promote the SQL to a draft block before treating it as reusable logic.',
1088
2002
  trustState: certified ? 'certified' : 'review_required',
1089
2003
  reviewStatus: certified ? 'certified' : 'review_required',
1090
2004
  };
@@ -1340,8 +2254,12 @@ function normalizeConversationMessages(messages) {
1340
2254
  async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1341
2255
  const question = cleanString(input.question) || investigation.question;
1342
2256
  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;
2257
+ const intent = normalizeInvestigationIntent('intent' in input ? input.intent : investigation.intent, question, context);
2258
+ const title = cleanString('title' in input ? input.title : undefined)
2259
+ || cleanString(investigation.title)
2260
+ || titleFromInvestigation(question, selectedBlockContext(context));
2261
+ const rebuildFromCertified = 'repairMode' in input && input.repairMode === 'rebuild_from_certified';
2262
+ let generatedSql = cleanString(input.generatedSql) || (rebuildFromCertified ? undefined : investigation.generatedSql);
1345
2263
  const lastRunAt = new Date().toISOString();
1346
2264
  storage.updateAppInvestigation(investigation.id, {
1347
2265
  question,
@@ -1361,6 +2279,11 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1361
2279
  const baselineGap = intent === 'diagnose_change' && hasSelectedRows(selected) && !hasComparableTimeBaseline(selected);
1362
2280
  const sourceTileId = investigation.sourceTileId ?? selectedContextString(context, 'tileId');
1363
2281
  const sourceBlockId = investigation.sourceBlockId ?? selectedContextString(context, 'blockId');
2282
+ const routeDecision = appAnalysisRouteDecisionFromContext(context, intent, selected, question);
2283
+ const sqlTemplateValues = buildInvestigationSqlTemplateValues(ctx.projectRoot, context, selected, sourceBlockId);
2284
+ if (generatedSql) {
2285
+ generatedSql = renderSqlTemplateParams(generatedSql, sqlTemplateValues).sql;
2286
+ }
1364
2287
  const deterministicGeneration = generatedSql || baselineGap
1365
2288
  ? undefined
1366
2289
  : buildDeterministicInvestigationSql(ctx.projectRoot, {
@@ -1368,11 +2291,12 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1368
2291
  intent,
1369
2292
  selected,
1370
2293
  sourceBlockId,
2294
+ context,
1371
2295
  });
1372
2296
  generatedSql = generatedSql || deterministicGeneration?.sql;
1373
- const agentGeneration = generatedSql || baselineGap
1374
- ? undefined
1375
- : await generateInvestigationSql(ctx, {
2297
+ let agentGeneration;
2298
+ if (!generatedSql && !baselineGap) {
2299
+ agentGeneration = await generateInvestigationSql(ctx, {
1376
2300
  appId: investigation.appId,
1377
2301
  dashboardId: investigation.dashboardId ?? selectedString(context, 'dashboardId'),
1378
2302
  sourceTileId,
@@ -1381,18 +2305,62 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1381
2305
  question,
1382
2306
  intent,
1383
2307
  context,
2308
+ mode: 'sql_and_memo',
1384
2309
  });
2310
+ }
1385
2311
  generatedSql = generatedSql || cleanString(agentGeneration?.sql);
1386
2312
  const generationError = cleanString(agentGeneration?.executionError);
1387
2313
  const sqlEvidence = agentGeneration?.result
1388
2314
  ? { preview: buildGeneratedSqlPreview(agentGeneration.result, generatedSql), error: generationError || undefined }
1389
2315
  : await runGeneratedSqlPreview(ctx, generatedSql);
1390
2316
  const sqlError = sqlEvidence.error ?? generationError;
2317
+ const sqlErrorKind = classifySqlPreviewError(sqlError);
1391
2318
  if (sqlEvidence.preview) {
1392
2319
  previews.unshift(sqlEvidence.preview);
1393
2320
  metricSnapshot = buildPreviewMetricSnapshot(sqlEvidence.preview, selectedString(selected, 'title'));
1394
2321
  driverCards = buildPreviewDriverCards(sqlEvidence.preview, intent);
1395
2322
  }
2323
+ const hasReportEvidence = previews.length > 0 || driverCards.length > 0 || Object.keys(metricSnapshot).length > 0;
2324
+ const reportSqlError = shouldSurfaceSqlPreviewIssue({
2325
+ sqlError,
2326
+ sqlErrorKind,
2327
+ generatedSql,
2328
+ hasReportEvidence,
2329
+ }) ? sqlError : undefined;
2330
+ const fallbackSummary = baselineGap
2331
+ ? buildMissingBaselineSummary(question, selected)
2332
+ : buildInvestigationSummary(intent, question, selected, metricSnapshot, driverCards);
2333
+ const recommendation = baselineGap
2334
+ ? buildMissingBaselineRecommendation(selected)
2335
+ : buildInvestigationRecommendation(intent, selected, reportSqlError, sqlErrorKind);
2336
+ const memoGeneration = !baselineGap && shouldRequestProviderMemo({
2337
+ ctx,
2338
+ generatedSql,
2339
+ agentGeneration,
2340
+ hasReportEvidence,
2341
+ })
2342
+ ? await generateInvestigationSql(ctx, {
2343
+ appId: investigation.appId,
2344
+ dashboardId: investigation.dashboardId ?? selectedString(context, 'dashboardId'),
2345
+ sourceTileId,
2346
+ sourceBlockId,
2347
+ title: investigation.title,
2348
+ question,
2349
+ intent,
2350
+ context,
2351
+ mode: 'memo_only',
2352
+ generatedSql,
2353
+ metrics: metricSnapshot,
2354
+ drivers: driverCards,
2355
+ resultPreviews: previews.slice(0, 4),
2356
+ summaryHint: fallbackSummary,
2357
+ recommendationHint: recommendation,
2358
+ sqlError: reportSqlError,
2359
+ sqlErrorKind,
2360
+ hasReportEvidence,
2361
+ })
2362
+ : undefined;
2363
+ const narrativeGeneration = cleanString(memoGeneration?.answer) ? memoGeneration : agentGeneration;
1396
2364
  const evidence = {
1397
2365
  trustStatus: buildInvestigationTrust(investigation, selected, sqlError),
1398
2366
  planner: {
@@ -1402,11 +2370,16 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1402
2370
  generatedSql: generatedSql || undefined,
1403
2371
  sqlExecuted: Boolean(sqlEvidence.preview),
1404
2372
  sqlError,
2373
+ sqlErrorKind,
1405
2374
  generationSource: baselineGap ? 'missing_baseline' : deterministicGeneration ? 'selected_block_metadata' : agentGeneration?.providerUsed ? 'ai_provider' : generatedSql ? 'provided_sql' : 'context_only',
2375
+ repairMode: rebuildFromCertified ? 'rebuild_from_certified' : undefined,
1406
2376
  baselineGap,
1407
2377
  sourceBlockPath: deterministicGeneration?.sourceBlockPath,
1408
2378
  sourceBlockName: deterministicGeneration?.sourceBlockName,
1409
2379
  providerUsed: agentGeneration?.providerUsed,
2380
+ memoProviderUsed: memoGeneration?.providerUsed,
2381
+ memoSource: cleanString(narrativeGeneration?.answer) ? 'ai_provider' : 'deterministic_template',
2382
+ routeDecision,
1410
2383
  },
1411
2384
  certifiedContext: {
1412
2385
  appId: investigation.appId,
@@ -1418,36 +2391,48 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1418
2391
  sourceBlockPath: deterministicGeneration?.sourceBlockPath ?? selectedString(selected, 'blockPath'),
1419
2392
  certificationStatus: selectedString(selected, 'certificationStatus'),
1420
2393
  },
2394
+ routeDecision,
1421
2395
  assumptions: [
1422
2396
  ...investigationAssumptions(intent, selected, generatedSql, sqlError),
1423
2397
  ...(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
2398
  ],
1425
2399
  context,
1426
- agentEvidence: agentGeneration?.evidence,
1427
- analysisPlan: agentGeneration?.analysisPlan,
1428
- citations: agentGeneration?.citations,
2400
+ agentEvidence: narrativeGeneration?.evidence ?? agentGeneration?.evidence,
2401
+ analysisPlan: narrativeGeneration?.analysisPlan ?? agentGeneration?.analysisPlan,
2402
+ citations: narrativeGeneration?.citations ?? agentGeneration?.citations,
1429
2403
  };
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);
2404
+ const summary = cleanString(narrativeGeneration?.answer) || fallbackSummary;
2405
+ const reportSections = buildInvestigationReportSections({
2406
+ intent,
2407
+ question,
2408
+ context,
2409
+ selected,
2410
+ metrics: metricSnapshot,
2411
+ drivers: driverCards,
2412
+ summary,
2413
+ recommendation,
2414
+ agentAnswer: narrativeGeneration?.answer,
2415
+ hasReportEvidence,
2416
+ sqlError: reportSqlError,
2417
+ sqlErrorKind,
2418
+ baselineGap,
2419
+ });
1436
2420
  return storage.updateAppInvestigation(investigation.id, {
1437
- title: cleanString(input.question) ? titleFromInvestigation(question, selected) : investigation.title,
2421
+ title,
1438
2422
  question,
1439
2423
  intent,
1440
2424
  context,
1441
- status: sqlEvidence.fatal ? 'error' : 'ready',
2425
+ status: sqlEvidence.fatal && !hasReportEvidence ? 'error' : 'ready',
1442
2426
  summary,
1443
2427
  recommendation,
1444
2428
  metrics: metricSnapshot,
1445
2429
  driverCards,
1446
2430
  resultPreviews: previews,
1447
2431
  evidence,
2432
+ reportSections,
1448
2433
  generatedSql,
1449
2434
  reviewStatus: 'needs_review',
1450
- error: sqlError ?? '',
2435
+ error: reportSqlError ?? '',
1451
2436
  lastRunAt,
1452
2437
  }) ?? investigation;
1453
2438
  }
@@ -1460,6 +2445,94 @@ async function runAppInvestigation(ctx, storage, investigation, input = {}) {
1460
2445
  }) ?? investigation;
1461
2446
  }
1462
2447
  }
2448
+ function scheduleAppInvestigationRun(ctx, appId, investigationId, input) {
2449
+ const runContext = { ...ctx };
2450
+ setTimeout(() => {
2451
+ void (async () => {
2452
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(runContext.projectRoot));
2453
+ try {
2454
+ const current = storage.getAppInvestigation(investigationId);
2455
+ if (!current || current.appId !== appId)
2456
+ return;
2457
+ await runAppInvestigation(runContext, storage, current, input);
2458
+ }
2459
+ finally {
2460
+ storage.close();
2461
+ }
2462
+ })();
2463
+ }, 0);
2464
+ }
2465
+ function createOrReuseAppInvestigation(storage, input) {
2466
+ if (!cleanString(input.generatedSql)) {
2467
+ const reusable = findReusableAppInvestigation(storage, {
2468
+ appId: input.appId,
2469
+ dashboardId: input.dashboardId,
2470
+ sourceTileId: input.sourceTileId,
2471
+ sourceBlockId: input.sourceBlockId,
2472
+ question: input.question,
2473
+ intent: input.intent,
2474
+ context: input.context,
2475
+ });
2476
+ if (reusable) {
2477
+ return storage.updateAppInvestigation(reusable.id, {
2478
+ title: input.title ?? reusable.title,
2479
+ question: input.question,
2480
+ intent: input.intent ?? reusable.intent,
2481
+ context: input.context,
2482
+ dashboardId: input.dashboardId,
2483
+ sourceTileId: input.sourceTileId,
2484
+ sourceBlockId: input.sourceBlockId,
2485
+ }) ?? reusable;
2486
+ }
2487
+ }
2488
+ return storage.createAppInvestigation(input);
2489
+ }
2490
+ function findReusableAppInvestigation(storage, input) {
2491
+ const storageWithMethod = storage;
2492
+ if (typeof storageWithMethod.findReusableAppInvestigation === 'function') {
2493
+ return storageWithMethod.findReusableAppInvestigation(input);
2494
+ }
2495
+ const target = appInvestigationReuseFingerprint(input);
2496
+ return storage.listAppInvestigations(input.appId, input.dashboardId).find((item) => {
2497
+ if (item.reviewStatus === 'rejected')
2498
+ return false;
2499
+ return appInvestigationReuseFingerprint(item) === target;
2500
+ }) ?? null;
2501
+ }
2502
+ function appInvestigationReuseFingerprint(input) {
2503
+ return [
2504
+ normalizeFingerprintString(input.appId),
2505
+ normalizeFingerprintString(input.dashboardId),
2506
+ normalizeFingerprintString(input.sourceTileId),
2507
+ normalizeFingerprintString(input.sourceBlockId),
2508
+ normalizeFingerprintString(input.question),
2509
+ normalizeFingerprintString(input.intent ?? 'driver_breakdown'),
2510
+ stableInvestigationFingerprintValue(input.context),
2511
+ ].join('|');
2512
+ }
2513
+ function normalizeFingerprintString(value) {
2514
+ return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').toLowerCase() : '';
2515
+ }
2516
+ function stableInvestigationFingerprintValue(value) {
2517
+ if (value === undefined || value === null)
2518
+ return '';
2519
+ return JSON.stringify(stableInvestigationFingerprintObject(value));
2520
+ }
2521
+ function stableInvestigationFingerprintObject(value) {
2522
+ if (Array.isArray(value))
2523
+ return value.map(stableInvestigationFingerprintObject);
2524
+ if (!value || typeof value !== 'object') {
2525
+ return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : value;
2526
+ }
2527
+ const record = value;
2528
+ const out = {};
2529
+ for (const key of Object.keys(record).sort()) {
2530
+ if (/^(nonce|timestamp|createdAt|updatedAt|lastRunAt)$/i.test(key))
2531
+ continue;
2532
+ out[key] = stableInvestigationFingerprintObject(record[key]);
2533
+ }
2534
+ return out;
2535
+ }
1463
2536
  function normalizeInvestigationIntent(value, question, context) {
1464
2537
  if (value === 'diagnose_change'
1465
2538
  || value === 'driver_breakdown'
@@ -1540,14 +2613,25 @@ function buildMetricSnapshot(selected) {
1540
2613
  context: 'Metric values were not available in the selected tile sample.',
1541
2614
  };
1542
2615
  }
1543
- const baselineValue = typeofNumber(rows[0]?.[metricColumn]);
1544
- const currentValue = typeofNumber(rows[rows.length - 1]?.[metricColumn]) ?? baselineValue;
2616
+ const topRow = rows.find((row) => typeofNumber(row[metricColumn]) !== null);
2617
+ const comparisonRow = rows.slice(1).find((row) => typeofNumber(row[metricColumn]) !== null);
2618
+ const dimensionColumn = columns.find((column) => column !== metricColumn && rows.some((row) => typeof row[column] === 'string'));
2619
+ const currentValue = typeofNumber(topRow?.[metricColumn]);
2620
+ const baselineValue = typeofNumber(comparisonRow?.[metricColumn]);
1545
2621
  const delta = currentValue !== null && baselineValue !== null ? currentValue - baselineValue : undefined;
2622
+ const topLabel = dimensionColumn && topRow ? cleanString(topRow[dimensionColumn]) : '';
2623
+ const comparisonLabel = dimensionColumn && comparisonRow ? cleanString(comparisonRow[dimensionColumn]) : '';
1546
2624
  return {
1547
2625
  metric: metricColumn,
1548
2626
  currentValue,
1549
2627
  baselineValue,
1550
2628
  delta,
2629
+ currentLabel: 'Top value',
2630
+ baselineLabel: 'Next comparison',
2631
+ deltaLabel: 'Top gap',
2632
+ currentDetail: topLabel ? `${topLabel} / ${metricColumn}` : 'highest ranked evidence row',
2633
+ baselineDetail: comparisonLabel ? `${comparisonLabel} / ${metricColumn}` : 'next comparable evidence row',
2634
+ deltaDetail: comparisonLabel ? `difference between ${topLabel || 'top row'} and ${comparisonLabel}` : 'difference to next comparison',
1551
2635
  rowsReviewed: rows.length,
1552
2636
  context: selectedString(selected, 'title') ?? 'Selected dashboard tile',
1553
2637
  };
@@ -1708,8 +2792,12 @@ function buildDeterministicInvestigationSql(projectRoot, input) {
1708
2792
  if (!block)
1709
2793
  return undefined;
1710
2794
  const source = readFileSync(join(projectRoot, block.path), 'utf-8');
1711
- const blockSql = extractDqlQuery(source);
1712
- if (!blockSql || /\{\{/.test(blockSql) || !isReadOnlySql(blockSql))
2795
+ const rawBlockSql = extractDqlQuery(source);
2796
+ if (!rawBlockSql)
2797
+ return undefined;
2798
+ const rendered = renderSqlTemplateParams(rawBlockSql, buildInvestigationSqlTemplateValues(projectRoot, input.context, input.selected, input.sourceBlockId));
2799
+ const blockSql = rendered.sql;
2800
+ if (rendered.unresolved.length > 0 || /\{\{/.test(blockSql) || !isReadOnlySql(blockSql))
1713
2801
  return undefined;
1714
2802
  const rows = selectedRows(input.selected);
1715
2803
  const columns = selectedColumns(input.selected, rows);
@@ -1928,7 +3016,14 @@ async function generateInvestigationSql(ctx, input) {
1928
3016
  if (!ctx.generateInvestigationSql)
1929
3017
  return undefined;
1930
3018
  try {
1931
- return await ctx.generateInvestigationSql(input);
3019
+ const timeoutMs = appResearchAiTimeoutMs();
3020
+ const result = await boundedPromise(ctx.generateInvestigationSql(input), timeoutMs);
3021
+ if (result.timedOut) {
3022
+ return {
3023
+ executionError: `AI SQL generation timed out after ${Math.round(timeoutMs / 1000)}s. DQL continued with deterministic app evidence and kept the analysis review-required.`,
3024
+ };
3025
+ }
3026
+ return result.value;
1932
3027
  }
1933
3028
  catch (err) {
1934
3029
  return {
@@ -1936,10 +3031,24 @@ async function generateInvestigationSql(ctx, input) {
1936
3031
  };
1937
3032
  }
1938
3033
  }
3034
+ function shouldRequestProviderMemo(input) {
3035
+ if (!input.ctx.generateInvestigationSql)
3036
+ return false;
3037
+ if (cleanString(input.agentGeneration?.answer))
3038
+ return false;
3039
+ return input.hasReportEvidence || Boolean(cleanString(input.generatedSql));
3040
+ }
1939
3041
  async function runGeneratedSqlPreview(ctx, generatedSql) {
1940
3042
  const sql = cleanString(generatedSql);
1941
3043
  if (!sql)
1942
3044
  return {};
3045
+ const unresolved = unresolvedSqlTemplateParams(sql);
3046
+ if (unresolved.length > 0) {
3047
+ return {
3048
+ error: `Generated SQL was not run because unresolved DQL parameters remain: ${unresolved.join(', ')}.`,
3049
+ fatal: false,
3050
+ };
3051
+ }
1943
3052
  if (!isReadOnlySql(sql)) {
1944
3053
  return {
1945
3054
  error: 'Generated SQL was not run because it was not a read-only SELECT or WITH query.',
@@ -1953,14 +3062,21 @@ async function runGeneratedSqlPreview(ctx, generatedSql) {
1953
3062
  };
1954
3063
  }
1955
3064
  try {
1956
- const result = await ctx.executeSql(boundedPreviewSql(sql));
3065
+ const timeoutMs = appResearchPreviewTimeoutMs();
3066
+ const result = await boundedPromise(ctx.executeSql(boundedPreviewSql(sql)), timeoutMs);
3067
+ if (result.timedOut) {
3068
+ return {
3069
+ 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.`,
3070
+ fatal: false,
3071
+ };
3072
+ }
1957
3073
  return {
1958
3074
  preview: {
1959
3075
  id: 'generated-sql-preview',
1960
3076
  title: 'Generated SQL preview',
1961
3077
  kind: 'table',
1962
3078
  reviewRequired: true,
1963
- result,
3079
+ result: result.value,
1964
3080
  },
1965
3081
  };
1966
3082
  }
@@ -1971,6 +3087,33 @@ async function runGeneratedSqlPreview(ctx, generatedSql) {
1971
3087
  };
1972
3088
  }
1973
3089
  }
3090
+ async function boundedPromise(promise, timeoutMs) {
3091
+ let timer;
3092
+ try {
3093
+ return await Promise.race([
3094
+ promise.then((value) => ({ timedOut: false, value })),
3095
+ new Promise((resolve) => {
3096
+ timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
3097
+ }),
3098
+ ]);
3099
+ }
3100
+ finally {
3101
+ if (timer)
3102
+ clearTimeout(timer);
3103
+ }
3104
+ }
3105
+ function appResearchPreviewTimeoutMs() {
3106
+ const raw = Number(process.env.DQL_APP_RESEARCH_PREVIEW_TIMEOUT_MS);
3107
+ if (Number.isFinite(raw) && raw >= 500)
3108
+ return Math.min(raw, 120000);
3109
+ return 12000;
3110
+ }
3111
+ function appResearchAiTimeoutMs() {
3112
+ const raw = Number(process.env.DQL_APP_RESEARCH_AI_TIMEOUT_MS);
3113
+ if (Number.isFinite(raw) && raw >= 500)
3114
+ return Math.min(raw, 120000);
3115
+ return 12000;
3116
+ }
1974
3117
  function buildGeneratedSqlPreview(result, generatedSql) {
1975
3118
  const record = asRecord(result);
1976
3119
  const rawRows = Array.isArray(record?.rows) ? record.rows : [];
@@ -2000,6 +3143,36 @@ function buildGeneratedSqlPreview(result, generatedSql) {
2000
3143
  },
2001
3144
  };
2002
3145
  }
3146
+ function classifySqlPreviewError(error) {
3147
+ const value = cleanString(error);
3148
+ if (!value)
3149
+ return undefined;
3150
+ if (/\bAI SQL generation timed out\b/i.test(value))
3151
+ return 'ai_generation_timeout';
3152
+ if (/\b(warehouse|suspended|resume|authenticat|permission|privilege|network|connection|connect|host cannot execute|cannot execute|not configured)\b/i.test(value)) {
3153
+ return 'runtime_unavailable';
3154
+ }
3155
+ if (/\b(timed out|timeout)\b/i.test(value))
3156
+ return 'timeout';
3157
+ if (/\b(not a read-only|not read-only|insert|update|delete|drop|alter|create|merge|truncate|grant|revoke)\b/i.test(value)) {
3158
+ return 'safety';
3159
+ }
3160
+ if (/\b(unresolved|parameter|invalid identifier|syntax|compilation|parse|unknown column|does not exist|ambiguous|not found)\b/i.test(value)) {
3161
+ return 'sql_repair';
3162
+ }
3163
+ return 'unknown';
3164
+ }
3165
+ function shouldSurfaceSqlPreviewIssue(input) {
3166
+ const error = cleanString(input.sqlError);
3167
+ if (!error)
3168
+ return false;
3169
+ if (input.hasReportEvidence &&
3170
+ !cleanString(input.generatedSql) &&
3171
+ (input.sqlErrorKind === 'unknown' || /\bAI provider did not return a governed answer\b/i.test(error))) {
3172
+ return false;
3173
+ }
3174
+ return true;
3175
+ }
2003
3176
  function isReadOnlySql(sql) {
2004
3177
  const trimmed = stripLeadingSqlComments(sql).replace(/;+\s*$/g, '');
2005
3178
  if (!/^(select|with)\b/i.test(trimmed))
@@ -2026,20 +3199,20 @@ function boundedPreviewSql(sql) {
2026
3199
  }
2027
3200
  function buildInvestigationTrust(investigation, selected, sqlError) {
2028
3201
  return {
2029
- label: 'AI-generated research',
3202
+ label: 'AI-generated analysis',
2030
3203
  uncertified: true,
2031
3204
  reviewStatus: 'needs_review',
2032
3205
  certifiedContext: selectedString(selected, 'certificationStatus') === 'certified' ? 'selected tile is certified' : 'selected tile certification needs review',
2033
3206
  sourceBlockId: investigation.sourceBlockId ?? selectedString(selected, 'blockId'),
2034
3207
  sourceTileId: investigation.sourceTileId ?? selectedString(selected, 'tileId'),
2035
3208
  caveats: [
2036
- 'Investigation output is not certified until a reviewer promotes or certifies the generated block.',
3209
+ 'Analysis output is not certified until a reviewer promotes or certifies the generated block.',
2037
3210
  ...(sqlError ? [`SQL preview caveat: ${sqlError}`] : []),
2038
3211
  ],
2039
3212
  };
2040
3213
  }
2041
3214
  function investigationSteps(intent) {
2042
- const common = ['trust check', 'evidence capture'];
3215
+ const common = ['trust check', 'proof capture'];
2043
3216
  if (intent === 'trust_gap_review')
2044
3217
  return ['certification review', 'lineage review', 'owner and caveat check', ...common];
2045
3218
  if (intent === 'entity_drilldown')
@@ -2062,30 +3235,477 @@ function investigationAssumptions(intent, selected, generatedSql, sqlError) {
2062
3235
  }
2063
3236
  function buildInvestigationSummary(intent, question, selected, metrics, drivers) {
2064
3237
  const target = selectedString(selected, 'title') ?? 'this dashboard question';
2065
- const delta = typeof metrics.delta === 'number' ? ` Delta from the sampled baseline is ${formatContribution(metrics.delta)}.` : '';
3238
+ const cleanQuestion = cleanInvestigationQuestion(question);
3239
+ const metricName = cleanString(metrics.metric) || 'selected metric';
3240
+ const currentValue = typeofNumber(metrics.currentValue);
3241
+ const baselineValue = typeofNumber(metrics.baselineValue);
3242
+ const deltaValue = typeofNumber(metrics.delta);
3243
+ const currentDetail = cleanString(metrics.currentDetail);
3244
+ const baselineDetail = cleanString(metrics.baselineDetail);
3245
+ const currentLabel = currentDetail ? currentDetail.replace(/\s*\/\s*[^/]+$/, '') : cleanString(metrics.currentLabel) || 'Current result';
3246
+ const baselineLabel = baselineDetail ? baselineDetail.replace(/\s*\/\s*[^/]+$/, '') : cleanString(metrics.baselineLabel) || 'comparison';
3247
+ const valueSentence = currentValue !== null
3248
+ ? `${currentLabel} leads on ${metricName} with ${formatMetricValue(currentValue)}${baselineValue !== null ? ` versus ${formatMetricValue(baselineValue)} for ${baselineLabel}` : ''}${deltaValue !== null ? `, a gap of ${formatMetricValue(deltaValue)}` : ''}.`
3249
+ : '';
2066
3250
  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}`;
3251
+ 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.'}`;
3252
+ }
3253
+ const driverTitle = cleanString(drivers[0]?.title);
3254
+ const driver = driverTitle && driverTitle !== currentLabel
3255
+ ? ` ${driverTitle} is the strongest visible driver in the bounded preview.`
3256
+ : '';
3257
+ const reviewBoundary = ' This remains review-required until SQL, grain, filters, and lineage are confirmed.';
3258
+ if (valueSentence) {
3259
+ return `${valueSentence}${driver}${reviewBoundary}`.trim();
2068
3260
  }
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}`;
3261
+ const fallbackSubject = cleanQuestion || target;
3262
+ return `This analysis investigates ${fallbackSubject}. The preview is bounded to the selected app context and needs analyst review before it becomes governed business logic.`;
3263
+ }
3264
+ function cleanInvestigationQuestion(value) {
3265
+ return cleanString(value)
3266
+ .replace(/^\/(ask|research|report|analy[sz]e|analysis|proof|evidence|validate|verify|add\s+block|create\s+block|draft\s+block|block)\b/i, '')
3267
+ .replace(/^(Analysis goal|Analysis question|Research question|Evidence question|Validation question|Reusable block goal|Business question|Question):\s*/i, '')
3268
+ .replace(/\bCurrent app filters:\s*[\s\S]*$/i, '')
3269
+ .replace(/\s+/g, ' ')
3270
+ .replace(/\s*[.?!]+$/, '')
3271
+ .trim();
2071
3272
  }
2072
3273
  function buildMissingBaselineSummary(question, selected) {
2073
3274
  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.`;
3275
+ 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
3276
  }
2076
3277
  function buildMissingBaselineRecommendation(selected) {
2077
3278
  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.`;
3279
+ 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
3280
  }
2080
- function buildInvestigationRecommendation(intent, selected, sqlError) {
3281
+ function buildInvestigationRecommendation(intent, selected, sqlError, sqlErrorKind) {
3282
+ if (sqlErrorKind === 'runtime_unavailable')
3283
+ return 'Resume or choose an active warehouse, then refresh the analysis. Keep the generated SQL review-required until a preview runs successfully.';
3284
+ if (sqlErrorKind === 'ai_generation_timeout')
3285
+ return 'Use the certified app result as the current answer, then retry AI SQL generation or draft the scoped SQL manually before promotion.';
3286
+ if (sqlErrorKind === 'timeout')
3287
+ return 'Simplify the generated SQL or narrow the app filters, then refresh the bounded preview before promotion.';
3288
+ if (sqlErrorKind === 'safety')
3289
+ return 'Rewrite the SQL as a read-only SELECT/WITH query before any preview or promotion.';
2081
3290
  if (sqlError)
2082
3291
  return 'Review the generated SQL or add a certified drilldown block before promoting this result.';
2083
3292
  if (intent === 'trust_gap_review')
2084
- return 'Use the certified tile for reporting, and promote only the reviewed gaps into a draft block.';
3293
+ return 'Use the certified tile for stakeholder reporting, and promote only the reviewed gaps into a draft block.';
2085
3294
  if (!selected)
2086
3295
  return 'Select a dashboard tile or provide SQL so DQL can rank drivers with stronger evidence.';
2087
3296
  return 'Review the driver evidence, then pin the useful answer or promote the SQL path into a draft DQL block.';
2088
3297
  }
3298
+ function buildInvestigationReportSections(input) {
3299
+ const actionMode = cleanString(asRecord(input.context)?.actionMode);
3300
+ const sourceName = selectedString(input.selected, 'title') ?? selectedString(input.selected, 'blockId') ?? 'selected app result';
3301
+ const topNumber = reportMetricBullet(input.metrics, 'current');
3302
+ const comparison = reportMetricBullet(input.metrics, 'baseline');
3303
+ const delta = reportMetricBullet(input.metrics, 'delta');
3304
+ const keyBullets = [topNumber, comparison, delta].filter((item) => Boolean(item));
3305
+ const driver = cleanString(input.drivers[0]?.title);
3306
+ const driverValue = cleanString(input.drivers[0]?.contribution ?? input.drivers[0]?.value ?? input.drivers[0]?.metric);
3307
+ const nextDriver = cleanString(input.drivers[1]?.title);
3308
+ const interpretation = reportInterpretationForIntent({
3309
+ intent: input.intent,
3310
+ actionMode,
3311
+ sourceName,
3312
+ driver,
3313
+ driverValue,
3314
+ nextDriver,
3315
+ baselineGap: input.baselineGap,
3316
+ });
3317
+ const interpretationMeta = reportInterpretationMeta(input.intent, actionMode);
3318
+ const providerSections = extractProviderReportSections(input.agentAnswer);
3319
+ if (providerSections.length >= 2) {
3320
+ const sections = [...providerSections];
3321
+ if (keyBullets.length > 0 && !hasReportSection(sections, /\b(number|metric|kpi|value)\b/i)) {
3322
+ sections.push({
3323
+ id: 'key-numbers',
3324
+ kind: 'key_numbers',
3325
+ title: keyNumbersTitleForIntent(input.intent),
3326
+ body: keyNumbersBodyForIntent(input.intent),
3327
+ tone: 'insight',
3328
+ bullets: keyBullets,
3329
+ evidenceRefs: reportEvidenceRefs(input.selected),
3330
+ });
3331
+ }
3332
+ if (input.baselineGap && !hasReportSection(sections, /\b(missing|baseline|comparison)\b/i)) {
3333
+ sections.push({
3334
+ id: 'missing-comparison',
3335
+ kind: 'custom',
3336
+ title: 'Missing comparison',
3337
+ 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.',
3338
+ tone: 'warning',
3339
+ evidenceRefs: reportEvidenceRefs(input.selected),
3340
+ });
3341
+ }
3342
+ const previewIssue = input.hasReportEvidence && input.sqlErrorKind === 'runtime_unavailable'
3343
+ ? undefined
3344
+ : reportPreviewIssue(input.sqlError, input.sqlErrorKind);
3345
+ if (previewIssue && !hasReportSection(sections, /\b(sql|preview|repair|warehouse|runtime)\b/i)) {
3346
+ sections.push({
3347
+ id: previewIssue.id,
3348
+ kind: 'custom',
3349
+ title: previewIssue.title,
3350
+ body: previewIssue.body,
3351
+ tone: previewIssue.tone,
3352
+ });
3353
+ }
3354
+ if (!hasReportSection(sections, /\b(next|recommend|action)\b/i)) {
3355
+ sections.push({
3356
+ id: 'recommended-next-step',
3357
+ kind: 'recommended_next_step',
3358
+ title: 'Recommended next step',
3359
+ body: input.recommendation,
3360
+ tone: input.sqlError ? 'warning' : 'neutral',
3361
+ });
3362
+ }
3363
+ if (!hasReportSection(sections, /\b(review boundary|review-required|governed boundary|trust boundary)\b/i)) {
3364
+ sections.push({
3365
+ id: 'review-boundary',
3366
+ kind: 'review_boundary',
3367
+ title: 'Review boundary',
3368
+ 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.',
3369
+ tone: 'review',
3370
+ });
3371
+ }
3372
+ return sections.filter((section) => cleanString(section.body));
3373
+ }
3374
+ const sections = [
3375
+ {
3376
+ id: 'executive-answer',
3377
+ kind: 'executive_answer',
3378
+ title: 'Executive answer',
3379
+ body: input.summary,
3380
+ tone: 'answer',
3381
+ evidenceRefs: reportEvidenceRefs(input.selected),
3382
+ },
3383
+ {
3384
+ id: interpretationMeta.id,
3385
+ kind: interpretationMeta.kind,
3386
+ title: interpretationMeta.title,
3387
+ body: interpretation,
3388
+ tone: interpretationMeta.tone,
3389
+ evidenceRefs: reportEvidenceRefs(input.selected),
3390
+ },
3391
+ ];
3392
+ if (keyBullets.length > 0) {
3393
+ sections.push({
3394
+ id: 'key-numbers',
3395
+ kind: 'key_numbers',
3396
+ title: keyNumbersTitleForIntent(input.intent),
3397
+ body: keyNumbersBodyForIntent(input.intent),
3398
+ tone: 'insight',
3399
+ bullets: keyBullets,
3400
+ evidenceRefs: reportEvidenceRefs(input.selected),
3401
+ });
3402
+ }
3403
+ if (input.baselineGap) {
3404
+ sections.push({
3405
+ id: 'missing-comparison',
3406
+ kind: 'custom',
3407
+ title: 'Missing comparison',
3408
+ 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.',
3409
+ tone: 'warning',
3410
+ evidenceRefs: reportEvidenceRefs(input.selected),
3411
+ });
3412
+ }
3413
+ const previewIssue = input.hasReportEvidence && input.sqlErrorKind === 'runtime_unavailable'
3414
+ ? undefined
3415
+ : reportPreviewIssue(input.sqlError, input.sqlErrorKind);
3416
+ if (previewIssue) {
3417
+ sections.push({
3418
+ id: previewIssue.id,
3419
+ kind: 'custom',
3420
+ title: previewIssue.title,
3421
+ body: previewIssue.body,
3422
+ tone: previewIssue.tone,
3423
+ });
3424
+ }
3425
+ sections.push({
3426
+ id: 'recommended-next-step',
3427
+ kind: 'recommended_next_step',
3428
+ title: 'Recommended next step',
3429
+ body: input.recommendation,
3430
+ tone: input.sqlError ? 'warning' : 'neutral',
3431
+ }, {
3432
+ id: 'review-boundary',
3433
+ kind: 'review_boundary',
3434
+ title: 'Review boundary',
3435
+ 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.',
3436
+ tone: 'review',
3437
+ });
3438
+ return sections.filter((section) => cleanString(section.body));
3439
+ }
3440
+ function extractProviderReportSections(answer) {
3441
+ const raw = cleanString(answer);
3442
+ if (!raw)
3443
+ return [];
3444
+ const text = raw
3445
+ .replace(/```[\s\S]*?```/g, '')
3446
+ .replace(/^\s*(SQL|Query)\s*:.*$/gim, '')
3447
+ .trim();
3448
+ const matches = Array.from(text.matchAll(/^#{2,4}\s+(.+)$/gm));
3449
+ if (matches.length < 2)
3450
+ return [];
3451
+ const sections = [];
3452
+ const seen = new Set();
3453
+ for (let index = 0; index < matches.length; index += 1) {
3454
+ const match = matches[index];
3455
+ const title = cleanProviderReportTitle(match[1]);
3456
+ const next = matches[index + 1];
3457
+ const bodyText = text.slice((match.index ?? 0) + match[0].length, next?.index ?? text.length).trim();
3458
+ if (!title || !bodyText || isTraceOnlyProviderSection(title))
3459
+ continue;
3460
+ const id = slugify(title) || `section-${index + 1}`;
3461
+ if (seen.has(id))
3462
+ continue;
3463
+ seen.add(id);
3464
+ const parsed = parseProviderSectionBody(bodyText);
3465
+ if (!parsed.body)
3466
+ continue;
3467
+ sections.push({
3468
+ id,
3469
+ kind: providerReportKind(title),
3470
+ title,
3471
+ body: parsed.body,
3472
+ bullets: parsed.bullets,
3473
+ tone: providerReportTone(title),
3474
+ });
3475
+ }
3476
+ return sections.slice(0, 8);
3477
+ }
3478
+ function cleanProviderReportTitle(value) {
3479
+ return cleanString(value)
3480
+ .replace(/\*\*/g, '')
3481
+ .replace(/[::]\s*$/, '')
3482
+ .replace(/^\d+[.)]\s*/, '')
3483
+ .trim()
3484
+ .slice(0, 90);
3485
+ }
3486
+ function isTraceOnlyProviderSection(title) {
3487
+ return /\b(sql|query|raw trace|appendix|implementation detail)\b/i.test(title);
3488
+ }
3489
+ function parseProviderSectionBody(value) {
3490
+ const lines = value
3491
+ .split(/\r?\n/)
3492
+ .map((line) => line.trim())
3493
+ .filter(Boolean)
3494
+ .filter((line) => !/^#{1,6}\s+/.test(line));
3495
+ const bullets = [];
3496
+ const prose = [];
3497
+ for (const line of lines) {
3498
+ const bullet = /^[-*]\s+(.+)$/.exec(line)?.[1]?.trim();
3499
+ if (bullet) {
3500
+ bullets.push(bullet);
3501
+ continue;
3502
+ }
3503
+ prose.push(line);
3504
+ }
3505
+ const body = prose.join('\n\n') || bullets.slice(0, 3).join(' ');
3506
+ return {
3507
+ body: cleanString(body),
3508
+ bullets: bullets.length ? bullets.slice(0, 8) : undefined,
3509
+ };
3510
+ }
3511
+ function providerReportKind(title) {
3512
+ if (/\b(executive|answer|summary|decision)\b/i.test(title))
3513
+ return 'executive_answer';
3514
+ if (/\b(number|metric|kpi|value)\b/i.test(title))
3515
+ return 'key_numbers';
3516
+ if (/\b(driver|why|interpret|segment|movement|trend|entity|anomaly)\b/i.test(title))
3517
+ return 'business_interpretation';
3518
+ if (/\b(proof|validate|evidence|trust)\b/i.test(title))
3519
+ return 'validation';
3520
+ if (/\b(block|logic|reuse|contract)\b/i.test(title))
3521
+ return 'reusable_logic';
3522
+ if (/\b(next|recommend|action)\b/i.test(title))
3523
+ return 'recommended_next_step';
3524
+ if (/\b(caveat|review|boundary|risk|assumption)\b/i.test(title))
3525
+ return 'review_boundary';
3526
+ return 'custom';
3527
+ }
3528
+ function providerReportTone(title) {
3529
+ if (/\b(executive|answer|summary|decision)\b/i.test(title))
3530
+ return 'answer';
3531
+ if (/\b(driver|why|interpret|segment|movement|trend|number|metric|kpi|value)\b/i.test(title))
3532
+ return 'insight';
3533
+ if (/\b(caveat|warning|risk|missing|error|unavailable|anomaly)\b/i.test(title))
3534
+ return 'warning';
3535
+ if (/\b(proof|validate|evidence|trust|review|boundary|block|logic|reuse)\b/i.test(title))
3536
+ return 'review';
3537
+ return 'neutral';
3538
+ }
3539
+ function hasReportSection(sections, pattern) {
3540
+ return sections.some((section) => pattern.test(section.title) || pattern.test(section.kind));
3541
+ }
3542
+ function reportPreviewIssue(sqlError, sqlErrorKind) {
3543
+ if (!sqlError)
3544
+ return undefined;
3545
+ if (sqlErrorKind === 'runtime_unavailable') {
3546
+ return {
3547
+ id: 'preview-unavailable',
3548
+ title: 'Preview unavailable',
3549
+ 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.',
3550
+ tone: 'warning',
3551
+ };
3552
+ }
3553
+ if (sqlErrorKind === 'ai_generation_timeout') {
3554
+ return {
3555
+ id: 'ai-generation-timeout',
3556
+ title: 'AI SQL generation timed out',
3557
+ 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.',
3558
+ tone: 'warning',
3559
+ };
3560
+ }
3561
+ if (sqlErrorKind === 'timeout') {
3562
+ return {
3563
+ id: 'preview-timeout',
3564
+ title: 'Preview timed out',
3565
+ 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.',
3566
+ tone: 'warning',
3567
+ };
3568
+ }
3569
+ if (sqlErrorKind === 'safety') {
3570
+ return {
3571
+ id: 'sql-safety-check',
3572
+ title: 'SQL safety check',
3573
+ 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.',
3574
+ tone: 'warning',
3575
+ };
3576
+ }
3577
+ return {
3578
+ id: 'sql-repair-path',
3579
+ title: 'SQL repair path',
3580
+ 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.',
3581
+ tone: 'warning',
3582
+ };
3583
+ }
3584
+ function reportInterpretationMeta(intent, actionMode) {
3585
+ if (actionMode === 'block') {
3586
+ return { id: 'reusable-logic', kind: 'reusable_logic', title: 'Reusable logic decision', tone: 'review' };
3587
+ }
3588
+ if (actionMode === 'evidence' || intent === 'trust_gap_review') {
3589
+ return { id: 'validation-result', kind: 'validation', title: 'Validation result', tone: 'review' };
3590
+ }
3591
+ if (intent === 'diagnose_change') {
3592
+ return { id: 'change-explanation', kind: 'business_interpretation', title: 'Change explanation', tone: 'insight' };
3593
+ }
3594
+ if (intent === 'segment_compare') {
3595
+ return { id: 'segment-readout', kind: 'business_interpretation', title: 'Segment readout', tone: 'insight' };
3596
+ }
3597
+ if (intent === 'entity_drilldown') {
3598
+ return { id: 'entity-drilldown', kind: 'business_interpretation', title: 'Entity drilldown', tone: 'insight' };
3599
+ }
3600
+ if (intent === 'anomaly_investigation') {
3601
+ return { id: 'anomaly-readout', kind: 'business_interpretation', title: 'Anomaly readout', tone: 'warning' };
3602
+ }
3603
+ return { id: 'driver-readout', kind: 'business_interpretation', title: 'Driver readout', tone: 'insight' };
3604
+ }
3605
+ function reportInterpretationForIntent(input) {
3606
+ if (input.baselineGap) {
3607
+ 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.';
3608
+ }
3609
+ if (input.actionMode === 'block') {
3610
+ return 'This is a reusable-logic candidate. Preserve the business question, parameters, allowed filters, output grain, source proof, and review path before certification.';
3611
+ }
3612
+ if (input.actionMode === 'evidence' || input.intent === 'trust_gap_review') {
3613
+ return `This validation is bounded to ${input.sourceName}. Treat the claim as trusted only after source block, lineage, filters, and preview rows are reviewed.`;
3614
+ }
3615
+ if (input.driver) {
3616
+ const driverSentence = `${input.driver} is the strongest visible driver${input.driverValue ? ` (${input.driverValue})` : ''}.`;
3617
+ const comparisonSentence = input.nextDriver ? ` The next visible comparison is ${input.nextDriver}.` : '';
3618
+ const reviewSentence = ' Treat the interpretation as directional until a reviewer confirms SQL, grain, filters, joins, and lineage.';
3619
+ if (input.intent === 'segment_compare')
3620
+ return `${driverSentence}${comparisonSentence} Use this as a segment readout only after confirming the grouping field and filter bindings.${reviewSentence}`;
3621
+ if (input.intent === 'entity_drilldown')
3622
+ return `${driverSentence}${comparisonSentence} Use this as an entity drilldown only after confirming the entity identifier and output grain.${reviewSentence}`;
3623
+ if (input.intent === 'anomaly_investigation')
3624
+ return `${driverSentence}${comparisonSentence} Review the baseline, outlier rows, and time grain before treating this as an anomaly narrative.${reviewSentence}`;
3625
+ if (input.intent === 'diagnose_change')
3626
+ return `${driverSentence}${comparisonSentence} Confirm a comparable baseline or prior period before treating this as a causal change explanation.${reviewSentence}`;
3627
+ return `${driverSentence}${comparisonSentence}${reviewSentence}`;
3628
+ }
3629
+ if (input.intent === 'diagnose_change') {
3630
+ 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.';
3631
+ }
3632
+ if (input.intent === 'segment_compare') {
3633
+ 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.';
3634
+ }
3635
+ if (input.intent === 'entity_drilldown') {
3636
+ 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.';
3637
+ }
3638
+ return 'The analysis does not yet have ranked drivers. Add a clearer metric, time grain, or segment field before treating it as complete.';
3639
+ }
3640
+ function keyNumbersTitleForIntent(intent) {
3641
+ if (intent === 'diagnose_change')
3642
+ return 'Movement numbers';
3643
+ if (intent === 'segment_compare')
3644
+ return 'Segment numbers';
3645
+ if (intent === 'entity_drilldown')
3646
+ return 'Entity numbers';
3647
+ if (intent === 'anomaly_investigation')
3648
+ return 'Anomaly numbers';
3649
+ if (intent === 'trust_gap_review')
3650
+ return 'Validation numbers';
3651
+ return 'Key numbers';
3652
+ }
3653
+ function keyNumbersBodyForIntent(intent) {
3654
+ if (intent === 'diagnose_change')
3655
+ 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.';
3656
+ if (intent === 'segment_compare')
3657
+ return 'The bounded preview includes the segment values DQL can compare from the selected context. Confirm the grouping field and filters before promotion.';
3658
+ if (intent === 'entity_drilldown')
3659
+ return 'The bounded preview includes entity-level values from the selected context. Confirm the entity key and grain before promotion.';
3660
+ if (intent === 'anomaly_investigation')
3661
+ return 'The bounded preview includes values that may explain the exception. Confirm the baseline, threshold, and time grain before promotion.';
3662
+ if (intent === 'trust_gap_review')
3663
+ return 'The bounded preview includes values available for validation. Confirm source proof, lineage, and owner review before promotion.';
3664
+ return 'The bounded preview includes the following decision-relevant values. These are useful for stakeholder framing but still require source validation before promotion.';
3665
+ }
3666
+ function investigationNarrativeAnswer(investigation) {
3667
+ const sections = Array.isArray(investigation.reportSections)
3668
+ ? investigation.reportSections
3669
+ .filter((section) => cleanString(section?.title) && cleanString(section?.body))
3670
+ .slice(0, 8)
3671
+ : [];
3672
+ if (!sections.length) {
3673
+ return cleanString(investigation.summary)
3674
+ || cleanString(investigation.recommendation)
3675
+ || cleanString(investigation.title)
3676
+ || 'Review-required app analysis.';
3677
+ }
3678
+ const stakeholderSections = sections
3679
+ .filter((section) => {
3680
+ if (section.kind === 'review_boundary')
3681
+ return false;
3682
+ return !/\b(sql|query|appendix|technical|repair|preview error)\b/i.test(section.title);
3683
+ })
3684
+ .slice(0, 5);
3685
+ return (stakeholderSections.length ? stakeholderSections : sections.slice(0, 3)).map((section) => {
3686
+ const bullets = Array.isArray(section.bullets) && section.bullets.length
3687
+ ? `\n${section.bullets.filter(Boolean).slice(0, 8).map((bullet) => `- ${bullet}`).join('\n')}`
3688
+ : '';
3689
+ return `## ${section.title.trim()}\n${section.body.trim()}${bullets}`;
3690
+ }).join('\n\n');
3691
+ }
3692
+ function reportMetricBullet(metrics, role) {
3693
+ const prefix = role === 'current' ? 'current' : role === 'baseline' ? 'baseline' : 'delta';
3694
+ const label = cleanString(metrics[`${prefix}Label`])
3695
+ || (role === 'current' ? 'Top value' : role === 'baseline' ? 'Next comparison' : 'Gap');
3696
+ const value = typeofNumber(metrics[role === 'current' ? 'currentValue' : role === 'baseline' ? 'baselineValue' : 'delta']);
3697
+ if (value === null)
3698
+ return undefined;
3699
+ const detail = cleanString(metrics[`${prefix}Detail`]);
3700
+ return `${label}: ${formatMetricValue(value)}${detail ? ` (${detail})` : ''}`;
3701
+ }
3702
+ function reportEvidenceRefs(selected) {
3703
+ return [
3704
+ selectedString(selected, 'blockId') ? `block:${selectedString(selected, 'blockId')}` : '',
3705
+ selectedString(selected, 'tileId') ? `tile:${selectedString(selected, 'tileId')}` : '',
3706
+ selectedString(selected, 'blockPath') ?? '',
3707
+ ].filter(Boolean);
3708
+ }
2089
3709
  function investigationPreviewResult(investigation) {
2090
3710
  const previews = Array.isArray(investigation.resultPreviews) ? investigation.resultPreviews : [];
2091
3711
  const first = previews.find((preview) => asRecord(preview)?.result);
@@ -2142,6 +3762,12 @@ function formatContribution(value) {
2142
3762
  const rounded = Math.abs(value) >= 100 ? Math.round(value).toLocaleString() : Number(value.toFixed(2)).toLocaleString();
2143
3763
  return value >= 0 ? `+${rounded}` : `-${rounded.replace(/^-/, '')}`;
2144
3764
  }
3765
+ function formatMetricValue(value) {
3766
+ const absolute = Math.abs(value);
3767
+ if (absolute >= 100)
3768
+ return Math.round(value).toLocaleString();
3769
+ return Number(value.toFixed(2)).toLocaleString();
3770
+ }
2145
3771
  function asRecord(value) {
2146
3772
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
2147
3773
  }
@@ -2266,6 +3892,10 @@ function createAiPinTile(projectRoot, appId, input) {
2266
3892
  const tileId = cleanString(input.tileId) || nextTileId(loaded.dashboard, slugify(title) || 'ai-pin');
2267
3893
  const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
2268
3894
  try {
3895
+ const existing = findExistingAiPinTile(storage, loaded.dashboard, appId, dashboardId, title, input);
3896
+ if (existing) {
3897
+ return { ok: true, pin: existing.pin, dashboard: loaded.dashboard, tile: existing.tile, deduped: true };
3898
+ }
2269
3899
  const pin = storage.createAiPin({
2270
3900
  appId,
2271
3901
  dashboardId,
@@ -2311,6 +3941,60 @@ function createAiPinTile(projectRoot, appId, input) {
2311
3941
  storage.close();
2312
3942
  }
2313
3943
  }
3944
+ function findExistingAiPinTile(storage, dashboard, appId, dashboardId, title, input) {
3945
+ const requested = aiPinDedupeFingerprint(title, input.question, input.analysisPlan, input.result);
3946
+ if (!requested)
3947
+ return null;
3948
+ const pins = storage.listAiPins(appId, dashboardId);
3949
+ for (const pin of pins) {
3950
+ const existing = aiPinDedupeFingerprint(pin.title, pin.question, pin.analysisPlan, pin.result);
3951
+ if (!existing || existing !== requested)
3952
+ continue;
3953
+ const tile = dashboard.layout.items.find((item) => item.aiPin?.id === pin.id || (pin.tileId && item.i === pin.tileId));
3954
+ if (tile)
3955
+ return { pin, tile };
3956
+ }
3957
+ return null;
3958
+ }
3959
+ function aiPinDedupeFingerprint(title, question, analysisPlan, result) {
3960
+ const plan = asRecord(analysisPlan);
3961
+ const sourceBlock = cleanString(plan?.sourceBlockId);
3962
+ const sourceTile = cleanString(plan?.sourceTileId);
3963
+ const questionKey = normalizeAiPinDedupeText(question) || normalizeAiPinDedupeText(title);
3964
+ if (!questionKey && !sourceBlock && !sourceTile)
3965
+ return '';
3966
+ return [
3967
+ questionKey,
3968
+ sourceBlock,
3969
+ sourceTile,
3970
+ aiPinResultFingerprint(result),
3971
+ ].filter(Boolean).join('|');
3972
+ }
3973
+ function normalizeAiPinDedupeText(value) {
3974
+ return cleanString(value)
3975
+ .toLowerCase()
3976
+ .replace(/[^a-z0-9]+/g, ' ')
3977
+ .replace(/\s+/g, ' ')
3978
+ .trim();
3979
+ }
3980
+ function aiPinResultFingerprint(result) {
3981
+ const record = asRecord(result);
3982
+ if (!record)
3983
+ return '';
3984
+ const columns = Array.isArray(record.columns) ? record.columns.map((column) => String(column).toLowerCase()).join(',') : '';
3985
+ const rows = Array.isArray(record.rows) ? record.rows.slice(0, 8).map((row) => stableFingerprintValue(row)).join(';') : '';
3986
+ return columns || rows ? `${columns}:${rows}` : '';
3987
+ }
3988
+ function stableFingerprintValue(value) {
3989
+ if (value === null || value === undefined)
3990
+ return '';
3991
+ if (typeof value !== 'object')
3992
+ return String(value);
3993
+ if (Array.isArray(value))
3994
+ return `[${value.map(stableFingerprintValue).join(',')}]`;
3995
+ const record = value;
3996
+ return `{${Object.keys(record).sort().map((key) => `${key}:${stableFingerprintValue(record[key])}`).join(',')}}`;
3997
+ }
2314
3998
  function promoteAiPinToDraftBlock(projectRoot, appId, pinId) {
2315
3999
  const loaded = loadAppById(projectRoot, appId);
2316
4000
  if (!loaded)
@@ -2709,7 +4393,7 @@ function listAiPins(projectRoot, appId) {
2709
4393
  try {
2710
4394
  const storage = new LocalAppStorage(dbPath);
2711
4395
  try {
2712
- return storage.listAiPins(appId);
4396
+ return dedupeLocalAiPins(storage.listAiPins(appId));
2713
4397
  }
2714
4398
  finally {
2715
4399
  storage.close();
@@ -2721,6 +4405,18 @@ function listAiPins(projectRoot, appId) {
2721
4405
  return [];
2722
4406
  }
2723
4407
  }
4408
+ function dedupeLocalAiPins(pins) {
4409
+ const seen = new Set();
4410
+ const out = [];
4411
+ for (const pin of pins) {
4412
+ const fingerprint = aiPinDedupeFingerprint(pin.title, pin.question, pin.analysisPlan, pin.result) || pin.id;
4413
+ if (seen.has(fingerprint))
4414
+ continue;
4415
+ seen.add(fingerprint);
4416
+ out.push(pin);
4417
+ }
4418
+ return out;
4419
+ }
2724
4420
  function listAppInvestigations(projectRoot, appId) {
2725
4421
  const dbPath = defaultLocalAppsDbPath(projectRoot);
2726
4422
  if (!existsSync(dbPath))
@@ -2728,7 +4424,7 @@ function listAppInvestigations(projectRoot, appId) {
2728
4424
  try {
2729
4425
  const storage = new LocalAppStorage(dbPath);
2730
4426
  try {
2731
- return storage.listAppInvestigations(appId);
4427
+ return dedupeAppInvestigationsForDisplay(storage.listAppInvestigations(appId));
2732
4428
  }
2733
4429
  finally {
2734
4430
  storage.close();
@@ -2738,6 +4434,18 @@ function listAppInvestigations(projectRoot, appId) {
2738
4434
  return [];
2739
4435
  }
2740
4436
  }
4437
+ function dedupeAppInvestigationsForDisplay(investigations) {
4438
+ const seen = new Set();
4439
+ const out = [];
4440
+ for (const investigation of investigations) {
4441
+ const fingerprint = appInvestigationReuseFingerprint(investigation);
4442
+ if (seen.has(fingerprint))
4443
+ continue;
4444
+ seen.add(fingerprint);
4445
+ out.push(investigation);
4446
+ }
4447
+ return out;
4448
+ }
2741
4449
  function listDashboardsFor(projectRoot, id) {
2742
4450
  const result = loadAppById(projectRoot, id);
2743
4451
  return result?.dashboards ?? null;
@@ -2835,10 +4543,23 @@ async function readJson(req) {
2835
4543
  });
2836
4544
  }
2837
4545
  export const __test__ = {
4546
+ buildMetricSnapshot,
2838
4547
  buildPreviewDriverCards,
2839
4548
  buildPreviewMetricSnapshot,
2840
4549
  buildDeterministicInvestigationSql,
4550
+ buildInvestigationSummary,
4551
+ buildInvestigationReportSections,
4552
+ classifySqlPreviewError,
4553
+ investigationNarrativeAnswer,
4554
+ createAiPinTile,
4555
+ runGeneratedSqlPreview,
4556
+ renderSqlTemplateParams,
4557
+ unresolvedSqlTemplateParams,
2841
4558
  selectedBlockContext,
4559
+ runAppInvestigation,
4560
+ askAppQuestion,
4561
+ collectAppsList,
4562
+ loadAppById,
2842
4563
  };
2843
4564
  // reference unused parseAppDocument/readFileSync to keep import stable for forward use
2844
4565
  void parseAppDocument;