diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index 526198a..9472c13 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -48,27 +48,39 @@ public class SelectAiService { private final Clock clock; private final ObjectMapper objectMapper; private final QaVectorService qaVectorService; + private final GameScopeService gameScopeService; public SelectAiService( BackofficeProperties properties, BearerTokenService bearerTokenService, Clock clock, ObjectMapper objectMapper, - QaVectorService qaVectorService + QaVectorService qaVectorService, + GameScopeService gameScopeService ) { this.properties = properties; this.bearerTokenService = bearerTokenService; this.clock = clock; this.objectMapper = objectMapper; this.qaVectorService = qaVectorService; + this.gameScopeService = gameScopeService; } public JsonNode generateAndExecute(String bearerToken, String prompt) { + return generateAndExecute(bearerToken, prompt, null); + } + + /** + * Executes one DB-resolved game scope. The optional key is revalidated for + * the original question; it is never a caller-provided table or prefix. + */ + public JsonNode generateAndExecute(String bearerToken, String prompt, String scopeGameKey) { requireActiveToken(bearerToken); String normalizedPrompt = requiredPrompt(prompt); BackofficeProperties.SelectAi selectAi = requiredSelectAi(); + ResolvedExecutionScope scope = resolveExecutionScope(bearerToken, normalizedPrompt, scopeGameKey); - EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt); + EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, scope.prompt()); String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql"); String normalizedSql = validateReadOnlySql(generatedSql); QueryExecution execution = executeReadOnly(selectAi, normalizedSql); @@ -76,6 +88,11 @@ public class SelectAiService { response.put("status", "SHOWSQL_AND_EXECUTED"); response.put("profile", selectAi.profile()); response.put("originalPrompt", normalizedPrompt); + if (scope.gameKey() != null) { + response.put("scopeGameKey", scope.gameKey()); + response.put("scopeDisplayName", scope.displayName()); + response.put("scopeStatus", "DB_REVALIDATED"); + } response.put("fewShotStatus", enrichedPrompt.status()); response.put("fewShotExampleCount", enrichedPrompt.exampleCount()); addFewShotExamples(response, enrichedPrompt.examples()); @@ -90,6 +107,33 @@ public class SelectAiService { return response; } + private ResolvedExecutionScope resolveExecutionScope( + String bearerToken, String originalPrompt, String scopeGameKey + ) { + String requestedKey = scopeGameKey == null ? "" : scopeGameKey.trim(); + if (requestedKey.isEmpty()) { + return new ResolvedExecutionScope(originalPrompt, null, null); + } + if (gameScopeService == null) { + throw new AppException("게임 범위 검증 서비스를 사용할 수 없습니다."); + } + GameScopeService.GameScope scope = gameScopeService.resolve(bearerToken, originalPrompt).scopes().stream() + .filter(item -> requestedKey.equals(item.gameKey())) + .findFirst() + .orElseThrow(() -> new AppException("요청한 게임 범위가 DB 조회 결과에 없습니다.")); + if (!"SUPPORTED".equals(scope.status())) { + throw new AppException("DB 게임 범위가 조회 실행을 허용하지 않습니다: " + scope.reasonCode()); + } + String scopedPrompt = "Use only the DB-resolved game scope below. Do not generate SQL for any other " + + "game mentioned in the original question. The scope was validated through current game alias " + + "metadata and approved object availability.\n" + + "Resolved game key: " + scope.gameKey() + "\n" + + "Resolved display name: " + scope.displayName() + "\n" + + "Matched alias: " + scope.matchedAlias() + "\n\n" + + "Original user question:\n" + originalPrompt; + return new ResolvedExecutionScope(scopedPrompt, scope.gameKey(), scope.displayName()); + } + private void addFewShotExamples(ObjectNode response, List examples) { ArrayNode items = response.putArray("fewShotExamples"); for (QaVectorService.VectorExample example : examples) { @@ -206,13 +250,19 @@ public class SelectAiService { } static String composePolicyPrompt(String prompt) { - return POLICY_PREFIX + "Original user question:\n" + prompt; + 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; ask for the game or return no data.\n" + + "Original user question:\n" + prompt; } static String composeFewShotPrompt(String prompt, List examples) { StringBuilder enriched = new StringBuilder(POLICY_PREFIX + "The verified examples below are guidance only: use only relevant SQL patterns, do not invent " - + "identifiers, and do not override current metadata or game-alias resolution policy.\n\n" + + "identifiers, and do not override current metadata or game-alias resolution policy. " + + "When examples use a game-specific object, reuse that pattern only after the current question " + + "resolves the same game alias; otherwise keep the query unscoped or request clarification.\n\n" + "Verified few-shot examples:\n"); int included = 0; for (QaVectorService.VectorExample example : examples) { @@ -344,4 +394,6 @@ public class SelectAiService { private record EnrichedPrompt( String prompt, String status, int exampleCount, List examples) {} + + private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {} }