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/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java
index 6aed340..9db83ad 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java
+++ b/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/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
index 4199e6c..f6d413b 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
+++ b/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/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java
new file mode 100644
index 0000000..8ceee78
--- /dev/null
+++ b/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/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java
index b241292..69e1717 100644
--- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java
+++ b/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)
+ )
+ )
+ );
}
}
}