diff --git a/sql/adb/79_sg_game_catalog_vector.sql b/sql/adb/79_sg_game_catalog_vector.sql index 03c9d9f..19d4c03 100644 --- a/sql/adb/79_sg_game_catalog_vector.sql +++ b/sql/adb/79_sg_game_catalog_vector.sql @@ -56,3 +56,25 @@ COMMENT ON TABLE sg_game_catalog IS COMMENT ON COLUMN sg_game_catalog.embedding IS 'Vector representation of game names and aliases; populated by the configured embedding job.'; / + +CREATE OR REPLACE FUNCTION sg_game_catalog_search( + p_question IN CLOB, + p_top_k IN PLS_INTEGER DEFAULT 5 +) RETURN SYS_REFCURSOR AUTHID DEFINER IS + v_query VECTOR; + v_result SYS_REFCURSOR; +BEGIN + v_query := DBMS_VECTOR.UTL_TO_EMBEDDING( + p_question, + JSON(sg_qa_vector_params('search_query')) + ); + OPEN v_result FOR + SELECT game_key, game_id, game_prefix, game_nm, game_alias_nm, + VECTOR_DISTANCE(embedding, v_query, COSINE) AS cosine_distance + FROM sg_game_catalog + WHERE active_yn = 'Y' AND embedding IS NOT NULL + ORDER BY VECTOR_DISTANCE(embedding, v_query, COSINE), priority, game_key + FETCH FIRST LEAST(GREATEST(NVL(p_top_k, 5), 1), 20) ROWS ONLY; + RETURN v_result; +END; +/ diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java new file mode 100644 index 0000000..59b8122 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java @@ -0,0 +1,37 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class GameCatalogVectorService { + private final BackofficeProperties properties; + private final BearerTokenService bearerTokenService; + + public GameCatalogVectorService(BackofficeProperties properties, BearerTokenService bearerTokenService) { + this.properties = properties; + this.bearerTokenService = bearerTokenService; + } + + public List search(String token, String question, int topK) { + if (token == null || token.isBlank() || bearerTokenService.findByPlainToken(token.trim()) == null) { + throw new VpdTokenAccessDeniedException(); + } + BackofficeProperties.SelectAi db = properties.selectAi(); + if (db == null || !db.configured()) throw new AppException("게임 카탈로그 DB 설정이 필요합니다."); + List result = new ArrayList<>(); + try (Connection c = DriverManager.getConnection(db.dbUrl(), db.dbUsername(), db.dbPassword()); + CallableStatement s = c.prepareCall("{ ? = call sg_game_catalog_search(?, ?) }")) { + s.registerOutParameter(1, Types.REF_CURSOR); s.setString(2, question); s.setInt(3, Math.min(Math.max(topK, 1), 20)); s.execute(); + try (ResultSet rs = (ResultSet) s.getObject(1)) { + while (rs.next()) result.add(new GameCandidate(rs.getString("GAME_KEY"), rs.getString("GAME_ID"), rs.getString("GAME_PREFIX"), rs.getString("GAME_NM"), rs.getString("GAME_ALIAS_NM"), rs.getDouble("COSINE_DISTANCE"))); + } + return result; + } catch (SQLException e) { throw new AppException("게임 카탈로그 벡터 검색 실패: " + e.getMessage()); } + } + + public record GameCandidate(String gameKey, String gameId, String gamePrefix, String gameName, String aliases, double similarity) {} +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 29d284a..3c93652 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -23,6 +23,7 @@ public class McpSseService { private final McpProperties mcpProperties; private final QaVectorService qaVectorService; private final GameScopeService gameScopeService; + private final GameCatalogVectorService gameCatalogVectorService; public McpSseService( SelectAiService selectAiService, @@ -30,7 +31,8 @@ public class McpSseService { BackofficeProperties properties, McpProperties mcpProperties, QaVectorService qaVectorService, - GameScopeService gameScopeService + GameScopeService gameScopeService, + GameCatalogVectorService gameCatalogVectorService ) { this.selectAiService = selectAiService; this.objectMapper = objectMapper; @@ -38,6 +40,7 @@ public class McpSseService { this.mcpProperties = mcpProperties; this.qaVectorService = qaVectorService; this.gameScopeService = gameScopeService; + this.gameCatalogVectorService = gameCatalogVectorService; } public ObjectNode handle(String contextPath, JsonNode request) { @@ -191,8 +194,8 @@ public class McpSseService { response = gameScopeResponse(gameScopeService.resolve( token, arguments.path("question").asText(""))); } else if (gameCatalogTool) { - response = gameScopeResponse(gameScopeService.resolve( - token, arguments.path("question").asText(""))); + response = gameCatalogVectorResponse(gameCatalogVectorService.search( + token, arguments.path("question").asText(""), arguments.path("topK").asInt(5))); } else { String prompt = arguments.path("prompt").asText(""); response = queryTool @@ -244,6 +247,19 @@ public class McpSseService { return response; } + private ObjectNode gameCatalogVectorResponse(List candidates) { + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", candidates.isEmpty() ? "NO_MATCH" : "CANDIDATES"); + ArrayNode items = response.putArray("matchedGames"); + for (GameCatalogVectorService.GameCandidate c : candidates) { + ObjectNode item = items.addObject(); + item.put("gameKey", c.gameKey()); item.put("gameId", c.gameId()); + item.put("gamePrefix", c.gamePrefix()); item.put("gameName", c.gameName()); + item.put("aliases", c.aliases()); item.put("cosineDistance", c.similarity()); + } + return response; + } + private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) { ObjectNode response = objectMapper.createObjectNode(); response.put("status", "QA_VECTOR_STORED");