@duckcodeailabs/dql-agent 1.8.1 → 1.8.2
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/agent-run-engine.d.ts +10 -0
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +13 -2
- package/dist/agent-run-engine.js.map +1 -1
- package/dist/answer-loop.d.ts +22 -1
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +387 -37
- package/dist/answer-loop.js.map +1 -1
- package/dist/cascade/budgets.d.ts +3 -1
- package/dist/cascade/budgets.d.ts.map +1 -1
- package/dist/cascade/budgets.js +10 -1
- package/dist/cascade/budgets.js.map +1 -1
- package/dist/grounding/context-ledger.d.ts +4 -0
- package/dist/grounding/context-ledger.d.ts.map +1 -1
- package/dist/grounding/context-ledger.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +7 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts +8 -0
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +50 -2
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/block-fit.js +24 -8
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts +7 -0
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +47 -0
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/sql-context-validation.d.ts +5 -1
- package/dist/metadata/sql-context-validation.d.ts.map +1 -1
- package/dist/metadata/sql-context-validation.js +67 -0
- package/dist/metadata/sql-context-validation.js.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +27 -3
- package/dist/router.js.map +1 -1
- package/package.json +4 -4
package/dist/answer-loop.js
CHANGED
|
@@ -164,6 +164,8 @@ function refusalCodeForValidation(code) {
|
|
|
164
164
|
}
|
|
165
165
|
if (code === 'ambiguous_filter')
|
|
166
166
|
return 'ambiguous';
|
|
167
|
+
if (code === 'misbound_filter')
|
|
168
|
+
return 'grounding_gap';
|
|
167
169
|
return 'model_declined';
|
|
168
170
|
}
|
|
169
171
|
/**
|
|
@@ -179,14 +181,18 @@ function refusalCodeForAnalyticalPolicy(code, hasExploratoryCandidate = false) {
|
|
|
179
181
|
return 'policy_blocked';
|
|
180
182
|
}
|
|
181
183
|
/**
|
|
182
|
-
* The governed guard is intentionally strict. Only
|
|
184
|
+
* The governed guard is intentionally strict. Only outcomes that mean
|
|
183
185
|
* "this repository has not modeled this join yet" may be handed to a host's
|
|
184
186
|
* exploratory lane. All other decisions are explicit governance/safety
|
|
185
187
|
* denials and must remain terminal in this loop.
|
|
186
188
|
*/
|
|
187
189
|
function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
|
|
188
190
|
const reason = decision.code;
|
|
189
|
-
|
|
191
|
+
const missingPathWithoutUnsafeProof = reason === 'unsafe_relationship' && decision.relationshipIds.length === 0;
|
|
192
|
+
if (decision.safe || (!missingPathWithoutUnsafeProof
|
|
193
|
+
&& reason !== 'unbound_relation'
|
|
194
|
+
&& reason !== 'unplanned_join'
|
|
195
|
+
&& reason !== 'relationship_not_certified')) {
|
|
190
196
|
return undefined;
|
|
191
197
|
}
|
|
192
198
|
// `unplanned_join` can also mean the model ignored an existing certified
|
|
@@ -914,9 +920,10 @@ async function runAnswerLoop(input) {
|
|
|
914
920
|
// generation, which can express the breakdown.
|
|
915
921
|
const wantedBreakdown = questionPlan.requestedShape.dimensions.length > 0
|
|
916
922
|
|| questionPlan.dimensionTerms.length > 0;
|
|
923
|
+
const requiredGroupingCount = requestedGroupingDimensions(questionPlan).length;
|
|
917
924
|
const dropsBreakdown = Boolean(composed)
|
|
918
925
|
&& wantedBreakdown
|
|
919
|
-
&& composed.dimensions.length
|
|
926
|
+
&& composed.dimensions.length < Math.max(1, requiredGroupingCount)
|
|
920
927
|
&& !composed.timeDimension;
|
|
921
928
|
if (composed && !dropsBreakdown) {
|
|
922
929
|
semanticBridgeAnswer = composed;
|
|
@@ -1047,6 +1054,14 @@ async function runAnswerLoop(input) {
|
|
|
1047
1054
|
});
|
|
1048
1055
|
}
|
|
1049
1056
|
catch (err) {
|
|
1057
|
+
// A request-level cancellation/deadline is orchestration state, not an
|
|
1058
|
+
// upstream-provider outage. Preserve the AbortSignal reason so the run
|
|
1059
|
+
// engine can report a bounded deadline (or explicit user cancellation)
|
|
1060
|
+
// instead of the misleading generic "Provider error" label.
|
|
1061
|
+
if (input.signal?.aborted)
|
|
1062
|
+
throw input.signal.reason ?? err;
|
|
1063
|
+
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
|
|
1064
|
+
throw err;
|
|
1050
1065
|
const text = `Provider error: ${err.message}`;
|
|
1051
1066
|
return {
|
|
1052
1067
|
kind: 'no_answer',
|
|
@@ -1119,6 +1134,7 @@ async function runAnswerLoop(input) {
|
|
|
1119
1134
|
intent,
|
|
1120
1135
|
initial: { raw: proposed, parsed },
|
|
1121
1136
|
contextLedger,
|
|
1137
|
+
followUp: input.followUp,
|
|
1122
1138
|
executeGeneratedSql: input.executeGeneratedSql,
|
|
1123
1139
|
signal: input.signal,
|
|
1124
1140
|
reasoningEffort: input.reasoningEffort,
|
|
@@ -1177,7 +1193,8 @@ async function runAnswerLoop(input) {
|
|
|
1177
1193
|
|| (input.contextPack?.allowedSqlContext?.relations.length ?? 0) > 0
|
|
1178
1194
|
|| (input.contextPack?.allowedSqlContext?.sourceBlockSql.length ?? 0) > 0
|
|
1179
1195
|
|| contextBlocks.length > 0;
|
|
1180
|
-
if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext
|
|
1196
|
+
if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext
|
|
1197
|
+
&& (analyticalPlan?.safe !== false || analyticalPlanAllowsDbtExploration(analyticalPlan))) {
|
|
1181
1198
|
try {
|
|
1182
1199
|
proposed = await generateProposalWithOptionalTools({
|
|
1183
1200
|
provider,
|
|
@@ -1280,16 +1297,66 @@ async function runAnswerLoop(input) {
|
|
|
1280
1297
|
intent,
|
|
1281
1298
|
filterValues: input.followUp?.filters,
|
|
1282
1299
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1300
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1283
1301
|
});
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1302
|
+
const rankedGrainGap = missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions);
|
|
1303
|
+
contextValidation = initialValidation.ok && rankedGrainGap
|
|
1304
|
+
? {
|
|
1287
1305
|
ok: false,
|
|
1288
|
-
code:
|
|
1289
|
-
error:
|
|
1306
|
+
code: 'insufficient_context',
|
|
1307
|
+
error: 'Generated SQL omits the ranked grouping dimension ' + rankedGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
|
|
1290
1308
|
warnings: initialValidation.warnings,
|
|
1291
|
-
|
|
1292
|
-
|
|
1309
|
+
}
|
|
1310
|
+
: initialValidation.ok
|
|
1311
|
+
? { ok: true, warnings: initialValidation.warnings }
|
|
1312
|
+
: {
|
|
1313
|
+
ok: false,
|
|
1314
|
+
code: initialValidation.code,
|
|
1315
|
+
error: initialValidation.error,
|
|
1316
|
+
warnings: initialValidation.warnings,
|
|
1317
|
+
offending: initialValidation.offending,
|
|
1318
|
+
};
|
|
1319
|
+
// A canonical member value that resolves to exactly one inspected column is
|
|
1320
|
+
// compiler-owned request state, not creative SQL. If the proposal omitted
|
|
1321
|
+
// that predicate entirely, inject it deterministically before spending the
|
|
1322
|
+
// bounded AI validation repair. Misbound or ambiguous predicates are never
|
|
1323
|
+
// rewritten here; they continue through the guarded correction/refusal path.
|
|
1324
|
+
if (!contextValidation.ok
|
|
1325
|
+
&& contextValidation.code === 'misbound_filter'
|
|
1326
|
+
&& !contextValidation.offending?.relation
|
|
1327
|
+
&& !contextValidation.offending?.column
|
|
1328
|
+
&& input.followUp?.memberBindings?.length) {
|
|
1329
|
+
const injected = injectUniqueResolvedMemberBindings(parsed.sql, input.followUp.memberBindings, schemaContext);
|
|
1330
|
+
if (injected) {
|
|
1331
|
+
parsed.sql = injected.sql;
|
|
1332
|
+
const rebound = contextLedger.validateSql(parsed.sql, {
|
|
1333
|
+
question,
|
|
1334
|
+
intent,
|
|
1335
|
+
filterValues: input.followUp?.filters,
|
|
1336
|
+
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1337
|
+
memberBindings: input.followUp.memberBindings,
|
|
1338
|
+
});
|
|
1339
|
+
const reboundGrainGap = rebound.ok
|
|
1340
|
+
? missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions)
|
|
1341
|
+
: undefined;
|
|
1342
|
+
contextValidation = rebound.ok && !reboundGrainGap
|
|
1343
|
+
? { ok: true, warnings: [...rebound.warnings, ...injected.notes] }
|
|
1344
|
+
: rebound.ok
|
|
1345
|
+
? {
|
|
1346
|
+
ok: false,
|
|
1347
|
+
code: 'insufficient_context',
|
|
1348
|
+
error: 'Generated SQL omits the ranked grouping dimension ' + reboundGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
|
|
1349
|
+
warnings: [...rebound.warnings, ...injected.notes],
|
|
1350
|
+
}
|
|
1351
|
+
: {
|
|
1352
|
+
ok: false,
|
|
1353
|
+
code: rebound.code,
|
|
1354
|
+
error: rebound.error,
|
|
1355
|
+
warnings: [...rebound.warnings, ...injected.notes],
|
|
1356
|
+
offending: rebound.offending,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1293
1360
|
// Semantic compilation owns metric meaning, but a later dimensional join can
|
|
1294
1361
|
// still make a compiled bare expression ambiguous at the warehouse binder.
|
|
1295
1362
|
// Revoke governed trust for an invalid composition so the bounded repair lane
|
|
@@ -1305,9 +1372,12 @@ async function runAnswerLoop(input) {
|
|
|
1305
1372
|
// stands instead of guessing a measure.
|
|
1306
1373
|
// 2) Otherwise fall back to a CLEAN governed-metric definition (direct or exact
|
|
1307
1374
|
// leaf-measure; no fuzzy family guess that could answer the wrong measure).
|
|
1308
|
-
if (!contextValidation.ok && semanticMetricRoute && semanticMetricMatch) {
|
|
1375
|
+
if (!contextValidation.ok && semanticMetricRoute && semanticMetricMatch && !rankedGrainGap) {
|
|
1309
1376
|
const grounded = contextLedger.validateRuntimeGrounding(parsed.sql);
|
|
1310
|
-
|
|
1377
|
+
// Runtime grounding proves that relations and columns exist; it cannot prove
|
|
1378
|
+
// that a resolved business member was bound to the right dimension. Never
|
|
1379
|
+
// let this recovery lane bypass AGT-012's typed member invariant.
|
|
1380
|
+
if (grounded?.ok && !(input.followUp?.memberBindings?.length)) {
|
|
1311
1381
|
contextValidation = { ok: true, warnings: grounded.warnings };
|
|
1312
1382
|
}
|
|
1313
1383
|
else if (scalarGovernedMetricRecoveryAllowed) {
|
|
@@ -1346,25 +1416,36 @@ async function runAnswerLoop(input) {
|
|
|
1346
1416
|
intent,
|
|
1347
1417
|
filterValues: input.followUp?.filters,
|
|
1348
1418
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1419
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1349
1420
|
});
|
|
1350
|
-
|
|
1421
|
+
const regroundedGrainGap = revalidated.ok
|
|
1422
|
+
? missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions)
|
|
1423
|
+
: undefined;
|
|
1424
|
+
contextValidation = revalidated.ok && regroundedGrainGap
|
|
1351
1425
|
? {
|
|
1352
|
-
ok: true,
|
|
1353
|
-
warnings: [
|
|
1354
|
-
...revalidated.warnings,
|
|
1355
|
-
...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
|
|
1356
|
-
],
|
|
1357
|
-
}
|
|
1358
|
-
: {
|
|
1359
1426
|
ok: false,
|
|
1360
|
-
code:
|
|
1361
|
-
error:
|
|
1362
|
-
warnings:
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1427
|
+
code: 'insufficient_context',
|
|
1428
|
+
error: 'Generated SQL omits the ranked grouping dimension ' + regroundedGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
|
|
1429
|
+
warnings: revalidated.warnings,
|
|
1430
|
+
}
|
|
1431
|
+
: revalidated.ok
|
|
1432
|
+
? {
|
|
1433
|
+
ok: true,
|
|
1434
|
+
warnings: [
|
|
1435
|
+
...revalidated.warnings,
|
|
1436
|
+
...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
|
|
1437
|
+
],
|
|
1438
|
+
}
|
|
1439
|
+
: {
|
|
1440
|
+
ok: false,
|
|
1441
|
+
code: revalidated.code,
|
|
1442
|
+
error: revalidated.error,
|
|
1443
|
+
warnings: [
|
|
1444
|
+
...revalidated.warnings,
|
|
1445
|
+
...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
|
|
1446
|
+
],
|
|
1447
|
+
offending: revalidated.offending,
|
|
1448
|
+
};
|
|
1368
1449
|
}
|
|
1369
1450
|
}
|
|
1370
1451
|
catch {
|
|
@@ -1378,14 +1459,18 @@ async function runAnswerLoop(input) {
|
|
|
1378
1459
|
// provider get the same single chance; any provider failure keeps the honest
|
|
1379
1460
|
// refusal below. This mirrors the engine-level repair loop, applied to the
|
|
1380
1461
|
// context-validation gate that previously refused on first failure.
|
|
1381
|
-
if (!contextValidation.ok && !governedMetricAnswer && canUseLaneRepair(repairBudgetState, '
|
|
1382
|
-
recordLaneRepair(repairBudgetState, '
|
|
1462
|
+
if (!contextValidation.ok && !governedMetricAnswer && canUseLaneRepair(repairBudgetState, 'validation')) {
|
|
1463
|
+
recordLaneRepair(repairBudgetState, 'validation');
|
|
1383
1464
|
try {
|
|
1384
1465
|
const failedSql = parsed.sql ?? '';
|
|
1385
1466
|
const repairPrompt = [
|
|
1386
1467
|
`Your SQL was rejected before execution: ${contextValidation.error}`,
|
|
1387
1468
|
formatOffendingValidationToken(contextValidation.offending),
|
|
1388
1469
|
formatValidationWarningsForPrompt(contextValidation.warnings),
|
|
1470
|
+
renderRequestedShapeForRepair(questionPlan),
|
|
1471
|
+
'For every required grouping alias, select the best matching inspected business column, project it with that exact alias, and include the same expression in GROUP BY.',
|
|
1472
|
+
'When the warehouse uses a different physical name (for example a location field for a requested region), keep the inspected physical column and alias it to the requested business name. Do not silently drop the grouping.',
|
|
1473
|
+
'Preserve every requested dimension and measure that was already correct; change only what the validation error requires.',
|
|
1389
1474
|
'Correct it using ONLY the relations and columns from the inspected context above.',
|
|
1390
1475
|
'If a needed column lives on a different relation, JOIN that relation using the suggested join paths.',
|
|
1391
1476
|
'If the requested column does not exist anywhere in the inspected context, return the closest answerable SQL and say what is missing.',
|
|
@@ -1400,8 +1485,12 @@ async function runAnswerLoop(input) {
|
|
|
1400
1485
|
intent,
|
|
1401
1486
|
filterValues: input.followUp?.filters,
|
|
1402
1487
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1488
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1403
1489
|
});
|
|
1404
|
-
|
|
1490
|
+
const repairedGrainGap = revalidated.ok
|
|
1491
|
+
? missingRankedGrainOutput(questionPlan, reparsed.sql, semanticBridgeAnswer?.dimensions)
|
|
1492
|
+
: undefined;
|
|
1493
|
+
if (revalidated.ok && !repairedGrainGap) {
|
|
1405
1494
|
const repairedSql = reparsed.sql;
|
|
1406
1495
|
parsed.sql = repairedSql;
|
|
1407
1496
|
parsed.text = reparsed.text || parsed.text;
|
|
@@ -1603,6 +1692,7 @@ async function runAnswerLoop(input) {
|
|
|
1603
1692
|
intent,
|
|
1604
1693
|
filterValues: input.followUp?.filters,
|
|
1605
1694
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1695
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1606
1696
|
});
|
|
1607
1697
|
if (localRepairValidation.ok) {
|
|
1608
1698
|
repairAttempts = 1;
|
|
@@ -1646,6 +1736,7 @@ async function runAnswerLoop(input) {
|
|
|
1646
1736
|
intent,
|
|
1647
1737
|
filterValues: input.followUp?.filters,
|
|
1648
1738
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1739
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1649
1740
|
});
|
|
1650
1741
|
if (repairedValidation.ok) {
|
|
1651
1742
|
repairAttempts += 1;
|
|
@@ -2180,6 +2271,16 @@ function renderAnalyticalPlanPrompt(plan) {
|
|
|
2180
2271
|
if (!plan || plan.entities.length < 2)
|
|
2181
2272
|
return undefined;
|
|
2182
2273
|
if (!plan.safe) {
|
|
2274
|
+
if (analyticalPlanAllowsDbtExploration(plan)) {
|
|
2275
|
+
return [
|
|
2276
|
+
'DQL GOVERNED RELATIONSHIP COVERAGE: MISSING.',
|
|
2277
|
+
plan.message ?? 'No certified analytical relationship path is available.',
|
|
2278
|
+
`Policy code: ${plan.code ?? 'unsafe_relationship'}.`,
|
|
2279
|
+
'This is not authorization for governed SQL. You MAY propose one bounded, read-only DBT-grounded exploratory SELECT/WITH using only the inspected relations and columns supplied below.',
|
|
2280
|
+
'Use explicit aliases and equality join keys visible in the inspected schema. Preserve the requested member bindings exactly. Do not introduce an uninspected relation, a cross-domain path, an attribution/allocation rule, or more than four joins.',
|
|
2281
|
+
'The host will independently parse, bound, probe, validate, and execute the candidate as analyst-review-required. If the inspected evidence cannot express the request, return no SQL.',
|
|
2282
|
+
].join('\n');
|
|
2283
|
+
}
|
|
2183
2284
|
return [
|
|
2184
2285
|
'DQL ANALYTICAL POLICY DECISION: BLOCKED.',
|
|
2185
2286
|
plan.message ?? 'No certified analytical relationship path is available.',
|
|
@@ -2197,6 +2298,17 @@ function renderAnalyticalPlanPrompt(plan) {
|
|
|
2197
2298
|
'Use only these relationships and exact key pairs. dbt DAG lineage and same-named columns are not join authorization. Any different join must be refused.',
|
|
2198
2299
|
].join('\n');
|
|
2199
2300
|
}
|
|
2301
|
+
/**
|
|
2302
|
+
* Missing modeling may enter the narrow EXP-001 host-validated lane. Explicit
|
|
2303
|
+
* unsafe, cross-domain, attribution, expiry, purpose, and proof failures never do.
|
|
2304
|
+
*/
|
|
2305
|
+
function analyticalPlanAllowsDbtExploration(plan) {
|
|
2306
|
+
if (plan.safe)
|
|
2307
|
+
return false;
|
|
2308
|
+
if (plan.code === 'unbound_relation' || plan.code === 'relationship_not_certified')
|
|
2309
|
+
return true;
|
|
2310
|
+
return plan.code === 'unsafe_relationship' && plan.relationshipIds.length === 0;
|
|
2311
|
+
}
|
|
2200
2312
|
// Metric-anchored generation (Tier 2.5): a governed metric matched the question but
|
|
2201
2313
|
// the semantic layer couldn't compose the exact shape. Reuse the metric's CERTIFIED
|
|
2202
2314
|
// definition as the measure rather than reinventing it, so the number stays consistent
|
|
@@ -2693,6 +2805,12 @@ function renderFollowUpContext(followUp) {
|
|
|
2693
2805
|
followUp.dimensions?.length ? `requested dimensions: ${followUp.dimensions.join(', ')}` : '',
|
|
2694
2806
|
followUp.priorResultColumns?.length ? `prior result columns: ${followUp.priorResultColumns.join(', ')}` : '',
|
|
2695
2807
|
followUp.priorResultValues ? `prior result values: ${formatPriorResultValues(followUp.priorResultValues)}` : '',
|
|
2808
|
+
followUp.memberBindings?.length
|
|
2809
|
+
? `required member bindings: ${followUp.memberBindings.map((binding) => `${binding.dimension} = ${binding.values.join(' | ')} (${binding.confidence})`).join('; ')}`
|
|
2810
|
+
: '',
|
|
2811
|
+
followUp.memberBindings?.length
|
|
2812
|
+
? 'binding enforcement: apply every required member value only to a column representing its named dimension. Never substitute another dimension; if the named dimension is unavailable, report that gap.'
|
|
2813
|
+
: '',
|
|
2696
2814
|
followUp.priorResultRef ? renderPriorResultReference(followUp.priorResultRef) : '',
|
|
2697
2815
|
followUp.priorDqlArtifact ? renderPriorDqlArtifactReference(followUp.priorDqlArtifact) : '',
|
|
2698
2816
|
followUp.priorLimit ? `prior result limit: ${followUp.priorLimit}` : '',
|
|
@@ -3033,14 +3151,243 @@ function escapeDqlArtifactTripleString(value) {
|
|
|
3033
3151
|
function generatedResultShapeIsPartial(resultShape) {
|
|
3034
3152
|
return resultShape.missingOutputs.length >= 2;
|
|
3035
3153
|
}
|
|
3154
|
+
/**
|
|
3155
|
+
* A ranked query without its ranked entity is a different answer, even when the
|
|
3156
|
+
* aggregate executes. Catch this before execution so the bounded validation
|
|
3157
|
+
* correction can restore the requested grain instead of presenting a region-only
|
|
3158
|
+
* total for a top-product question.
|
|
3159
|
+
*/
|
|
3160
|
+
export function missingRankedGrainOutput(questionPlan, sql, governedSemanticDimensions = []) {
|
|
3161
|
+
const grain = questionPlan.requestedShape.grain;
|
|
3162
|
+
if (!grain || questionPlan.requestedShape.topN?.scope !== 'per_group')
|
|
3163
|
+
return undefined;
|
|
3164
|
+
const shape = extractSimpleSelectShape(sql);
|
|
3165
|
+
if (!shape)
|
|
3166
|
+
return undefined;
|
|
3167
|
+
const columns = shape.selectExpressions
|
|
3168
|
+
.map(selectExpressionOutputName)
|
|
3169
|
+
.filter((column) => Boolean(column));
|
|
3170
|
+
const requiredGroupingDimensions = rankedGroupingDimensions(questionPlan);
|
|
3171
|
+
const literalGap = requiredGroupingDimensions.find((dimension) => !columns.some((output) => outputConceptMatches(output, dimension)));
|
|
3172
|
+
if (!literalGap)
|
|
3173
|
+
return undefined;
|
|
3174
|
+
// A bounded Lane-2 meaning selection may map a user's business word to a
|
|
3175
|
+
// differently named governed dimension (for example "region" to
|
|
3176
|
+
// `location_name`). Trust that mapping only when the semantic compiler
|
|
3177
|
+
// projected every selected governed dimension and the selection covers the
|
|
3178
|
+
// complete grouping cardinality. Arbitrary generated aliases never receive
|
|
3179
|
+
// this exception.
|
|
3180
|
+
const projectedGovernedDimensions = governedSemanticDimensions.filter((dimension) => columns.some((output) => outputConceptMatches(output, dimension)));
|
|
3181
|
+
if (projectedGovernedDimensions.length >= requiredGroupingDimensions.length)
|
|
3182
|
+
return undefined;
|
|
3183
|
+
return literalGap;
|
|
3184
|
+
}
|
|
3185
|
+
function requestedGroupingDimensions(questionPlan) {
|
|
3186
|
+
const grain = questionPlan.requestedShape.grain;
|
|
3187
|
+
const dimensions = [
|
|
3188
|
+
...(grain ? [grain] : []),
|
|
3189
|
+
...questionPlan.requestedShape.dimensions,
|
|
3190
|
+
].filter((dimension) => {
|
|
3191
|
+
const isGrain = Boolean(grain) && conceptsEquivalent(dimension, grain);
|
|
3192
|
+
if (isGrain && !isCompoundMeasurePhrase(dimension, questionPlan.requestedShape.measures))
|
|
3193
|
+
return true;
|
|
3194
|
+
return !isMeasurePhrase(dimension, questionPlan.requestedShape.measures);
|
|
3195
|
+
});
|
|
3196
|
+
const seen = new Set();
|
|
3197
|
+
return dimensions.filter((dimension) => {
|
|
3198
|
+
const key = normalizedConceptTokens(dimension).join('_');
|
|
3199
|
+
if (!key || seen.has(key))
|
|
3200
|
+
return false;
|
|
3201
|
+
seen.add(key);
|
|
3202
|
+
return true;
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
3205
|
+
function rankedGroupingDimensions(questionPlan) {
|
|
3206
|
+
const grain = questionPlan.requestedShape.grain;
|
|
3207
|
+
if (!grain || questionPlan.requestedShape.topN?.scope !== 'per_group')
|
|
3208
|
+
return [];
|
|
3209
|
+
return requestedGroupingDimensions(questionPlan);
|
|
3210
|
+
}
|
|
3211
|
+
function renderRequestedShapeForRepair(questionPlan) {
|
|
3212
|
+
return 'Required answer contract: ' + JSON.stringify({
|
|
3213
|
+
grain: questionPlan.requestedShape.grain,
|
|
3214
|
+
dimensions: questionPlan.requestedShape.dimensions,
|
|
3215
|
+
measures: questionPlan.requestedShape.measures,
|
|
3216
|
+
requiredOutputs: questionPlan.requestedShape.requiredOutputs,
|
|
3217
|
+
requiredGroupingAliases: rankedGroupingDimensions(questionPlan),
|
|
3218
|
+
topN: questionPlan.requestedShape.topN,
|
|
3219
|
+
});
|
|
3220
|
+
}
|
|
3221
|
+
function outputConceptMatches(output, concept) {
|
|
3222
|
+
const outputTokens = normalizedConceptTokens(output);
|
|
3223
|
+
const conceptTokens = normalizedConceptTokens(concept);
|
|
3224
|
+
return conceptTokens.length > 0 && conceptTokens.every((token) => outputTokens.includes(token));
|
|
3225
|
+
}
|
|
3226
|
+
function isCompoundMeasurePhrase(concept, measures) {
|
|
3227
|
+
const conceptTokens = normalizedConceptTokens(concept);
|
|
3228
|
+
return measures.some((measure) => {
|
|
3229
|
+
const measureTokens = normalizedConceptTokens(measure);
|
|
3230
|
+
return measureTokens.length > 0
|
|
3231
|
+
&& conceptTokens.length > measureTokens.length
|
|
3232
|
+
&& measureTokens.every((token) => conceptTokens.includes(token));
|
|
3233
|
+
});
|
|
3234
|
+
}
|
|
3235
|
+
function isMeasurePhrase(concept, measures) {
|
|
3236
|
+
const conceptTokens = normalizedConceptTokens(concept);
|
|
3237
|
+
return measures.some((measure) => {
|
|
3238
|
+
const measureTokens = normalizedConceptTokens(measure);
|
|
3239
|
+
return measureTokens.length > 0
|
|
3240
|
+
&& conceptTokens.length >= measureTokens.length
|
|
3241
|
+
&& measureTokens.every((token) => conceptTokens.includes(token));
|
|
3242
|
+
});
|
|
3243
|
+
}
|
|
3244
|
+
function conceptsEquivalent(left, right) {
|
|
3245
|
+
const leftTokens = normalizedConceptTokens(left);
|
|
3246
|
+
const rightTokens = normalizedConceptTokens(right);
|
|
3247
|
+
return leftTokens.length === rightTokens.length
|
|
3248
|
+
&& leftTokens.every((token, index) => token === rightTokens[index]);
|
|
3249
|
+
}
|
|
3250
|
+
function normalizedConceptTokens(value) {
|
|
3251
|
+
return value
|
|
3252
|
+
.toLowerCase()
|
|
3253
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
3254
|
+
.trim()
|
|
3255
|
+
.split(/\s+/)
|
|
3256
|
+
.filter((token) => token && token !== 'name' && token !== 'label')
|
|
3257
|
+
.map((token) => token.endsWith('s') && token.length > 3 ? token.slice(0, -1) : token);
|
|
3258
|
+
}
|
|
3036
3259
|
function trustedFollowUpFilterValues(followUp) {
|
|
3037
3260
|
const filters = new Set((followUp?.filters ?? []).map((value) => value.trim()).filter(Boolean));
|
|
3038
|
-
|
|
3261
|
+
const boundValues = (followUp?.memberBindings ?? [])
|
|
3262
|
+
.flatMap((binding) => binding.values)
|
|
3263
|
+
.map((value) => value.trim())
|
|
3264
|
+
.filter(Boolean);
|
|
3265
|
+
const priorValues = Object.values(followUp?.priorResultValues ?? {}).flat().map((value) => value.trim()).filter(Boolean);
|
|
3266
|
+
const trustedCandidates = uniqueDrilldownStrings([...boundValues, ...priorValues]);
|
|
3267
|
+
if (!trustedCandidates.length)
|
|
3039
3268
|
return undefined;
|
|
3040
|
-
|
|
3041
|
-
|
|
3269
|
+
// Typed bindings were resolved by the deterministic member resolver and are
|
|
3270
|
+
// trusted even if a legacy caller omitted the mirrored flat `filters` array.
|
|
3271
|
+
const trusted = trustedCandidates.filter((value) => boundValues.includes(value) || filters.has(value));
|
|
3042
3272
|
return trusted.length > 0 ? uniqueDrilldownStrings(trusted) : undefined;
|
|
3043
3273
|
}
|
|
3274
|
+
function injectUniqueResolvedMemberBindings(sql, bindings, schemaContext) {
|
|
3275
|
+
if (!/^\s*select\b/i.test(sql) || /;\s*\S/.test(sql))
|
|
3276
|
+
return undefined;
|
|
3277
|
+
const relationAliases = sqlRelationAliases(sql);
|
|
3278
|
+
const predicates = [];
|
|
3279
|
+
const notes = [];
|
|
3280
|
+
for (const binding of bindings) {
|
|
3281
|
+
const values = Array.from(new Set(binding.values.map((value) => value.trim()).filter(Boolean)));
|
|
3282
|
+
if (values.length === 0)
|
|
3283
|
+
continue;
|
|
3284
|
+
// Do not add a second interpretation when the proposal already contains a
|
|
3285
|
+
// canonical bound value. The validator will determine whether it is on the
|
|
3286
|
+
// correct dimension and reject a misbinding.
|
|
3287
|
+
if (values.some((value) => sql.toLowerCase().includes(sqlStringLiteral(value).toLowerCase())))
|
|
3288
|
+
continue;
|
|
3289
|
+
const candidates = schemaContext.flatMap((table) => table.columns.flatMap((column) => {
|
|
3290
|
+
if (!outputConceptMatches(column.name, binding.dimension))
|
|
3291
|
+
return [];
|
|
3292
|
+
if (values.some((value) => /[\p{L}]/u.test(value)) && isJoinKeyColumn(column.name))
|
|
3293
|
+
return [];
|
|
3294
|
+
const samples = new Set((column.sampleValues ?? []).map(normalizeValueIndexText));
|
|
3295
|
+
// The typed binding is already canonical request state. Samples may
|
|
3296
|
+
// strengthen the match when this turn has them, but snapshot scoping must
|
|
3297
|
+
// not erase a prior governed value. A conflicting non-empty sample set is
|
|
3298
|
+
// rejected; an absent sample set may proceed only through the unique
|
|
3299
|
+
// dimension-column + existing-query-path proof below.
|
|
3300
|
+
if (samples.size > 0 && !values.every((value) => samples.has(normalizeValueIndexText(value))))
|
|
3301
|
+
return [];
|
|
3302
|
+
const alias = findSqlAliasForRelation(table.relation, relationAliases);
|
|
3303
|
+
if (!alias || !safeSqlIdentifier(alias) || !safeSqlIdentifier(column.name))
|
|
3304
|
+
return [];
|
|
3305
|
+
return [{ alias, column: column.name }];
|
|
3306
|
+
}));
|
|
3307
|
+
const uniqueCandidates = Array.from(new Map(candidates.map((candidate) => [
|
|
3308
|
+
`${candidate.alias.toLowerCase()}.${candidate.column.toLowerCase()}`,
|
|
3309
|
+
candidate,
|
|
3310
|
+
])).values());
|
|
3311
|
+
if (uniqueCandidates.length !== 1)
|
|
3312
|
+
return undefined;
|
|
3313
|
+
const candidate = uniqueCandidates[0];
|
|
3314
|
+
const qualifiedColumn = `${candidate.alias}.${candidate.column}`;
|
|
3315
|
+
const predicate = values.length === 1
|
|
3316
|
+
? `${qualifiedColumn} = ${sqlStringLiteral(values[0])}`
|
|
3317
|
+
: `${qualifiedColumn} IN (${values.map(sqlStringLiteral).join(', ')})`;
|
|
3318
|
+
predicates.push(predicate);
|
|
3319
|
+
notes.push(`Applied the resolved ${binding.dimension} member binding deterministically on ${qualifiedColumn}.`);
|
|
3320
|
+
}
|
|
3321
|
+
if (predicates.length === 0)
|
|
3322
|
+
return undefined;
|
|
3323
|
+
const clauseIndex = firstTopLevelSqlClauseIndex(sql, ['group by', 'having', 'qualify', 'order by', 'limit']);
|
|
3324
|
+
const head = clauseIndex >= 0 ? sql.slice(0, clauseIndex).trimEnd() : sql.trimEnd();
|
|
3325
|
+
const tail = clauseIndex >= 0 ? ` ${sql.slice(clauseIndex).trimStart()}` : '';
|
|
3326
|
+
const joiner = /\bwhere\b/i.test(head) ? ' AND ' : ' WHERE ';
|
|
3327
|
+
return { sql: `${head}${joiner}${predicates.join(' AND ')}${tail}`, notes };
|
|
3328
|
+
}
|
|
3329
|
+
function sqlRelationAliases(sql) {
|
|
3330
|
+
const matches = [];
|
|
3331
|
+
const pattern = /\b(?:from|join)\s+((?:["`]?\w+["`]?\s*\.\s*)*["`]?\w+["`]?)\s*(?:as\s+)?([A-Za-z_][\w$]*)?/gi;
|
|
3332
|
+
for (const match of sql.matchAll(pattern)) {
|
|
3333
|
+
const relation = (match[1] ?? '').replace(/["`\s]/g, '');
|
|
3334
|
+
if (!relation)
|
|
3335
|
+
continue;
|
|
3336
|
+
const implicitAlias = relation.split('.').pop() ?? relation;
|
|
3337
|
+
const candidateAlias = match[2] && !SQL_CLAUSE_WORDS.has(match[2].toLowerCase()) ? match[2] : implicitAlias;
|
|
3338
|
+
matches.push({ relation, alias: candidateAlias });
|
|
3339
|
+
}
|
|
3340
|
+
return matches;
|
|
3341
|
+
}
|
|
3342
|
+
const SQL_CLAUSE_WORDS = new Set(['where', 'join', 'left', 'right', 'inner', 'outer', 'full', 'cross', 'group', 'having', 'qualify', 'order', 'limit', 'on']);
|
|
3343
|
+
function findSqlAliasForRelation(relation, aliases) {
|
|
3344
|
+
const normalized = relation.replace(/["`\s]/g, '').toLowerCase();
|
|
3345
|
+
const short = normalized.split('.').pop();
|
|
3346
|
+
return aliases.find((candidate) => {
|
|
3347
|
+
const candidateNormalized = candidate.relation.toLowerCase();
|
|
3348
|
+
return candidateNormalized === normalized || candidateNormalized.split('.').pop() === short;
|
|
3349
|
+
})?.alias;
|
|
3350
|
+
}
|
|
3351
|
+
function firstTopLevelSqlClauseIndex(sql, clauses) {
|
|
3352
|
+
let depth = 0;
|
|
3353
|
+
let quote;
|
|
3354
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
3355
|
+
const char = sql[index];
|
|
3356
|
+
if (quote) {
|
|
3357
|
+
if (char === quote) {
|
|
3358
|
+
if (quote === "'" && sql[index + 1] === "'")
|
|
3359
|
+
index += 1;
|
|
3360
|
+
else
|
|
3361
|
+
quote = undefined;
|
|
3362
|
+
}
|
|
3363
|
+
continue;
|
|
3364
|
+
}
|
|
3365
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
3366
|
+
quote = char;
|
|
3367
|
+
continue;
|
|
3368
|
+
}
|
|
3369
|
+
if (char === '(') {
|
|
3370
|
+
depth += 1;
|
|
3371
|
+
continue;
|
|
3372
|
+
}
|
|
3373
|
+
if (char === ')') {
|
|
3374
|
+
depth = Math.max(0, depth - 1);
|
|
3375
|
+
continue;
|
|
3376
|
+
}
|
|
3377
|
+
if (depth !== 0)
|
|
3378
|
+
continue;
|
|
3379
|
+
const rest = sql.slice(index).toLowerCase();
|
|
3380
|
+
if (clauses.some((clause) => rest.startsWith(clause) && (!rest[clause.length] || /\s/.test(rest[clause.length]))))
|
|
3381
|
+
return index;
|
|
3382
|
+
}
|
|
3383
|
+
return -1;
|
|
3384
|
+
}
|
|
3385
|
+
function safeSqlIdentifier(value) {
|
|
3386
|
+
return /^[A-Za-z_][\w$]*$/.test(value);
|
|
3387
|
+
}
|
|
3388
|
+
function sqlStringLiteral(value) {
|
|
3389
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
3390
|
+
}
|
|
3044
3391
|
function buildCandidateJoinPaths(schemaContext) {
|
|
3045
3392
|
const tables = [...schemaContext]
|
|
3046
3393
|
.filter((table) => table.columns.some((column) => isJoinKeyColumn(column.name)))
|
|
@@ -3972,6 +4319,9 @@ async function scoreDeepGeneratedProposalCandidate(input, candidate) {
|
|
|
3972
4319
|
const validation = input.contextLedger.validateSql(parsed.sql, {
|
|
3973
4320
|
question: input.question,
|
|
3974
4321
|
intent: input.intent,
|
|
4322
|
+
filterValues: input.followUp?.filters,
|
|
4323
|
+
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
4324
|
+
memberBindings: input.followUp?.memberBindings,
|
|
3975
4325
|
});
|
|
3976
4326
|
validationOk = validation.ok;
|
|
3977
4327
|
if (validation.ok) {
|
|
@@ -4692,13 +5042,13 @@ function cascadeBudgetEvidenceRouteSteps(trace) {
|
|
|
4692
5042
|
if (!trace)
|
|
4693
5043
|
return [];
|
|
4694
5044
|
const { usage, limits } = trace;
|
|
4695
|
-
const used = usage.laneRegroundAttemptsUsed + usage.laneExecutionAttemptsUsed + usage.engineEscalationsUsed;
|
|
5045
|
+
const used = usage.laneRegroundAttemptsUsed + usage.laneValidationAttemptsUsed + usage.laneExecutionAttemptsUsed + usage.engineEscalationsUsed;
|
|
4696
5046
|
if (used === 0)
|
|
4697
5047
|
return [];
|
|
4698
5048
|
return [{
|
|
4699
5049
|
tool: 'cascade_budget',
|
|
4700
5050
|
status: 'checked',
|
|
4701
|
-
label: `Repair budget used: re-ground ${usage.laneRegroundAttemptsUsed}/${limits.lane.reground}, execution ${usage.laneExecutionAttemptsUsed}/${limits.lane.execution}`,
|
|
5051
|
+
label: `Repair budget used: re-ground ${usage.laneRegroundAttemptsUsed}/${limits.lane.reground}, validation ${usage.laneValidationAttemptsUsed}/${limits.lane.validation}, execution ${usage.laneExecutionAttemptsUsed}/${limits.lane.execution}`,
|
|
4702
5052
|
detail: `engine escalations ${usage.engineEscalationsUsed}/${limits.engineEscalations}`,
|
|
4703
5053
|
}];
|
|
4704
5054
|
}
|