@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.
@@ -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 searchQuery = buildFollowUpSearchQuery(request.question, followUp);
107
- const searchRows = catalog.searchObjects({
108
- query: searchQuery,
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: searchQuery,
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 objects = rankMetadataObjects({
126
- rows: mergeObjects([...followUpSourceObjects, ...followUpObjects, ...selected, ...graphObjects, ...runtimeObjects, ...selectedObjects]),
127
- question: searchQuery,
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: searchQuery,
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 exact = findExactCertifiedObject(input.request.question, intent, input.objects);
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 && (intent === 'exact_certified_lookup'
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 === relation.source ? existing.source : 'local metadata catalog',
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()).sort((a, b) => a.relation.localeCompare(b.relation)),
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 score = scoreMetadataObject(row, terms);
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
  }