@duckcodeailabs/dql-agent 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 (160) hide show
  1. package/dist/agent-run-engine.d.ts +287 -0
  2. package/dist/agent-run-engine.d.ts.map +1 -0
  3. package/dist/agent-run-engine.js +916 -0
  4. package/dist/agent-run-engine.js.map +1 -0
  5. package/dist/agent-run-gates.d.ts +26 -0
  6. package/dist/agent-run-gates.d.ts.map +1 -0
  7. package/dist/agent-run-gates.js +170 -0
  8. package/dist/agent-run-gates.js.map +1 -0
  9. package/dist/agent-run-planner.d.ts +32 -0
  10. package/dist/agent-run-planner.d.ts.map +1 -0
  11. package/dist/agent-run-planner.js +280 -0
  12. package/dist/agent-run-planner.js.map +1 -0
  13. package/dist/answer-loop.d.ts +49 -0
  14. package/dist/answer-loop.d.ts.map +1 -1
  15. package/dist/answer-loop.js +292 -17
  16. package/dist/answer-loop.js.map +1 -1
  17. package/dist/app-builder.d.ts +44 -2
  18. package/dist/app-builder.d.ts.map +1 -1
  19. package/dist/app-builder.js +558 -123
  20. package/dist/app-builder.js.map +1 -1
  21. package/dist/app-planner.d.ts +78 -0
  22. package/dist/app-planner.d.ts.map +1 -0
  23. package/dist/app-planner.js +145 -0
  24. package/dist/app-planner.js.map +1 -0
  25. package/dist/embeddings/provider.d.ts +66 -0
  26. package/dist/embeddings/provider.d.ts.map +1 -0
  27. package/dist/embeddings/provider.js +105 -0
  28. package/dist/embeddings/provider.js.map +1 -0
  29. package/dist/hints/git-store.d.ts +69 -0
  30. package/dist/hints/git-store.d.ts.map +1 -0
  31. package/dist/hints/git-store.js +244 -0
  32. package/dist/hints/git-store.js.map +1 -0
  33. package/dist/hints/retrieval.d.ts +49 -0
  34. package/dist/hints/retrieval.d.ts.map +1 -0
  35. package/dist/hints/retrieval.js +60 -0
  36. package/dist/hints/retrieval.js.map +1 -0
  37. package/dist/hints/store.d.ts +39 -0
  38. package/dist/hints/store.d.ts.map +1 -0
  39. package/dist/hints/store.js +279 -0
  40. package/dist/hints/store.js.map +1 -0
  41. package/dist/hints/types.d.ts +147 -0
  42. package/dist/hints/types.d.ts.map +1 -0
  43. package/dist/hints/types.js +92 -0
  44. package/dist/hints/types.js.map +1 -0
  45. package/dist/index.d.ts +43 -6
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +35 -3
  48. package/dist/index.js.map +1 -1
  49. package/dist/intent-controller.d.ts +63 -0
  50. package/dist/intent-controller.d.ts.map +1 -0
  51. package/dist/intent-controller.js +150 -0
  52. package/dist/intent-controller.js.map +1 -0
  53. package/dist/kg/build.d.ts.map +1 -1
  54. package/dist/kg/build.js +2 -0
  55. package/dist/kg/build.js.map +1 -1
  56. package/dist/kg/sqlite-fts.js +4 -0
  57. package/dist/kg/sqlite-fts.js.map +1 -1
  58. package/dist/kg/types.d.ts +10 -0
  59. package/dist/kg/types.d.ts.map +1 -1
  60. package/dist/metadata/catalog.d.ts +81 -4
  61. package/dist/metadata/catalog.d.ts.map +1 -1
  62. package/dist/metadata/catalog.js +208 -1
  63. package/dist/metadata/catalog.js.map +1 -1
  64. package/dist/metadata/drafts.d.ts +22 -0
  65. package/dist/metadata/drafts.d.ts.map +1 -1
  66. package/dist/metadata/drafts.js +126 -2
  67. package/dist/metadata/drafts.js.map +1 -1
  68. package/dist/metadata/grain-gate.d.ts +61 -0
  69. package/dist/metadata/grain-gate.d.ts.map +1 -0
  70. package/dist/metadata/grain-gate.js +265 -0
  71. package/dist/metadata/grain-gate.js.map +1 -0
  72. package/dist/metadata/identity.d.ts +47 -0
  73. package/dist/metadata/identity.d.ts.map +1 -0
  74. package/dist/metadata/identity.js +147 -0
  75. package/dist/metadata/identity.js.map +1 -0
  76. package/dist/metadata/metric-match.d.ts +93 -0
  77. package/dist/metadata/metric-match.d.ts.map +1 -0
  78. package/dist/metadata/metric-match.js +233 -0
  79. package/dist/metadata/metric-match.js.map +1 -0
  80. package/dist/metadata/sql-grounding.d.ts +149 -0
  81. package/dist/metadata/sql-grounding.d.ts.map +1 -0
  82. package/dist/metadata/sql-grounding.js +397 -0
  83. package/dist/metadata/sql-grounding.js.map +1 -0
  84. package/dist/metadata/sql-retrieval.d.ts +29 -0
  85. package/dist/metadata/sql-retrieval.d.ts.map +1 -0
  86. package/dist/metadata/sql-retrieval.js +79 -0
  87. package/dist/metadata/sql-retrieval.js.map +1 -0
  88. package/dist/narrate.d.ts +52 -0
  89. package/dist/narrate.d.ts.map +1 -0
  90. package/dist/narrate.js +263 -0
  91. package/dist/narrate.js.map +1 -0
  92. package/dist/propose/build-from-prompt.d.ts +160 -0
  93. package/dist/propose/build-from-prompt.d.ts.map +1 -0
  94. package/dist/propose/build-from-prompt.js +815 -0
  95. package/dist/propose/build-from-prompt.js.map +1 -0
  96. package/dist/propose/classify.d.ts +55 -0
  97. package/dist/propose/classify.d.ts.map +1 -0
  98. package/dist/propose/classify.js +167 -0
  99. package/dist/propose/classify.js.map +1 -0
  100. package/dist/propose/config.d.ts +39 -0
  101. package/dist/propose/config.d.ts.map +1 -0
  102. package/dist/propose/config.js +52 -0
  103. package/dist/propose/config.js.map +1 -0
  104. package/dist/propose/dbt-artifacts.d.ts +134 -0
  105. package/dist/propose/dbt-artifacts.d.ts.map +1 -0
  106. package/dist/propose/dbt-artifacts.js +320 -0
  107. package/dist/propose/dbt-artifacts.js.map +1 -0
  108. package/dist/propose/edit-block.d.ts +74 -0
  109. package/dist/propose/edit-block.d.ts.map +1 -0
  110. package/dist/propose/edit-block.js +133 -0
  111. package/dist/propose/edit-block.js.map +1 -0
  112. package/dist/propose/enrich.d.ts +48 -0
  113. package/dist/propose/enrich.d.ts.map +1 -0
  114. package/dist/propose/enrich.js +125 -0
  115. package/dist/propose/enrich.js.map +1 -0
  116. package/dist/propose/generate-sql.d.ts +61 -0
  117. package/dist/propose/generate-sql.d.ts.map +1 -0
  118. package/dist/propose/generate-sql.js +180 -0
  119. package/dist/propose/generate-sql.js.map +1 -0
  120. package/dist/propose/index.d.ts +24 -0
  121. package/dist/propose/index.d.ts.map +1 -0
  122. package/dist/propose/index.js +15 -0
  123. package/dist/propose/index.js.map +1 -0
  124. package/dist/propose/propose.d.ts +226 -0
  125. package/dist/propose/propose.d.ts.map +1 -0
  126. package/dist/propose/propose.js +668 -0
  127. package/dist/propose/propose.js.map +1 -0
  128. package/dist/propose/reflect-block.d.ts +99 -0
  129. package/dist/propose/reflect-block.d.ts.map +1 -0
  130. package/dist/propose/reflect-block.js +220 -0
  131. package/dist/propose/reflect-block.js.map +1 -0
  132. package/dist/propose/write-draft.d.ts +90 -0
  133. package/dist/propose/write-draft.d.ts.map +1 -0
  134. package/dist/propose/write-draft.js +185 -0
  135. package/dist/propose/write-draft.js.map +1 -0
  136. package/dist/providers/claude.d.ts +13 -1
  137. package/dist/providers/claude.d.ts.map +1 -1
  138. package/dist/providers/claude.js +20 -2
  139. package/dist/providers/claude.js.map +1 -1
  140. package/dist/providers/gemini.d.ts +13 -1
  141. package/dist/providers/gemini.d.ts.map +1 -1
  142. package/dist/providers/gemini.js +24 -3
  143. package/dist/providers/gemini.js.map +1 -1
  144. package/dist/providers/index.d.ts +3 -3
  145. package/dist/providers/index.d.ts.map +1 -1
  146. package/dist/providers/index.js +3 -3
  147. package/dist/providers/index.js.map +1 -1
  148. package/dist/research-loop.d.ts +62 -0
  149. package/dist/research-loop.d.ts.map +1 -0
  150. package/dist/research-loop.js +210 -0
  151. package/dist/research-loop.js.map +1 -0
  152. package/dist/skills/defaults.d.ts +37 -0
  153. package/dist/skills/defaults.d.ts.map +1 -0
  154. package/dist/skills/defaults.js +152 -0
  155. package/dist/skills/defaults.js.map +1 -0
  156. package/dist/skills/loader.d.ts +60 -0
  157. package/dist/skills/loader.d.ts.map +1 -1
  158. package/dist/skills/loader.js +164 -2
  159. package/dist/skills/loader.js.map +1 -1
  160. package/package.json +7 -5
@@ -24,13 +24,13 @@ export const APP_BUILDER_SKILLS = [
24
24
  },
25
25
  {
26
26
  id: "draft_missing_sections",
27
- title: "Draft missing sections",
28
- description: "Create clearly marked draft tiles when a needed explanation, drilldown, or metric is not certified yet.",
27
+ title: "Scope report gaps",
28
+ description: "Turn missing explanations, drilldowns, and metrics into scoped analyst reports instead of dashboard placeholders.",
29
29
  },
30
30
  {
31
31
  id: "route_review",
32
32
  title: "Route review",
33
- description: "Keep generated sections uncertified and attach concrete review tasks before stakeholder use.",
33
+ description: "Keep generated analysis review-required and attach concrete next steps before stakeholder use.",
34
34
  },
35
35
  ];
36
36
  export function planAppFromPrompt(input) {
@@ -38,64 +38,43 @@ export function planAppFromPrompt(input) {
38
38
  if (!prompt)
39
39
  throw new Error("prompt is required");
40
40
  const domain = normalizeToken(input.domain) ?? inferDomain(prompt) ?? "general";
41
- const audience = inferAudience(prompt) ?? inferDefaultAudience(prompt);
41
+ const audience = input.audience?.trim() || inferAudience(prompt) || inferDefaultAudience(prompt);
42
42
  const analysisIntent = inferAnalysisIntent(prompt);
43
43
  const appName = titleForPrompt(prompt, inferFallbackName(prompt, domain));
44
44
  const appId = suggestAppId(appName);
45
45
  const maxCertifiedTiles = input.maxCertifiedTiles ?? 4;
46
+ const targetCertifiedTiles = Math.max(maxCertifiedTiles, input.preferredBlockIds?.length ?? 0);
46
47
  const preferredNodes = findPreferredCertifiedBlockNodes(input.kg, input.preferredBlockIds ?? []);
47
- const matchedNodes = findCertifiedBlockNodes(input.kg, prompt, domain, Math.max(maxCertifiedTiles, preferredNodes.length));
48
- const certifiedNodes = mergeCertifiedBlockNodes(preferredNodes, matchedNodes).slice(0, Math.max(maxCertifiedTiles, preferredNodes.length));
48
+ const matchedNodes = findCertifiedBlockNodes(input.kg, prompt, domain, Math.max(targetCertifiedTiles * 3, preferredNodes.length));
49
+ const certifiedNodes = dedupeCertifiedBlockNodes(mergeCertifiedBlockNodes(preferredNodes, matchedNodes), new Set(preferredNodes.map((node) => node.nodeId))).slice(0, targetCertifiedTiles);
49
50
  const contextNodes = findCertifiedContextNodes(input.kg, prompt, domain, 6);
50
- const filters = inferFilters(prompt);
51
- const certifiedTiles = certifiedNodes.map((node, index) => tileFromCertifiedNode(node, index, analysisIntent));
52
- const draftTiles = inferDraftTiles(prompt, domain, analysisIntent, certifiedNodes).map((tile, index) => ({
53
- id: slugify(tile.title) || `draft-${index + 1}`,
54
- title: tile.title,
55
- kind: "draft_placeholder",
56
- description: tile.description,
57
- viz: tile.viz,
58
- certification: "uncertified",
59
- reviewStatus: "draft_ready",
60
- display: displayForDraftTile(tile.title, tile.viz, index),
61
- rationale: "No certified block was selected for this generated app section.",
62
- reviewTasks: [
63
- "Validate metric definition, grain, filters, and source tables.",
64
- "Promote to a certified block before treating this tile as governed.",
65
- ],
66
- }));
67
- const narrativeTile = {
68
- id: "business-brief",
69
- title: "Business brief",
70
- kind: "narrative",
71
- description: businessBriefDescription(prompt, audience, analysisIntent),
72
- viz: "text",
73
- certification: "uncertified",
74
- reviewStatus: "review_required",
75
- display: {
76
- role: "business_summary",
77
- recommendedDisplayType: "text",
78
- layoutPriority: 0,
79
- expectedGrain: "app",
80
- trustState: "review_required",
81
- followUpActions: ["ask_follow_up", "review_trust"],
82
- rationale: "Compact business brief keeps the app goal visible without dominating the dashboard.",
83
- genUi: buildGenUiContract({
84
- title: "Business brief",
85
- role: "business_summary",
86
- viz: "text",
87
- text: prompt,
88
- trustState: "review_required",
89
- reviewStatus: "review_required",
90
- followUpActions: ["ask_follow_up", "review_trust"],
91
- rationale: "Compact business brief keeps the app goal visible without dominating the dashboard.",
92
- }),
93
- },
94
- rationale: "Narrative text is generated scaffolding and should be reviewed with the app.",
95
- reviewTasks: [
96
- "Confirm the audience, business goal, caveats, and review cadence.",
97
- ],
98
- };
51
+ // Bind the global filter bar to what the certified tiles actually accept (their
52
+ // declared allowedFilters), not prompt words falling back to prompt inference
53
+ // only when no tile declares any filter. Keeps the dashboard's filters dynamic.
54
+ const promptFilters = inferFilters(prompt);
55
+ const blockFilters = filtersFromCertifiedNodes(certifiedNodes);
56
+ const filters = blockFilters.length > 0 ? mergeAppFilters(blockFilters, promptFilters) : promptFilters;
57
+ const certifiedTiles = certifiedNodes.map((node, index) => tileFromCertifiedNode(node, index, analysisIntent, filters));
58
+ const scopedReports = inferScopedReports(prompt, domain, analysisIntent, certifiedNodes);
59
+ const certifiedContextSummary = summarizeCertifiedContext([
60
+ ...certifiedNodes,
61
+ ...contextNodes,
62
+ ]);
63
+ const missingEvidence = Array.from(new Set([
64
+ ...missingEvidenceForPlan(prompt, analysisIntent, certifiedNodes, contextNodes),
65
+ ...scopedReports.map((report) => `${report.title}: ${report.description}`),
66
+ ]));
67
+ const stakeholderSummary = stakeholderSummaryForPlan({
68
+ prompt,
69
+ appName,
70
+ audience,
71
+ domain,
72
+ analysisIntent,
73
+ certifiedTiles: certifiedTiles.length,
74
+ scopedReports: scopedReports.length,
75
+ certifiedNames: certifiedNodes.map((node) => node.name),
76
+ filterLabels: filters.map((filter) => filter.label),
77
+ });
99
78
  return {
100
79
  version: 1,
101
80
  appId,
@@ -107,23 +86,22 @@ export function planAppFromPrompt(input) {
107
86
  analysisIntent,
108
87
  audience,
109
88
  domain,
110
- certifiedContext: summarizeCertifiedContext([
111
- ...certifiedNodes,
112
- ...contextNodes,
113
- ]),
114
- missingEvidence: missingEvidenceForPlan(prompt, analysisIntent, certifiedNodes, contextNodes),
89
+ certifiedContext: certifiedContextSummary,
90
+ missingEvidence,
91
+ scopedReports,
115
92
  displayStrategy: displayStrategyForIntent(analysisIntent),
116
- layoutRationale: "Business-first layout: certified KPIs, trends, breakdowns, and evidence tiles only; generated gaps stay in the review backlog until promoted.",
93
+ layoutRationale: "Business-first layout: certified KPIs, trends, breakdowns, and proof-backed tiles only; generated gaps stay as scoped analysis memos until promoted.",
117
94
  handoffPlan: [
118
95
  "Use certified block tiles as the governed dashboard surface.",
119
- "Keep generated narrative, trust gaps, and drilldown ideas in the review backlog.",
120
- "Use app chat and Research to run additional SQL, inspect previews, then pin or promote reviewed results into the app.",
96
+ "Keep generated narrative, trust gaps, and drilldown ideas as scoped analysis memos until reviewed.",
97
+ "Use app Copilot analysis to run additional SQL, inspect previews, then pin or promote reviewed results into the app.",
121
98
  ],
122
99
  },
123
100
  skills: APP_BUILDER_SKILLS,
124
101
  domain,
125
102
  audience,
126
103
  businessGoal: prompt,
104
+ stakeholderSummary,
127
105
  owner: input.owner?.trim() || `${process.env.USER ?? "owner"}@local`,
128
106
  lifecycle: "draft",
129
107
  tags: Array.from(new Set([
@@ -131,24 +109,56 @@ export function planAppFromPrompt(input) {
131
109
  ...inferTags(prompt, domain),
132
110
  `audience:${slugify(audience)}`,
133
111
  ])),
112
+ appSections: [
113
+ {
114
+ id: "dashboard",
115
+ title: "Stakeholder view",
116
+ purpose: "Clean dashboard pages backed by certified blocks and active filters.",
117
+ reviewStatus: certifiedTiles.length > 0 ? "certified" : "draft_ready",
118
+ },
119
+ {
120
+ id: "research",
121
+ title: "Analysis",
122
+ purpose: "Review-required analyst memos for follow-up questions that need new SQL or deeper analysis.",
123
+ reviewStatus: "draft_ready",
124
+ },
125
+ ],
126
+ globalFilters: filters,
127
+ selectedEvidence: certifiedContextSummary.map((item) => ({
128
+ source: `${item.kind}:${item.name}`,
129
+ reason: item.reason,
130
+ kind: item.kind,
131
+ nodeId: item.nodeId,
132
+ trustState: "certified",
133
+ })),
134
+ missingEvidence,
135
+ scopedReports,
134
136
  pages: [
135
137
  {
136
138
  id: "overview",
137
139
  title: inferDashboardTitle(prompt, domain, appName),
138
- description: `Certified app surface for ${audience}. Draft gaps stay in Research until reviewed.`,
140
+ description: `Certified app surface for ${audience}. Draft gaps stay in reports until reviewed.`,
139
141
  filters,
140
- tiles: [narrativeTile, ...certifiedTiles, ...draftTiles],
142
+ tiles: certifiedTiles,
141
143
  },
142
144
  ],
143
145
  caveats: [
144
146
  "Generated app plans are local draft artifacts until reviewed.",
145
- "Generated dashboards render certified block tiles only; draft and narrative suggestions require analyst review before they become app tiles.",
147
+ "Generated dashboards render certified block tiles only; draft narrative, proof, and analysis suggestions stay as scoped memos, Copilot answers, or promoted reviewed insights.",
146
148
  ],
147
149
  reviewTasks: [
148
- "Review every backlog item before stakeholder use.",
150
+ "Review every scoped analysis memo before stakeholder use.",
151
+ ...scopedReports.map((report) => `Run scoped analysis "${report.title}" from app Copilot with the stakeholder question and active filters, then pin or promote only after review.`),
149
152
  "Run dql app build after accepting the generated files.",
150
- "Use app chat or Research for additional questions, then promote reviewed SQL results to draft or certified blocks.",
153
+ "Use app Copilot analysis for additional questions, then promote reviewed SQL results to draft or certified blocks.",
151
154
  ],
155
+ coverage: {
156
+ certifiedTiles: certifiedTiles.length,
157
+ gaps: scopedReports.length,
158
+ ratio: certifiedTiles.length + scopedReports.length > 0
159
+ ? certifiedTiles.length / (certifiedTiles.length + scopedReports.length)
160
+ : 0,
161
+ },
152
162
  };
153
163
  }
154
164
  export function validateAppPlan(plan, kg) {
@@ -199,6 +209,7 @@ export function validateAppPlan(plan, kg) {
199
209
  }
200
210
  else {
201
211
  draftTiles += 1;
212
+ issues.push(error(path, "stakeholder dashboard tiles must be certified blocks; route generated story, trust, and analysis work through scoped reports or Copilot investigations"));
202
213
  if (tile.certification === "certified") {
203
214
  issues.push(error(path, "non-block generated tiles cannot be marked certified"));
204
215
  }
@@ -209,7 +220,7 @@ export function validateAppPlan(plan, kg) {
209
220
  }
210
221
  }
211
222
  if (certifiedTiles === 0) {
212
- issues.push(warn("pages", "no certified blocks matched; generated app will contain only draft/review tiles"));
223
+ issues.push(warn("pages", "no certified blocks matched; generated dashboard has no governed result tiles yet"));
213
224
  }
214
225
  return {
215
226
  ok: issues.every((issue) => issue.level !== "error"),
@@ -315,6 +326,7 @@ export function generateAppFromPlan(projectRoot, plan, kg, options = {}) {
315
326
  id: filter.id,
316
327
  type: filter.type,
317
328
  default: filter.default,
329
+ ...(filter.options?.length ? { options: filter.options } : {}),
318
330
  bindsTo: filter.bindsTo,
319
331
  })),
320
332
  layout: {
@@ -407,6 +419,133 @@ function mergeCertifiedBlockNodes(preferredNodes, matchedNodes) {
407
419
  }
408
420
  return merged;
409
421
  }
422
+ function dedupeCertifiedBlockNodes(nodes, preferredNodeIds = new Set()) {
423
+ const selected = [];
424
+ const signatureIndex = new Map();
425
+ for (const node of nodes) {
426
+ const signatures = certifiedBlockDuplicateSignatures(node);
427
+ let duplicateIndex = -1;
428
+ for (const signature of signatures) {
429
+ const existing = signatureIndex.get(signature);
430
+ if (existing !== undefined) {
431
+ duplicateIndex = existing;
432
+ break;
433
+ }
434
+ }
435
+ if (duplicateIndex < 0) {
436
+ signatureIndex.set(node.nodeId, selected.length);
437
+ for (const signature of signatures)
438
+ signatureIndex.set(signature, selected.length);
439
+ selected.push(node);
440
+ continue;
441
+ }
442
+ const current = selected[duplicateIndex];
443
+ if (certifiedBlockSelectionScore(node, preferredNodeIds) > certifiedBlockSelectionScore(current, preferredNodeIds)) {
444
+ selected[duplicateIndex] = node;
445
+ signatureIndex.set(node.nodeId, duplicateIndex);
446
+ for (const signature of signatures)
447
+ signatureIndex.set(signature, duplicateIndex);
448
+ }
449
+ }
450
+ return selected;
451
+ }
452
+ function certifiedBlockDuplicateSignatures(node) {
453
+ const signatures = new Set([node.nodeId]);
454
+ if (node.sqlFingerprints?.exact)
455
+ signatures.add(`sql:${node.sqlFingerprints.exact}`);
456
+ if (node.sqlFingerprints?.parameterized)
457
+ signatures.add(`sqlp:${node.sqlFingerprints.parameterized}`);
458
+ if (node.businessFingerprint?.hash)
459
+ signatures.add(`business:${node.businessFingerprint.hash}`);
460
+ const topic = certifiedBlockTopicSignature(node);
461
+ if (topic)
462
+ signatures.add(`topic:${topic}`);
463
+ return Array.from(signatures);
464
+ }
465
+ function certifiedBlockTopicSignature(node) {
466
+ const text = [
467
+ node.name,
468
+ node.description,
469
+ node.llmContext,
470
+ node.businessOutcome,
471
+ node.decisionUse,
472
+ node.grain,
473
+ ...(node.tags ?? []),
474
+ ...(node.entities ?? []),
475
+ ...(node.declaredOutputs ?? []),
476
+ ...(node.dimensions ?? []),
477
+ ...(node.allowedFilters ?? []),
478
+ ...(node.businessFingerprint?.tokens ?? []),
479
+ ].join(" ").toLowerCase();
480
+ const sources = normalizedTopicTokens([
481
+ ...(node.sourceSystems ?? []),
482
+ ...(node.businessFingerprint?.tokens ?? [])
483
+ .filter((token) => /^(source|system):/i.test(token))
484
+ .map((token) => token.replace(/^(source|system):/i, "")),
485
+ ...Array.from(text.matchAll(/\b(?:from|join)\s+([a-z0-9_.]+)/gi)).map((match) => match[1]),
486
+ ...Array.from(text.matchAll(/\b([a-z0-9_]*int_player_stats|[a-z0-9_]*fct_[a-z0-9_]+|[a-z0-9_]*dim_[a-z0-9_]+)\b/gi)).map((match) => match[1]),
487
+ ]);
488
+ const entity = firstTopicFamily(text, [
489
+ ["player", /\b(player|players|scorer|scorers|athlete)\b/],
490
+ ["customer", /\b(customer|account|user|subscriber)\b/],
491
+ ["order", /\b(order|orders|purchase)\b/],
492
+ ["product", /\b(product|sku|item)\b/],
493
+ ["team", /\b(team|teams)\b/],
494
+ ]);
495
+ const intent = firstTopicFamily(text, [
496
+ ["availability", /\b(availability|freshness|record count|records|quality|coverage)\b/],
497
+ ["ranking", /\b(top|bottom|rank|ranking|leader|leaderboard|scorer|scorers)\b/],
498
+ ["trend", /\b(trend|weekly|monthly|daily|time series|over time)\b/],
499
+ ["segment", /\b(segment|breakdown|split|by segment|cohort|region|channel)\b/],
500
+ ["profile", /\b(profile|360|detail|drilldown)\b/],
501
+ ["kpi", /\b(kpi|metric|total|summary|scorecard)\b/],
502
+ ]);
503
+ const metric = firstTopicFamily(text, [
504
+ ["points", /\b(point|points|pts|score|scoring|scorer|scorers)\b/],
505
+ ["revenue", /\b(revenue|arr|sales|amount|bookings)\b/],
506
+ ["count", /\b(count|records|games played|orders|users)\b/],
507
+ ["quality", /\b(availability|freshness|quality|coverage)\b/],
508
+ ["conversion", /\b(conversion|rate|pct|percent|ratio)\b/],
509
+ ]);
510
+ if (!sources.length || !intent || !metric)
511
+ return "";
512
+ return [sources.slice(0, 3).join("+"), entity, intent, metric].filter(Boolean).join("|");
513
+ }
514
+ function normalizedTopicTokens(values) {
515
+ return Array.from(new Set(values
516
+ .map((value) => value.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, ""))
517
+ .map((value) => value
518
+ .replace(/^.*\./, "")
519
+ .replace(/^transformed_/, "")
520
+ .replace(/^nba_analytics_/, ""))
521
+ .filter(Boolean)))
522
+ .sort();
523
+ }
524
+ function firstTopicFamily(text, patterns) {
525
+ return patterns.find(([, pattern]) => pattern.test(text))?.[0] ?? "";
526
+ }
527
+ function certifiedBlockSelectionScore(node, preferredNodeIds) {
528
+ let score = preferredNodeIds.has(node.nodeId) ? 10000 : 0;
529
+ score += (node.declaredOutputs?.length ?? 0) * 70;
530
+ score += (node.allowedFilters?.length ?? 0) * 45;
531
+ score += (node.parameterPolicy?.length ?? 0) * 45;
532
+ score += (node.entities?.length ?? 0) * 35;
533
+ score += (node.businessFingerprint?.tokens?.length ?? 0) * 8;
534
+ if (node.grain)
535
+ score += 90;
536
+ if (node.pattern)
537
+ score += 60;
538
+ if (node.sqlFingerprints?.parameterized)
539
+ score += 40;
540
+ if (node.description && node.description.length > 80)
541
+ score += 25;
542
+ const text = [node.name, node.description, node.llmContext, ...(node.tags ?? [])].join(" ").toLowerCase();
543
+ if (/\b(raw-sql|imported|codex-e2e|test)\b/.test(text))
544
+ score -= 80;
545
+ if (/\breview required\b/.test(text))
546
+ score -= 10;
547
+ return score;
548
+ }
410
549
  function findCertifiedContextNodes(kg, prompt, domain, limit) {
411
550
  const kinds = [
412
551
  "business_view",
@@ -458,13 +597,22 @@ function normalizeGoal(prompt) {
458
597
  .trim()
459
598
  .replace(/\.$/, "");
460
599
  }
461
- function businessBriefDescription(prompt, audience, intent) {
462
- return [
463
- `Generated from prompt: ${normalizeGoal(prompt)}.`,
464
- `Audience: ${audience}.`,
465
- `Intent: ${titleCase(intent.replace(/_/g, " "))}.`,
466
- "Use certified tiles as governed evidence; review generated guidance before stakeholder use.",
467
- ].join("\n\n");
600
+ function stakeholderSummaryForPlan(input) {
601
+ // Ground the narrative in the actual certified blocks + filter bar — the assets the
602
+ // app is built on — not just the prompt echo.
603
+ const built = input.certifiedNames && input.certifiedNames.length > 0
604
+ ? ` Built on ${input.certifiedNames.slice(0, 3).join(", ")}${input.certifiedNames.length > 3 ? ", …" : ""}.`
605
+ : "";
606
+ const coverage = input.certifiedTiles > 0
607
+ ? `${input.certifiedTiles} certified tile${input.certifiedTiles === 1 ? "" : "s"} anchor the app.`
608
+ : "No certified tiles matched strongly enough yet.";
609
+ const filterLine = input.filterLabels && input.filterLabels.length > 0
610
+ ? ` One filter set — ${input.filterLabels.slice(0, 4).join(", ")} — refreshes every tile that shares it.`
611
+ : "";
612
+ const review = input.scopedReports > 0
613
+ ? `${input.scopedReports} scoped analysis memo${input.scopedReports === 1 ? "" : "s"} capture questions that need deeper review.`
614
+ : "No scoped analysis memos were needed.";
615
+ return `${input.appName} is a ${titleCase(input.domain)} decision room for ${input.audience}.${built} ${coverage}${filterLine} ${review} Intent: ${titleCase(input.analysisIntent.replace(/_/g, " "))}.`;
468
616
  }
469
617
  function summarizeCertifiedContext(nodes) {
470
618
  const seen = new Set();
@@ -490,13 +638,13 @@ function missingEvidenceForPlan(prompt, intent, certifiedNodes, contextNodes) {
490
638
  const lower = prompt.toLowerCase();
491
639
  const missing = new Set();
492
640
  if (certifiedNodes.length === 0) {
493
- missing.add("No certified block matched strongly enough for the primary dashboard evidence.");
641
+ missing.add("No certified block matched strongly enough for the primary dashboard proof.");
494
642
  }
495
643
  if (contextNodes.length === 0) {
496
- missing.add("No certified business view, term, semantic object, or dbt context was matched for extra explanation.");
644
+ missing.add("No certified business view, term, semantic object, or dbt context was matched for supporting explanation.");
497
645
  }
498
646
  if (intent === "driver_analysis" || /\bwhy|driver|break\s*down/.test(lower)) {
499
- missing.add("Driver analysis should be opened as review-required Research until a certified drilldown block exists.");
647
+ missing.add("Driver analysis should be opened as a review-required report until a certified drilldown block exists.");
500
648
  }
501
649
  if (intent === "trust_review" || /\btrust|rely|lineage/.test(lower)) {
502
650
  missing.add("Leadership trust checks need lineage, owner, caveats, and review cadence confirmation.");
@@ -509,19 +657,19 @@ function missingEvidenceForPlan(prompt, intent, certifiedNodes, contextNodes) {
509
657
  function displayStrategyForIntent(intent) {
510
658
  switch (intent) {
511
659
  case "driver_analysis":
512
- return "Lead with certified summary evidence, then expose breakdown and Research actions for root-cause analysis.";
660
+ return "Lead with certified summary proof, then route root-cause questions into Copilot reports.";
513
661
  case "entity_drilldown":
514
662
  return "Lead with entity context and certified performance blocks, then provide drilldown and trust review paths.";
515
663
  case "trust_review":
516
664
  return "Lead with certification, lineage, caveats, and review tasks before any generated interpretation.";
517
665
  case "data_quality":
518
- return "Lead with availability/freshness evidence, then list gaps and review actions.";
666
+ return "Lead with availability and freshness proof, then list gaps and review actions.";
519
667
  case "experiment_readout":
520
668
  return "Lead with KPI impact, guardrails, segment readout, and decision caveats.";
521
669
  case "metric_monitoring":
522
670
  return "Lead with KPIs, trends, and breakdowns for recurring operating review.";
523
671
  default:
524
- return "Lead with the business brief, certified metrics, supporting breakdowns, then trust and Research actions.";
672
+ return "Lead with the business brief, certified metrics, supporting breakdowns, then proof and report paths.";
525
673
  }
526
674
  }
527
675
  function displayForCertifiedNode(node, viz, index, intent) {
@@ -541,7 +689,7 @@ function displayForCertifiedNode(node, viz, index, intent) {
541
689
  ? "evidence"
542
690
  : /\btop\s*\d+|rank|ranking|leader|leaderboard|scorer|score|goal|segment|breakdown|split|driver|player|customer|account|region|channel\b/.test(text)
543
691
  ? "breakdown"
544
- : "evidence";
692
+ : "breakdown";
545
693
  const followUpActions = [
546
694
  "ask_follow_up",
547
695
  "open_research",
@@ -585,7 +733,7 @@ function displayForDraftTile(title, viz, index) {
585
733
  "review_trust",
586
734
  "create_draft_block",
587
735
  ];
588
- const rationale = "Generated section captures missing evidence or review work without implying certification.";
736
+ const rationale = "Generated section captures missing proof or review work without implying certification.";
589
737
  return {
590
738
  role,
591
739
  recommendedDisplayType: viz,
@@ -709,9 +857,9 @@ function insightTitleForPresentation(title, role, component) {
709
857
  if (component === "BusinessBrief")
710
858
  return "Business context";
711
859
  if (component === "TrustCallout")
712
- return "Trust and evidence";
860
+ return "Proof and lineage";
713
861
  if (component === "ResearchActions")
714
- return "Follow-up research";
862
+ return "Follow-up analysis";
715
863
  if (role === "kpi")
716
864
  return title;
717
865
  if (component === "RankingPanel")
@@ -735,9 +883,11 @@ function inferExpectedGrain(text, intent) {
735
883
  return "metric";
736
884
  return "dashboard";
737
885
  }
738
- function tileFromCertifiedNode(node, index, intent) {
886
+ function tileFromCertifiedNode(node, index, intent, globalFilters = []) {
739
887
  const blockId = node.name;
740
888
  const viz = inferVizForNode(node, index);
889
+ const filterBindings = filterBindingsForCertifiedNode(node, globalFilters);
890
+ const parameterBindings = parameterBindingsForCertifiedNode(node, filterBindings);
741
891
  return {
742
892
  id: slugify(blockId) || `certified-${index + 1}`,
743
893
  title: node.name,
@@ -748,6 +898,20 @@ function tileFromCertifiedNode(node, index, intent) {
748
898
  viz,
749
899
  certification: "certified",
750
900
  reviewStatus: "certified",
901
+ trustState: "certified",
902
+ ...(filterBindings.length ? { filterBindings } : {}),
903
+ ...(parameterBindings.length ? { parameterBindings } : {}),
904
+ sourceEvidence: [{
905
+ source: node.nodeId,
906
+ reason: node.decisionUse ??
907
+ node.businessOutcome ??
908
+ node.description ??
909
+ "Certified DQL block matched the app prompt.",
910
+ kind: node.kind,
911
+ nodeId: node.nodeId,
912
+ path: node.sourcePath,
913
+ trustState: "certified",
914
+ }],
751
915
  display: displayForCertifiedNode(node, viz, index, intent),
752
916
  rationale: node.decisionUse ??
753
917
  node.businessOutcome ??
@@ -758,11 +922,108 @@ function tileFromCertifiedNode(node, index, intent) {
758
922
  ],
759
923
  };
760
924
  }
925
+ function filterBindingsForCertifiedNode(node, globalFilters) {
926
+ const bindings = (node.filterBindings ?? []).map((entry) => ({
927
+ filter: entry.filter,
928
+ binding: entry.binding,
929
+ mode: "predicate",
930
+ }));
931
+ const dynamicParams = new Set((node.parameterPolicy ?? [])
932
+ .filter((entry) => entry.policy === "dynamic")
933
+ .map((entry) => entry.name));
934
+ const allowedFilters = new Set((node.allowedFilters ?? []).map((entry) => entry.toLowerCase()));
935
+ for (const filter of globalFilters) {
936
+ if (bindings.some((entry) => entry.filter === filter.id))
937
+ continue;
938
+ if (dynamicParams.has(filter.id)) {
939
+ bindings.push({
940
+ filter: filter.id,
941
+ binding: filter.bindsTo ?? filter.id,
942
+ mode: "parameter",
943
+ paramNames: [filter.id],
944
+ });
945
+ continue;
946
+ }
947
+ const matchingParam = parameterForFilter(filter, dynamicParams);
948
+ if (matchingParam) {
949
+ bindings.push({
950
+ filter: filter.id,
951
+ binding: filter.bindsTo ?? matchingParam,
952
+ mode: "parameter",
953
+ paramNames: [matchingParam],
954
+ });
955
+ continue;
956
+ }
957
+ if (filter.bindsTo && allowedFilters.has(filter.bindsTo.toLowerCase())) {
958
+ bindings.push({
959
+ filter: filter.id,
960
+ binding: filter.bindsTo,
961
+ mode: "predicate",
962
+ });
963
+ continue;
964
+ }
965
+ if (filter.id && allowedFilters.has(filter.id.toLowerCase())) {
966
+ bindings.push({
967
+ filter: filter.id,
968
+ binding: filter.bindsTo ?? filter.id,
969
+ mode: "predicate",
970
+ });
971
+ continue;
972
+ }
973
+ if (filter.id) {
974
+ bindings.push({
975
+ filter: filter.id,
976
+ binding: filter.bindsTo,
977
+ unsupportedReason: "No matching certified block parameter or allowed filter was declared.",
978
+ });
979
+ }
980
+ }
981
+ return uniqueTileFilterBindings(bindings);
982
+ }
983
+ function parameterBindingsForCertifiedNode(node, filterBindings) {
984
+ const dynamicParams = (node.parameterPolicy ?? [])
985
+ .filter((entry) => entry.policy === "dynamic")
986
+ .map((entry) => entry.name);
987
+ return dynamicParams.map((param) => {
988
+ const filterBinding = filterBindings.find((entry) => entry.paramNames?.includes(param) || entry.filter === param);
989
+ return {
990
+ param,
991
+ source: filterBinding ? "dashboard_filter" : "variable",
992
+ ...(filterBinding?.filter ? { filter: filterBinding.filter } : {}),
993
+ field: filterBinding?.binding ?? param,
994
+ };
995
+ });
996
+ }
997
+ function parameterForFilter(filter, dynamicParams) {
998
+ const candidates = [];
999
+ if (filter.id === "season")
1000
+ candidates.push("season", "season_year", "year");
1001
+ if (filter.id === "season_start")
1002
+ candidates.push("season_start", "start_year", "from_year");
1003
+ if (filter.id === "season_end")
1004
+ candidates.push("season_end", "end_year", "to_year");
1005
+ if (filter.id === "top_n")
1006
+ candidates.push("top_n", "limit", "n");
1007
+ candidates.push(filter.id);
1008
+ return candidates.find((candidate) => dynamicParams.has(candidate)) ?? null;
1009
+ }
1010
+ function uniqueTileFilterBindings(bindings) {
1011
+ const seen = new Set();
1012
+ const out = [];
1013
+ for (const binding of bindings) {
1014
+ const key = `${binding.filter}:${binding.binding ?? ""}:${binding.mode ?? ""}:${(binding.paramNames ?? []).join(",")}:${binding.unsupportedReason ?? ""}`;
1015
+ if (seen.has(key))
1016
+ continue;
1017
+ seen.add(key);
1018
+ out.push(binding);
1019
+ }
1020
+ return out;
1021
+ }
761
1022
  function buildLayoutItems(tiles) {
762
1023
  let x = 0;
763
1024
  let y = 0;
764
1025
  let rowH = 0;
765
- const orderedTiles = [...tiles].sort((a, b) => {
1026
+ const orderedTiles = [...tiles].filter(isDashboardTile).sort((a, b) => {
766
1027
  const priorityA = a.display?.layoutPriority ?? 50;
767
1028
  const priorityB = b.display?.layoutPriority ?? 50;
768
1029
  return priorityA - priorityB;
@@ -799,6 +1060,11 @@ function buildLayoutItems(tiles) {
799
1060
  },
800
1061
  },
801
1062
  display: displayMetadataForTile(tile, genUi),
1063
+ ...(tile.filterBindings?.length ? { filterBindings: tile.filterBindings } : {}),
1064
+ ...(tile.parameterBindings?.length ? { parameterBindings: tile.parameterBindings } : {}),
1065
+ ...(tile.sourceEvidence?.length ? { sourceEvidence: tile.sourceEvidence } : {}),
1066
+ trustState: tile.trustState ?? tile.display?.trustState ?? (tile.certification === "certified" ? "certified" : "draft_ready"),
1067
+ reviewStatus: tile.reviewStatus,
802
1068
  };
803
1069
  if (isDashboardTile(tile)) {
804
1070
  item.block = { blockId: tile.blockId };
@@ -835,9 +1101,25 @@ function isDashboardTile(tile) {
835
1101
  return tile.kind === "certified_block" && tile.certification === "certified" && Boolean(tile.blockId);
836
1102
  }
837
1103
  function appPlanReadme(plan, validation) {
1104
+ const reviewDashboardItems = plan.pages.flatMap((page) => page.tiles
1105
+ .filter((tile) => !isDashboardTile(tile))
1106
+ .map((tile) => `- ${tile.title}: ${tile.description ?? tile.rationale ?? "Review before adding to the app."}`));
1107
+ const scopedReports = plan.scopedReports.length > 0
1108
+ ? plan.scopedReports.map((report) => [
1109
+ `- ${report.title}: ${report.description}`,
1110
+ ` - Question: ${report.question}`,
1111
+ ` - Evidence needed: ${report.evidenceNeeded.join(", ")}`,
1112
+ ].join("\n"))
1113
+ : reviewDashboardItems.length > 0
1114
+ ? reviewDashboardItems
1115
+ : plan.missingEvidence.length > 0
1116
+ ? plan.missingEvidence.map((item) => `- ${item}`)
1117
+ : ["- No scoped analysis memos were suggested. Use app Copilot for follow-up questions."];
838
1118
  return [
839
1119
  `# ${plan.name}`,
840
1120
  "",
1121
+ plan.stakeholderSummary,
1122
+ "",
841
1123
  plan.businessGoal,
842
1124
  "",
843
1125
  `- Generated from prompt: ${plan.prompt}`,
@@ -846,8 +1128,10 @@ function appPlanReadme(plan, validation) {
846
1128
  `- Domain: ${plan.domain}`,
847
1129
  `- Audience: ${plan.audience}`,
848
1130
  `- Lifecycle: ${plan.lifecycle}`,
849
- `- Certified tiles: ${validation.certifiedTiles}`,
850
- `- Review backlog items: ${validation.draftTiles}`,
1131
+ `- Certified dashboard tiles: ${validation.certifiedTiles}`,
1132
+ `- Review-required dashboard tiles: ${validation.draftTiles}`,
1133
+ `- Scoped analysis memos: ${plan.scopedReports.length}`,
1134
+ `- Global filters: ${plan.globalFilters.map((filter) => filter.id).join(", ") || "none"}`,
851
1135
  "",
852
1136
  "## Planner brief",
853
1137
  "",
@@ -861,17 +1145,15 @@ function appPlanReadme(plan, validation) {
861
1145
  ? plan.planning.certifiedContext.map((item) => `- ${item.kind}:${item.name} — ${item.reason}`)
862
1146
  : ["- No certified context matched strongly enough."]),
863
1147
  "",
864
- "## Missing evidence",
1148
+ "## Missing proof",
865
1149
  "",
866
1150
  ...(plan.planning.missingEvidence.length
867
1151
  ? plan.planning.missingEvidence.map((item) => `- ${item}`)
868
- : ["- No missing evidence was identified by the deterministic planner."]),
1152
+ : ["- No missing proof was identified by the deterministic planner."]),
869
1153
  "",
870
- "## Review backlog",
1154
+ "## Scoped analysis memos",
871
1155
  "",
872
- ...plan.pages.flatMap((page) => page.tiles
873
- .filter((tile) => !isDashboardTile(tile))
874
- .map((tile) => `- ${tile.title}: ${tile.description ?? tile.rationale ?? "Review before adding to the app."}`)),
1156
+ ...scopedReports,
875
1157
  "",
876
1158
  "## Agent skills applied",
877
1159
  "",
@@ -942,59 +1224,83 @@ function inferTags(prompt, domain) {
942
1224
  tags.add("monthly-review");
943
1225
  return Array.from(tags).filter(Boolean);
944
1226
  }
945
- function inferDraftTiles(prompt, domain, intent, certifiedNodes) {
1227
+ function inferScopedReports(prompt, domain, intent, certifiedNodes) {
946
1228
  const lower = prompt.toLowerCase();
947
1229
  const domainLabel = titleCase(domain);
948
- const tiles = [
1230
+ const reports = [
949
1231
  {
950
- title: `${domainLabel} decision story`,
951
- description: "Compact draft explanation of the business story, decision context, and caveats inferred from the prompt.",
952
- viz: "text",
1232
+ title: `${domainLabel} decision story report`,
1233
+ question: `What is the decision story behind "${normalizeGoal(prompt)}"?`,
1234
+ description: "Analyst narrative that ties certified results to the business decision, caveats, and recommended next action.",
1235
+ intent,
1236
+ evidenceNeeded: ["certified block results", "active filters", "lineage and owner context"],
953
1237
  },
954
1238
  ];
955
1239
  if (intent === "driver_analysis" || /\bwhy|driver|break\s*down|root cause\b/.test(lower)) {
956
- tiles.push({
957
- title: "Research drilldowns to review",
958
- description: "Open review-required investigations for drivers, exceptions, segment comparison, and entity drilldown.",
959
- viz: "table",
1240
+ reports.push({
1241
+ title: "Driver analysis report",
1242
+ question: "Which drivers, segments, or entities explain the movement?",
1243
+ description: "Review-required report for drivers, exceptions, segment comparison, and entity drilldown.",
1244
+ intent: "driver_analysis",
1245
+ evidenceNeeded: ["comparison period", "segment fields", "metric definition", "previewed SQL"],
960
1246
  });
961
1247
  }
962
1248
  else if (/\brisk|caveat|issue|anomal|quality\b/.test(lower)) {
963
- tiles.push({
964
- title: "Open risks and caveats",
965
- description: "Generated review checklist for risks that need certified evidence before stakeholder use.",
966
- viz: "table",
1249
+ reports.push({
1250
+ title: "Risk and caveat report",
1251
+ question: "Which caveats or quality risks should stakeholders understand before using this app?",
1252
+ description: "Review checklist for risks that need certified evidence before stakeholder use.",
1253
+ intent: "data_quality",
1254
+ evidenceNeeded: ["freshness checks", "row counts", "known caveats", "owner review"],
967
1255
  });
968
1256
  }
969
1257
  else if (/\bsegment|cohort|region|location|product|channel|customer\b/.test(lower)) {
970
- tiles.push({
971
- title: "Missing drilldowns to certify",
1258
+ reports.push({
1259
+ title: "Drilldown certification report",
1260
+ question: "Which slices or drilldowns should become reusable certified blocks?",
972
1261
  description: "Candidate slices and drilldowns the agent could not fully back with certified blocks yet.",
973
- viz: "table",
1262
+ intent: "entity_drilldown",
1263
+ evidenceNeeded: ["slice dimensions", "grain", "allowed filters", "previewed SQL"],
974
1264
  });
975
1265
  }
976
1266
  else {
977
- tiles.push({
978
- title: "Trust and evidence gaps",
979
- description: "Open evidence, lineage, and review tasks to complete before this app is governed.",
980
- viz: "table",
1267
+ reports.push({
1268
+ title: "Proof and lineage gaps",
1269
+ question: "What proof is still missing before this app can be treated as governed?",
1270
+ description: "Open proof, lineage, and review tasks to complete before this app is governed.",
1271
+ intent: "proof_review",
1272
+ evidenceNeeded: ["lineage", "certification state", "tests", "review cadence"],
981
1273
  });
982
1274
  }
983
1275
  if (certifiedNodes.length === 0) {
984
- tiles.push({
1276
+ reports.push({
985
1277
  title: "Certified block search",
1278
+ question: "Which existing or new DQL blocks should support this app?",
986
1279
  description: "No certified blocks matched strongly enough; review suggested sources and create certified blocks.",
987
- viz: "table",
1280
+ intent: "proof_review",
1281
+ evidenceNeeded: ["candidate dbt models", "semantic metrics", "business terms", "draft block plan"],
988
1282
  });
989
1283
  }
990
- if (!tiles.some((tile) => /\btrust|evidence|risk|caveat|gap/.test(tile.title.toLowerCase()))) {
991
- tiles.push({
992
- title: "Trust and evidence gaps",
1284
+ if (!reports.some((report) => /\btrust|evidence|risk|caveat|gap|proof/.test(report.title.toLowerCase()))) {
1285
+ reports.push({
1286
+ title: "Proof and lineage gaps",
1287
+ question: "What lineage and certification proof should be reviewed?",
993
1288
  description: "Certification, lineage, caveats, and review actions that must be confirmed before stakeholder use.",
994
- viz: "table",
1289
+ intent: "proof_review",
1290
+ evidenceNeeded: ["lineage", "certification state", "owner", "tests"],
995
1291
  });
996
1292
  }
997
- return tiles;
1293
+ return reports.map((report, index) => ({
1294
+ id: slugify(report.title) || `scoped-report-${index + 1}`,
1295
+ title: report.title,
1296
+ question: report.question,
1297
+ description: report.description,
1298
+ intent: report.intent,
1299
+ reviewStatus: "draft_ready",
1300
+ source: "app_builder",
1301
+ evidenceNeeded: report.evidenceNeeded,
1302
+ suggestedActions: ["open_research", "review_trust", "create_draft_block"],
1303
+ }));
998
1304
  }
999
1305
  function inferDomain(prompt) {
1000
1306
  const lower = prompt.toLowerCase();
@@ -1026,6 +1332,12 @@ function inferAudience(prompt) {
1026
1332
  function inferFilters(prompt) {
1027
1333
  const filters = [];
1028
1334
  const lower = prompt.toLowerCase();
1335
+ const wantsSeasonStart = /\bseason[_\s-]*start\b/.test(lower);
1336
+ const wantsSeasonEnd = /\bseason[_\s-]*end\b/.test(lower);
1337
+ const wantsTopN = /\btop[_\s-]*n\b/.test(lower);
1338
+ const years = Array.from(new Set(Array.from(prompt.matchAll(/\b(20\d{2}|19\d{2})\b/g)).map((match) => Number(match[1]))))
1339
+ .filter((year) => Number.isFinite(year))
1340
+ .sort((a, b) => a - b);
1029
1341
  if (/\bweekly|week|last week|this week\b/.test(lower)) {
1030
1342
  filters.push({
1031
1343
  id: "week",
@@ -1042,6 +1354,47 @@ function inferFilters(prompt) {
1042
1354
  bindsTo: "date",
1043
1355
  });
1044
1356
  }
1357
+ if (wantsSeasonStart || wantsSeasonEnd || /\bseason|year|annual|nba\b/.test(lower) || years.length > 0) {
1358
+ if (years.length >= 2 || wantsSeasonStart || wantsSeasonEnd) {
1359
+ filters.push({
1360
+ id: "season_start",
1361
+ label: "Season start",
1362
+ type: "select",
1363
+ ...(years[0] ? { default: years[0] } : {}),
1364
+ ...(years.length ? { options: years.map(String) } : {}),
1365
+ bindsTo: "game_date_est.year",
1366
+ });
1367
+ filters.push({
1368
+ id: "season_end",
1369
+ label: "Season end",
1370
+ type: "select",
1371
+ ...(years[years.length - 1] ? { default: years[years.length - 1] } : {}),
1372
+ ...(years.length ? { options: years.map(String) } : {}),
1373
+ bindsTo: "game_date_est.year",
1374
+ });
1375
+ }
1376
+ else {
1377
+ filters.push({
1378
+ id: "season",
1379
+ label: "Season",
1380
+ type: "select",
1381
+ ...(years[0] ? { default: years[0] } : {}),
1382
+ ...(years.length ? { options: years.map(String) } : {}),
1383
+ bindsTo: "game_date_est.year",
1384
+ });
1385
+ }
1386
+ }
1387
+ const topN = /\btop(?:[_\s-]*n)?\s+(\d+)\b/i.exec(prompt)?.[1]
1388
+ ?? /\btop[_\s-]*n\s*[=:]?\s*(\d+)\b/i.exec(prompt)?.[1];
1389
+ if (topN || wantsTopN) {
1390
+ filters.push({
1391
+ id: "top_n",
1392
+ label: "Top N",
1393
+ type: "number",
1394
+ ...(topN ? { default: Number(topN) } : {}),
1395
+ bindsTo: "limit",
1396
+ });
1397
+ }
1045
1398
  if (/\benterprise|segment|smb|mid-market\b/.test(lower)) {
1046
1399
  filters.push({
1047
1400
  id: "segment",
@@ -1050,8 +1403,90 @@ function inferFilters(prompt) {
1050
1403
  bindsTo: "segment",
1051
1404
  });
1052
1405
  }
1406
+ return uniquePlanFilters(filters);
1407
+ }
1408
+ function uniquePlanFilters(filters) {
1409
+ const seen = new Set();
1410
+ const out = [];
1411
+ for (const filter of filters) {
1412
+ if (seen.has(filter.id))
1413
+ continue;
1414
+ seen.add(filter.id);
1415
+ out.push(filter);
1416
+ }
1417
+ return out;
1418
+ }
1419
+ const APP_FILTER_TIME_RE = /(_at$|_date$|_time$|_ts$|^date$|^month$|^week$|^day$|ordered_at|created)/i;
1420
+ const APP_FILTER_NUMBER_RE = /(top[_-]?n|limit|count|amount|year|score|rank)/i;
1421
+ /** Map a block filter column to the dashboard control the runtime filter engine renders. */
1422
+ function controlTypeForColumn(column) {
1423
+ if (APP_FILTER_TIME_RE.test(column))
1424
+ return "daterange";
1425
+ if (APP_FILTER_NUMBER_RE.test(column))
1426
+ return "number";
1427
+ return "select"; // categorical → dropdown (options filled by /api/dashboard/filter-options)
1428
+ }
1429
+ /**
1430
+ * Derive the global filter bar from what the certified tiles ACTUALLY accept — the
1431
+ * union of their declared `allowedFilters` (a block's governed, app-safe filters) —
1432
+ * instead of guessing from prompt words. Each control binds to a real column, so the
1433
+ * existing tile-binding logic refreshes exactly the tiles that declare it.
1434
+ */
1435
+ function filtersFromCertifiedNodes(nodes) {
1436
+ const seen = new Set();
1437
+ const filters = [];
1438
+ for (const node of nodes) {
1439
+ for (const column of node.allowedFilters ?? []) {
1440
+ const key = column.toLowerCase();
1441
+ if (!column.trim() || seen.has(key))
1442
+ continue;
1443
+ seen.add(key);
1444
+ filters.push({
1445
+ id: column,
1446
+ label: titleCase(column.replace(/[_.]+/g, " ")),
1447
+ type: controlTypeForColumn(column),
1448
+ bindsTo: column,
1449
+ });
1450
+ }
1451
+ }
1053
1452
  return filters;
1054
1453
  }
1454
+ /**
1455
+ * Merge the block-derived bar (authoritative) with prompt-inferred filters: copy a
1456
+ * prompt default/options onto a matching column control, keep prompt-only PARAM
1457
+ * controls (e.g. top_n → LIMIT), and DROP prompt column filters no tile supports.
1458
+ */
1459
+ function mergeAppFilters(blockFilters, promptFilters) {
1460
+ const merged = blockFilters.map((f) => ({ ...f }));
1461
+ // Match by exact id first (a prompt filter for the same governed filter), else by a
1462
+ // prompt filter that binds to a column which IS a block filter. Avoid matching on a
1463
+ // shared physical bindsTo — that collides once two filters resolve to the same column.
1464
+ const matchOf = (pf) => merged.find((bf) => bf.id.toLowerCase() === pf.id.toLowerCase()) ??
1465
+ (pf.bindsTo
1466
+ ? merged.find((bf) => bf.id.toLowerCase() === pf.bindsTo.toLowerCase())
1467
+ : undefined);
1468
+ for (const pf of promptFilters) {
1469
+ const match = matchOf(pf);
1470
+ if (match) {
1471
+ if (pf.default !== undefined && match.default === undefined)
1472
+ match.default = pf.default;
1473
+ if (pf.options && !match.options)
1474
+ match.options = pf.options;
1475
+ // Prefer the prompt's more specific physical binding when the block filter only
1476
+ // self-binds (bindsTo === id) and the prompt resolved an actual column/expression.
1477
+ if (pf.bindsTo && (!match.bindsTo || match.bindsTo.toLowerCase() === match.id.toLowerCase())) {
1478
+ match.bindsTo = pf.bindsTo;
1479
+ }
1480
+ continue;
1481
+ }
1482
+ // A param-style control (LIMIT, not a column predicate) is always useful — keep it.
1483
+ if (pf.bindsTo === "limit" || /^(top_n|limit)$/i.test(pf.id)) {
1484
+ merged.push({ ...pf });
1485
+ }
1486
+ // else: a prompt column filter that no certified tile accepts — drop it (no orphans).
1487
+ }
1488
+ return uniquePlanFilters(merged);
1489
+ }
1055
1490
  function inferReviewCadence(prompt) {
1056
1491
  const lower = prompt.toLowerCase();
1057
1492
  if (lower.includes("daily"))