refs #737: preserve unmatched game mentions in query plan

This commit is contained in:
devmrko
2026-07-27 16:25:33 +09:00
parent 3cc033e4e6
commit 8dffa2f515
5 changed files with 360 additions and 48 deletions

View File

@@ -46,6 +46,19 @@ Smilegate view는 전체 게임 마스터와 alias를 기준으로 하고, 현
`AMBIGUOUS`는 SQL 실행 없이 결과에 표시한다. `AMBIGUOUS`는 SQL 실행 없이 결과에 표시한다.
5. Few-shot worker는 scope token을 검증하고, token에 담긴 DB scope로만 prompt를 보강한다. 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 미연결 게임을 각각 반환하는지 확인한다. - view가 지원 게임과 object list 미연결 게임을 각각 반환하는지 확인한다.

View File

@@ -1,66 +1,226 @@
package com.cloudhandson.vpdbackoffice.service; package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; 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; import org.springframework.stereotype.Service;
@Service @Service
public class GameCatalogVectorService { public class GameCatalogVectorService {
private final BackofficeProperties properties; private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService; private final BearerTokenService bearerTokenService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public GameCatalogVectorService(BackofficeProperties properties, BearerTokenService bearerTokenService, ObjectMapper objectMapper) { public GameCatalogVectorService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
ObjectMapper objectMapper
) {
this.properties = properties; this.properties = properties;
this.bearerTokenService = bearerTokenService; this.bearerTokenService = bearerTokenService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
/**
* Resolves each extracted game mention independently.
*
* <p>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.</p>
*/
public Resolution resolve(String token, String question, int topK) { public Resolution resolve(String token, String question, int topK) {
String raw = extractMentions(token, question); requireActiveToken(token);
JsonNode extracted;
try { try {
JsonNode json = objectMapper.readTree(raw); extracted = objectMapper.readTree(extractMentions(question));
String hint = json.path("scope_hint").asText("UNKNOWN"); } catch (Exception exception) {
List<GameCandidate> all = new ArrayList<>(); throw new AppException(
for (JsonNode mention : json.path("game_mentions")) { "게임명 추출 결과 JSON 파싱 실패: " + exception.getMessage());
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) { String scopeType = extracted.path("scope_hint").asText("UNKNOWN");
BackofficeProperties.SelectAi db = properties.selectAi(); Map<String, MentionResolution> mentionsByNormalizedText =
try (Connection c = DriverManager.getConnection(db.dbUrl(), db.dbUsername(), db.dbPassword()); new LinkedHashMap<>();
CallableStatement s = c.prepareCall("{ ? = call sg_game_extract_mentions(?) }")) { Map<String, GameCandidate> supportedByGameKey = new LinkedHashMap<>();
s.registerOutParameter(1, Types.CLOB); s.setString(2, question); s.execute();
Clob value = (Clob) s.getObject(1); for (JsonNode item : extracted.path("game_mentions")) {
return value == null ? "{\"game_mentions\":[],\"scope_hint\":\"UNKNOWN\"}" String mention = item.asText("").trim();
String normalizedMention = normalizeIdentity(mention);
if (normalizedMention.isEmpty()
|| mentionsByNormalizedText.containsKey(normalizedMention)) {
continue;
}
List<GameCandidate> candidates = search(token, mention, topK);
Optional<GameCandidate> 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()); : value.getSubString(1, (int) value.length());
} catch (SQLException e) { throw new AppException("ADB 게임명 추출 실패: " + e.getMessage()); } } catch (SQLException exception) {
throw new AppException(
"ADB 게임명 추출 실패: " + exception.getMessage());
}
} }
public List<GameCandidate> search(String token, String question, int topK) { public List<GameCandidate> search(
if (token == null || token.isBlank() || bearerTokenService.findByPlainToken(token.trim()) == null) { String token, String question, int topK
throw new VpdTokenAccessDeniedException(); ) {
} requireActiveToken(token);
BackofficeProperties.SelectAi db = properties.selectAi(); BackofficeProperties.SelectAi database = requiredDatabase();
if (db == null || !db.configured()) throw new AppException("게임 카탈로그 DB 설정이 필요합니다.");
List<GameCandidate> result = new ArrayList<>(); List<GameCandidate> result = new ArrayList<>();
try (Connection c = DriverManager.getConnection(db.dbUrl(), db.dbUsername(), db.dbPassword()); try (Connection connection = DriverManager.getConnection(
CallableStatement s = c.prepareCall("{ ? = call sg_game_catalog_search(?, ?) }")) { database.dbUrl(), database.dbUsername(), database.dbPassword());
s.registerOutParameter(1, Types.REF_CURSOR); s.setString(2, question); s.setInt(3, Math.min(Math.max(topK, 1), 20)); s.execute(); CallableStatement statement = connection.prepareCall(
try (ResultSet rs = (ResultSet) s.getObject(1)) { "{ ? = call sg_game_catalog_search(?, ?) }")) {
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"))); 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; 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) {} static Optional<GameCandidate> selectMatchedCandidate(
public record Resolution(String scopeType, List<GameCandidate> candidates) {} String mention, List<GameCandidate> 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<String> 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<GameCandidate> candidates
) {}
public record Resolution(
String scopeType,
List<GameCandidate> candidates,
List<MentionResolution> mentions
) {
public Resolution(
String scopeType, List<GameCandidate> candidates
) {
this(scopeType, candidates, List.of());
}
}
} }

View File

@@ -261,17 +261,47 @@ public class McpSseService {
private ObjectNode gameCatalogVectorResponse(GameCatalogVectorService.Resolution resolution) { private ObjectNode gameCatalogVectorResponse(GameCatalogVectorService.Resolution resolution) {
ObjectNode response = objectMapper.createObjectNode(); ObjectNode response = objectMapper.createObjectNode();
response.put("scopeType", resolution.scopeType()); 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"); ArrayNode items = response.putArray("matchedGames");
for (GameCatalogVectorService.GameCandidate c : resolution.candidates()) { for (GameCatalogVectorService.GameCandidate c : resolution.candidates()) {
ObjectNode item = items.addObject(); addGameCandidate(items.addObject(), c, true);
item.put("gameKey", c.gameKey()); item.put("gameId", c.gameId()); }
item.put("gamePrefix", c.gamePrefix()); item.put("gameName", c.gameName()); ArrayNode mentionResults = response.putArray("mentionResults");
item.put("aliases", c.aliases()); item.put("cosineDistance", c.similarity()); 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; 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) { private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) {
ObjectNode response = objectMapper.createObjectNode(); ObjectNode response = objectMapper.createObjectNode();
response.put("status", "QA_VECTOR_STORED"); response.put("status", "QA_VECTOR_STORED");
@@ -381,7 +411,12 @@ public class McpSseService {
private ObjectNode gameQueryPlanResponse(GameCatalogVectorService.Resolution resolution) { private ObjectNode gameQueryPlanResponse(GameCatalogVectorService.Resolution resolution) {
ObjectNode response = gameCatalogVectorResponse(resolution); ObjectNode response = gameCatalogVectorResponse(resolution);
response.put("status", resolution.candidates().isEmpty() ? "NO_MATCH" : "SUPPORTED"); List<GameCatalogVectorService.MentionResolution> 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"); ArrayNode supported = response.putArray("supportedGames");
for (GameCatalogVectorService.GameCandidate c : resolution.candidates()) { for (GameCatalogVectorService.GameCandidate c : resolution.candidates()) {
ObjectNode item = supported.addObject(); ObjectNode item = supported.addObject();
@@ -391,7 +426,13 @@ public class McpSseService {
item.put("gamePrefix", c.gamePrefix()); item.put("gamePrefix", c.gamePrefix());
item.put("cosineDistance", c.similarity()); 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() response.put("nextAction", resolution.candidates().isEmpty()
? "STOP_NO_MATCH" ? "STOP_NO_MATCH"
: ("MULTI_GAME".equals(resolution.scopeType()) : ("MULTI_GAME".equals(resolution.scopeType())

View File

@@ -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);
}
}

View File

@@ -245,6 +245,26 @@ class McpSseServiceTest {
.contains("REPORT_UNSUPPORTED"); .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) { private ObjectNode request(int id, String method) {
ObjectNode request = objectMapper.createObjectNode(); ObjectNode request = objectMapper.createObjectNode();
request.put("jsonrpc", "2.0"); request.put("jsonrpc", "2.0");
@@ -351,14 +371,40 @@ class McpSseServiceTest {
@Override @Override
public Resolution resolve(String token, String question, int topK) { 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",
"SUPPORTED_GAME_ID", "SUPPORTED_GAME_ID",
"SUPPORTED_PREFIX", "SUPPORTED_PREFIX",
"Supported game", "Supported game",
"Supported", "Supported",
0.1 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)
)
)
);
} }
} }
} }