@duckcodeailabs/dql-agent 1.8.1 → 1.8.3
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 +11 -1
- 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/agent-run-gates.d.ts.map +1 -1
- package/dist/agent-run-gates.js +2 -1
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/agent-run-store.d.ts +33 -0
- package/dist/agent-run-store.d.ts.map +1 -0
- package/dist/agent-run-store.js +167 -0
- package/dist/agent-run-store.js.map +1 -0
- package/dist/answer-loop.d.ts +32 -3
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +496 -49
- 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 +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- 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/analytical-policy.d.ts +40 -2
- package/dist/metadata/analytical-policy.d.ts.map +1 -1
- package/dist/metadata/analytical-policy.js +276 -53
- package/dist/metadata/analytical-policy.js.map +1 -1
- package/dist/metadata/block-fit.js +32 -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 +57 -3
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/dbt-first-safety.d.ts +8 -1
- package/dist/metadata/dbt-first-safety.d.ts.map +1 -1
- package/dist/metadata/dbt-first-safety.js +6 -1
- package/dist/metadata/dbt-first-safety.js.map +1 -1
- package/dist/metadata/health.d.ts +19 -0
- package/dist/metadata/health.d.ts.map +1 -0
- package/dist/metadata/health.js +72 -0
- package/dist/metadata/health.js.map +1 -0
- 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 +93 -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/dist/tools/registry.js +9 -9
- package/dist/tools/registry.js.map +1 -1
- package/package.json +4 -4
package/dist/answer-loop.js
CHANGED
|
@@ -26,7 +26,7 @@ import { createContextLedger } from './grounding/context-ledger.js';
|
|
|
26
26
|
import { validateAnswerResultShape } from './answer-shape.js';
|
|
27
27
|
import { fanoutWarningsForSql } from './metadata/grain-ledger.js';
|
|
28
28
|
import { evaluateDbtFirstGeneratedSql } from './metadata/dbt-first-safety.js';
|
|
29
|
-
import { planAnalyticalPath } from './metadata/analytical-policy.js';
|
|
29
|
+
import { planAnalyticalPath, humanizeAnalyticalEntityId, analyticalPolicyUserFacingReason, } from './metadata/analytical-policy.js';
|
|
30
30
|
import { planCertifiedAdaptation } from './metadata/block-adapt.js';
|
|
31
31
|
import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
|
|
32
32
|
import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers, } from './semantic-bridge/compose.js';
|
|
@@ -158,12 +158,43 @@ const BUSINESS_CONTEXT_KINDS = ['term', 'business_view', 'domain', 'skill', 'rel
|
|
|
158
158
|
const ARTIFACT_KINDS = [...EXECUTABLE_ARTIFACT_KINDS, ...BUSINESS_CONTEXT_KINDS];
|
|
159
159
|
const SEMANTIC_KINDS = ['metric', 'dimension', 'measure', 'entity', 'semantic_model', 'saved_query'];
|
|
160
160
|
const MANIFEST_KINDS = ['dbt_model', 'dbt_source'];
|
|
161
|
+
/**
|
|
162
|
+
* Business-language explanation for a failed SQL context validation. The chat
|
|
163
|
+
* surface shows THIS; the validator's machine message (relation ids, repair
|
|
164
|
+
* tool guidance) belongs in refusalDetails/validationWarnings only.
|
|
165
|
+
*/
|
|
166
|
+
function renderContextValidationRefusalForUser(code, machineError, memberBindings) {
|
|
167
|
+
switch (code) {
|
|
168
|
+
case 'unknown_relation':
|
|
169
|
+
return 'I drafted a query, but it uses a table that was not part of the metadata retrieved for this question, so I did not run it. Ask again (retrieval usually finds it on a follow-up), name the table or metric explicitly, or re-sync the dbt metadata.';
|
|
170
|
+
case 'unknown_column':
|
|
171
|
+
return 'I drafted a query, but it references a column that is not in the inspected metadata for those tables, so I did not run it. Name the exact field you mean, or re-sync the dbt metadata if the column is new.';
|
|
172
|
+
case 'misbound_filter': {
|
|
173
|
+
const binding = memberBindings?.[0];
|
|
174
|
+
const target = binding ? `${humanizeAnalyticalEntityId(binding.dimension)} "${binding.values[0] ?? ''}"` : 'the requested value';
|
|
175
|
+
return `I need to filter this answer to ${target}, but the query I drafted did not apply that filter to a matching column, so I did not run it. Tell me which column identifies ${binding ? humanizeAnalyticalEntityId(binding.dimension) : 'that value'}, or rephrase with the exact field name.`;
|
|
176
|
+
}
|
|
177
|
+
case 'ambiguous_filter':
|
|
178
|
+
return 'The value you asked about matches more than one column in the inspected data, so I did not guess. Tell me which field it belongs to and I will run it.';
|
|
179
|
+
case 'missing_baseline':
|
|
180
|
+
return 'This comparison needs a baseline period or value that the drafted query did not include. Say what to compare against (for example, the prior month) and I will run it.';
|
|
181
|
+
case 'unsafe_sql':
|
|
182
|
+
return 'The drafted query used a statement type that is not allowed in this governed preview, so I did not run it.';
|
|
183
|
+
case 'insufficient_context':
|
|
184
|
+
default:
|
|
185
|
+
return machineError.trim().length > 0 && !/inspect_metadata_context/i.test(machineError)
|
|
186
|
+
? `I could not prepare a governed query yet: ${machineError.replace(/\s*Use inspect_metadata_context[^.]*\.\s*$/i, '').trim()}`
|
|
187
|
+
: 'I could not prepare a governed query from the retrieved metadata. Name the specific metric or table and how to break it down, and I can generate a review-required draft.';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
161
190
|
function refusalCodeForValidation(code) {
|
|
162
191
|
if (code === 'unknown_relation' || code === 'unknown_column' || code === 'insufficient_context' || code === 'missing_baseline') {
|
|
163
192
|
return 'grounding_gap';
|
|
164
193
|
}
|
|
165
194
|
if (code === 'ambiguous_filter')
|
|
166
195
|
return 'ambiguous';
|
|
196
|
+
if (code === 'misbound_filter')
|
|
197
|
+
return 'grounding_gap';
|
|
167
198
|
return 'model_declined';
|
|
168
199
|
}
|
|
169
200
|
/**
|
|
@@ -179,14 +210,34 @@ function refusalCodeForAnalyticalPolicy(code, hasExploratoryCandidate = false) {
|
|
|
179
210
|
return 'policy_blocked';
|
|
180
211
|
}
|
|
181
212
|
/**
|
|
182
|
-
* The governed guard is intentionally strict. Only
|
|
213
|
+
* The governed guard is intentionally strict. Only outcomes that mean
|
|
183
214
|
* "this repository has not modeled this join yet" may be handed to a host's
|
|
184
215
|
* exploratory lane. All other decisions are explicit governance/safety
|
|
185
216
|
* denials and must remain terminal in this loop.
|
|
186
217
|
*/
|
|
187
218
|
function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
|
|
219
|
+
// The policy service's disposition is authoritative: a declared draft path
|
|
220
|
+
// re-bound to this SQL's actual joins enters the bounded lane directly.
|
|
221
|
+
if (decision.disposition === 'exploratory_candidate') {
|
|
222
|
+
return {
|
|
223
|
+
kind: 'dbt_grounded_exploration',
|
|
224
|
+
reason: 'relationship_not_certified',
|
|
225
|
+
sql,
|
|
226
|
+
message: decision.technicalDetail ?? decision.message
|
|
227
|
+
?? 'This query follows a declared but uncertified DQL relationship path.',
|
|
228
|
+
userFacingReason: decision.userFacingReason,
|
|
229
|
+
modeledEntityIds: decision.entities,
|
|
230
|
+
relationshipIds: decision.relationshipIds,
|
|
231
|
+
exploratoryPath: decision.exploratoryPath,
|
|
232
|
+
executionStatus: 'not_executed',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
188
235
|
const reason = decision.code;
|
|
189
|
-
|
|
236
|
+
const missingPathWithoutUnsafeProof = reason === 'unsafe_relationship' && decision.relationshipIds.length === 0;
|
|
237
|
+
if (decision.safe || (!missingPathWithoutUnsafeProof
|
|
238
|
+
&& reason !== 'unbound_relation'
|
|
239
|
+
&& reason !== 'unplanned_join'
|
|
240
|
+
&& reason !== 'relationship_not_certified')) {
|
|
190
241
|
return undefined;
|
|
191
242
|
}
|
|
192
243
|
// `unplanned_join` can also mean the model ignored an existing certified
|
|
@@ -201,6 +252,7 @@ function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
|
|
|
201
252
|
sql,
|
|
202
253
|
message: decision.message
|
|
203
254
|
?? 'This query is grounded in dbt metadata but has no certified DQL relationship path yet.',
|
|
255
|
+
userFacingReason: decision.userFacingReason,
|
|
204
256
|
modeledEntityIds: decision.entities,
|
|
205
257
|
relationshipIds: decision.relationshipIds,
|
|
206
258
|
executionStatus: 'not_executed',
|
|
@@ -914,9 +966,10 @@ async function runAnswerLoop(input) {
|
|
|
914
966
|
// generation, which can express the breakdown.
|
|
915
967
|
const wantedBreakdown = questionPlan.requestedShape.dimensions.length > 0
|
|
916
968
|
|| questionPlan.dimensionTerms.length > 0;
|
|
969
|
+
const requiredGroupingCount = requestedGroupingDimensions(questionPlan).length;
|
|
917
970
|
const dropsBreakdown = Boolean(composed)
|
|
918
971
|
&& wantedBreakdown
|
|
919
|
-
&& composed.dimensions.length
|
|
972
|
+
&& composed.dimensions.length < Math.max(1, requiredGroupingCount)
|
|
920
973
|
&& !composed.timeDimension;
|
|
921
974
|
if (composed && !dropsBreakdown) {
|
|
922
975
|
semanticBridgeAnswer = composed;
|
|
@@ -1047,6 +1100,14 @@ async function runAnswerLoop(input) {
|
|
|
1047
1100
|
});
|
|
1048
1101
|
}
|
|
1049
1102
|
catch (err) {
|
|
1103
|
+
// A request-level cancellation/deadline is orchestration state, not an
|
|
1104
|
+
// upstream-provider outage. Preserve the AbortSignal reason so the run
|
|
1105
|
+
// engine can report a bounded deadline (or explicit user cancellation)
|
|
1106
|
+
// instead of the misleading generic "Provider error" label.
|
|
1107
|
+
if (input.signal?.aborted)
|
|
1108
|
+
throw input.signal.reason ?? err;
|
|
1109
|
+
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
|
|
1110
|
+
throw err;
|
|
1050
1111
|
const text = `Provider error: ${err.message}`;
|
|
1051
1112
|
return {
|
|
1052
1113
|
kind: 'no_answer',
|
|
@@ -1119,6 +1180,7 @@ async function runAnswerLoop(input) {
|
|
|
1119
1180
|
intent,
|
|
1120
1181
|
initial: { raw: proposed, parsed },
|
|
1121
1182
|
contextLedger,
|
|
1183
|
+
followUp: input.followUp,
|
|
1122
1184
|
executeGeneratedSql: input.executeGeneratedSql,
|
|
1123
1185
|
signal: input.signal,
|
|
1124
1186
|
reasoningEffort: input.reasoningEffort,
|
|
@@ -1177,7 +1239,8 @@ async function runAnswerLoop(input) {
|
|
|
1177
1239
|
|| (input.contextPack?.allowedSqlContext?.relations.length ?? 0) > 0
|
|
1178
1240
|
|| (input.contextPack?.allowedSqlContext?.sourceBlockSql.length ?? 0) > 0
|
|
1179
1241
|
|| contextBlocks.length > 0;
|
|
1180
|
-
if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext
|
|
1242
|
+
if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext
|
|
1243
|
+
&& (analyticalPlan?.safe !== false || analyticalPlanAllowsDbtExploration(analyticalPlan))) {
|
|
1181
1244
|
try {
|
|
1182
1245
|
proposed = await generateProposalWithOptionalTools({
|
|
1183
1246
|
provider,
|
|
@@ -1192,7 +1255,12 @@ async function runAnswerLoop(input) {
|
|
|
1192
1255
|
});
|
|
1193
1256
|
parsed = parseProposal(proposed);
|
|
1194
1257
|
}
|
|
1195
|
-
catch {
|
|
1258
|
+
catch (err) {
|
|
1259
|
+
// Deadline/cancellation must surface as itself, never as a policy refusal.
|
|
1260
|
+
if (input.signal?.aborted)
|
|
1261
|
+
throw input.signal.reason ?? err;
|
|
1262
|
+
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
|
|
1263
|
+
throw err;
|
|
1196
1264
|
// keep the original decline; fall through to the honest no_answer.
|
|
1197
1265
|
}
|
|
1198
1266
|
}
|
|
@@ -1205,9 +1273,15 @@ async function runAnswerLoop(input) {
|
|
|
1205
1273
|
// consistent, actionable message so the same question yields the same outcome
|
|
1206
1274
|
// every run and every surface — instead of passing through the model's varying
|
|
1207
1275
|
// text. A genuinely context-less ask keeps the plain honest message.
|
|
1276
|
+
// The user-facing text is BUSINESS language; the machine policy detail
|
|
1277
|
+
// (qualified ids, codes) lives only in refusalDetails + validationWarnings.
|
|
1208
1278
|
const declinedDespiteContext = wantsGeneratedData && hasGeneratableContext;
|
|
1279
|
+
const policyDetail = analyticalPlan?.safe === false
|
|
1280
|
+
? analyticalPlan.technicalDetail ?? analyticalPlan.message ?? 'DQL could not prove a safe analytical relationship path.'
|
|
1281
|
+
: undefined;
|
|
1209
1282
|
const text = analyticalPlan?.safe === false
|
|
1210
|
-
? analyticalPlan.
|
|
1283
|
+
? analyticalPlan.userFacingReason
|
|
1284
|
+
?? analyticalPolicyUserFacingReason(analyticalPlan.code ?? 'unsafe_relationship', analyticalPlan.entities)
|
|
1211
1285
|
: declinedDespiteContext
|
|
1212
1286
|
? 'I could not compose a governed query for this from the available tables and metrics. This usually needs a clearer join path or an explicit metric and grouping — name the specific measure and how to break it down, and I can generate a review-required draft.'
|
|
1213
1287
|
: parsed.text || 'No answer (the model declined to propose SQL).';
|
|
@@ -1219,14 +1293,17 @@ async function runAnswerLoop(input) {
|
|
|
1219
1293
|
confidence: 0.1,
|
|
1220
1294
|
text,
|
|
1221
1295
|
refusalCode: analyticalPlan?.safe === false
|
|
1222
|
-
?
|
|
1296
|
+
? analyticalPlan.disposition === 'exploratory_candidate'
|
|
1297
|
+
? 'modeling_gap'
|
|
1298
|
+
: refusalCodeForAnalyticalPolicy(analyticalPlan.code, analyticalPlanAllowsDbtExploration(analyticalPlan))
|
|
1223
1299
|
: 'model_declined',
|
|
1224
1300
|
refusalDetails: {
|
|
1225
1301
|
code: analyticalPlan?.safe === false
|
|
1226
1302
|
? analyticalPlan.code ?? 'unsafe_relationship'
|
|
1227
1303
|
: 'model_declined',
|
|
1228
|
-
message: text,
|
|
1304
|
+
message: policyDetail ?? text,
|
|
1229
1305
|
},
|
|
1306
|
+
validationWarnings: policyDetail ? [`Analytical policy detail: ${policyDetail}`] : undefined,
|
|
1230
1307
|
answer: text,
|
|
1231
1308
|
citations: [],
|
|
1232
1309
|
memoryContext: input.memoryContext,
|
|
@@ -1280,16 +1357,66 @@ async function runAnswerLoop(input) {
|
|
|
1280
1357
|
intent,
|
|
1281
1358
|
filterValues: input.followUp?.filters,
|
|
1282
1359
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1360
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1283
1361
|
});
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1362
|
+
const rankedGrainGap = missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions);
|
|
1363
|
+
contextValidation = initialValidation.ok && rankedGrainGap
|
|
1364
|
+
? {
|
|
1287
1365
|
ok: false,
|
|
1288
|
-
code:
|
|
1289
|
-
error:
|
|
1366
|
+
code: 'insufficient_context',
|
|
1367
|
+
error: 'Generated SQL omits the ranked grouping dimension ' + rankedGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
|
|
1290
1368
|
warnings: initialValidation.warnings,
|
|
1291
|
-
|
|
1292
|
-
|
|
1369
|
+
}
|
|
1370
|
+
: initialValidation.ok
|
|
1371
|
+
? { ok: true, warnings: initialValidation.warnings }
|
|
1372
|
+
: {
|
|
1373
|
+
ok: false,
|
|
1374
|
+
code: initialValidation.code,
|
|
1375
|
+
error: initialValidation.error,
|
|
1376
|
+
warnings: initialValidation.warnings,
|
|
1377
|
+
offending: initialValidation.offending,
|
|
1378
|
+
};
|
|
1379
|
+
// A canonical member value that resolves to exactly one inspected column is
|
|
1380
|
+
// compiler-owned request state, not creative SQL. If the proposal omitted
|
|
1381
|
+
// that predicate entirely, inject it deterministically before spending the
|
|
1382
|
+
// bounded AI validation repair. Misbound or ambiguous predicates are never
|
|
1383
|
+
// rewritten here; they continue through the guarded correction/refusal path.
|
|
1384
|
+
if (!contextValidation.ok
|
|
1385
|
+
&& contextValidation.code === 'misbound_filter'
|
|
1386
|
+
&& !contextValidation.offending?.relation
|
|
1387
|
+
&& !contextValidation.offending?.column
|
|
1388
|
+
&& input.followUp?.memberBindings?.length) {
|
|
1389
|
+
const injected = injectUniqueResolvedMemberBindings(parsed.sql, input.followUp.memberBindings, schemaContext);
|
|
1390
|
+
if (injected) {
|
|
1391
|
+
parsed.sql = injected.sql;
|
|
1392
|
+
const rebound = contextLedger.validateSql(parsed.sql, {
|
|
1393
|
+
question,
|
|
1394
|
+
intent,
|
|
1395
|
+
filterValues: input.followUp?.filters,
|
|
1396
|
+
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1397
|
+
memberBindings: input.followUp.memberBindings,
|
|
1398
|
+
});
|
|
1399
|
+
const reboundGrainGap = rebound.ok
|
|
1400
|
+
? missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions)
|
|
1401
|
+
: undefined;
|
|
1402
|
+
contextValidation = rebound.ok && !reboundGrainGap
|
|
1403
|
+
? { ok: true, warnings: [...rebound.warnings, ...injected.notes] }
|
|
1404
|
+
: rebound.ok
|
|
1405
|
+
? {
|
|
1406
|
+
ok: false,
|
|
1407
|
+
code: 'insufficient_context',
|
|
1408
|
+
error: 'Generated SQL omits the ranked grouping dimension ' + reboundGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
|
|
1409
|
+
warnings: [...rebound.warnings, ...injected.notes],
|
|
1410
|
+
}
|
|
1411
|
+
: {
|
|
1412
|
+
ok: false,
|
|
1413
|
+
code: rebound.code,
|
|
1414
|
+
error: rebound.error,
|
|
1415
|
+
warnings: [...rebound.warnings, ...injected.notes],
|
|
1416
|
+
offending: rebound.offending,
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1293
1420
|
// Semantic compilation owns metric meaning, but a later dimensional join can
|
|
1294
1421
|
// still make a compiled bare expression ambiguous at the warehouse binder.
|
|
1295
1422
|
// Revoke governed trust for an invalid composition so the bounded repair lane
|
|
@@ -1305,9 +1432,12 @@ async function runAnswerLoop(input) {
|
|
|
1305
1432
|
// stands instead of guessing a measure.
|
|
1306
1433
|
// 2) Otherwise fall back to a CLEAN governed-metric definition (direct or exact
|
|
1307
1434
|
// leaf-measure; no fuzzy family guess that could answer the wrong measure).
|
|
1308
|
-
if (!contextValidation.ok && semanticMetricRoute && semanticMetricMatch) {
|
|
1435
|
+
if (!contextValidation.ok && semanticMetricRoute && semanticMetricMatch && !rankedGrainGap) {
|
|
1309
1436
|
const grounded = contextLedger.validateRuntimeGrounding(parsed.sql);
|
|
1310
|
-
|
|
1437
|
+
// Runtime grounding proves that relations and columns exist; it cannot prove
|
|
1438
|
+
// that a resolved business member was bound to the right dimension. Never
|
|
1439
|
+
// let this recovery lane bypass AGT-012's typed member invariant.
|
|
1440
|
+
if (grounded?.ok && !(input.followUp?.memberBindings?.length)) {
|
|
1311
1441
|
contextValidation = { ok: true, warnings: grounded.warnings };
|
|
1312
1442
|
}
|
|
1313
1443
|
else if (scalarGovernedMetricRecoveryAllowed) {
|
|
@@ -1346,28 +1476,43 @@ async function runAnswerLoop(input) {
|
|
|
1346
1476
|
intent,
|
|
1347
1477
|
filterValues: input.followUp?.filters,
|
|
1348
1478
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1479
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1349
1480
|
});
|
|
1350
|
-
|
|
1481
|
+
const regroundedGrainGap = revalidated.ok
|
|
1482
|
+
? missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions)
|
|
1483
|
+
: undefined;
|
|
1484
|
+
contextValidation = revalidated.ok && regroundedGrainGap
|
|
1351
1485
|
? {
|
|
1352
|
-
ok: true,
|
|
1353
|
-
warnings: [
|
|
1354
|
-
...revalidated.warnings,
|
|
1355
|
-
...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
|
|
1356
|
-
],
|
|
1357
|
-
}
|
|
1358
|
-
: {
|
|
1359
1486
|
ok: false,
|
|
1360
|
-
code:
|
|
1361
|
-
error:
|
|
1362
|
-
warnings:
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1487
|
+
code: 'insufficient_context',
|
|
1488
|
+
error: 'Generated SQL omits the ranked grouping dimension ' + regroundedGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
|
|
1489
|
+
warnings: revalidated.warnings,
|
|
1490
|
+
}
|
|
1491
|
+
: revalidated.ok
|
|
1492
|
+
? {
|
|
1493
|
+
ok: true,
|
|
1494
|
+
warnings: [
|
|
1495
|
+
...revalidated.warnings,
|
|
1496
|
+
...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
|
|
1497
|
+
],
|
|
1498
|
+
}
|
|
1499
|
+
: {
|
|
1500
|
+
ok: false,
|
|
1501
|
+
code: revalidated.code,
|
|
1502
|
+
error: revalidated.error,
|
|
1503
|
+
warnings: [
|
|
1504
|
+
...revalidated.warnings,
|
|
1505
|
+
...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
|
|
1506
|
+
],
|
|
1507
|
+
offending: revalidated.offending,
|
|
1508
|
+
};
|
|
1368
1509
|
}
|
|
1369
1510
|
}
|
|
1370
|
-
catch {
|
|
1511
|
+
catch (err) {
|
|
1512
|
+
if (input.signal?.aborted)
|
|
1513
|
+
throw input.signal.reason ?? err;
|
|
1514
|
+
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
|
|
1515
|
+
throw err;
|
|
1371
1516
|
// Re-grounding is best-effort; the bounded self-repair below still runs.
|
|
1372
1517
|
}
|
|
1373
1518
|
}
|
|
@@ -1378,14 +1523,18 @@ async function runAnswerLoop(input) {
|
|
|
1378
1523
|
// provider get the same single chance; any provider failure keeps the honest
|
|
1379
1524
|
// refusal below. This mirrors the engine-level repair loop, applied to the
|
|
1380
1525
|
// context-validation gate that previously refused on first failure.
|
|
1381
|
-
if (!contextValidation.ok && !governedMetricAnswer && canUseLaneRepair(repairBudgetState, '
|
|
1382
|
-
recordLaneRepair(repairBudgetState, '
|
|
1526
|
+
if (!contextValidation.ok && !governedMetricAnswer && canUseLaneRepair(repairBudgetState, 'validation')) {
|
|
1527
|
+
recordLaneRepair(repairBudgetState, 'validation');
|
|
1383
1528
|
try {
|
|
1384
1529
|
const failedSql = parsed.sql ?? '';
|
|
1385
1530
|
const repairPrompt = [
|
|
1386
1531
|
`Your SQL was rejected before execution: ${contextValidation.error}`,
|
|
1387
1532
|
formatOffendingValidationToken(contextValidation.offending),
|
|
1388
1533
|
formatValidationWarningsForPrompt(contextValidation.warnings),
|
|
1534
|
+
renderRequestedShapeForRepair(questionPlan),
|
|
1535
|
+
'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.',
|
|
1536
|
+
'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.',
|
|
1537
|
+
'Preserve every requested dimension and measure that was already correct; change only what the validation error requires.',
|
|
1389
1538
|
'Correct it using ONLY the relations and columns from the inspected context above.',
|
|
1390
1539
|
'If a needed column lives on a different relation, JOIN that relation using the suggested join paths.',
|
|
1391
1540
|
'If the requested column does not exist anywhere in the inspected context, return the closest answerable SQL and say what is missing.',
|
|
@@ -1400,8 +1549,12 @@ async function runAnswerLoop(input) {
|
|
|
1400
1549
|
intent,
|
|
1401
1550
|
filterValues: input.followUp?.filters,
|
|
1402
1551
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1552
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1403
1553
|
});
|
|
1404
|
-
|
|
1554
|
+
const repairedGrainGap = revalidated.ok
|
|
1555
|
+
? missingRankedGrainOutput(questionPlan, reparsed.sql, semanticBridgeAnswer?.dimensions)
|
|
1556
|
+
: undefined;
|
|
1557
|
+
if (revalidated.ok && !repairedGrainGap) {
|
|
1405
1558
|
const repairedSql = reparsed.sql;
|
|
1406
1559
|
parsed.sql = repairedSql;
|
|
1407
1560
|
parsed.text = reparsed.text || parsed.text;
|
|
@@ -1414,7 +1567,11 @@ async function runAnswerLoop(input) {
|
|
|
1414
1567
|
}
|
|
1415
1568
|
}
|
|
1416
1569
|
}
|
|
1417
|
-
catch {
|
|
1570
|
+
catch (err) {
|
|
1571
|
+
if (input.signal?.aborted)
|
|
1572
|
+
throw input.signal.reason ?? err;
|
|
1573
|
+
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
|
|
1574
|
+
throw err;
|
|
1418
1575
|
// Repair is best-effort — the refusal below stays the honest fallback.
|
|
1419
1576
|
}
|
|
1420
1577
|
}
|
|
@@ -1434,7 +1591,10 @@ async function runAnswerLoop(input) {
|
|
|
1434
1591
|
}
|
|
1435
1592
|
}
|
|
1436
1593
|
if (!contextValidation.ok) {
|
|
1437
|
-
|
|
1594
|
+
// Business-language chat text; the validator's machine message (with
|
|
1595
|
+
// relation ids and tool guidance for the repair prompt) stays in
|
|
1596
|
+
// refusalDetails + validationWarnings for the Inspect surface.
|
|
1597
|
+
const text = renderContextValidationRefusalForUser(contextValidation.code, contextValidation.error, input.followUp?.memberBindings);
|
|
1438
1598
|
const analysisPlan = buildAnalysisPlan({
|
|
1439
1599
|
question,
|
|
1440
1600
|
intent,
|
|
@@ -1468,7 +1628,11 @@ async function runAnswerLoop(input) {
|
|
|
1468
1628
|
trustLabel: input.contextPack?.trustLabel,
|
|
1469
1629
|
sourceCertifiedBlock: followUpSourceBlock?.name ?? input.followUp?.sourceBlockName,
|
|
1470
1630
|
contextPackId: input.contextPack?.id,
|
|
1471
|
-
validationWarnings: [
|
|
1631
|
+
validationWarnings: [
|
|
1632
|
+
...contextValidation.warnings,
|
|
1633
|
+
`SQL context validation detail: ${contextValidation.error}`,
|
|
1634
|
+
...deepCandidateNotes,
|
|
1635
|
+
],
|
|
1472
1636
|
selectedEvidence: input.contextPack?.evidenceRoles?.slice(0, 12),
|
|
1473
1637
|
citations: [],
|
|
1474
1638
|
memoryContext: input.memoryContext,
|
|
@@ -1493,8 +1657,12 @@ async function runAnswerLoop(input) {
|
|
|
1493
1657
|
? evaluateDbtFirstGeneratedSql(parsed.sql, input.manifest, input.domainContext?.purpose, input.domainContext)
|
|
1494
1658
|
: undefined;
|
|
1495
1659
|
if (dbtFirstJoinSafety && !dbtFirstJoinSafety.safe) {
|
|
1496
|
-
|
|
1660
|
+
// Business-language text for the chat surface; the machine policy message
|
|
1661
|
+
// stays in refusalDetails + validationWarnings for Inspect.
|
|
1662
|
+
const policyDetail = dbtFirstJoinSafety.technicalDetail ?? dbtFirstJoinSafety.message
|
|
1497
1663
|
?? 'DQL could not prove this generated join from certified analytical relationships.';
|
|
1664
|
+
const text = dbtFirstJoinSafety.userFacingReason
|
|
1665
|
+
?? analyticalPolicyUserFacingReason(dbtFirstJoinSafety.code ?? 'unsafe_relationship', dbtFirstJoinSafety.entities);
|
|
1498
1666
|
const exploratoryCandidate = exploratoryCandidateFromDbtFirstGuard(parsed.sql, dbtFirstJoinSafety);
|
|
1499
1667
|
const analysisPlan = buildAnalysisPlan({
|
|
1500
1668
|
question,
|
|
@@ -1522,13 +1690,16 @@ async function runAnswerLoop(input) {
|
|
|
1522
1690
|
proposedSql: parsed.sql,
|
|
1523
1691
|
sql: parsed.sql,
|
|
1524
1692
|
...(exploratoryCandidate ? { exploratoryCandidate } : {}),
|
|
1525
|
-
refusalCode:
|
|
1693
|
+
refusalCode: dbtFirstJoinSafety.disposition === 'exploratory_candidate'
|
|
1694
|
+
? 'modeling_gap'
|
|
1695
|
+
: refusalCodeForAnalyticalPolicy(dbtFirstJoinSafety.code, Boolean(exploratoryCandidate)),
|
|
1526
1696
|
refusalDetails: {
|
|
1527
1697
|
code: dbtFirstJoinSafety.code ?? 'unsafe_relationship',
|
|
1528
|
-
message:
|
|
1698
|
+
message: policyDetail,
|
|
1529
1699
|
},
|
|
1530
1700
|
validationWarnings: [
|
|
1531
1701
|
...contextValidation.warnings,
|
|
1702
|
+
`Analytical policy detail: ${policyDetail}`,
|
|
1532
1703
|
`DQL v3 relationship guard: ${dbtFirstJoinSafety.code ?? 'unsafe relationship'}.`,
|
|
1533
1704
|
...(exploratoryCandidate
|
|
1534
1705
|
? ['A bounded DBT-grounded exploratory route may validate this missing modeled relationship; governed SQL was not executed.']
|
|
@@ -1603,6 +1774,7 @@ async function runAnswerLoop(input) {
|
|
|
1603
1774
|
intent,
|
|
1604
1775
|
filterValues: input.followUp?.filters,
|
|
1605
1776
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1777
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1606
1778
|
});
|
|
1607
1779
|
if (localRepairValidation.ok) {
|
|
1608
1780
|
repairAttempts = 1;
|
|
@@ -1646,6 +1818,7 @@ async function runAnswerLoop(input) {
|
|
|
1646
1818
|
intent,
|
|
1647
1819
|
filterValues: input.followUp?.filters,
|
|
1648
1820
|
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1821
|
+
memberBindings: input.followUp?.memberBindings,
|
|
1649
1822
|
});
|
|
1650
1823
|
if (repairedValidation.ok) {
|
|
1651
1824
|
repairAttempts += 1;
|
|
@@ -2180,6 +2353,27 @@ function renderAnalyticalPlanPrompt(plan) {
|
|
|
2180
2353
|
if (!plan || plan.entities.length < 2)
|
|
2181
2354
|
return undefined;
|
|
2182
2355
|
if (!plan.safe) {
|
|
2356
|
+
if (analyticalPlanAllowsDbtExploration(plan)) {
|
|
2357
|
+
const declaredPath = plan.exploratoryPath?.edges.length
|
|
2358
|
+
? [
|
|
2359
|
+
'DECLARED (UNCERTIFIED) DRAFT JOIN PATH — suggestion only, NOT authorization:',
|
|
2360
|
+
...plan.exploratoryPath.edges.map((edge, index) => [
|
|
2361
|
+
`${index + 1}. ${edge.fromEntity} (${edge.fromRelation ?? 'bound dbt model'}) -> ${edge.toEntity} (${edge.toRelation ?? 'bound dbt model'})`,
|
|
2362
|
+
` relationship=${edge.relationshipId}; keys=${edge.keys.map((key) => `${key.from}=${key.to}`).join(', ')}; cardinality=${edge.cardinality}; fanout=${edge.fanout}; status=${edge.lifecycle}`,
|
|
2363
|
+
].join('\n')),
|
|
2364
|
+
'Prefer these declared relations and exact key pairs over inferring joins from column names. The result remains analyst-review-required.',
|
|
2365
|
+
]
|
|
2366
|
+
: [];
|
|
2367
|
+
return [
|
|
2368
|
+
'DQL GOVERNED RELATIONSHIP COVERAGE: MISSING.',
|
|
2369
|
+
plan.message ?? 'No certified analytical relationship path is available.',
|
|
2370
|
+
`Policy code: ${plan.code ?? 'unsafe_relationship'}.`,
|
|
2371
|
+
...declaredPath,
|
|
2372
|
+
'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.',
|
|
2373
|
+
'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.',
|
|
2374
|
+
'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.',
|
|
2375
|
+
].join('\n');
|
|
2376
|
+
}
|
|
2183
2377
|
return [
|
|
2184
2378
|
'DQL ANALYTICAL POLICY DECISION: BLOCKED.',
|
|
2185
2379
|
plan.message ?? 'No certified analytical relationship path is available.',
|
|
@@ -2197,6 +2391,21 @@ function renderAnalyticalPlanPrompt(plan) {
|
|
|
2197
2391
|
'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
2392
|
].join('\n');
|
|
2199
2393
|
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Missing modeling may enter the narrow EXP-001 host-validated lane. Explicit
|
|
2396
|
+
* unsafe, cross-domain, attribution, expiry, purpose, and proof failures never do.
|
|
2397
|
+
* The planner's `disposition` is authoritative; the code checks remain only for
|
|
2398
|
+
* plans produced by older manifests/paths that predate the disposition contract.
|
|
2399
|
+
*/
|
|
2400
|
+
function analyticalPlanAllowsDbtExploration(plan) {
|
|
2401
|
+
if (plan.disposition === 'exploratory_candidate')
|
|
2402
|
+
return true;
|
|
2403
|
+
if (plan.safe)
|
|
2404
|
+
return false;
|
|
2405
|
+
if (plan.code === 'unbound_relation' || plan.code === 'relationship_not_certified')
|
|
2406
|
+
return true;
|
|
2407
|
+
return plan.code === 'unsafe_relationship' && plan.relationshipIds.length === 0;
|
|
2408
|
+
}
|
|
2200
2409
|
// Metric-anchored generation (Tier 2.5): a governed metric matched the question but
|
|
2201
2410
|
// the semantic layer couldn't compose the exact shape. Reuse the metric's CERTIFIED
|
|
2202
2411
|
// definition as the measure rather than reinventing it, so the number stays consistent
|
|
@@ -2693,6 +2902,12 @@ function renderFollowUpContext(followUp) {
|
|
|
2693
2902
|
followUp.dimensions?.length ? `requested dimensions: ${followUp.dimensions.join(', ')}` : '',
|
|
2694
2903
|
followUp.priorResultColumns?.length ? `prior result columns: ${followUp.priorResultColumns.join(', ')}` : '',
|
|
2695
2904
|
followUp.priorResultValues ? `prior result values: ${formatPriorResultValues(followUp.priorResultValues)}` : '',
|
|
2905
|
+
followUp.memberBindings?.length
|
|
2906
|
+
? `required member bindings: ${followUp.memberBindings.map((binding) => `${binding.dimension} = ${binding.values.join(' | ')} (${binding.confidence})`).join('; ')}`
|
|
2907
|
+
: '',
|
|
2908
|
+
followUp.memberBindings?.length
|
|
2909
|
+
? '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.'
|
|
2910
|
+
: '',
|
|
2696
2911
|
followUp.priorResultRef ? renderPriorResultReference(followUp.priorResultRef) : '',
|
|
2697
2912
|
followUp.priorDqlArtifact ? renderPriorDqlArtifactReference(followUp.priorDqlArtifact) : '',
|
|
2698
2913
|
followUp.priorLimit ? `prior result limit: ${followUp.priorLimit}` : '',
|
|
@@ -3033,14 +3248,243 @@ function escapeDqlArtifactTripleString(value) {
|
|
|
3033
3248
|
function generatedResultShapeIsPartial(resultShape) {
|
|
3034
3249
|
return resultShape.missingOutputs.length >= 2;
|
|
3035
3250
|
}
|
|
3251
|
+
/**
|
|
3252
|
+
* A ranked query without its ranked entity is a different answer, even when the
|
|
3253
|
+
* aggregate executes. Catch this before execution so the bounded validation
|
|
3254
|
+
* correction can restore the requested grain instead of presenting a region-only
|
|
3255
|
+
* total for a top-product question.
|
|
3256
|
+
*/
|
|
3257
|
+
export function missingRankedGrainOutput(questionPlan, sql, governedSemanticDimensions = []) {
|
|
3258
|
+
const grain = questionPlan.requestedShape.grain;
|
|
3259
|
+
if (!grain || questionPlan.requestedShape.topN?.scope !== 'per_group')
|
|
3260
|
+
return undefined;
|
|
3261
|
+
const shape = extractSimpleSelectShape(sql);
|
|
3262
|
+
if (!shape)
|
|
3263
|
+
return undefined;
|
|
3264
|
+
const columns = shape.selectExpressions
|
|
3265
|
+
.map(selectExpressionOutputName)
|
|
3266
|
+
.filter((column) => Boolean(column));
|
|
3267
|
+
const requiredGroupingDimensions = rankedGroupingDimensions(questionPlan);
|
|
3268
|
+
const literalGap = requiredGroupingDimensions.find((dimension) => !columns.some((output) => outputConceptMatches(output, dimension)));
|
|
3269
|
+
if (!literalGap)
|
|
3270
|
+
return undefined;
|
|
3271
|
+
// A bounded Lane-2 meaning selection may map a user's business word to a
|
|
3272
|
+
// differently named governed dimension (for example "region" to
|
|
3273
|
+
// `location_name`). Trust that mapping only when the semantic compiler
|
|
3274
|
+
// projected every selected governed dimension and the selection covers the
|
|
3275
|
+
// complete grouping cardinality. Arbitrary generated aliases never receive
|
|
3276
|
+
// this exception.
|
|
3277
|
+
const projectedGovernedDimensions = governedSemanticDimensions.filter((dimension) => columns.some((output) => outputConceptMatches(output, dimension)));
|
|
3278
|
+
if (projectedGovernedDimensions.length >= requiredGroupingDimensions.length)
|
|
3279
|
+
return undefined;
|
|
3280
|
+
return literalGap;
|
|
3281
|
+
}
|
|
3282
|
+
function requestedGroupingDimensions(questionPlan) {
|
|
3283
|
+
const grain = questionPlan.requestedShape.grain;
|
|
3284
|
+
const dimensions = [
|
|
3285
|
+
...(grain ? [grain] : []),
|
|
3286
|
+
...questionPlan.requestedShape.dimensions,
|
|
3287
|
+
].filter((dimension) => {
|
|
3288
|
+
const isGrain = Boolean(grain) && conceptsEquivalent(dimension, grain);
|
|
3289
|
+
if (isGrain && !isCompoundMeasurePhrase(dimension, questionPlan.requestedShape.measures))
|
|
3290
|
+
return true;
|
|
3291
|
+
return !isMeasurePhrase(dimension, questionPlan.requestedShape.measures);
|
|
3292
|
+
});
|
|
3293
|
+
const seen = new Set();
|
|
3294
|
+
return dimensions.filter((dimension) => {
|
|
3295
|
+
const key = normalizedConceptTokens(dimension).join('_');
|
|
3296
|
+
if (!key || seen.has(key))
|
|
3297
|
+
return false;
|
|
3298
|
+
seen.add(key);
|
|
3299
|
+
return true;
|
|
3300
|
+
});
|
|
3301
|
+
}
|
|
3302
|
+
function rankedGroupingDimensions(questionPlan) {
|
|
3303
|
+
const grain = questionPlan.requestedShape.grain;
|
|
3304
|
+
if (!grain || questionPlan.requestedShape.topN?.scope !== 'per_group')
|
|
3305
|
+
return [];
|
|
3306
|
+
return requestedGroupingDimensions(questionPlan);
|
|
3307
|
+
}
|
|
3308
|
+
function renderRequestedShapeForRepair(questionPlan) {
|
|
3309
|
+
return 'Required answer contract: ' + JSON.stringify({
|
|
3310
|
+
grain: questionPlan.requestedShape.grain,
|
|
3311
|
+
dimensions: questionPlan.requestedShape.dimensions,
|
|
3312
|
+
measures: questionPlan.requestedShape.measures,
|
|
3313
|
+
requiredOutputs: questionPlan.requestedShape.requiredOutputs,
|
|
3314
|
+
requiredGroupingAliases: rankedGroupingDimensions(questionPlan),
|
|
3315
|
+
topN: questionPlan.requestedShape.topN,
|
|
3316
|
+
});
|
|
3317
|
+
}
|
|
3318
|
+
function outputConceptMatches(output, concept) {
|
|
3319
|
+
const outputTokens = normalizedConceptTokens(output);
|
|
3320
|
+
const conceptTokens = normalizedConceptTokens(concept);
|
|
3321
|
+
return conceptTokens.length > 0 && conceptTokens.every((token) => outputTokens.includes(token));
|
|
3322
|
+
}
|
|
3323
|
+
function isCompoundMeasurePhrase(concept, measures) {
|
|
3324
|
+
const conceptTokens = normalizedConceptTokens(concept);
|
|
3325
|
+
return measures.some((measure) => {
|
|
3326
|
+
const measureTokens = normalizedConceptTokens(measure);
|
|
3327
|
+
return measureTokens.length > 0
|
|
3328
|
+
&& conceptTokens.length > measureTokens.length
|
|
3329
|
+
&& measureTokens.every((token) => conceptTokens.includes(token));
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
function isMeasurePhrase(concept, measures) {
|
|
3333
|
+
const conceptTokens = normalizedConceptTokens(concept);
|
|
3334
|
+
return measures.some((measure) => {
|
|
3335
|
+
const measureTokens = normalizedConceptTokens(measure);
|
|
3336
|
+
return measureTokens.length > 0
|
|
3337
|
+
&& conceptTokens.length >= measureTokens.length
|
|
3338
|
+
&& measureTokens.every((token) => conceptTokens.includes(token));
|
|
3339
|
+
});
|
|
3340
|
+
}
|
|
3341
|
+
function conceptsEquivalent(left, right) {
|
|
3342
|
+
const leftTokens = normalizedConceptTokens(left);
|
|
3343
|
+
const rightTokens = normalizedConceptTokens(right);
|
|
3344
|
+
return leftTokens.length === rightTokens.length
|
|
3345
|
+
&& leftTokens.every((token, index) => token === rightTokens[index]);
|
|
3346
|
+
}
|
|
3347
|
+
function normalizedConceptTokens(value) {
|
|
3348
|
+
return value
|
|
3349
|
+
.toLowerCase()
|
|
3350
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
3351
|
+
.trim()
|
|
3352
|
+
.split(/\s+/)
|
|
3353
|
+
.filter((token) => token && token !== 'name' && token !== 'label')
|
|
3354
|
+
.map((token) => token.endsWith('s') && token.length > 3 ? token.slice(0, -1) : token);
|
|
3355
|
+
}
|
|
3036
3356
|
function trustedFollowUpFilterValues(followUp) {
|
|
3037
3357
|
const filters = new Set((followUp?.filters ?? []).map((value) => value.trim()).filter(Boolean));
|
|
3038
|
-
|
|
3358
|
+
const boundValues = (followUp?.memberBindings ?? [])
|
|
3359
|
+
.flatMap((binding) => binding.values)
|
|
3360
|
+
.map((value) => value.trim())
|
|
3361
|
+
.filter(Boolean);
|
|
3362
|
+
const priorValues = Object.values(followUp?.priorResultValues ?? {}).flat().map((value) => value.trim()).filter(Boolean);
|
|
3363
|
+
const trustedCandidates = uniqueDrilldownStrings([...boundValues, ...priorValues]);
|
|
3364
|
+
if (!trustedCandidates.length)
|
|
3039
3365
|
return undefined;
|
|
3040
|
-
|
|
3041
|
-
|
|
3366
|
+
// Typed bindings were resolved by the deterministic member resolver and are
|
|
3367
|
+
// trusted even if a legacy caller omitted the mirrored flat `filters` array.
|
|
3368
|
+
const trusted = trustedCandidates.filter((value) => boundValues.includes(value) || filters.has(value));
|
|
3042
3369
|
return trusted.length > 0 ? uniqueDrilldownStrings(trusted) : undefined;
|
|
3043
3370
|
}
|
|
3371
|
+
function injectUniqueResolvedMemberBindings(sql, bindings, schemaContext) {
|
|
3372
|
+
if (!/^\s*select\b/i.test(sql) || /;\s*\S/.test(sql))
|
|
3373
|
+
return undefined;
|
|
3374
|
+
const relationAliases = sqlRelationAliases(sql);
|
|
3375
|
+
const predicates = [];
|
|
3376
|
+
const notes = [];
|
|
3377
|
+
for (const binding of bindings) {
|
|
3378
|
+
const values = Array.from(new Set(binding.values.map((value) => value.trim()).filter(Boolean)));
|
|
3379
|
+
if (values.length === 0)
|
|
3380
|
+
continue;
|
|
3381
|
+
// Do not add a second interpretation when the proposal already contains a
|
|
3382
|
+
// canonical bound value. The validator will determine whether it is on the
|
|
3383
|
+
// correct dimension and reject a misbinding.
|
|
3384
|
+
if (values.some((value) => sql.toLowerCase().includes(sqlStringLiteral(value).toLowerCase())))
|
|
3385
|
+
continue;
|
|
3386
|
+
const candidates = schemaContext.flatMap((table) => table.columns.flatMap((column) => {
|
|
3387
|
+
if (!outputConceptMatches(column.name, binding.dimension))
|
|
3388
|
+
return [];
|
|
3389
|
+
if (values.some((value) => /[\p{L}]/u.test(value)) && isJoinKeyColumn(column.name))
|
|
3390
|
+
return [];
|
|
3391
|
+
const samples = new Set((column.sampleValues ?? []).map(normalizeValueIndexText));
|
|
3392
|
+
// The typed binding is already canonical request state. Samples may
|
|
3393
|
+
// strengthen the match when this turn has them, but snapshot scoping must
|
|
3394
|
+
// not erase a prior governed value. A conflicting non-empty sample set is
|
|
3395
|
+
// rejected; an absent sample set may proceed only through the unique
|
|
3396
|
+
// dimension-column + existing-query-path proof below.
|
|
3397
|
+
if (samples.size > 0 && !values.every((value) => samples.has(normalizeValueIndexText(value))))
|
|
3398
|
+
return [];
|
|
3399
|
+
const alias = findSqlAliasForRelation(table.relation, relationAliases);
|
|
3400
|
+
if (!alias || !safeSqlIdentifier(alias) || !safeSqlIdentifier(column.name))
|
|
3401
|
+
return [];
|
|
3402
|
+
return [{ alias, column: column.name }];
|
|
3403
|
+
}));
|
|
3404
|
+
const uniqueCandidates = Array.from(new Map(candidates.map((candidate) => [
|
|
3405
|
+
`${candidate.alias.toLowerCase()}.${candidate.column.toLowerCase()}`,
|
|
3406
|
+
candidate,
|
|
3407
|
+
])).values());
|
|
3408
|
+
if (uniqueCandidates.length !== 1)
|
|
3409
|
+
return undefined;
|
|
3410
|
+
const candidate = uniqueCandidates[0];
|
|
3411
|
+
const qualifiedColumn = `${candidate.alias}.${candidate.column}`;
|
|
3412
|
+
const predicate = values.length === 1
|
|
3413
|
+
? `${qualifiedColumn} = ${sqlStringLiteral(values[0])}`
|
|
3414
|
+
: `${qualifiedColumn} IN (${values.map(sqlStringLiteral).join(', ')})`;
|
|
3415
|
+
predicates.push(predicate);
|
|
3416
|
+
notes.push(`Applied the resolved ${binding.dimension} member binding deterministically on ${qualifiedColumn}.`);
|
|
3417
|
+
}
|
|
3418
|
+
if (predicates.length === 0)
|
|
3419
|
+
return undefined;
|
|
3420
|
+
const clauseIndex = firstTopLevelSqlClauseIndex(sql, ['group by', 'having', 'qualify', 'order by', 'limit']);
|
|
3421
|
+
const head = clauseIndex >= 0 ? sql.slice(0, clauseIndex).trimEnd() : sql.trimEnd();
|
|
3422
|
+
const tail = clauseIndex >= 0 ? ` ${sql.slice(clauseIndex).trimStart()}` : '';
|
|
3423
|
+
const joiner = /\bwhere\b/i.test(head) ? ' AND ' : ' WHERE ';
|
|
3424
|
+
return { sql: `${head}${joiner}${predicates.join(' AND ')}${tail}`, notes };
|
|
3425
|
+
}
|
|
3426
|
+
function sqlRelationAliases(sql) {
|
|
3427
|
+
const matches = [];
|
|
3428
|
+
const pattern = /\b(?:from|join)\s+((?:["`]?\w+["`]?\s*\.\s*)*["`]?\w+["`]?)\s*(?:as\s+)?([A-Za-z_][\w$]*)?/gi;
|
|
3429
|
+
for (const match of sql.matchAll(pattern)) {
|
|
3430
|
+
const relation = (match[1] ?? '').replace(/["`\s]/g, '');
|
|
3431
|
+
if (!relation)
|
|
3432
|
+
continue;
|
|
3433
|
+
const implicitAlias = relation.split('.').pop() ?? relation;
|
|
3434
|
+
const candidateAlias = match[2] && !SQL_CLAUSE_WORDS.has(match[2].toLowerCase()) ? match[2] : implicitAlias;
|
|
3435
|
+
matches.push({ relation, alias: candidateAlias });
|
|
3436
|
+
}
|
|
3437
|
+
return matches;
|
|
3438
|
+
}
|
|
3439
|
+
const SQL_CLAUSE_WORDS = new Set(['where', 'join', 'left', 'right', 'inner', 'outer', 'full', 'cross', 'group', 'having', 'qualify', 'order', 'limit', 'on']);
|
|
3440
|
+
function findSqlAliasForRelation(relation, aliases) {
|
|
3441
|
+
const normalized = relation.replace(/["`\s]/g, '').toLowerCase();
|
|
3442
|
+
const short = normalized.split('.').pop();
|
|
3443
|
+
return aliases.find((candidate) => {
|
|
3444
|
+
const candidateNormalized = candidate.relation.toLowerCase();
|
|
3445
|
+
return candidateNormalized === normalized || candidateNormalized.split('.').pop() === short;
|
|
3446
|
+
})?.alias;
|
|
3447
|
+
}
|
|
3448
|
+
function firstTopLevelSqlClauseIndex(sql, clauses) {
|
|
3449
|
+
let depth = 0;
|
|
3450
|
+
let quote;
|
|
3451
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
3452
|
+
const char = sql[index];
|
|
3453
|
+
if (quote) {
|
|
3454
|
+
if (char === quote) {
|
|
3455
|
+
if (quote === "'" && sql[index + 1] === "'")
|
|
3456
|
+
index += 1;
|
|
3457
|
+
else
|
|
3458
|
+
quote = undefined;
|
|
3459
|
+
}
|
|
3460
|
+
continue;
|
|
3461
|
+
}
|
|
3462
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
3463
|
+
quote = char;
|
|
3464
|
+
continue;
|
|
3465
|
+
}
|
|
3466
|
+
if (char === '(') {
|
|
3467
|
+
depth += 1;
|
|
3468
|
+
continue;
|
|
3469
|
+
}
|
|
3470
|
+
if (char === ')') {
|
|
3471
|
+
depth = Math.max(0, depth - 1);
|
|
3472
|
+
continue;
|
|
3473
|
+
}
|
|
3474
|
+
if (depth !== 0)
|
|
3475
|
+
continue;
|
|
3476
|
+
const rest = sql.slice(index).toLowerCase();
|
|
3477
|
+
if (clauses.some((clause) => rest.startsWith(clause) && (!rest[clause.length] || /\s/.test(rest[clause.length]))))
|
|
3478
|
+
return index;
|
|
3479
|
+
}
|
|
3480
|
+
return -1;
|
|
3481
|
+
}
|
|
3482
|
+
function safeSqlIdentifier(value) {
|
|
3483
|
+
return /^[A-Za-z_][\w$]*$/.test(value);
|
|
3484
|
+
}
|
|
3485
|
+
function sqlStringLiteral(value) {
|
|
3486
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
3487
|
+
}
|
|
3044
3488
|
function buildCandidateJoinPaths(schemaContext) {
|
|
3045
3489
|
const tables = [...schemaContext]
|
|
3046
3490
|
.filter((table) => table.columns.some((column) => isJoinKeyColumn(column.name)))
|
|
@@ -3972,6 +4416,9 @@ async function scoreDeepGeneratedProposalCandidate(input, candidate) {
|
|
|
3972
4416
|
const validation = input.contextLedger.validateSql(parsed.sql, {
|
|
3973
4417
|
question: input.question,
|
|
3974
4418
|
intent: input.intent,
|
|
4419
|
+
filterValues: input.followUp?.filters,
|
|
4420
|
+
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
4421
|
+
memberBindings: input.followUp?.memberBindings,
|
|
3975
4422
|
});
|
|
3976
4423
|
validationOk = validation.ok;
|
|
3977
4424
|
if (validation.ok) {
|
|
@@ -4692,13 +5139,13 @@ function cascadeBudgetEvidenceRouteSteps(trace) {
|
|
|
4692
5139
|
if (!trace)
|
|
4693
5140
|
return [];
|
|
4694
5141
|
const { usage, limits } = trace;
|
|
4695
|
-
const used = usage.laneRegroundAttemptsUsed + usage.laneExecutionAttemptsUsed + usage.engineEscalationsUsed;
|
|
5142
|
+
const used = usage.laneRegroundAttemptsUsed + usage.laneValidationAttemptsUsed + usage.laneExecutionAttemptsUsed + usage.engineEscalationsUsed;
|
|
4696
5143
|
if (used === 0)
|
|
4697
5144
|
return [];
|
|
4698
5145
|
return [{
|
|
4699
5146
|
tool: 'cascade_budget',
|
|
4700
5147
|
status: 'checked',
|
|
4701
|
-
label: `Repair budget used: re-ground ${usage.laneRegroundAttemptsUsed}/${limits.lane.reground}, execution ${usage.laneExecutionAttemptsUsed}/${limits.lane.execution}`,
|
|
5148
|
+
label: `Repair budget used: re-ground ${usage.laneRegroundAttemptsUsed}/${limits.lane.reground}, validation ${usage.laneValidationAttemptsUsed}/${limits.lane.validation}, execution ${usage.laneExecutionAttemptsUsed}/${limits.lane.execution}`,
|
|
4702
5149
|
detail: `engine escalations ${usage.engineEscalationsUsed}/${limits.engineEscalations}`,
|
|
4703
5150
|
}];
|
|
4704
5151
|
}
|