package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.math.BigDecimal; import java.math.BigInteger; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.Statement; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.List; import java.util.regex.Pattern; import org.springframework.stereotype.Service; /** * Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile. */ @Service public class SelectAiService { private static final int MAX_PROMPT_LENGTH = 4_000; private static final int MAX_RESULT_ROWS = 100; private static final int QUERY_TIMEOUT_SECONDS = 30; private static final int MAX_FEW_SHOT_EXAMPLES = 3; 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. State that the required game identifier is missing instead.\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|" + "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b" ); private final BackofficeProperties properties; private final BearerTokenService bearerTokenService; private final Clock clock; private final ObjectMapper objectMapper; private final QaVectorService qaVectorService; private final GameScopeService gameScopeService; private final GameCatalogVectorService gameCatalogVectorService; public SelectAiService( BackofficeProperties properties, BearerTokenService bearerTokenService, Clock clock, ObjectMapper objectMapper, QaVectorService qaVectorService, GameScopeService gameScopeService, GameCatalogVectorService gameCatalogVectorService ) { this.properties = properties; this.bearerTokenService = bearerTokenService; this.clock = clock; this.objectMapper = objectMapper; this.qaVectorService = qaVectorService; this.gameScopeService = gameScopeService; this.gameCatalogVectorService = gameCatalogVectorService; } 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); GameContext gameContext = resolveGameContext(bearerToken, normalizedPrompt); EnrichedPrompt enrichedPrompt = enrichWithFewShot( bearerToken, selectAi, gameContext.prompt(scope.prompt())); String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql"); String normalizedSql = validateReadOnlySql(generatedSql); 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()); 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()); response.put("generatedSql", normalizedSql); response.put("execution", "READ_ONLY_EXECUTED"); response.put("rowCount", execution.items().size()); response.put("truncated", execution.truncated()); response.set("items", execution.items()); response.put("nextStep", execution.truncated() ? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다." : "생성 SQL을 읽기 전용으로 실행한 결과입니다."); return response; } private GameContext resolveGameContext(String bearerToken, String question) { try { List candidates = gameCatalogVectorService.search(bearerToken, question, 5); String status = candidates.isEmpty() ? "NO_MATCH" : "RESOLVED"; String scopeType = candidates.isEmpty() ? "UNKNOWN" : "SINGLE_GAME"; return new GameContext(scopeType, status, candidates); } catch (Exception ignored) { return new GameContext("UNKNOWN", "UNAVAILABLE", List.of()); } } 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) { ObjectNode item = items.addObject(); item.put("exampleId", example.exampleId()); item.put("question", example.question()); item.put("answerSql", example.answerSql()); if (example.answer() != null) { item.put("answer", example.answer()); } item.put("embeddingModel", example.embeddingModel()); item.put("cosineDistance", example.cosineDistance()); } } /** Returns the prompt Select AI assembled for SQL generation without executing generated SQL. */ public JsonNode generatePrompt(String bearerToken, String prompt) { requireActiveToken(bearerToken); String normalizedPrompt = requiredPrompt(prompt); BackofficeProperties.SelectAi selectAi = requiredSelectAi(); EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt); String selectAiPrompt = generate(selectAi, enrichedPrompt.prompt(), "showprompt"); ObjectNode response = objectMapper.createObjectNode(); response.put("status", "SHOWPROMPT"); response.put("profile", selectAi.profile()); response.put("originalPrompt", normalizedPrompt); response.put("fewShotStatus", enrichedPrompt.status()); response.put("fewShotExampleCount", enrichedPrompt.exampleCount()); response.put("selectAiPrompt", selectAiPrompt); return response; } private BackofficeProperties.SelectAi requiredSelectAi() { BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); if (selectAi == null || !selectAi.configured()) { throw new AppException("Select AI 연결 설정이 필요합니다. " + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, " + "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요."); } return selectAi; } private void requireActiveToken(String bearerToken) { if (bearerToken == null || bearerToken.isBlank()) { throw new VpdTokenAccessDeniedException(); } BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim()); LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault())); if (token == null || !token.active(now)) { throw new VpdTokenAccessDeniedException(); } } private String requiredPrompt(String prompt) { String normalized = prompt == null ? "" : prompt.trim(); if (normalized.isEmpty()) { throw new AppException("prompt는 필수입니다."); } if (normalized.length() > MAX_PROMPT_LENGTH) { throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다."); } return normalized; } private String generate( BackofficeProperties.SelectAi selectAi, String prompt, String action ) { String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, ?) FROM dual"; try (Connection connection = DriverManager.getConnection( selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, prompt); statement.setString(2, selectAi.profile()); statement.setString(3, action); try (ResultSet resultSet = statement.executeQuery()) { if (!resultSet.next() || resultSet.getString(1) == null) { throw new AppException("Select AI가 " + action.toUpperCase() + " 결과를 반환하지 않았습니다."); } return resultSet.getString(1); } } catch (AppException exception) { throw exception; } catch (Exception exception) { throw new AppException("Select AI " + action.toUpperCase() + " 생성 실패: " + exception.getMessage()); } } private EnrichedPrompt enrichWithFewShot( String bearerToken, BackofficeProperties.SelectAi selectAi, String prompt ) { if (!fewShotEnabled(selectAi) || qaVectorService == null) { return new EnrichedPrompt(prompt, "DISABLED", 0, List.of()); } try { List examples = qaVectorService .search(bearerToken, prompt, fewShotTopK(selectAi)) .examples(); if (examples.isEmpty()) { return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of()); } return new EnrichedPrompt( composeFewShotPrompt(prompt, examples), "APPLIED", Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES), examples.subList(0, Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES))); } catch (Exception ignored) { // Vector retrieval is an optional prompt aid; preserve the normal Text2SQL path on failure. return new EnrichedPrompt(composePolicyPrompt(prompt), "UNAVAILABLE", 0, List.of()); } } static String composePolicyPrompt(String 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. Return a read-only no-match result " + "and preserve the resolver status for the answer layer; do not invent a user-facing explanation.\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. " + "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) { if (included >= MAX_FEW_SHOT_EXAMPLES) { break; } String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS); if (answerSql.isBlank()) { continue; } String candidate = "Example " + (included + 1) + " question:\n" + example.question() + "\nExample " + (included + 1) + " verified SQL:\n" + answerSql + "\n\n"; if (enriched.length() + candidate.length() + prompt.length() > MAX_ENRICHED_PROMPT_LENGTH) { break; } enriched.append(candidate); included++; } if (included == 0) { return composePolicyPrompt(prompt); } return enriched.append("Original user question:\n").append(prompt).toString(); } private static String truncate(String value, int maxLength) { String normalized = value == null ? "" : value.trim(); return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); } private boolean fewShotEnabled(BackofficeProperties.SelectAi selectAi) { return selectAi.fewShotEnabled() == null || selectAi.fewShotEnabled(); } private int fewShotTopK(BackofficeProperties.SelectAi selectAi) { Integer configured = selectAi.fewShotTopK(); if (configured == null) { return MAX_FEW_SHOT_EXAMPLES; } return Math.max(1, Math.min(configured, MAX_FEW_SHOT_EXAMPLES)); } private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) { ArrayNode items = objectMapper.createArrayNode(); boolean truncated = false; try (Connection connection = DriverManager.getConnection( selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); Statement transaction = connection.createStatement()) { connection.setAutoCommit(false); connection.setReadOnly(true); transaction.execute("SET TRANSACTION READ ONLY"); try (PreparedStatement statement = connection.prepareStatement(generatedSql)) { statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS); statement.setFetchSize(MAX_RESULT_ROWS + 1); statement.setMaxRows(MAX_RESULT_ROWS + 1); try (ResultSet resultSet = statement.executeQuery()) { ResultSetMetaData metadata = resultSet.getMetaData(); while (resultSet.next()) { if (items.size() >= MAX_RESULT_ROWS) { truncated = true; break; } ObjectNode row = items.addObject(); for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) { String column = metadata.getColumnLabel(columnIndex); if (column == null || column.isBlank()) { column = metadata.getColumnName(columnIndex); } putResultValue(row, column, resultSet.getObject(columnIndex)); } } } } finally { connection.rollback(); } } catch (Exception exception) { throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage()); } return new QueryExecution(items, truncated); } private void putResultValue(ObjectNode row, String column, Object value) { if (value == null) { row.putNull(column); } else if (value instanceof BigDecimal number) { row.put(column, number); } else if (value instanceof BigInteger number) { row.put(column, number); } else if (value instanceof Integer number) { row.put(column, number); } else if (value instanceof Long number) { row.put(column, number); } else if (value instanceof Short number) { row.put(column, number); } else if (value instanceof Float number) { row.put(column, number); } else if (value instanceof Double number) { row.put(column, number); } else if (value instanceof Boolean bool) { row.put(column, bool); } else { row.put(column, String.valueOf(value)); } } private String validateReadOnlySql(String generatedSql) { String normalized = generatedSql == null ? "" : generatedSql.trim(); if (normalized.startsWith("```")) { int firstLineEnd = normalized.indexOf('\n'); int closingFence = normalized.lastIndexOf("```"); if (firstLineEnd >= 0 && closingFence > firstLineEnd) { normalized = normalized.substring(firstLineEnd + 1, closingFence).trim(); } } normalized = normalized.replaceFirst(";\\s*$", "").trim(); if (!normalized.matches("(?is)^(select|with)\\b.*")) { throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다."); } if (normalized.contains(";")) { throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다."); } if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/") || UNSAFE_SQL.matcher(normalized).find()) { throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다."); } return normalized; } private record QueryExecution(ArrayNode items, boolean truncated) {} private record EnrichedPrompt( String prompt, String status, int exampleCount, List examples) {} private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {} private record GameContext( String scopeType, String status, List candidates) { String prompt(String original) { StringBuilder context = new StringBuilder(); context.append("[GAME CATALOG MATCH]\n") .append("scope_type: ").append(scopeType).append('\n') .append("status: ").append(status).append('\n'); for (GameCatalogVectorService.GameCandidate candidate : candidates) { context.append("game_key: ").append(candidate.gameKey()).append('\n') .append("game_id: ").append(candidate.gameId()).append('\n') .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("cosine_distance: ").append(candidate.similarity()).append('\n'); } context.append("[GAME SCOPE METADATA]\n") .append("The following resolver facts are authoritative metadata. ") .append("Apply the table and column annotations associated with these facts; ") .append("do not invent identifiers or scope rules.\n\n") .append(original); return context.toString(); } } }