refs #737: move game query planning into ADB MCP tool

This commit is contained in:
devmrko
2026-07-27 16:39:58 +09:00
parent 5a3b03060d
commit 8298ecb511
6 changed files with 263 additions and 288 deletions

View File

@@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.sql.CallableStatement;
import java.sql.Clob;
import java.sql.Connection;
@@ -11,15 +12,17 @@ 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;
/**
* MCP database gateway for the ADB-owned game query planning functions.
*
* <p>Game mention extraction, vector candidate retrieval, and OCI GenAI
* candidate selection run inside {@code SG_GAME_QUERY_PLAN}. Java validates
* authentication and transports the structured JSON result without applying
* game-name matching rules.</p>
*/
@Service
public class GameCatalogVectorService {
@@ -37,76 +40,38 @@ public class GameCatalogVectorService {
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 ObjectNode queryPlan(
String token, String question, int topK
) {
requireActiveToken(token);
JsonNode extracted;
try {
extracted = objectMapper.readTree(extractMentions(question));
} catch (Exception exception) {
throw new AppException(
"게임명 추출 결과 JSON 파싱 실패: " + exception.getMessage());
}
String scopeType = extracted.path("scope_hint").asText("UNKNOWN");
Map<String, MentionResolution> mentionsByNormalizedText =
new LinkedHashMap<>();
Map<String, GameCandidate> 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<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(?) }")) {
"{ ? = call sg_game_query_plan(?, ?) }")) {
statement.registerOutParameter(1, Types.CLOB);
statement.setString(2, question);
statement.setInt(3, boundedTopK(topK));
statement.execute();
Clob value = (Clob) statement.getObject(1);
return value == null
? "{\"game_mentions\":[],\"scope_hint\":\"UNKNOWN\"}"
: value.getSubString(1, (int) value.length());
String raw = value == null ? "" :
value.getSubString(1, (int) value.length());
JsonNode parsed = objectMapper.readTree(raw);
if (!(parsed instanceof ObjectNode plan)
|| !plan.hasNonNull("status")
|| !plan.path("supportedGames").isArray()
|| !plan.path("unmatchedGames").isArray()) {
throw new AppException("ADB 게임 질의 계획 응답 형식이 올바르지 않습니다.");
}
return plan;
} catch (SQLException exception) {
throw new AppException(
"ADB 게임명 추출 실패: " + exception.getMessage());
"ADB 게임 질의 계획 실패: " + exception.getMessage());
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException(
"ADB 게임 질의 계획 JSON 파싱 실패: " + exception.getMessage());
}
}
@@ -122,7 +87,7 @@ public class GameCatalogVectorService {
"{ ? = 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.setInt(3, boundedTopK(topK));
statement.execute();
try (ResultSet rows = (ResultSet) statement.getObject(1)) {
while (rows.next()) {
@@ -143,40 +108,8 @@ public class GameCatalogVectorService {
}
}
static Optional<GameCandidate> selectMatchedCandidate(
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;
static int boundedTopK(int topK) {
return Math.min(Math.max(topK, 1), 20);
}
private BackofficeProperties.SelectAi requiredDatabase() {
@@ -204,23 +137,4 @@ public class GameCatalogVectorService {
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

@@ -200,12 +200,12 @@ public class McpSseService {
} else if (gameScopeTool) {
response = gameScopeResponse(gameScopeService.resolve(
token, arguments.path("question").asText("")));
} else if (gameCatalogTool) {
response = gameCatalogVectorResponse(gameCatalogVectorService.resolve(
token, arguments.path("question").asText(""), arguments.path("topK").asInt(5)));
} else if (gameQueryPlanTool) {
response = gameQueryPlanResponse(gameCatalogVectorService.resolve(
token, arguments.path("question").asText(""), arguments.path("topK").asInt(5)));
} else if (gameCatalogTool || gameQueryPlanTool) {
response = gameCatalogVectorService.queryPlan(
token,
arguments.path("question").asText(""),
arguments.path("topK").asInt(5)
);
} else {
String prompt = arguments.path("prompt").asText("");
response = queryTool
@@ -258,50 +258,6 @@ public class McpSseService {
return response;
}
private ObjectNode gameCatalogVectorResponse(GameCatalogVectorService.Resolution resolution) {
ObjectNode response = objectMapper.createObjectNode();
response.put("scopeType", resolution.scopeType());
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()) {
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");
@@ -409,44 +365,6 @@ public class McpSseService {
-1L, "게임 질의 계획", SELECT_AI_TOOL_PATH);
}
private ObjectNode gameQueryPlanResponse(GameCatalogVectorService.Resolution resolution) {
ObjectNode response = gameCatalogVectorResponse(resolution);
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");
for (GameCatalogVectorService.GameCandidate c : resolution.candidates()) {
ObjectNode item = supported.addObject();
item.put("gameKey", c.gameKey());
item.put("gameId", c.gameId());
item.put("gameName", c.gameName());
item.put("gamePrefix", c.gamePrefix());
item.put("cosineDistance", c.similarity());
}
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())
? "CALL_FEWSHOT_PER_GAME" : "CALL_FEWSHOT"));
String mode = switch (resolution.scopeType()) {
case "SINGLE_GAME" -> "SINGLE";
case "MULTI_GAME" -> "FAN_OUT";
case "ALL_GAMES" -> "GROUPED";
default -> "BLOCKED";
};
response.put("executionMode", mode);
return response;
}
private String selectAiProfile() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {

View File

@@ -2,51 +2,14 @@ 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);
void boundsCandidateCountPassedToTheAdbPlanningTool() {
assertThat(GameCatalogVectorService.boundedTopK(0)).isEqualTo(1);
assertThat(GameCatalogVectorService.boundedTopK(3)).isEqualTo(3);
assertThat(GameCatalogVectorService.boundedTopK(100)).isEqualTo(20);
}
}

View File

@@ -262,7 +262,7 @@ class McpSseServiceTest {
.contains("\"status\" : \"PARTIAL\"")
.contains("\"gameKey\" : \"SUPPORTED_GAME\"")
.contains("\"mention\" : \"Unknown game\"")
.contains("\"reasonCode\" : \"NO_CATALOG_IDENTITY_MATCH\"");
.contains("\"reasonCode\" : \"LLM_NO_CANDIDATE_MATCH\"");
}
private ObjectNode request(int id, String method) {
@@ -370,41 +370,24 @@ class McpSseServiceTest {
}
@Override
public Resolution resolve(String token, String question, int topK) {
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)
)
)
);
public ObjectNode queryPlan(String token, String question, int topK) {
ObjectNode plan = new ObjectMapper().createObjectNode();
plan.put("scopeType", "MULTI_GAME");
plan.put("status", "PARTIAL");
plan.putArray("matchedGames")
.addObject()
.put("gameKey", "SUPPORTED_GAME");
plan.putArray("mentionResults");
plan.putArray("supportedGames")
.addObject()
.put("gameKey", "SUPPORTED_GAME");
plan.putArray("unmatchedGames")
.addObject()
.put("mention", "Unknown game")
.put("reasonCode", "LLM_NO_CANDIDATE_MATCH");
plan.put("nextAction", "CALL_FEWSHOT_PER_GAME");
plan.put("executionMode", "FAN_OUT");
return plan;
}
}
}