From 5a3b03060dc07c8e18e778df2a8467f45f0c1777 Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 27 Jul 2026 16:25:33 +0900 Subject: [PATCH] refs #737: preserve unmatched game mentions in query plan --- .../731-sgmp-game-scope-resolver/README.md | 13 + .../service/GameCatalogVectorService.java | 238 +++++++++++++++--- .../vpdbackoffice/service/McpSseService.java | 55 +++- .../service/GameCatalogVectorServiceTest.java | 52 ++++ .../service/McpSseServiceTest.java | 50 +++- 5 files changed, 360 insertions(+), 48 deletions(-) create mode 100644 vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java diff --git a/docs/design/731-sgmp-game-scope-resolver/README.md b/docs/design/731-sgmp-game-scope-resolver/README.md index c9864e6..a19dd8f 100644 --- a/docs/design/731-sgmp-game-scope-resolver/README.md +++ b/docs/design/731-sgmp-game-scope-resolver/README.md @@ -46,6 +46,19 @@ Smilegate view는 전체 게임 마스터와 alias를 기준으로 하고, 현 `AMBIGUOUS`는 SQL 실행 없이 결과에 표시한다. 5. Few-shot worker는 scope token을 검증하고, token에 담긴 DB scope로만 prompt를 보강한다. +## 추출 게임명별 판정 계약 + +- ADB Chat이 반환한 `game_mentions`의 각 항목은 서로 독립적으로 판정한다. 벡터 검색 결과를 + 하나의 목록으로 합쳐 모든 후보를 지원 게임으로 취급하지 않는다. +- 벡터 검색은 후보를 찾는 단계다. 후보의 카탈로그 명칭, 별칭, `GAME_ID`, `GAME_PREFIX` 중 + 추출 명칭과 정규화 매칭되는 항목만 `supportedGames`에 넣는다. +- 추출 명칭과 매칭되는 카탈로그 항목이 없으면 해당 원문 명칭을 `unmatchedGames`에 남긴다. + 유사도 순위가 높다는 이유만으로 다른 게임에 대입하지 않는다. +- 동일 게임이 여러 명칭으로 검색되더라도 `supportedGames`는 `gameKey` 기준으로 중복을 + 제거한다. `matchedGames`와 `unmatchedGames`에는 mention별 판정 근거를 유지한다. +- 일부만 지원되는 복수 게임 질문은 `status=PARTIAL`로 반환하고, 지원 게임의 worker 실행과 + 미매칭 게임 안내를 함께 수행한다. + ## 검증 - view가 지원 게임과 object list 미연결 게임을 각각 반환하는지 확인한다. diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java index 6aed340..9db83ad 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java @@ -1,66 +1,226 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; -import java.sql.*; -import java.util.ArrayList; -import java.util.List; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.sql.CallableStatement; +import java.sql.Clob; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; import org.springframework.stereotype.Service; @Service public class GameCatalogVectorService { + private final BackofficeProperties properties; private final BearerTokenService bearerTokenService; private final ObjectMapper objectMapper; - public GameCatalogVectorService(BackofficeProperties properties, BearerTokenService bearerTokenService, ObjectMapper objectMapper) { + public GameCatalogVectorService( + BackofficeProperties properties, + BearerTokenService bearerTokenService, + ObjectMapper objectMapper + ) { this.properties = properties; this.bearerTokenService = bearerTokenService; this.objectMapper = objectMapper; } + /** + * Resolves each extracted game mention independently. + * + *

Vector search supplies candidates. A candidate becomes executable only + * when the extracted mention also matches catalog identity text. This keeps + * an unknown mention from inheriting the nearest unrelated game while + * retaining the original mention for partial-result reporting.

+ */ public Resolution resolve(String token, String question, int topK) { - String raw = extractMentions(token, question); + requireActiveToken(token); + JsonNode extracted; try { - JsonNode json = objectMapper.readTree(raw); - String hint = json.path("scope_hint").asText("UNKNOWN"); - List all = new ArrayList<>(); - for (JsonNode mention : json.path("game_mentions")) { - all.addAll(search(token, mention.asText(""), topK)); - } - return new Resolution(hint, all); - } catch (Exception e) { throw new AppException("게임명 추출 결과 JSON 파싱 실패: " + e.getMessage()); } - } - - private String extractMentions(String token, String question) { - BackofficeProperties.SelectAi db = properties.selectAi(); - try (Connection c = DriverManager.getConnection(db.dbUrl(), db.dbUsername(), db.dbPassword()); - CallableStatement s = c.prepareCall("{ ? = call sg_game_extract_mentions(?) }")) { - s.registerOutParameter(1, Types.CLOB); s.setString(2, question); s.execute(); - Clob value = (Clob) s.getObject(1); - return value == null ? "{\"game_mentions\":[],\"scope_hint\":\"UNKNOWN\"}" - : value.getSubString(1, (int) value.length()); - } catch (SQLException e) { throw new AppException("ADB 게임명 추출 실패: " + e.getMessage()); } - } - - public List search(String token, String question, int topK) { - if (token == null || token.isBlank() || bearerTokenService.findByPlainToken(token.trim()) == null) { - throw new VpdTokenAccessDeniedException(); + extracted = objectMapper.readTree(extractMentions(question)); + } catch (Exception exception) { + throw new AppException( + "게임명 추출 결과 JSON 파싱 실패: " + exception.getMessage()); } - BackofficeProperties.SelectAi db = properties.selectAi(); - if (db == null || !db.configured()) throw new AppException("게임 카탈로그 DB 설정이 필요합니다."); + + String scopeType = extracted.path("scope_hint").asText("UNKNOWN"); + Map mentionsByNormalizedText = + new LinkedHashMap<>(); + Map supportedByGameKey = new LinkedHashMap<>(); + + for (JsonNode item : extracted.path("game_mentions")) { + String mention = item.asText("").trim(); + String normalizedMention = normalizeIdentity(mention); + if (normalizedMention.isEmpty() + || mentionsByNormalizedText.containsKey(normalizedMention)) { + continue; + } + + List candidates = search(token, mention, topK); + Optional matched = + selectMatchedCandidate(mention, candidates); + MentionResolution resolution; + if (matched.isPresent()) { + GameCandidate game = matched.get(); + supportedByGameKey.putIfAbsent(game.gameKey(), game); + resolution = new MentionResolution( + mention, "MATCHED", game, candidates); + } else { + resolution = new MentionResolution( + mention, "UNMATCHED", null, candidates); + } + mentionsByNormalizedText.put(normalizedMention, resolution); + } + + return new Resolution( + scopeType, + List.copyOf(supportedByGameKey.values()), + List.copyOf(mentionsByNormalizedText.values()) + ); + } + + private String extractMentions(String question) { + BackofficeProperties.SelectAi database = requiredDatabase(); + try (Connection connection = DriverManager.getConnection( + database.dbUrl(), database.dbUsername(), database.dbPassword()); + CallableStatement statement = connection.prepareCall( + "{ ? = call sg_game_extract_mentions(?) }")) { + statement.registerOutParameter(1, Types.CLOB); + statement.setString(2, question); + statement.execute(); + Clob value = (Clob) statement.getObject(1); + return value == null + ? "{\"game_mentions\":[],\"scope_hint\":\"UNKNOWN\"}" + : value.getSubString(1, (int) value.length()); + } catch (SQLException exception) { + throw new AppException( + "ADB 게임명 추출 실패: " + exception.getMessage()); + } + } + + public List search( + String token, String question, int topK + ) { + requireActiveToken(token); + BackofficeProperties.SelectAi database = requiredDatabase(); 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"))); + try (Connection connection = DriverManager.getConnection( + database.dbUrl(), database.dbUsername(), database.dbPassword()); + CallableStatement statement = connection.prepareCall( + "{ ? = call sg_game_catalog_search(?, ?) }")) { + statement.registerOutParameter(1, Types.REF_CURSOR); + statement.setString(2, question); + statement.setInt(3, Math.min(Math.max(topK, 1), 20)); + statement.execute(); + try (ResultSet rows = (ResultSet) statement.getObject(1)) { + while (rows.next()) { + result.add(new GameCandidate( + rows.getString("GAME_KEY"), + rows.getString("GAME_ID"), + rows.getString("GAME_PREFIX"), + rows.getString("GAME_NM"), + rows.getString("GAME_ALIAS_NM"), + rows.getDouble("COSINE_DISTANCE") + )); + } } return result; - } catch (SQLException e) { throw new AppException("게임 카탈로그 벡터 검색 실패: " + e.getMessage()); } + } catch (SQLException exception) { + throw new AppException( + "게임 카탈로그 벡터 검색 실패: " + exception.getMessage()); + } } - public record GameCandidate(String gameKey, String gameId, String gamePrefix, String gameName, String aliases, double similarity) {} - public record Resolution(String scopeType, List candidates) {} + static Optional selectMatchedCandidate( + String mention, List candidates + ) { + String normalizedMention = normalizeIdentity(mention); + if (normalizedMention.isEmpty()) { + return Optional.empty(); + } + return candidates.stream() + .filter(candidate -> identityFields(candidate) + .map(GameCatalogVectorService::normalizeIdentity) + .anyMatch(field -> field.contains(normalizedMention))) + .min(Comparator.comparingDouble(GameCandidate::similarity)); + } + + private static Stream identityFields(GameCandidate candidate) { + return Stream.of( + nullToEmpty(candidate.gameKey()), + nullToEmpty(candidate.gameId()), + nullToEmpty(candidate.gamePrefix()), + nullToEmpty(candidate.gameName()), + nullToEmpty(candidate.aliases()) + ); + } + + private static String normalizeIdentity(String value) { + if (value == null) { + return ""; + } + return value.toUpperCase(Locale.ROOT) + .replaceAll("[\\s\\p{Punct}]", ""); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private BackofficeProperties.SelectAi requiredDatabase() { + BackofficeProperties.SelectAi database = + properties == null ? null : properties.selectAi(); + if (database == null || !database.configured()) { + throw new AppException("게임 카탈로그 DB 설정이 필요합니다."); + } + return database; + } + + private void requireActiveToken(String token) { + if (token == null || token.isBlank() + || bearerTokenService == null + || bearerTokenService.findByPlainToken(token.trim()) == null) { + throw new VpdTokenAccessDeniedException(); + } + } + + public record GameCandidate( + String gameKey, + String gameId, + String gamePrefix, + String gameName, + String aliases, + double similarity + ) {} + + public record MentionResolution( + String mention, + String status, + GameCandidate matchedGame, + List candidates + ) {} + + public record Resolution( + String scopeType, + List candidates, + List mentions + ) { + public Resolution( + String scopeType, List candidates + ) { + this(scopeType, candidates, List.of()); + } + } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 4199e6c..f6d413b 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -261,17 +261,47 @@ public class McpSseService { private ObjectNode gameCatalogVectorResponse(GameCatalogVectorService.Resolution resolution) { ObjectNode response = objectMapper.createObjectNode(); response.put("scopeType", resolution.scopeType()); - response.put("status", resolution.candidates().isEmpty() ? "NO_MATCH" : "CANDIDATES"); + boolean hasUnmatched = resolution.mentions().stream() + .anyMatch(mention -> "UNMATCHED".equals(mention.status())); + response.put("status", resolution.candidates().isEmpty() + ? "NO_MATCH" : hasUnmatched ? "PARTIAL" : "CANDIDATES"); ArrayNode items = response.putArray("matchedGames"); for (GameCatalogVectorService.GameCandidate c : resolution.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()); + addGameCandidate(items.addObject(), c, true); + } + ArrayNode mentionResults = response.putArray("mentionResults"); + for (GameCatalogVectorService.MentionResolution mention + : resolution.mentions()) { + ObjectNode item = mentionResults.addObject(); + item.put("mention", mention.mention()); + item.put("status", mention.status()); + if (mention.matchedGame() != null) { + item.put("matchedGameKey", mention.matchedGame().gameKey()); + } + ArrayNode candidates = item.putArray("candidateGames"); + for (GameCatalogVectorService.GameCandidate candidate + : mention.candidates()) { + addGameCandidate(candidates.addObject(), candidate, false); + } } return response; } + private void addGameCandidate( + ObjectNode item, + GameCatalogVectorService.GameCandidate candidate, + boolean includeAliases + ) { + item.put("gameKey", candidate.gameKey()); + item.put("gameId", candidate.gameId()); + item.put("gamePrefix", candidate.gamePrefix()); + item.put("gameName", candidate.gameName()); + if (includeAliases) { + item.put("aliases", candidate.aliases()); + } + item.put("cosineDistance", candidate.similarity()); + } + private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) { ObjectNode response = objectMapper.createObjectNode(); response.put("status", "QA_VECTOR_STORED"); @@ -381,7 +411,12 @@ public class McpSseService { private ObjectNode gameQueryPlanResponse(GameCatalogVectorService.Resolution resolution) { ObjectNode response = gameCatalogVectorResponse(resolution); - response.put("status", resolution.candidates().isEmpty() ? "NO_MATCH" : "SUPPORTED"); + List unmatchedMentions = + resolution.mentions().stream() + .filter(mention -> "UNMATCHED".equals(mention.status())) + .toList(); + response.put("status", resolution.candidates().isEmpty() + ? "NO_MATCH" : unmatchedMentions.isEmpty() ? "SUPPORTED" : "PARTIAL"); ArrayNode supported = response.putArray("supportedGames"); for (GameCatalogVectorService.GameCandidate c : resolution.candidates()) { ObjectNode item = supported.addObject(); @@ -391,7 +426,13 @@ public class McpSseService { item.put("gamePrefix", c.gamePrefix()); item.put("cosineDistance", c.similarity()); } - response.putArray("unmatchedGames"); + ArrayNode unmatched = response.putArray("unmatchedGames"); + for (GameCatalogVectorService.MentionResolution mention + : unmatchedMentions) { + ObjectNode item = unmatched.addObject(); + item.put("mention", mention.mention()); + item.put("reasonCode", "NO_CATALOG_IDENTITY_MATCH"); + } response.put("nextAction", resolution.candidates().isEmpty() ? "STOP_NO_MATCH" : ("MULTI_GAME".equals(resolution.scopeType()) diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java new file mode 100644 index 0000000..8ceee78 --- /dev/null +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java @@ -0,0 +1,52 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class GameCatalogVectorServiceTest { + + private static final GameCatalogVectorService.GameCandidate CATALOG_GAME = + new GameCatalogVectorService.GameCandidate( + "GAME_KEY", + "GAME_ID", + "GME", + "Catalog Game", + "GME Catalog Game 카탈로그게임", + 0.31 + ); + + @Test + void acceptsCandidateWhoseCatalogIdentityContainsTheExtractedMention() { + var match = GameCatalogVectorService.selectMatchedCandidate( + "카탈로그 게임", List.of(CATALOG_GAME)); + + assertThat(match).contains(CATALOG_GAME); + } + + @Test + void rejectsNearestVectorCandidateWithoutCatalogIdentityMatch() { + var match = GameCatalogVectorService.selectMatchedCandidate( + "Unknown title", List.of(CATALOG_GAME)); + + assertThat(match).isEmpty(); + } + + @Test + void choosesClosestCandidateAmongIdentityMatches() { + var farther = new GameCatalogVectorService.GameCandidate( + "GAME_KEY_2", + "GAME_ID_2", + "GM2", + "Catalog Game Plus", + "Catalog Game Plus", + 0.44 + ); + + var match = GameCatalogVectorService.selectMatchedCandidate( + "Catalog Game", List.of(farther, CATALOG_GAME)); + + assertThat(match).contains(CATALOG_GAME); + } +} diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index b241292..69e1717 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -245,6 +245,26 @@ class McpSseServiceTest { .contains("REPORT_UNSUPPORTED"); } + @Test + void keepsUnmatchedMentionsInPartialGameQueryPlan() { + ObjectNode request = request(9, "tools/call"); + ObjectNode params = (ObjectNode) request.putObject("params"); + params.put("name", "oracle.select_ai.game_query_plan"); + params.putObject("arguments") + .put("question", "supported game and unknown game"); + + ObjectNode response = service.handle("default", request, "user-bearer"); + + assertThat(response.path("result").path("isError").asBoolean()).isFalse(); + String payload = response.path("result").path("content").get(0) + .path("text").asText(); + assertThat(payload) + .contains("\"status\" : \"PARTIAL\"") + .contains("\"gameKey\" : \"SUPPORTED_GAME\"") + .contains("\"mention\" : \"Unknown game\"") + .contains("\"reasonCode\" : \"NO_CATALOG_IDENTITY_MATCH\""); + } + private ObjectNode request(int id, String method) { ObjectNode request = objectMapper.createObjectNode(); request.put("jsonrpc", "2.0"); @@ -351,14 +371,40 @@ class McpSseServiceTest { @Override public Resolution resolve(String token, String question, int topK) { - return new Resolution("SINGLE_GAME", java.util.List.of(new GameCandidate( + GameCandidate supported = new GameCandidate( "SUPPORTED_GAME", "SUPPORTED_GAME_ID", "SUPPORTED_PREFIX", "Supported game", "Supported", 0.1 - ))); + ); + GameCandidate nearestUnknownCandidate = new GameCandidate( + "OTHER_GAME", + "OTHER_GAME_ID", + "OTHER_PREFIX", + "Other game", + "Other", + 0.6 + ); + return new Resolution( + "MULTI_GAME", + java.util.List.of(supported), + java.util.List.of( + new MentionResolution( + "Supported game", + "MATCHED", + supported, + java.util.List.of(supported) + ), + new MentionResolution( + "Unknown game", + "UNMATCHED", + null, + java.util.List.of(nearestUnknownCandidate) + ) + ) + ); } } }