@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.
- package/dist/apps-api.d.ts +216 -2
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +1767 -46
- package/dist/apps-api.js.map +1 -1
- package/dist/args.d.ts +10 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +19 -0
- package/dist/args.js.map +1 -1
- package/dist/assets/dql-notebook/assets/{codemirror-DJYUkPr1.js → codemirror-Co0dCEDr.js} +1 -1
- package/dist/assets/dql-notebook/assets/index-D9Owd4EN.css +1 -0
- package/dist/assets/dql-notebook/assets/index-nDZHVgfg.js +5636 -0
- package/dist/assets/dql-notebook/assets/{react-CRB3T2We.js → react-BRY8hXab.js} +2 -2
- package/dist/assets/dql-notebook/index.html +4 -4
- package/dist/commands/agent.d.ts +1 -1
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +50 -10
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/app.d.ts +4 -4
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +22 -19
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/certify.d.ts.map +1 -1
- package/dist/commands/certify.js +70 -25
- package/dist/commands/certify.js.map +1 -1
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +108 -5
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/doctor.d.ts +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +68 -7
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/eval.d.ts +121 -0
- package/dist/commands/eval.d.ts.map +1 -0
- package/dist/commands/eval.js +350 -0
- package/dist/commands/eval.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +50 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/notebook.d.ts +15 -0
- package/dist/commands/notebook.d.ts.map +1 -1
- package/dist/commands/notebook.js +32 -11
- package/dist/commands/notebook.js.map +1 -1
- package/dist/commands/propose.d.ts +23 -0
- package/dist/commands/propose.d.ts.map +1 -0
- package/dist/commands/propose.js +184 -0
- package/dist/commands/propose.js.map +1 -0
- package/dist/index.js +64 -11
- package/dist/index.js.map +1 -1
- package/dist/llm/analytics-tools.d.ts.map +1 -1
- package/dist/llm/analytics-tools.js +9 -4
- package/dist/llm/analytics-tools.js.map +1 -1
- package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
- package/dist/llm/providers/dql-agent-provider.js +21 -32
- package/dist/llm/providers/dql-agent-provider.js.map +1 -1
- package/dist/llm/providers/native-sdk-provider.d.ts.map +1 -1
- package/dist/llm/providers/native-sdk-provider.js +5 -2
- package/dist/llm/providers/native-sdk-provider.js.map +1 -1
- package/dist/local-runtime.d.ts +132 -4
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +2120 -46
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- package/dist/propose-enrich.d.ts +13 -0
- package/dist/propose-enrich.d.ts.map +1 -0
- package/dist/propose-enrich.js +34 -0
- package/dist/propose-enrich.js.map +1 -0
- package/dist/settings/provider-settings.js +2 -2
- package/dist/settings/provider-settings.js.map +1 -1
- package/package.json +11 -11
- package/dist/assets/dql-notebook/assets/index-Dgqvyn5s.js +0 -3886
- 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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 ||
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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(
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
|
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:
|
|
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
|
|
1544
|
-
const
|
|
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
|
|
1712
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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', '
|
|
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
|
|
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
|
|
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
|
|
2070
|
-
return `
|
|
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
|
|
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
|
|
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;
|