refs #739: generalize game target profile guidance

This commit is contained in:
devmrko
2026-07-28 13:25:00 +09:00
parent 0a27ce9cd5
commit 5ca02e646a
6 changed files with 179 additions and 27 deletions

View File

@@ -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;
}