refs #739: generalize game target profile guidance
This commit is contained in:
@@ -38,11 +38,7 @@ public class SelectAiService {
|
||||
private static final int MAX_FEW_SHOT_SQL_CHARS = 4_000;
|
||||
private static final int MAX_ENRICHED_PROMPT_LENGTH = 16_000;
|
||||
private static final String POLICY_PREFIX =
|
||||
"Answer the original user question using the current approved object list and policy. "
|
||||
+ "If no game identifier resolves through game-alias metadata, do not select a prefix-specific object "
|
||||
+ "and do not infer a default game. Continue a game-neutral question with approved common objects. "
|
||||
+ "Only report a missing game identifier when the requested operation inherently requires a "
|
||||
+ "game-specific object.\n\n";
|
||||
"Answer the original user question using the current approved object list and profile policy.\n\n";
|
||||
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
||||
@@ -149,8 +145,16 @@ public class SelectAiService {
|
||||
&& gameScopeService.configured()
|
||||
&& gameScopeService.referencesGameScopedObjectOutsidePrefixes(
|
||||
bearerToken, normalizedSql, allowedPrefixes)) {
|
||||
throw new AppException(
|
||||
"Select AI 생성 SQL이 게임 질의 계획에 없는 prefix 전용 객체를 참조했습니다.");
|
||||
// This is a model-output validation failure, not an answer fallback.
|
||||
// Give Select AI its own violated-plan feedback once and validate the
|
||||
// regenerated SQL under exactly the same read-only and scope rules.
|
||||
generatedSql = generate(selectAi, scopeCorrectionPrompt(enrichedPrompt.prompt()), "showsql");
|
||||
normalizedSql = validateReadOnlySql(generatedSql);
|
||||
if (gameScopeService.referencesGameScopedObjectOutsidePrefixes(
|
||||
bearerToken, normalizedSql, allowedPrefixes)) {
|
||||
throw new AppException(
|
||||
"Select AI 생성 SQL이 게임 질의 계획에 없는 prefix 전용 객체를 참조했습니다.");
|
||||
}
|
||||
}
|
||||
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
@@ -216,9 +220,9 @@ public class SelectAiService {
|
||||
* This is transport normalization only; the target contract itself remains
|
||||
* the authoritative data-driven policy.
|
||||
*/
|
||||
private JsonNode unwrapMcpToolResult(JsonNode candidate) {
|
||||
JsonNode unwrapMcpToolResult(JsonNode candidate) {
|
||||
JsonNode current = candidate;
|
||||
for (int depth = 0; depth < 3 && current != null; depth++) {
|
||||
for (int depth = 0; depth < 6 && current != null; depth++) {
|
||||
if (current.isTextual()) {
|
||||
try {
|
||||
current = objectMapper.readTree(current.asText());
|
||||
@@ -227,6 +231,14 @@ public class SelectAiService {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
// LangChain can forward an MCP TextContent item verbatim as
|
||||
// {"type":"text","text":"{...tools/call response...}"}. Unwrap the
|
||||
// transport envelope before looking for the response payload.
|
||||
if (current.isObject() && "text".equals(current.path("type").asText())
|
||||
&& current.path("text").isTextual()) {
|
||||
current = current.path("text");
|
||||
continue;
|
||||
}
|
||||
JsonNode response = current.path("response");
|
||||
if (response.isObject() || response.isTextual()) {
|
||||
current = response;
|
||||
@@ -412,6 +424,14 @@ public class SelectAiService {
|
||||
+ "Original user question:\n" + prompt;
|
||||
}
|
||||
|
||||
static String scopeCorrectionPrompt(String originalPrompt) {
|
||||
return originalPrompt
|
||||
+ "\n\n[SQL VALIDATION FEEDBACK]\n"
|
||||
+ "The previous SQL selected a game-scoped object outside the authoritative query plan. "
|
||||
+ "Regenerate one read-only SQL statement using the same plan. Do not substitute any "
|
||||
+ "game-scoped object. Apply the active profile instructions and do not report a SQL failure.\n";
|
||||
}
|
||||
|
||||
static String composeFewShotPrompt(String prompt, List<QaVectorService.VectorExample> examples) {
|
||||
StringBuilder enriched = new StringBuilder(POLICY_PREFIX
|
||||
+ "The verified examples below are guidance only: use only relevant SQL patterns, do not invent "
|
||||
@@ -443,7 +463,10 @@ public class SelectAiService {
|
||||
private static String referenceCandidate(int index, QaVectorService.VectorExample example) {
|
||||
String prefix = "Example " + index + " question:\n" + example.question()
|
||||
+ "\nExample " + index + " target type: " + example.targetType()
|
||||
+ "\nExample " + index + " reference kind: " + example.referenceKind() + "\n";
|
||||
+ "\nExample " + index + " reference kind: " + example.referenceKind()
|
||||
+ "\nExample " + index + " logical object role: "
|
||||
+ (example.objectRole() == null || example.objectRole().isBlank()
|
||||
? "UNSPECIFIED" : example.objectRole()) + "\n";
|
||||
if ("NO_TARGET".equals(example.referenceKind())
|
||||
|| "OBJECT_UNAVAILABLE".equals(example.referenceKind())) {
|
||||
String boundary = truncate(example.answer(), MAX_FEW_SHOT_SQL_CHARS);
|
||||
@@ -451,7 +474,10 @@ public class SelectAiService {
|
||||
if (boundary.isBlank() || answerSql.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return prefix + "Boundary rule:\n" + boundary
|
||||
return prefix + "Boundary rule (when the current plan is NONE and this logical object role "
|
||||
+ "matches the requested operation, follow this boundary SQL template instead of "
|
||||
+ "substituting a game-scoped object; do not apply it to an approved common-object operation):\n"
|
||||
+ boundary
|
||||
+ "\nVerified boundary SQL template:\n" + answerSql + "\n\n";
|
||||
}
|
||||
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
|
||||
@@ -576,16 +602,7 @@ public class SelectAiService {
|
||||
JsonNode plan
|
||||
) {
|
||||
String prompt(String originalQuestion) {
|
||||
return "Use the authoritative game query plan below without independently rematching "
|
||||
+ "the game scope. Always answer the original question with one read-only SQL statement. "
|
||||
+ "For NONE, keep the query game-neutral and never select a prefix-specific object. "
|
||||
+ "For SINGLE, use only the matched target metadata. For MULTI or ALL, use the supplied "
|
||||
+ "target identifiers with IN and GROUP BY when a common object fits the question, or "
|
||||
+ "combine only the supplied non-null physical objects with UNION ALL when separate "
|
||||
+ "objects are required. A target with a null physical object must remain unresolved and "
|
||||
+ "must never be substituted with another target's object. Do not invent identifiers, "
|
||||
+ "prefixes, or physical object names.\n"
|
||||
+ "[AUTHORITATIVE GAME QUERY PLAN]\n" + plan
|
||||
return "[AUTHORITATIVE GAME QUERY PLAN]\n" + plan
|
||||
+ "\n[ORIGINAL USER QUESTION]\n" + originalQuestion;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -30,13 +31,12 @@ class SelectAiFewShotPromptTest {
|
||||
.contains("Verified few-shot examples")
|
||||
.contains("SELECT COUNT(*) AS AU_COUNT FROM APP_USER")
|
||||
.contains("Original user question:\ncurrent active users")
|
||||
.contains("current approved object list and policy")
|
||||
.contains("do not infer a default game")
|
||||
.contains("Continue a game-neutral question with approved common objects");
|
||||
.contains("current approved object list and profile policy")
|
||||
.contains("do not override current metadata or game-alias resolution policy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersNoTargetExamplesAsBoundaryRatherThanSqlPattern() {
|
||||
void scopesNoTargetBoundaryToItsLogicalObjectInsteadOfBlockingCommonObjects() {
|
||||
String prompt = SelectAiService.composeFewShotPrompt(
|
||||
"common user count without a game",
|
||||
List.of(new QaVectorService.VectorExample(
|
||||
@@ -47,7 +47,7 @@ class SelectAiFewShotPromptTest {
|
||||
"cohere.embed-v4.0",
|
||||
"NO_TARGET",
|
||||
"NONE",
|
||||
null,
|
||||
"GAME_USER_MASTER",
|
||||
null,
|
||||
null,
|
||||
0.01
|
||||
@@ -57,7 +57,38 @@ class SelectAiFewShotPromptTest {
|
||||
assertThat(prompt)
|
||||
.contains("Boundary rule")
|
||||
.contains("Do not select a game-scoped object")
|
||||
.contains("SELECT CAST(NULL AS NUMBER)");
|
||||
.contains("SELECT CAST(NULL AS NUMBER)")
|
||||
.contains("logical object role: GAME_USER_MASTER")
|
||||
.contains("follow this boundary SQL template")
|
||||
.contains("do not apply it to an approved common-object operation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unwrapsLangChainMcpTextContentBeforeReadingTheQueryPlan() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
SelectAiService service = new SelectAiService(null, null, null, mapper, null, null, null);
|
||||
var plan = mapper.createObjectNode();
|
||||
plan.put("targetType", "NONE");
|
||||
plan.putArray("targets").addObject().putNull("gameKey");
|
||||
var toolEnvelope = mapper.createObjectNode();
|
||||
toolEnvelope.set("response", plan);
|
||||
var textContent = mapper.createObjectNode();
|
||||
textContent.put("type", "text");
|
||||
textContent.put("text", mapper.writeValueAsString(toolEnvelope));
|
||||
|
||||
assertThat(service.unwrapMcpToolResult(textContent).path("targetType").asText())
|
||||
.isEqualTo("NONE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesCorrectionAsGenericModelFeedbackRatherThanAnAnswerFallback() {
|
||||
String correction = SelectAiService.scopeCorrectionPrompt("Original prompt");
|
||||
|
||||
assertThat(correction)
|
||||
.contains("Original prompt")
|
||||
.contains("SQL VALIDATION FEEDBACK")
|
||||
.contains("Regenerate one read-only SQL statement")
|
||||
.contains("active profile instructions");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user