package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; import com.fasterxml.jackson.core.JsonProcessingException; 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.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; 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 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|" + "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(); // Game identity is resolved only in ADB by sg_game_query_plan. That // function invokes OCI GenAI chat and validates its candidate choice // against the database catalog; Java never infers aliases or tables. QueryPlanContext queryPlan = requiredQueryPlan( gameCatalogVectorService.queryPlan(bearerToken, normalizedPrompt, 5)); ResolvedExecutionScope scope = queryPlan.scope(scopeGameKey); return generateAndExecutePrepared( bearerToken, normalizedPrompt, selectAi, queryPlan.prompt(normalizedPrompt, scope.gameKey()), queryPlan.targetType(), queryPlan.status(), queryPlan.targetCount(), scope, queryPlan.allowedPrefixes(), queryPlan, List.of() ); } public JsonNode generateAndExecute( String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) { return generateAndExecute(bearerToken, prompt, scopeGameKey, priorToolContext, null); } /** * Uses only rehydrated approved pattern ids from the preceding preflight. * The preflight runs before the OCI Chat game plan; the plan still controls * the final target-type compatibility and all game identity. */ public JsonNode generateAndExecute( String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext, JsonNode fewShotPreflight) { 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); List preflightExamples = approvedPreflightExamples( bearerToken, fewShotPreflight, queryPlan.targetType()); return generateAndExecutePrepared( bearerToken, normalizedPrompt, selectAi, queryPlan.prompt(normalizedPrompt, scope.gameKey()), queryPlan.targetType(), queryPlan.status(), queryPlan.targetCount(), scope, queryPlan.allowedPrefixes(), queryPlan, preflightExamples ); } private JsonNode generateAndExecutePrepared( String bearerToken, String originalPrompt, BackofficeProperties.SelectAi selectAi, String executionPrompt, String gameScopeType, String gameScopeStatus, int gameCandidateCount, ResolvedExecutionScope scope, Set allowedPrefixes, QueryPlanContext queryPlan, List preflightExamples ) { EnrichedPrompt enrichedPrompt = enrichWithFewShot( bearerToken, selectAi, originalPrompt, executionPrompt, queryPlan == null ? "ANY" : queryPlan.targetType(), preflightExamples); 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", 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()); response.put("selectAiReference", queryPlan.selectAiReference()); } if (scope.gameKey() != null) { response.put("scopeGameKey", scope.gameKey()); response.put("scopeDisplayName", scope.displayName()); response.put("scopeStatus", queryPlan == null ? "DB_REVALIDATED" : "QUERY_PLAN_VALIDATED"); } 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 List approvedPreflightExamples( String bearerToken, JsonNode fewShotPreflight, String targetType) { if (fewShotPreflight == null || fewShotPreflight.isNull() || fewShotPreflight.isMissingNode() || qaVectorService == null) { return List.of(); } JsonNode preflight = unwrapMcpToolResult(fewShotPreflight); if (!"FEWSHOT_PREFLIGHT".equals(preflight.path("status").asText())) { return List.of(); } List ids = new java.util.ArrayList<>(); for (JsonNode example : preflight.path("examples")) { if (example.path("exampleId").canConvertToLong()) { ids.add(example.path("exampleId").asLong()); } } return qaVectorService.findApprovedExamples(bearerToken, ids, targetType); } private QueryPlanContext requiredQueryPlan(JsonNode plan) { JsonNode normalizedPlan = unwrapMcpToolResult(plan); String targetType = normalizedPlan.path("targetType") .asText("").trim().toUpperCase(Locale.ROOT); if (!Set.of("NONE", "SINGLE", "MULTI", "ALL").contains(targetType) || !normalizedPlan.path("gameTargets").isArray() || normalizedPlan.path("selectAiReference").asText("").isBlank()) { throw new AppException( "queryPlan은 targetType, gameTargets, selectAiReference가 필요합니다."); } Set allowedPrefixes = new LinkedHashSet<>(); for (JsonNode target : normalizedPlan.path("gameTargets")) { String prefix = target.path("gamePrefix").asText("").trim(); if (!prefix.isEmpty()) { allowedPrefixes.add(prefix.toUpperCase(Locale.ROOT)); } } return new QueryPlanContext( targetType, normalizedPlan.path("status").asText(""), normalizedPlan.path("gameTargets").size(), Set.copyOf(allowedPrefixes), normalizedPlan.path("selectAiReference").asText().trim(), normalizedPlan.deepCopy() ); } /** * Accept the direct game-query-plan contract as well as the standard MCP * tools/call envelope the ReAct model can copy from a preceding observation. * This is transport normalization only; the target contract itself remains * the authoritative data-driven policy. */ JsonNode unwrapMcpToolResult(JsonNode candidate) { JsonNode current = candidate; for (int depth = 0; depth < 6 && current != null; depth++) { if (current.isTextual()) { try { current = objectMapper.readTree(current.asText()); continue; } catch (JsonProcessingException ignored) { 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; continue; } break; } return current == null ? objectMapper.createObjectNode() : current; } 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("referenceKind", example.referenceKind()); item.put("targetType", example.targetType()); if (example.objectRole() != null) { item.put("objectRole", example.objectRole()); } if (example.sourceCaseId() != null) { item.put("sourceCaseId", example.sourceCaseId()); } if (example.sourceType() != null) { item.put("sourceType", example.sourceType()); } 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, normalizedPrompt, "ANY", List.of()); 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 retrievalQuestion, String generationPrompt, String targetType, List preflightExamples) { if (!fewShotEnabled(selectAi) || qaVectorService == null) { return new EnrichedPrompt(generationPrompt, "DISABLED", 0, List.of()); } try { List examples = preflightExamples == null || preflightExamples.isEmpty() ? qaVectorService.search(bearerToken, retrievalQuestion, fewShotTopK(selectAi), targetType).examples() : preflightExamples; if (examples.isEmpty()) { return new EnrichedPrompt(composePolicyPrompt(generationPrompt), "NO_MATCH", 0, List.of()); } return new EnrichedPrompt( composeFewShotPrompt(generationPrompt, 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(generationPrompt), "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 a game-specific object. Follow the authoritative scope guidance " + "and preserve the resolver status for the answer layer.\n" + "Original user question:\n" + prompt; } static String composeFewShotPrompt(String prompt, List examples) { StringBuilder enriched = new StringBuilder(POLICY_PREFIX + "Reference precedence:\n" + "1. A [REQUIRED BOUNDARY REFERENCE] is a verified decision reference for its matching " + "target type and logical object role. You must apply its boundary decision before generating SQL. " + "Do not replace it with a game-scoped object.\n" + "2. Normal SQL-pattern examples are required result-shape references when their logical object role " + "and requested result grain match the original question. Preserve the matching aggregate versus " + "individual-detail shape; do not replace an aggregate example with detail rows, or the reverse, " + "unless the user explicitly asks for that different shape. Do not invent " + "identifiers, and do not override current metadata or game-alias resolution policy. " + "When an example uses 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 references:\n"); List ordered = new java.util.ArrayList<>(examples.size()); for (QaVectorService.VectorExample example : examples) { if (isBoundaryReference(example)) { ordered.add(example); } } for (QaVectorService.VectorExample example : examples) { if (!isBoundaryReference(example)) { ordered.add(example); } } int included = 0; for (QaVectorService.VectorExample example : ordered) { if (included >= MAX_FEW_SHOT_EXAMPLES) { break; } String candidate = referenceCandidate(included + 1, example); if (candidate.isBlank()) { continue; } 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 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() + "\nExample " + index + " logical object role: " + (example.objectRole() == null || example.objectRole().isBlank() ? "UNSPECIFIED" : example.objectRole()) + "\n"; if (isBoundaryReference(example)) { String boundary = truncate(example.answer(), MAX_FEW_SHOT_SQL_CHARS); String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS); if (boundary.isBlank() || answerSql.isBlank()) { return ""; } return "[REQUIRED BOUNDARY REFERENCE]\n" + prefix + "This verified boundary reference must be applied when the current target type and logical " + "object role match. Use 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); String answerGuide = truncate(example.answer(), MAX_FEW_SHOT_SQL_CHARS); if (answerSql.isBlank()) { return ""; } return prefix + "Verified SQL template:\n" + answerSql + (answerGuide.isBlank() ? "" : "\nExpected answer guidance:\n" + answerGuide) + "\n\n"; } private static boolean isBoundaryReference(QaVectorService.VectorExample example) { return "NO_TARGET".equals(example.referenceKind()) || "OBJECT_UNAVAILABLE".equals(example.referenceKind()); } 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 QueryPlanContext( String targetType, String status, int targetCount, Set allowedPrefixes, String selectAiReference, JsonNode plan ) { String prompt(String originalQuestion, String scopeGameKey) { if (scopeGameKey == null || scopeGameKey.isBlank()) { return selectAiReference + "\n\n[ORIGINAL USER QUESTION]\n" + originalQuestion; } if (plan.path("gameTargets").size() != 1) { throw new AppException("Worker queryPlan must contain exactly one target."); } JsonNode target = plan.path("gameTargets").get(0); if (!scopeGameKey.equals(target.path("gameKey").asText(""))) { throw new AppException("scopeGameKey가 worker queryPlan 대상과 일치하지 않습니다."); } return selectAiReference + "\n\n[TASK QUESTION]\n" + originalQuestion; } ResolvedExecutionScope scope(String requestedGameKey) { String requested = requestedGameKey == null ? "" : requestedGameKey.trim(); if (requested.isEmpty()) { return new ResolvedExecutionScope("", null, null); } if (plan.path("gameTargets").size() != 1) { throw new AppException("Worker queryPlan must contain exactly one target."); } for (JsonNode target : plan.path("gameTargets")) { if (requested.equals(target.path("gameKey").asText(""))) { return new ResolvedExecutionScope( "", requested, target.path("gameName").asText(requested)); } } throw new AppException("scopeGameKey가 queryPlan targets에 없습니다."); } } }