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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user