refs #737: preserve unmatched game mentions in query plan
This commit is contained in:
@@ -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.
|
||||
*
|
||||
* <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) {
|
||||
String raw = extractMentions(token, question);
|
||||
requireActiveToken(token);
|
||||
JsonNode extracted;
|
||||
try {
|
||||
JsonNode json = objectMapper.readTree(raw);
|
||||
String hint = json.path("scope_hint").asText("UNKNOWN");
|
||||
List<GameCandidate> 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<GameCandidate> 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<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(?) }")) {
|
||||
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<GameCandidate> search(
|
||||
String token, String question, int topK
|
||||
) {
|
||||
requireActiveToken(token);
|
||||
BackofficeProperties.SelectAi database = requiredDatabase();
|
||||
List<GameCandidate> 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<GameCandidate> candidates) {}
|
||||
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;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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();
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user