refs #739: unify Smilegate game target planning

This commit is contained in:
devmrko
2026-07-28 10:09:12 +09:00
parent 9f15ef6be1
commit 7bc13446bc
9 changed files with 664 additions and 138 deletions

View File

@@ -17,7 +17,10 @@ import java.sql.Statement;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
@@ -36,7 +39,9 @@ public class SelectAiService {
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. State that the required game identifier is missing instead.\n\n";
+ "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";
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|"
@@ -84,22 +89,86 @@ public class SelectAiService {
ResolvedExecutionScope scope = resolveExecutionScope(bearerToken, normalizedPrompt, scopeGameKey);
GameContext gameContext = resolveGameContext(bearerToken, normalizedPrompt);
return generateAndExecutePrepared(
bearerToken,
normalizedPrompt,
selectAi,
gameContext.prompt(scope.prompt()),
gameContext.scopeType(),
gameContext.status(),
gameContext.candidates().size(),
scope,
null,
null
);
}
public JsonNode generateAndExecute(
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) {
if (priorToolContext == null || priorToolContext.isMissingNode()
|| priorToolContext.isNull()) {
return generateAndExecute(bearerToken, prompt, scopeGameKey);
}
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
QueryPlanContext queryPlan = requiredQueryPlan(priorToolContext);
ResolvedExecutionScope scope = queryPlan.scope(scopeGameKey);
return generateAndExecutePrepared(
bearerToken,
normalizedPrompt,
selectAi,
queryPlan.prompt(normalizedPrompt),
queryPlan.targetType(),
queryPlan.status(),
queryPlan.targetCount(),
scope,
queryPlan.allowedPrefixes(),
queryPlan
);
}
private JsonNode generateAndExecutePrepared(
String bearerToken,
String originalPrompt,
BackofficeProperties.SelectAi selectAi,
String executionPrompt,
String gameScopeType,
String gameScopeStatus,
int gameCandidateCount,
ResolvedExecutionScope scope,
Set<String> allowedPrefixes,
QueryPlanContext queryPlan
) {
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
bearerToken, selectAi, gameContext.prompt(scope.prompt()));
bearerToken, selectAi, executionPrompt);
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
String normalizedSql = validateReadOnlySql(generatedSql);
if (allowedPrefixes != null && gameScopeService != null
&& gameScopeService.configured()
&& gameScopeService.referencesGameScopedObjectOutsidePrefixes(
bearerToken, normalizedSql, allowedPrefixes)) {
throw new AppException(
"Select AI 생성 SQL이 게임 질의 계획에 없는 prefix 전용 객체를 참조했습니다.");
}
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "SHOWSQL_AND_EXECUTED");
response.put("profile", selectAi.profile());
response.put("originalPrompt", normalizedPrompt);
response.put("gameScopeType", gameContext.scopeType());
response.put("gameScopeStatus", gameContext.status());
response.put("gameCandidateCount", gameContext.candidates().size());
response.put("originalPrompt", originalPrompt);
response.put("gameScopeType", gameScopeType);
response.put("gameScopeStatus", gameScopeStatus);
response.put("gameCandidateCount", gameCandidateCount);
if (queryPlan != null) {
response.put("queryPlanTargetType", queryPlan.targetType());
response.put("queryPlanStatus", queryPlan.status());
response.put("queryPlanTargetCount", queryPlan.targetCount());
}
if (scope.gameKey() != null) {
response.put("scopeGameKey", scope.gameKey());
response.put("scopeDisplayName", scope.displayName());
response.put("scopeStatus", "DB_REVALIDATED");
response.put("scopeStatus", queryPlan == null
? "DB_REVALIDATED" : "QUERY_PLAN_VALIDATED");
}
response.put("fewShotStatus", enrichedPrompt.status());
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
@@ -115,11 +184,27 @@ public class SelectAiService {
return response;
}
public JsonNode generateAndExecute(
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) {
String context = priorToolContext == null || priorToolContext.isMissingNode()
|| priorToolContext.isNull() ? "" : "\n[PREVIOUS TOOL RESULT]\n" + priorToolContext;
return generateAndExecute(bearerToken, prompt + context, scopeGameKey);
private QueryPlanContext requiredQueryPlan(JsonNode plan) {
String targetType = plan.path("targetType").asText("").trim().toUpperCase(Locale.ROOT);
if (!Set.of("NONE", "SINGLE", "MULTI", "ALL").contains(targetType)
|| !plan.path("targets").isArray()) {
throw new AppException(
"queryPlan은 targetType(NONE/SINGLE/MULTI/ALL)과 targets 배열이 필요합니다.");
}
Set<String> allowedPrefixes = new LinkedHashSet<>();
for (JsonNode target : plan.path("targets")) {
String prefix = target.path("gamePrefix").asText("").trim();
if (!prefix.isEmpty()) {
allowedPrefixes.add(prefix.toUpperCase(Locale.ROOT));
}
}
return new QueryPlanContext(
targetType,
plan.path("status").asText(""),
plan.path("targets").size(),
Set.copyOf(allowedPrefixes),
plan.deepCopy()
);
}
private GameContext resolveGameContext(String bearerToken, String question) {
@@ -280,8 +365,8 @@ public class SelectAiService {
return POLICY_PREFIX
+ "Resolve business terms and game names from the approved game-alias metadata before selecting a "
+ "game-scoped object. A generic term such as common user means no particular game. If no game alias "
+ "is resolved, do not substitute an arbitrary game-scoped object. Return a read-only no-match result "
+ "and preserve the resolver status for the answer layer; do not invent a user-facing explanation.\n"
+ "is resolved, do not substitute an arbitrary game-scoped object. Keep common-object questions "
+ "game-neutral and preserve the resolver status for the answer layer.\n"
+ "Original user question:\n" + prompt;
}
@@ -425,6 +510,42 @@ public class SelectAiService {
private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {}
private record QueryPlanContext(
String targetType,
String status,
int targetCount,
Set<String> allowedPrefixes,
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
+ "\n[ORIGINAL USER QUESTION]\n" + originalQuestion;
}
ResolvedExecutionScope scope(String requestedGameKey) {
String requested = requestedGameKey == null ? "" : requestedGameKey.trim();
if (requested.isEmpty()) {
return new ResolvedExecutionScope("", null, null);
}
for (JsonNode target : plan.path("targets")) {
if (requested.equals(target.path("gameKey").asText(""))) {
return new ResolvedExecutionScope(
"", requested, target.path("gameName").asText(requested));
}
}
throw new AppException("scopeGameKey가 queryPlan targets에 없습니다.");
}
}
private record GameContext(
String scopeType, String status, List<GameCatalogVectorService.GameCandidate> candidates) {
String prompt(String original) {
@@ -438,6 +559,8 @@ public class SelectAiService {
.append("game_prefix: ").append(candidate.gamePrefix()).append('\n')
.append("game_name: ").append(candidate.gameName()).append('\n')
.append("matched_aliases: ").append(candidate.aliases()).append('\n')
.append("user_master_object_name: ")
.append(candidate.userMasterObjectName()).append('\n')
.append("cosine_distance: ").append(candidate.similarity()).append('\n');
}
context.append("[GAME SCOPE METADATA]\n")