@duckcodeailabs/dql-agent 1.6.7 → 1.6.9
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/answer-loop.d.ts +12 -0
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +998 -52
- package/dist/answer-loop.js.map +1 -1
- package/dist/app-builder.d.ts +27 -0
- package/dist/app-builder.d.ts.map +1 -1
- package/dist/app-builder.js +191 -14
- package/dist/app-builder.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts +52 -0
- package/dist/metadata/analysis-planner.d.ts.map +1 -0
- package/dist/metadata/analysis-planner.js +632 -0
- package/dist/metadata/analysis-planner.js.map +1 -0
- package/dist/metadata/catalog.d.ts +32 -0
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +705 -22
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/sql-context-validation.d.ts.map +1 -1
- package/dist/metadata/sql-context-validation.js +41 -13
- package/dist/metadata/sql-context-validation.js.map +1 -1
- package/dist/metadata/sql-shape.d.ts +13 -0
- package/dist/metadata/sql-shape.d.ts.map +1 -0
- package/dist/metadata/sql-shape.js +92 -0
- package/dist/metadata/sql-shape.js.map +1 -0
- package/package.json +4 -4
package/dist/metadata/catalog.js
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
* context pack before answering.
|
|
8
8
|
*/
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
|
-
import { mkdirSync } from 'node:fs';
|
|
10
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
11
11
|
import { createRequire } from 'node:module';
|
|
12
12
|
import { dirname, join } from 'node:path';
|
|
13
13
|
import { buildManifest, loadProjectConfig, resolveDbtManifestPath, resolveSemanticLayerWithDiagnostics, } from '@duckcodeailabs/dql-core';
|
|
14
14
|
import { buildKGFromManifest, buildKGFromSemanticLayer } from '../kg/build.js';
|
|
15
|
+
import { buildAnalysisQuestionPlan, certifiedApplicabilityForObject, scoreAllowedSqlRelationWithAnalysisPlan, scoreMetadataObjectWithAnalysisPlan, sortAllowedSqlContextForAnalysisPlan, } from './analysis-planner.js';
|
|
16
|
+
import { extractSimpleSelectShape, sourceSqlShapeColumns } from './sql-shape.js';
|
|
15
17
|
const require = createRequire(import.meta.url);
|
|
16
18
|
let databaseCtor = null;
|
|
17
19
|
function loadDatabase() {
|
|
@@ -98,23 +100,30 @@ export async function buildLocalContextPack(projectRoot, request) {
|
|
|
98
100
|
try {
|
|
99
101
|
const mode = request.mode ?? 'question';
|
|
100
102
|
const followUp = normalizeFollowUpContext(request.followUp);
|
|
103
|
+
const questionPlan = buildAnalysisQuestionPlan(request.question, followUp ?? undefined);
|
|
101
104
|
const runtimeSnapshot = request.runtimeSchemaSnapshot ?? catalog.latestRuntimeSchemaSnapshot();
|
|
102
105
|
const runtimeObjects = runtimeSnapshot ? runtimeSchemaObjects(runtimeSnapshot) : [];
|
|
103
106
|
const selectedObjects = selectedContextObjects(request.selectedContext);
|
|
104
107
|
const followUpObjects = followUpContextObjects(followUp);
|
|
105
108
|
const followUpSourceObjects = catalog.getObjectsByKeys(followUpSourceObjectKeys(followUp));
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
const searchQueries = uniqueMetadataSearchQueries([
|
|
110
|
+
buildFollowUpSearchQuery(request.question, followUp),
|
|
111
|
+
...questionPlan.searchQueries,
|
|
112
|
+
]);
|
|
113
|
+
const searchRows = mergeObjects(searchQueries.flatMap((query) => catalog.searchObjects({
|
|
114
|
+
query,
|
|
109
115
|
objectTypes: request.objectTypes,
|
|
110
116
|
limit: Math.max(request.limit ?? 80, 20),
|
|
111
|
-
});
|
|
117
|
+
})));
|
|
118
|
+
const schemaShapeCandidates = schemaShapeCandidateObjects(catalog, questionPlan, request, runtimeObjects);
|
|
119
|
+
const schemaShapeObjects = schemaShapeCandidates.map((candidate) => candidate.object);
|
|
112
120
|
const exact = request.focusObjectKey ? catalog.getObject(request.focusObjectKey) : null;
|
|
113
121
|
const ranked = rankMetadataObjects({
|
|
114
122
|
rows: mergeObjects(exact
|
|
115
|
-
? [exact, ...followUpSourceObjects, ...followUpObjects, ...searchRows, ...runtimeObjects, ...selectedObjects]
|
|
116
|
-
: [...followUpSourceObjects, ...followUpObjects, ...searchRows, ...runtimeObjects, ...selectedObjects]),
|
|
117
|
-
question:
|
|
123
|
+
? [exact, ...followUpSourceObjects, ...followUpObjects, ...searchRows, ...schemaShapeObjects, ...runtimeObjects, ...selectedObjects]
|
|
124
|
+
: [...followUpSourceObjects, ...followUpObjects, ...searchRows, ...schemaShapeObjects, ...runtimeObjects, ...selectedObjects]),
|
|
125
|
+
question: searchQueries.join(' '),
|
|
126
|
+
questionPlan,
|
|
118
127
|
limit: request.limit ?? 80,
|
|
119
128
|
});
|
|
120
129
|
const selected = mergeObjects([...followUpSourceObjects, ...followUpObjects, ...ranked.selected]);
|
|
@@ -122,11 +131,14 @@ export async function buildLocalContextPack(projectRoot, request) {
|
|
|
122
131
|
const edgeWalk = catalog.edgesForKeys(selected.map((row) => row.objectKey), 3);
|
|
123
132
|
const edgeObjectKeys = Array.from(new Set(edgeWalk.flatMap((edge) => [edge.fromKey, edge.toKey])));
|
|
124
133
|
const graphObjects = catalog.getObjectsByKeys(edgeObjectKeys);
|
|
125
|
-
const
|
|
126
|
-
rows: mergeObjects([...followUpSourceObjects, ...followUpObjects, ...selected, ...graphObjects, ...runtimeObjects, ...selectedObjects]),
|
|
127
|
-
question:
|
|
134
|
+
const rankedObjects = rankMetadataObjects({
|
|
135
|
+
rows: mergeObjects([...followUpSourceObjects, ...followUpObjects, ...selected, ...graphObjects, ...schemaShapeObjects, ...runtimeObjects, ...selectedObjects]),
|
|
136
|
+
question: searchQueries.join(' '),
|
|
137
|
+
questionPlan,
|
|
128
138
|
limit: request.limit ?? 120,
|
|
129
139
|
}).selected;
|
|
140
|
+
const sqlParentObjects = sqlParentObjectsForSelectedColumns(rankedObjects, mergeObjects([...graphObjects, ...runtimeObjects]), questionPlan);
|
|
141
|
+
const objects = mergeObjects([...rankedObjects, ...sqlParentObjects]);
|
|
130
142
|
const objectKeys = objects.map((row) => row.objectKey);
|
|
131
143
|
const queryRuns = catalog.queryRunsForObjectKeys(objectKeys, 20);
|
|
132
144
|
const diagnostics = catalog.diagnostics();
|
|
@@ -134,11 +146,25 @@ export async function buildLocalContextPack(projectRoot, request) {
|
|
|
134
146
|
const trustLabel = deriveTrust(objects);
|
|
135
147
|
const citations = buildCitations(objects, edgeWalk);
|
|
136
148
|
const evidenceSummaries = buildEvidenceSummaries(objects, edgeWalk, queryRuns, diagnostics);
|
|
137
|
-
const allowedSqlContext = buildAllowedSqlContext(objects, edgeWalk);
|
|
149
|
+
const allowedSqlContext = sortAllowedSqlContextForAnalysisPlan(buildAllowedSqlContext(objects, edgeWalk), questionPlan);
|
|
150
|
+
const selectedRelations = allowedSqlContext.relations.slice(0, 24).map((relation, index) => {
|
|
151
|
+
const scored = scoreAllowedSqlRelationWithAnalysisPlan(relation, questionPlan);
|
|
152
|
+
return {
|
|
153
|
+
relation: relation.relation,
|
|
154
|
+
name: relation.name,
|
|
155
|
+
source: relation.source,
|
|
156
|
+
score: scored.score,
|
|
157
|
+
reason: scored.reasons.join('; ') || 'relation retained as inspected SQL context',
|
|
158
|
+
columns: relation.columns.slice(0, 24).map((column) => column.name),
|
|
159
|
+
rank: index + 1,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
const selectedJoinPaths = buildSelectedJoinPaths(allowedSqlContext);
|
|
138
163
|
const evidenceRoles = buildEvidenceRoles(objects, queryRuns);
|
|
139
164
|
const reranked = rankMetadataObjects({
|
|
140
|
-
rows: mergeObjects([...searchRows, ...objects]),
|
|
141
|
-
question:
|
|
165
|
+
rows: mergeObjects([...searchRows, ...schemaShapeObjects, ...objects]),
|
|
166
|
+
question: searchQueries.join(' '),
|
|
167
|
+
questionPlan,
|
|
142
168
|
limit: request.limit ?? 120,
|
|
143
169
|
});
|
|
144
170
|
const conflicts = buildCandidateConflicts(reranked.ranked);
|
|
@@ -149,6 +175,7 @@ export async function buildLocalContextPack(projectRoot, request) {
|
|
|
149
175
|
evidenceRoles,
|
|
150
176
|
diagnostics,
|
|
151
177
|
trustLabel,
|
|
178
|
+
questionPlan,
|
|
152
179
|
});
|
|
153
180
|
const payload = {
|
|
154
181
|
id: '',
|
|
@@ -156,6 +183,7 @@ export async function buildLocalContextPack(projectRoot, request) {
|
|
|
156
183
|
followUp: followUp ?? undefined,
|
|
157
184
|
focusObjectKey,
|
|
158
185
|
mode,
|
|
186
|
+
questionPlan,
|
|
159
187
|
trustLabel,
|
|
160
188
|
objects,
|
|
161
189
|
edges: edgeWalk,
|
|
@@ -180,6 +208,15 @@ export async function buildLocalContextPack(projectRoot, request) {
|
|
|
180
208
|
score: item.score,
|
|
181
209
|
priorityTier: item.priorityTier,
|
|
182
210
|
})),
|
|
211
|
+
selectedRelations,
|
|
212
|
+
selectedJoinPaths,
|
|
213
|
+
schemaShapeCandidates: schemaShapeCandidates.slice(0, 16).map((candidate) => ({
|
|
214
|
+
objectKey: candidate.object.objectKey,
|
|
215
|
+
relation: candidate.relation.relation,
|
|
216
|
+
score: candidate.score,
|
|
217
|
+
reason: candidate.reasons.join('; '),
|
|
218
|
+
columns: candidate.relation.columns.slice(0, 16).map((column) => column.name),
|
|
219
|
+
})),
|
|
183
220
|
topRejected: reranked.rejected,
|
|
184
221
|
candidateConflicts: conflicts,
|
|
185
222
|
},
|
|
@@ -255,6 +292,7 @@ export function buildMetadataSnapshot(projectRoot, manifest, semanticLayer) {
|
|
|
255
292
|
}
|
|
256
293
|
addManifestBlockDetails(manifest, objects);
|
|
257
294
|
addDbtDagObjects(manifest, objects, edges, diagnostics);
|
|
295
|
+
addRawDbtManifestCatalogObjects(projectRoot, manifest, objects, edges, diagnostics);
|
|
258
296
|
addBlockDependencyEdges(manifest, edges);
|
|
259
297
|
const nodeKeyMap = new Map();
|
|
260
298
|
for (const node of [...manifestGraph.nodes, ...semanticGraph.nodes]) {
|
|
@@ -490,6 +528,41 @@ export class MetadataCatalog {
|
|
|
490
528
|
`).all(...params, options.limit ?? 100);
|
|
491
529
|
return rows.map(rowToObject);
|
|
492
530
|
}
|
|
531
|
+
scanObjects(options, visit) {
|
|
532
|
+
const filters = [];
|
|
533
|
+
const params = [];
|
|
534
|
+
if (options.objectTypes && options.objectTypes.length > 0) {
|
|
535
|
+
filters.push(`object_type IN (${options.objectTypes.map(() => '?').join(', ')})`);
|
|
536
|
+
params.push(...options.objectTypes);
|
|
537
|
+
}
|
|
538
|
+
if (options.domain) {
|
|
539
|
+
filters.push('domain = ?');
|
|
540
|
+
params.push(options.domain);
|
|
541
|
+
}
|
|
542
|
+
const batchSize = Math.max(1, options.batchSize ?? 500);
|
|
543
|
+
let lastObjectKey = '';
|
|
544
|
+
while (true) {
|
|
545
|
+
const pageFilters = [...filters];
|
|
546
|
+
const pageParams = [...params];
|
|
547
|
+
if (lastObjectKey) {
|
|
548
|
+
pageFilters.push('object_key > ?');
|
|
549
|
+
pageParams.push(lastObjectKey);
|
|
550
|
+
}
|
|
551
|
+
const where = pageFilters.length > 0 ? `WHERE ${pageFilters.join(' AND ')}` : '';
|
|
552
|
+
const rows = this.db.prepare(`
|
|
553
|
+
SELECT * FROM metadata_objects
|
|
554
|
+
${where}
|
|
555
|
+
ORDER BY object_key
|
|
556
|
+
LIMIT ?
|
|
557
|
+
`).all(...pageParams, batchSize);
|
|
558
|
+
if (rows.length === 0)
|
|
559
|
+
break;
|
|
560
|
+
visit(rows.map(rowToObject));
|
|
561
|
+
lastObjectKey = rows[rows.length - 1]?.object_key ?? lastObjectKey;
|
|
562
|
+
if (rows.length < batchSize)
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
493
566
|
getObject(objectKey) {
|
|
494
567
|
const row = this.db.prepare('SELECT * FROM metadata_objects WHERE object_key = ?').get(objectKey);
|
|
495
568
|
return row ? rowToObject(row) : null;
|
|
@@ -822,6 +895,151 @@ function addDbtDagObjects(manifest, objects, edges, diagnostics) {
|
|
|
822
895
|
});
|
|
823
896
|
}
|
|
824
897
|
}
|
|
898
|
+
function addRawDbtManifestCatalogObjects(projectRoot, manifest, objects, edges, diagnostics) {
|
|
899
|
+
const manifestPath = manifest.dbtImport?.manifestPath ?? resolveDbtManifestPath(projectRoot);
|
|
900
|
+
if (!manifestPath)
|
|
901
|
+
return;
|
|
902
|
+
let raw;
|
|
903
|
+
try {
|
|
904
|
+
raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
diagnostics.push({
|
|
908
|
+
kind: 'dbt',
|
|
909
|
+
severity: 'warning',
|
|
910
|
+
message: `Could not read dbt manifest catalog metadata from ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
911
|
+
filePath: manifestPath,
|
|
912
|
+
});
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
for (const [uniqueId, node] of Object.entries(raw.nodes ?? {})) {
|
|
916
|
+
if (node.resource_type !== 'model')
|
|
917
|
+
continue;
|
|
918
|
+
const name = rawDbtName(node);
|
|
919
|
+
if (!name)
|
|
920
|
+
continue;
|
|
921
|
+
addRawDbtCatalogObject({
|
|
922
|
+
uniqueId,
|
|
923
|
+
name,
|
|
924
|
+
node,
|
|
925
|
+
objectKey: `dbt:model:${name}`,
|
|
926
|
+
objectType: 'dbt_model',
|
|
927
|
+
objects,
|
|
928
|
+
edges,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
for (const [uniqueId, source] of Object.entries(raw.sources ?? {})) {
|
|
932
|
+
const name = rawDbtName(source);
|
|
933
|
+
if (!name)
|
|
934
|
+
continue;
|
|
935
|
+
addRawDbtCatalogObject({
|
|
936
|
+
uniqueId,
|
|
937
|
+
name,
|
|
938
|
+
node: source,
|
|
939
|
+
objectKey: `dbt:source:${name}`,
|
|
940
|
+
objectType: 'dbt_source',
|
|
941
|
+
objects,
|
|
942
|
+
edges,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
function addRawDbtCatalogObject(input) {
|
|
947
|
+
const existing = input.objects.get(input.objectKey);
|
|
948
|
+
const database = stringValue(input.node.database);
|
|
949
|
+
const schema = stringValue(input.node.schema);
|
|
950
|
+
const relation = [database, schema, input.name].filter(Boolean).join('.');
|
|
951
|
+
const columns = rawDbtColumns(input.node.columns);
|
|
952
|
+
input.objects.set(input.objectKey, mergeObject(existing, {
|
|
953
|
+
objectKey: input.objectKey,
|
|
954
|
+
objectType: input.objectType,
|
|
955
|
+
name: input.name,
|
|
956
|
+
fullName: relation || input.name,
|
|
957
|
+
description: stringValue(input.node.description),
|
|
958
|
+
status: existing?.status ?? 'dbt_catalog',
|
|
959
|
+
sourcePath: stringValue(input.node.original_file_path) ?? stringValue(input.node.path),
|
|
960
|
+
sourceSystem: existing?.sourceSystem ?? 'dbt manifest.json catalog',
|
|
961
|
+
payload: compactObject({
|
|
962
|
+
...(existing?.payload ?? {}),
|
|
963
|
+
uniqueId: input.uniqueId,
|
|
964
|
+
relation,
|
|
965
|
+
database,
|
|
966
|
+
schema,
|
|
967
|
+
materialized: rawDbtMaterialization(input.node),
|
|
968
|
+
dependsOn: rawDbtDependsOn(input.node),
|
|
969
|
+
tags: metadataStringArray(input.node.tags),
|
|
970
|
+
catalogOnly: existing ? undefined : true,
|
|
971
|
+
columns,
|
|
972
|
+
}),
|
|
973
|
+
}));
|
|
974
|
+
for (const column of columns) {
|
|
975
|
+
const columnKey = `dbt:column:${input.name}.${column.name}`;
|
|
976
|
+
const existingColumn = input.objects.get(columnKey);
|
|
977
|
+
input.objects.set(columnKey, mergeObject(existingColumn, {
|
|
978
|
+
objectKey: columnKey,
|
|
979
|
+
objectType: 'dbt_column',
|
|
980
|
+
name: column.name,
|
|
981
|
+
fullName: `${input.name}.${column.name}`,
|
|
982
|
+
description: column.description,
|
|
983
|
+
status: existingColumn?.status ?? 'dbt_catalog',
|
|
984
|
+
sourcePath: stringValue(input.node.original_file_path) ?? stringValue(input.node.path),
|
|
985
|
+
sourceSystem: existingColumn?.sourceSystem ?? 'dbt manifest.json catalog',
|
|
986
|
+
payload: compactObject({
|
|
987
|
+
...(existingColumn?.payload ?? {}),
|
|
988
|
+
model: input.name,
|
|
989
|
+
uniqueId: input.uniqueId,
|
|
990
|
+
type: column.type,
|
|
991
|
+
relation,
|
|
992
|
+
catalogOnly: existingColumn ? undefined : true,
|
|
993
|
+
}),
|
|
994
|
+
}));
|
|
995
|
+
putEdge(input.edges, {
|
|
996
|
+
edgeType: 'contains',
|
|
997
|
+
fromKey: input.objectKey,
|
|
998
|
+
toKey: columnKey,
|
|
999
|
+
confidence: 1,
|
|
1000
|
+
payload: { source: 'raw dbt manifest column catalog' },
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
for (const dep of rawDbtDependsOn(input.node)) {
|
|
1004
|
+
putEdge(input.edges, {
|
|
1005
|
+
edgeType: 'depends_on',
|
|
1006
|
+
fromKey: input.objectKey,
|
|
1007
|
+
toKey: dbtDependencyKey(dep),
|
|
1008
|
+
confidence: 1,
|
|
1009
|
+
payload: { source: 'raw dbt manifest depends_on catalog', uniqueId: dep },
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
function rawDbtName(node) {
|
|
1014
|
+
return stringValue(node.alias) ?? stringValue(node.identifier) ?? stringValue(node.name);
|
|
1015
|
+
}
|
|
1016
|
+
function rawDbtMaterialization(node) {
|
|
1017
|
+
const config = node.config && typeof node.config === 'object' ? node.config : null;
|
|
1018
|
+
return stringValue(config?.materialized);
|
|
1019
|
+
}
|
|
1020
|
+
function rawDbtDependsOn(node) {
|
|
1021
|
+
const dependsOn = node.depends_on && typeof node.depends_on === 'object'
|
|
1022
|
+
? node.depends_on
|
|
1023
|
+
: null;
|
|
1024
|
+
return metadataStringArray(dependsOn?.nodes);
|
|
1025
|
+
}
|
|
1026
|
+
function rawDbtColumns(value) {
|
|
1027
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1028
|
+
return [];
|
|
1029
|
+
return Object.values(value).flatMap((entry) => {
|
|
1030
|
+
if (!entry || typeof entry !== 'object')
|
|
1031
|
+
return [];
|
|
1032
|
+
const record = entry;
|
|
1033
|
+
const name = stringValue(record.name) ?? stringValue(record.column_name);
|
|
1034
|
+
if (!name)
|
|
1035
|
+
return [];
|
|
1036
|
+
return [{
|
|
1037
|
+
name,
|
|
1038
|
+
type: stringValue(record.data_type) ?? stringValue(record.type),
|
|
1039
|
+
description: stringValue(record.description),
|
|
1040
|
+
}];
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
825
1043
|
function addManifestBlockDetails(manifest, objects) {
|
|
826
1044
|
for (const block of Object.values(manifest.blocks ?? {})) {
|
|
827
1045
|
const objectKey = `dql:block:${block.name}`;
|
|
@@ -961,12 +1179,26 @@ function addProjectDiagnostics(manifest, semanticLayer, diagnostics) {
|
|
|
961
1179
|
}
|
|
962
1180
|
}
|
|
963
1181
|
function planContextPackRoute(input) {
|
|
964
|
-
const intent = input.request.intent ?? classifyMetadataIntent(input.request.question, input.request.followUp);
|
|
965
|
-
const
|
|
1182
|
+
const intent = input.request.intent ?? input.questionPlan.routeIntent ?? classifyMetadataIntent(input.request.question, input.request.followUp);
|
|
1183
|
+
const applicabilityByKey = certifiedApplicabilities(input.objects, input.questionPlan);
|
|
1184
|
+
const exactByApplicability = [...applicabilityByKey.values()]
|
|
1185
|
+
.filter((item) => item.kind === 'exact_answer' || item.kind === 'safe_parameterized')
|
|
1186
|
+
.sort((a, b) => b.score - a.score)[0];
|
|
1187
|
+
const exact = exactByApplicability
|
|
1188
|
+
? input.objects.find((object) => object.objectKey === exactByApplicability.objectKey)
|
|
1189
|
+
: findExactCertifiedObject(input.request.question, intent, input.objects);
|
|
1190
|
+
const certifiedApplicability = exact
|
|
1191
|
+
? applicabilityByKey.get(exact.objectKey) ?? certifiedApplicabilityForObject(exact, input.questionPlan)
|
|
1192
|
+
: undefined;
|
|
1193
|
+
const contextApplicability = [...applicabilityByKey.values()]
|
|
1194
|
+
.filter((item) => item.kind === 'context_only')
|
|
1195
|
+
.sort((a, b) => b.score - a.score)[0];
|
|
966
1196
|
const exactExampleMatch = exact ? hasExactExampleQuestion(input.request.question, exact) : false;
|
|
967
1197
|
const missingContext = buildMissingContext(input.request, intent, input.objects, input.allowedSqlContext);
|
|
968
1198
|
const selectedEvidence = input.evidenceRoles.slice(0, 16);
|
|
969
|
-
if (exact && (
|
|
1199
|
+
if (exact && (certifiedApplicability?.kind === 'exact_answer'
|
|
1200
|
+
|| certifiedApplicability?.kind === 'safe_parameterized'
|
|
1201
|
+
|| intent === 'exact_certified_lookup'
|
|
970
1202
|
|| intent === 'definition_lookup'
|
|
971
1203
|
|| exactExampleMatch
|
|
972
1204
|
|| (intent === 'ad_hoc_ranking' && objectNameInQuestion(input.request.question, exact)))) {
|
|
@@ -977,6 +1209,7 @@ function planContextPackRoute(input) {
|
|
|
977
1209
|
trustLabel: 'certified',
|
|
978
1210
|
reviewStatus: 'certified',
|
|
979
1211
|
exactObjectKey: exact.objectKey,
|
|
1212
|
+
certifiedApplicability,
|
|
980
1213
|
selectedEvidence,
|
|
981
1214
|
missingContext: [],
|
|
982
1215
|
followUps: buildMetadataFollowUps(intent, input.allowedSqlContext),
|
|
@@ -996,6 +1229,7 @@ function planContextPackRoute(input) {
|
|
|
996
1229
|
reason: 'Trust questions need a certification, lineage, owner, caveat, and diagnostic review rather than a metric SQL preview.',
|
|
997
1230
|
trustLabel: input.trustLabel,
|
|
998
1231
|
reviewStatus: 'needs_review',
|
|
1232
|
+
certifiedApplicability: contextApplicability,
|
|
999
1233
|
selectedEvidence,
|
|
1000
1234
|
missingContext,
|
|
1001
1235
|
followUps: buildMetadataFollowUps(intent, input.allowedSqlContext),
|
|
@@ -1014,6 +1248,7 @@ function planContextPackRoute(input) {
|
|
|
1014
1248
|
reason: 'The question asks for a different grain, ranking, breakdown, comparison, entity drilldown, or diagnostic analysis, so certified artifacts are context only.',
|
|
1015
1249
|
trustLabel: input.trustLabel === 'certified' ? 'mixed' : input.trustLabel,
|
|
1016
1250
|
reviewStatus: 'draft_ready',
|
|
1251
|
+
certifiedApplicability: contextApplicability,
|
|
1017
1252
|
selectedEvidence,
|
|
1018
1253
|
missingContext,
|
|
1019
1254
|
followUps: buildMetadataFollowUps(intent, input.allowedSqlContext),
|
|
@@ -1027,6 +1262,7 @@ function planContextPackRoute(input) {
|
|
|
1027
1262
|
trustLabel: 'certified',
|
|
1028
1263
|
reviewStatus: 'certified',
|
|
1029
1264
|
exactObjectKey: exact.objectKey,
|
|
1265
|
+
certifiedApplicability,
|
|
1030
1266
|
selectedEvidence,
|
|
1031
1267
|
missingContext: [],
|
|
1032
1268
|
followUps: buildMetadataFollowUps('exact_certified_lookup', input.allowedSqlContext),
|
|
@@ -1038,6 +1274,14 @@ function planContextPackRoute(input) {
|
|
|
1038
1274
|
message: 'The local metadata matched some context, but not enough to choose a safe metric, table, or grain.',
|
|
1039
1275
|
}]);
|
|
1040
1276
|
}
|
|
1277
|
+
function certifiedApplicabilities(objects, questionPlan) {
|
|
1278
|
+
const items = objects
|
|
1279
|
+
.filter((object) => object.objectType === 'dql_block' && isCertifiedMetadataObject(object))
|
|
1280
|
+
.map((object) => certifiedApplicabilityForObject(object, questionPlan))
|
|
1281
|
+
.filter((item) => item.kind !== 'not_applicable')
|
|
1282
|
+
.sort((a, b) => b.score - a.score);
|
|
1283
|
+
return new Map(items.map((item) => [item.objectKey, item]));
|
|
1284
|
+
}
|
|
1041
1285
|
function clarifyDecision(intent, trustLabel, selectedEvidence, missingContext) {
|
|
1042
1286
|
return {
|
|
1043
1287
|
route: 'clarify',
|
|
@@ -1344,6 +1588,254 @@ function evidenceReasonForObject(object) {
|
|
|
1344
1588
|
return 'Runtime schema supplies executable table and column context.';
|
|
1345
1589
|
return reasonForObject(object);
|
|
1346
1590
|
}
|
|
1591
|
+
function sqlParentObjectsForSelectedColumns(selectedObjects, candidateObjects, questionPlan) {
|
|
1592
|
+
const selectedKeys = new Set(selectedObjects.map((object) => object.objectKey));
|
|
1593
|
+
const parentByRelation = new Map();
|
|
1594
|
+
for (const object of candidateObjects) {
|
|
1595
|
+
if (!isSqlParentObject(object) || selectedKeys.has(object.objectKey))
|
|
1596
|
+
continue;
|
|
1597
|
+
const relation = metadataRelationFromObject(object);
|
|
1598
|
+
const key = relation ? normalizeRelationKey(relation.relation) : '';
|
|
1599
|
+
if (!relation || !key)
|
|
1600
|
+
continue;
|
|
1601
|
+
const existing = parentByRelation.get(key) ?? [];
|
|
1602
|
+
existing.push({ object, relation });
|
|
1603
|
+
parentByRelation.set(key, existing);
|
|
1604
|
+
}
|
|
1605
|
+
const scoredParents = new Map();
|
|
1606
|
+
for (const object of selectedObjects) {
|
|
1607
|
+
if (!isSqlColumnObject(object))
|
|
1608
|
+
continue;
|
|
1609
|
+
const relation = metadataRelationFromObject(object);
|
|
1610
|
+
const key = relation ? normalizeRelationKey(relation.relation) : '';
|
|
1611
|
+
if (!key)
|
|
1612
|
+
continue;
|
|
1613
|
+
for (const parent of parentByRelation.get(key) ?? []) {
|
|
1614
|
+
const relationScore = scoreAllowedSqlRelationWithAnalysisPlan(parent.relation, questionPlan).score;
|
|
1615
|
+
const columnScore = scoreMetadataObjectWithAnalysisPlan(object, questionPlan).score;
|
|
1616
|
+
const score = relationScore + columnScore;
|
|
1617
|
+
const existing = scoredParents.get(parent.object.objectKey);
|
|
1618
|
+
if (!existing || score > existing.score) {
|
|
1619
|
+
scoredParents.set(parent.object.objectKey, { object: parent.object, score });
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return Array.from(scoredParents.values())
|
|
1624
|
+
.sort((a, b) => b.score - a.score || a.object.name.localeCompare(b.object.name))
|
|
1625
|
+
.slice(0, 24)
|
|
1626
|
+
.map((item) => item.object);
|
|
1627
|
+
}
|
|
1628
|
+
function schemaShapeCandidateObjects(catalog, questionPlan, request, runtimeObjects) {
|
|
1629
|
+
if (!questionPlan.needsGeneratedSql)
|
|
1630
|
+
return [];
|
|
1631
|
+
const candidates = new Map();
|
|
1632
|
+
const considerObject = (object) => {
|
|
1633
|
+
const relation = metadataRelationFromObject(object);
|
|
1634
|
+
if (!relation || relation.columns.length === 0)
|
|
1635
|
+
return;
|
|
1636
|
+
const shape = schemaShapeMatchForQuestion(relation, questionPlan);
|
|
1637
|
+
if (shape.score <= 0)
|
|
1638
|
+
return;
|
|
1639
|
+
const relationScore = scoreAllowedSqlRelationWithAnalysisPlan(relation, questionPlan);
|
|
1640
|
+
const objectScore = scoreMetadataObjectWithAnalysisPlan(object, questionPlan);
|
|
1641
|
+
const score = Number((shape.score + relationScore.score + objectScore.score).toFixed(3));
|
|
1642
|
+
if (score < schemaShapeMinimumScore(questionPlan))
|
|
1643
|
+
return;
|
|
1644
|
+
candidates.set(object.objectKey, {
|
|
1645
|
+
object,
|
|
1646
|
+
relation,
|
|
1647
|
+
score,
|
|
1648
|
+
reasons: [
|
|
1649
|
+
...shape.reasons,
|
|
1650
|
+
...relationScore.reasons.filter((reason) => /analysis terms|metric terms|dimension terms|columns match|inspected\/projected columns/i.test(reason)).slice(0, 3),
|
|
1651
|
+
],
|
|
1652
|
+
});
|
|
1653
|
+
trimSchemaShapeCandidateMap(candidates, 64);
|
|
1654
|
+
};
|
|
1655
|
+
catalog.scanObjects({
|
|
1656
|
+
objectTypes: ['dbt_model', 'dbt_source', 'warehouse_table'],
|
|
1657
|
+
batchSize: schemaShapeScanBatchSize(request),
|
|
1658
|
+
}, (objects) => {
|
|
1659
|
+
for (const object of objects)
|
|
1660
|
+
considerObject(object);
|
|
1661
|
+
});
|
|
1662
|
+
for (const object of mergeObjects(runtimeObjects.filter(isSqlParentObject))) {
|
|
1663
|
+
considerObject(object);
|
|
1664
|
+
}
|
|
1665
|
+
return Array.from(candidates.values())
|
|
1666
|
+
.sort((a, b) => b.score - a.score || a.relation.relation.localeCompare(b.relation.relation))
|
|
1667
|
+
.slice(0, 24);
|
|
1668
|
+
}
|
|
1669
|
+
function schemaShapeScanBatchSize(request) {
|
|
1670
|
+
if (request.strictness === 'exploratory')
|
|
1671
|
+
return 1000;
|
|
1672
|
+
return 500;
|
|
1673
|
+
}
|
|
1674
|
+
function trimSchemaShapeCandidateMap(candidates, maxCandidates) {
|
|
1675
|
+
if (candidates.size <= maxCandidates)
|
|
1676
|
+
return;
|
|
1677
|
+
const topCandidates = Array.from(candidates.values())
|
|
1678
|
+
.sort((a, b) => b.score - a.score || a.relation.relation.localeCompare(b.relation.relation))
|
|
1679
|
+
.slice(0, maxCandidates);
|
|
1680
|
+
candidates.clear();
|
|
1681
|
+
for (const candidate of topCandidates) {
|
|
1682
|
+
candidates.set(candidate.object.objectKey, candidate);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
function schemaShapeMinimumScore(questionPlan) {
|
|
1686
|
+
switch (questionPlan.mode) {
|
|
1687
|
+
case 'entity_profile':
|
|
1688
|
+
case 'entity_drilldown':
|
|
1689
|
+
return 40;
|
|
1690
|
+
case 'trend':
|
|
1691
|
+
case 'diagnose_change':
|
|
1692
|
+
case 'driver_breakdown':
|
|
1693
|
+
case 'comparison':
|
|
1694
|
+
return 42;
|
|
1695
|
+
case 'ranking':
|
|
1696
|
+
case 'general_analysis':
|
|
1697
|
+
return 38;
|
|
1698
|
+
default:
|
|
1699
|
+
return 44;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
function schemaShapeMatchForQuestion(relation, questionPlan) {
|
|
1703
|
+
const columns = relation.columns;
|
|
1704
|
+
const entityColumns = columns.filter((column) => isEntityIdentifyingColumn(column.name));
|
|
1705
|
+
const measureColumns = columns.filter(isMeasureLikeColumn);
|
|
1706
|
+
const timeColumns = columns.filter((column) => isTimeLikeColumn(column.name));
|
|
1707
|
+
const dimensionColumns = columns.filter((column) => isDimensionLikeColumn(column.name));
|
|
1708
|
+
const relationText = normalizeSearchText(relationSearchTextForCatalog(relation));
|
|
1709
|
+
const termHits = informativeSchemaTerms(questionPlan)
|
|
1710
|
+
.filter((term) => term.length >= 3 && relationText.includes(term))
|
|
1711
|
+
.slice(0, 8);
|
|
1712
|
+
let score = termHits.length * 5;
|
|
1713
|
+
const reasons = [];
|
|
1714
|
+
if (termHits.length > 0)
|
|
1715
|
+
reasons.push(`schema terms matched: ${termHits.join(', ')}`);
|
|
1716
|
+
switch (questionPlan.mode) {
|
|
1717
|
+
case 'entity_profile':
|
|
1718
|
+
case 'entity_drilldown':
|
|
1719
|
+
if (entityColumns.length > 0 && measureColumns.length > 0) {
|
|
1720
|
+
score += 34;
|
|
1721
|
+
reasons.push(`entity identifiers: ${entityColumns.slice(0, 3).map((column) => column.name).join(', ')}`);
|
|
1722
|
+
reasons.push(`measures: ${measureColumns.slice(0, 4).map((column) => column.name).join(', ')}`);
|
|
1723
|
+
}
|
|
1724
|
+
else if (entityColumns.length > 0 && (dimensionColumns.length > 0 || relationLooksEntityCentric(relation))) {
|
|
1725
|
+
score += 24;
|
|
1726
|
+
reasons.push(`entity/profile columns: ${entityColumns.slice(0, 4).map((column) => column.name).join(', ')}`);
|
|
1727
|
+
}
|
|
1728
|
+
if (timeColumns.length > 0 && (entityColumns.length > 0 || measureColumns.length > 0)) {
|
|
1729
|
+
score += 6;
|
|
1730
|
+
reasons.push(`time columns: ${timeColumns.slice(0, 3).map((column) => column.name).join(', ')}`);
|
|
1731
|
+
}
|
|
1732
|
+
break;
|
|
1733
|
+
case 'trend':
|
|
1734
|
+
case 'diagnose_change':
|
|
1735
|
+
if (measureColumns.length > 0 && timeColumns.length > 0) {
|
|
1736
|
+
score += 34;
|
|
1737
|
+
reasons.push(`trend-ready measures: ${measureColumns.slice(0, 4).map((column) => column.name).join(', ')}`);
|
|
1738
|
+
reasons.push(`time columns: ${timeColumns.slice(0, 3).map((column) => column.name).join(', ')}`);
|
|
1739
|
+
}
|
|
1740
|
+
break;
|
|
1741
|
+
case 'driver_breakdown':
|
|
1742
|
+
case 'comparison':
|
|
1743
|
+
if (measureColumns.length > 0 && (dimensionColumns.length > 0 || entityColumns.length > 0)) {
|
|
1744
|
+
score += 32;
|
|
1745
|
+
reasons.push(`breakdown dimensions: ${[...dimensionColumns, ...entityColumns].slice(0, 4).map((column) => column.name).join(', ')}`);
|
|
1746
|
+
reasons.push(`measures: ${measureColumns.slice(0, 4).map((column) => column.name).join(', ')}`);
|
|
1747
|
+
}
|
|
1748
|
+
break;
|
|
1749
|
+
case 'ranking':
|
|
1750
|
+
case 'general_analysis':
|
|
1751
|
+
if (measureColumns.length > 0 && (dimensionColumns.length > 0 || entityColumns.length > 0)) {
|
|
1752
|
+
score += 28;
|
|
1753
|
+
reasons.push(`rankable dimensions: ${[...dimensionColumns, ...entityColumns].slice(0, 4).map((column) => column.name).join(', ')}`);
|
|
1754
|
+
reasons.push(`measures: ${measureColumns.slice(0, 4).map((column) => column.name).join(', ')}`);
|
|
1755
|
+
}
|
|
1756
|
+
break;
|
|
1757
|
+
default:
|
|
1758
|
+
break;
|
|
1759
|
+
}
|
|
1760
|
+
if (score === 0 && termHits.length >= 2 && (measureColumns.length > 0 || entityColumns.length > 0)) {
|
|
1761
|
+
score += 18;
|
|
1762
|
+
}
|
|
1763
|
+
return {
|
|
1764
|
+
score,
|
|
1765
|
+
reasons: reasons.length > 0 ? reasons : ['relation has analytical schema shape for generated SQL'],
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
function informativeSchemaTerms(questionPlan) {
|
|
1769
|
+
const entityTerms = new Set(questionPlan.entities.flatMap((entity) => normalizeSearchText(entity.text).split(/\s+/)));
|
|
1770
|
+
const generic = new Set([
|
|
1771
|
+
'complete', 'detail', 'details', 'full', 'history', 'overview', 'profile', 'research',
|
|
1772
|
+
'reserach', 'stat', 'stats', 'statistics', 'summary',
|
|
1773
|
+
]);
|
|
1774
|
+
return uniqueStringValues([
|
|
1775
|
+
...questionPlan.metricTerms,
|
|
1776
|
+
...questionPlan.dimensionTerms,
|
|
1777
|
+
...questionPlan.timeTerms,
|
|
1778
|
+
...questionPlan.searchTerms,
|
|
1779
|
+
].flatMap((value) => normalizeSearchText(value).split(/\s+/)))
|
|
1780
|
+
.filter((term) => term.length >= 3 && !entityTerms.has(term) && !generic.has(term));
|
|
1781
|
+
}
|
|
1782
|
+
function relationSearchTextForCatalog(relation) {
|
|
1783
|
+
return [
|
|
1784
|
+
relation.relation,
|
|
1785
|
+
relation.name,
|
|
1786
|
+
relation.source,
|
|
1787
|
+
relation.columns.map((column) => `${column.name} ${column.type ?? ''} ${column.description ?? ''}`).join(' '),
|
|
1788
|
+
].join(' ');
|
|
1789
|
+
}
|
|
1790
|
+
function relationLooksEntityCentric(relation) {
|
|
1791
|
+
return /\b(dim|dimension|entity|profile|customer|account|user|person|member|player|athlete|product|vendor|supplier|employee|merchant|team)\b/i
|
|
1792
|
+
.test(`${relation.name} ${relation.relation}`);
|
|
1793
|
+
}
|
|
1794
|
+
function isEntityIdentifyingColumn(name) {
|
|
1795
|
+
const normalized = normalizeColumnToken(name);
|
|
1796
|
+
if (/(^|_)(name|full_name|display_name|title|email|username)$/.test(normalized))
|
|
1797
|
+
return true;
|
|
1798
|
+
if (/(^|_)(customer|account|user|member|person|player|athlete|product|sku|vendor|supplier|employee|merchant|team|organization|company|store|location)_(id|key|uuid|sk|name|email)$/.test(normalized)) {
|
|
1799
|
+
return true;
|
|
1800
|
+
}
|
|
1801
|
+
return /(^|_)(customer|account|user|member|person|player|athlete|product|vendor|supplier|employee|merchant|team|organization|company|store|location)$/.test(normalized);
|
|
1802
|
+
}
|
|
1803
|
+
function isDimensionLikeColumn(name) {
|
|
1804
|
+
const normalized = normalizeColumnToken(name);
|
|
1805
|
+
if (isTimeLikeColumn(normalized) || isMeasureColumnName(normalized))
|
|
1806
|
+
return false;
|
|
1807
|
+
return /(^|_)(category|channel|class|cohort|country|department|division|group|market|name|position|region|segment|status|team|territory|type|zone)$/.test(normalized) ||
|
|
1808
|
+
/(customer|account|user|member|person|player|athlete|product|sku|vendor|supplier|employee|merchant|team|organization|company|store|location)_(name|type|segment|category|status|group|region)$/.test(normalized);
|
|
1809
|
+
}
|
|
1810
|
+
function isMeasureLikeColumn(column) {
|
|
1811
|
+
const normalized = normalizeColumnToken(column.name);
|
|
1812
|
+
if (isJoinKeyColumnForCatalog(normalized) || isTimeLikeColumn(normalized))
|
|
1813
|
+
return false;
|
|
1814
|
+
if (/^(?:season|year|month|week|quarter|rank|row_number)$/.test(normalized))
|
|
1815
|
+
return false;
|
|
1816
|
+
return isMeasureColumnName(normalized) || isNumericColumnType(column.type);
|
|
1817
|
+
}
|
|
1818
|
+
function isMeasureColumnName(normalizedName) {
|
|
1819
|
+
return /(^|_)(amount|arr|ast|assist|assists|avg|average|balance|bookings|count|cost|duration|expense|goal|goals|margin|minutes|mrr|orders|points|profit|pts|quantity|rate|reb|rebound|rebounds|revenue|sales|score|scores|spend|stat|stats|total|usage|value|volume)$/.test(normalizedName);
|
|
1820
|
+
}
|
|
1821
|
+
function isNumericColumnType(type) {
|
|
1822
|
+
return Boolean(type && /\b(bigint|decimal|double|float|int|integer|number|numeric|real)\b/i.test(type));
|
|
1823
|
+
}
|
|
1824
|
+
function normalizeColumnToken(value) {
|
|
1825
|
+
return value.replace(/["`]/g, '').replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
|
|
1826
|
+
}
|
|
1827
|
+
function uniqueStringValues(values) {
|
|
1828
|
+
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
1829
|
+
}
|
|
1830
|
+
function isSqlColumnObject(object) {
|
|
1831
|
+
return object.objectType === 'dbt_column' || object.objectType === 'runtime_column';
|
|
1832
|
+
}
|
|
1833
|
+
function isSqlParentObject(object) {
|
|
1834
|
+
return object.objectType === 'dbt_model' ||
|
|
1835
|
+
object.objectType === 'dbt_source' ||
|
|
1836
|
+
object.objectType === 'warehouse_table' ||
|
|
1837
|
+
object.objectType === 'runtime_table';
|
|
1838
|
+
}
|
|
1347
1839
|
function buildAllowedSqlContext(objects, edges) {
|
|
1348
1840
|
const byRelation = new Map();
|
|
1349
1841
|
const objectsByKey = new Map(objects.map((object) => [object.objectKey, object]));
|
|
@@ -1359,7 +1851,7 @@ function buildAllowedSqlContext(objects, edges) {
|
|
|
1359
1851
|
byRelation.set(key, {
|
|
1360
1852
|
...existing,
|
|
1361
1853
|
objectKey: existing.objectKey ?? relation.objectKey,
|
|
1362
|
-
source: existing.source
|
|
1854
|
+
source: mergeRelationSources(existing.source, relation.source),
|
|
1363
1855
|
columns: dedupeRuntimeColumns([...existing.columns, ...relation.columns]).slice(0, 120),
|
|
1364
1856
|
});
|
|
1365
1857
|
};
|
|
@@ -1391,6 +1883,17 @@ function buildAllowedSqlContext(objects, edges) {
|
|
|
1391
1883
|
columns: [],
|
|
1392
1884
|
});
|
|
1393
1885
|
}
|
|
1886
|
+
const sourceSql = typeof object.payload?.sql === 'string' ? object.payload.sql.trim() : '';
|
|
1887
|
+
const shape = sourceSql ? extractSimpleSelectShape(sourceSql) : undefined;
|
|
1888
|
+
if (shape) {
|
|
1889
|
+
addRelation({
|
|
1890
|
+
relation: shape.relation,
|
|
1891
|
+
name: shape.relation.split('.').at(-1) ?? shape.relation,
|
|
1892
|
+
objectKey: object.objectKey,
|
|
1893
|
+
source: 'certified source SQL shape',
|
|
1894
|
+
columns: sourceSqlShapeColumns(sourceSql),
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1394
1897
|
}
|
|
1395
1898
|
for (const edge of edges) {
|
|
1396
1899
|
if (edge.edgeType !== 'maps_to_dbt_model' && edge.edgeType !== 'uses_dbt_model')
|
|
@@ -1403,7 +1906,8 @@ function buildAllowedSqlContext(objects, edges) {
|
|
|
1403
1906
|
addRelation({ ...fromRelation, columns: toRelation.columns, source: 'dbt mapped warehouse table' });
|
|
1404
1907
|
}
|
|
1405
1908
|
return {
|
|
1406
|
-
relations: Array.from(byRelation.values())
|
|
1909
|
+
relations: coalesceRelationAliases(Array.from(byRelation.values()))
|
|
1910
|
+
.sort((a, b) => a.relation.localeCompare(b.relation)),
|
|
1407
1911
|
sourceBlockSql: objects
|
|
1408
1912
|
.filter((object) => object.objectType === 'dql_block' &&
|
|
1409
1913
|
isCertifiedMetadataObject(object) &&
|
|
@@ -1418,6 +1922,167 @@ function buildAllowedSqlContext(objects, edges) {
|
|
|
1418
1922
|
})),
|
|
1419
1923
|
};
|
|
1420
1924
|
}
|
|
1925
|
+
function buildSelectedJoinPaths(allowedSqlContext) {
|
|
1926
|
+
const relations = allowedSqlContext.relations
|
|
1927
|
+
.filter((relation) => relation.columns.some((column) => isJoinKeyColumnForCatalog(column.name)))
|
|
1928
|
+
.slice(0, 16);
|
|
1929
|
+
const joins = [];
|
|
1930
|
+
const seen = new Set();
|
|
1931
|
+
for (let leftIndex = 0; leftIndex < relations.length; leftIndex += 1) {
|
|
1932
|
+
for (let rightIndex = leftIndex + 1; rightIndex < relations.length; rightIndex += 1) {
|
|
1933
|
+
const left = relations[leftIndex];
|
|
1934
|
+
const right = relations[rightIndex];
|
|
1935
|
+
const join = pickCatalogJoinColumns(left, right);
|
|
1936
|
+
if (!join)
|
|
1937
|
+
continue;
|
|
1938
|
+
const key = [
|
|
1939
|
+
normalizeRelationKey(left.relation),
|
|
1940
|
+
join.leftColumn.toLowerCase(),
|
|
1941
|
+
normalizeRelationKey(right.relation),
|
|
1942
|
+
join.rightColumn.toLowerCase(),
|
|
1943
|
+
].join('|');
|
|
1944
|
+
if (seen.has(key))
|
|
1945
|
+
continue;
|
|
1946
|
+
seen.add(key);
|
|
1947
|
+
joins.push({
|
|
1948
|
+
leftRelation: left.relation,
|
|
1949
|
+
leftColumn: join.leftColumn,
|
|
1950
|
+
rightRelation: right.relation,
|
|
1951
|
+
rightColumn: join.rightColumn,
|
|
1952
|
+
reason: catalogJoinReason(join.leftColumn, join.rightColumn, join.confidence),
|
|
1953
|
+
confidence: join.confidence,
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
return joins
|
|
1958
|
+
.sort((a, b) => b.confidence - a.confidence || a.leftRelation.localeCompare(b.leftRelation))
|
|
1959
|
+
.slice(0, 12);
|
|
1960
|
+
}
|
|
1961
|
+
function pickCatalogJoinColumns(left, right) {
|
|
1962
|
+
const candidates = [];
|
|
1963
|
+
const leftColumns = left.columns.filter((column) => isJoinKeyColumnForCatalog(column.name)).slice(0, 32);
|
|
1964
|
+
const rightColumns = right.columns.filter((column) => isJoinKeyColumnForCatalog(column.name)).slice(0, 32);
|
|
1965
|
+
for (const leftColumn of leftColumns) {
|
|
1966
|
+
for (const rightColumn of rightColumns) {
|
|
1967
|
+
const confidence = catalogJoinConfidence(leftColumn.name, rightColumn.name, left, right);
|
|
1968
|
+
if (confidence <= 0)
|
|
1969
|
+
continue;
|
|
1970
|
+
candidates.push({ leftColumn: leftColumn.name, rightColumn: rightColumn.name, confidence });
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
return candidates.sort((a, b) => b.confidence - a.confidence || a.leftColumn.localeCompare(b.leftColumn))[0];
|
|
1974
|
+
}
|
|
1975
|
+
function catalogJoinConfidence(leftColumn, rightColumn, leftRelation, rightRelation) {
|
|
1976
|
+
const left = normalizeJoinColumnName(leftColumn);
|
|
1977
|
+
const right = normalizeJoinColumnName(rightColumn);
|
|
1978
|
+
if (!isJoinKeyColumnForCatalog(left) || !isJoinKeyColumnForCatalog(right))
|
|
1979
|
+
return 0;
|
|
1980
|
+
if (left === right)
|
|
1981
|
+
return 0.92;
|
|
1982
|
+
const leftSubject = joinSubjectForCatalog(left);
|
|
1983
|
+
const rightSubject = joinSubjectForCatalog(right);
|
|
1984
|
+
if (leftSubject && rightSubject && leftSubject === rightSubject)
|
|
1985
|
+
return 0.86;
|
|
1986
|
+
const leftRelationTokens = relationEntityTokensForCatalog(leftRelation);
|
|
1987
|
+
const rightRelationTokens = relationEntityTokensForCatalog(rightRelation);
|
|
1988
|
+
if (leftSubject && right === 'id' && rightRelationTokens.has(leftSubject))
|
|
1989
|
+
return 0.78;
|
|
1990
|
+
if (rightSubject && left === 'id' && leftRelationTokens.has(rightSubject))
|
|
1991
|
+
return 0.78;
|
|
1992
|
+
return 0;
|
|
1993
|
+
}
|
|
1994
|
+
function catalogJoinReason(leftColumn, rightColumn, confidence) {
|
|
1995
|
+
if (leftColumn.toLowerCase() === rightColumn.toLowerCase())
|
|
1996
|
+
return `shared key ${leftColumn}`;
|
|
1997
|
+
const leftSubject = joinSubjectForCatalog(leftColumn);
|
|
1998
|
+
const rightSubject = joinSubjectForCatalog(rightColumn);
|
|
1999
|
+
if (leftSubject && rightSubject && leftSubject === rightSubject)
|
|
2000
|
+
return `matching ${leftSubject} key`;
|
|
2001
|
+
if (confidence >= 0.75)
|
|
2002
|
+
return 'foreign-key style id match';
|
|
2003
|
+
return 'join-key style column match';
|
|
2004
|
+
}
|
|
2005
|
+
function isJoinKeyColumnForCatalog(column) {
|
|
2006
|
+
const normalized = normalizeJoinColumnName(column);
|
|
2007
|
+
return normalized === 'id' ||
|
|
2008
|
+
/(^|_)(id|key|uuid|sk)$/.test(normalized) ||
|
|
2009
|
+
/_(id|key|uuid|sk)$/.test(normalized);
|
|
2010
|
+
}
|
|
2011
|
+
function joinSubjectForCatalog(column) {
|
|
2012
|
+
const normalized = normalizeJoinColumnName(column);
|
|
2013
|
+
const subject = normalized.replace(/_(id|key|uuid|sk)$/i, '');
|
|
2014
|
+
if (!subject || subject === normalized || subject === 'id' || subject === 'key')
|
|
2015
|
+
return undefined;
|
|
2016
|
+
return normalizeSingularToken(subject.split('_').at(-1) ?? subject);
|
|
2017
|
+
}
|
|
2018
|
+
function relationEntityTokensForCatalog(relation) {
|
|
2019
|
+
const tokens = new Set();
|
|
2020
|
+
for (const raw of [relation.name, relation.relation.split('.').at(-1) ?? relation.relation].join(' ').toLowerCase().match(/[a-z0-9_]+/g) ?? []) {
|
|
2021
|
+
for (const part of raw.split('_')) {
|
|
2022
|
+
const token = normalizeSingularToken(part);
|
|
2023
|
+
if (!token || token.length < 2 || ['dim', 'fct', 'fact', 'stg', 'stage', 'model', 'table'].includes(token))
|
|
2024
|
+
continue;
|
|
2025
|
+
tokens.add(token);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
return tokens;
|
|
2029
|
+
}
|
|
2030
|
+
function normalizeJoinColumnName(column) {
|
|
2031
|
+
return column.replace(/["`]/g, '').replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
|
|
2032
|
+
}
|
|
2033
|
+
function normalizeSingularToken(token) {
|
|
2034
|
+
if (token.endsWith('ies') && token.length > 4)
|
|
2035
|
+
return `${token.slice(0, -3)}y`;
|
|
2036
|
+
if (token.endsWith('s') && token.length > 4)
|
|
2037
|
+
return token.slice(0, -1);
|
|
2038
|
+
return token;
|
|
2039
|
+
}
|
|
2040
|
+
function coalesceRelationAliases(relations) {
|
|
2041
|
+
const qualifiedByTail = new Map();
|
|
2042
|
+
for (const relation of relations) {
|
|
2043
|
+
const tailKey = relationTailKey(relation.relation);
|
|
2044
|
+
if (!tailKey || !relation.relation.includes('.'))
|
|
2045
|
+
continue;
|
|
2046
|
+
const existing = qualifiedByTail.get(tailKey) ?? [];
|
|
2047
|
+
existing.push(relation);
|
|
2048
|
+
qualifiedByTail.set(tailKey, existing);
|
|
2049
|
+
}
|
|
2050
|
+
const mergedByRelation = new Map(relations.map((relation) => [normalizeRelationKey(relation.relation), relation]));
|
|
2051
|
+
const skipped = new Set();
|
|
2052
|
+
for (const relation of relations) {
|
|
2053
|
+
const relationKey = normalizeRelationKey(relation.relation);
|
|
2054
|
+
const tailKey = relationTailKey(relation.relation);
|
|
2055
|
+
if (!tailKey || relation.relation.includes('.'))
|
|
2056
|
+
continue;
|
|
2057
|
+
const targets = qualifiedByTail.get(tailKey) ?? [];
|
|
2058
|
+
if (targets.length !== 1)
|
|
2059
|
+
continue;
|
|
2060
|
+
const target = targets[0];
|
|
2061
|
+
const targetKey = normalizeRelationKey(target.relation);
|
|
2062
|
+
mergedByRelation.set(targetKey, {
|
|
2063
|
+
...target,
|
|
2064
|
+
objectKey: target.objectKey ?? relation.objectKey,
|
|
2065
|
+
source: mergeRelationSources(target.source, relation.source),
|
|
2066
|
+
columns: dedupeRuntimeColumns([...target.columns, ...relation.columns]).slice(0, 120),
|
|
2067
|
+
});
|
|
2068
|
+
skipped.add(relationKey);
|
|
2069
|
+
}
|
|
2070
|
+
return Array.from(mergedByRelation.entries())
|
|
2071
|
+
.filter(([key]) => !skipped.has(key))
|
|
2072
|
+
.map(([, relation]) => relation);
|
|
2073
|
+
}
|
|
2074
|
+
function relationTailKey(relation) {
|
|
2075
|
+
const parts = normalizeRelationKey(relation).split('.').filter(Boolean);
|
|
2076
|
+
return parts.at(-1) ?? '';
|
|
2077
|
+
}
|
|
2078
|
+
function mergeRelationSources(left, right) {
|
|
2079
|
+
if (left === right)
|
|
2080
|
+
return left;
|
|
2081
|
+
const parts = [...left.split(/\s+\+\s+/), ...right.split(/\s+\+\s+/)]
|
|
2082
|
+
.map((part) => part.trim())
|
|
2083
|
+
.filter(Boolean);
|
|
2084
|
+
return Array.from(new Set(parts)).join(' + ');
|
|
2085
|
+
}
|
|
1421
2086
|
function warehouseTableHasTrustedReference(object, objectsByKey) {
|
|
1422
2087
|
const refs = metadataStringArray(object.payload?.referencedBy);
|
|
1423
2088
|
if (refs.length === 0)
|
|
@@ -1582,6 +2247,21 @@ function buildFollowUpSearchQuery(question, followUp) {
|
|
|
1582
2247
|
...(followUp.dimensions ?? []),
|
|
1583
2248
|
].filter(Boolean).join(' ');
|
|
1584
2249
|
}
|
|
2250
|
+
function uniqueMetadataSearchQueries(values) {
|
|
2251
|
+
const seen = new Set();
|
|
2252
|
+
const out = [];
|
|
2253
|
+
for (const value of values) {
|
|
2254
|
+
const clean = value.replace(/\s+/g, ' ').trim();
|
|
2255
|
+
if (!clean)
|
|
2256
|
+
continue;
|
|
2257
|
+
const key = normalizeSearchText(clean);
|
|
2258
|
+
if (!key || seen.has(key))
|
|
2259
|
+
continue;
|
|
2260
|
+
seen.add(key);
|
|
2261
|
+
out.push(clean);
|
|
2262
|
+
}
|
|
2263
|
+
return out.slice(0, 5);
|
|
2264
|
+
}
|
|
1585
2265
|
function normalizeRuntimeSchemaTables(tables) {
|
|
1586
2266
|
const byRelation = new Map();
|
|
1587
2267
|
for (const table of tables ?? []) {
|
|
@@ -1807,12 +2487,14 @@ function rankMetadataObjects(args) {
|
|
|
1807
2487
|
const terms = tokenize(args.question).slice(0, 12);
|
|
1808
2488
|
const ranked = mergeObjects(args.rows)
|
|
1809
2489
|
.map((row) => {
|
|
1810
|
-
const
|
|
2490
|
+
const baseScore = scoreMetadataObject(row, terms);
|
|
2491
|
+
const planScore = args.questionPlan ? scoreMetadataObjectWithAnalysisPlan(row, args.questionPlan) : { score: 0, reasons: [] };
|
|
2492
|
+
const score = Number((baseScore + planScore.score).toFixed(3));
|
|
1811
2493
|
return {
|
|
1812
2494
|
row,
|
|
1813
2495
|
rank: 0,
|
|
1814
2496
|
score,
|
|
1815
|
-
reason: selectionReason(row, score),
|
|
2497
|
+
reason: selectionReason(row, score, planScore.reasons),
|
|
1816
2498
|
priorityTier: priorityTier(row),
|
|
1817
2499
|
};
|
|
1818
2500
|
})
|
|
@@ -1879,10 +2561,11 @@ function priorityTier(row) {
|
|
|
1879
2561
|
return 'consumption_evidence';
|
|
1880
2562
|
return 'metadata';
|
|
1881
2563
|
}
|
|
1882
|
-
function selectionReason(row, score) {
|
|
2564
|
+
function selectionReason(row, score, plannerReasons = []) {
|
|
1883
2565
|
const reasons = [reasonForObject(row), `priority tier: ${priorityTier(row)}`];
|
|
1884
2566
|
if (row.status === 'certified')
|
|
1885
2567
|
reasons.push('certified status');
|
|
2568
|
+
reasons.push(...plannerReasons.slice(0, 3));
|
|
1886
2569
|
reasons.push(`score ${score.toFixed(1)}`);
|
|
1887
2570
|
return reasons.join('; ');
|
|
1888
2571
|
}
|