refs #737: move game query planning into ADB MCP tool
This commit is contained in:
194
database/adb/81_sg_game_query_plan.sql
Normal file
194
database/adb/81_sg_game_query_plan.sql
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
-- OCI GenAI candidate selection. Vector distance only orders candidates; the
|
||||||
|
-- model may select one supplied game_key or explicitly reject all candidates.
|
||||||
|
CREATE OR REPLACE FUNCTION sg_game_match_candidate(
|
||||||
|
p_mention IN CLOB,
|
||||||
|
p_candidates_json IN CLOB
|
||||||
|
) RETURN CLOB AUTHID DEFINER
|
||||||
|
IS
|
||||||
|
v_prompt CLOB;
|
||||||
|
v_result CLOB;
|
||||||
|
BEGIN
|
||||||
|
v_prompt := 'Decide whether the extracted game-name mention refers to exactly '
|
||||||
|
|| 'one game in the supplied candidate array. Candidate vector distance is '
|
||||||
|
|| 'retrieval evidence only and must not establish identity. Compare the '
|
||||||
|
|| 'mention with candidate gameName, aliases, gameId, and gamePrefix. '
|
||||||
|
|| 'If one candidate clearly identifies the same game, return exactly '
|
||||||
|
|| '{"status":"MATCHED","game_key":"candidate key","reason":"short reason"}. '
|
||||||
|
|| 'If none clearly identifies the same game, return exactly '
|
||||||
|
|| '{"status":"UNMATCHED","game_key":null,"reason":"short reason"}. '
|
||||||
|
|| 'Never invent a game_key and never return a key absent from the supplied '
|
||||||
|
|| 'candidate array. Mention: '
|
||||||
|
|| DBMS_LOB.SUBSTR(p_mention, 1000, 1)
|
||||||
|
|| ' Candidates: '
|
||||||
|
|| DBMS_LOB.SUBSTR(p_candidates_json, 12000, 1);
|
||||||
|
v_result := DBMS_CLOUD_AI.GENERATE(
|
||||||
|
prompt => v_prompt,
|
||||||
|
profile_name => 'SGMP_POC_OCI_GPT54MINI',
|
||||||
|
action => 'chat'
|
||||||
|
);
|
||||||
|
RETURN v_result;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
-- Complete game query planning inside ADB. The MCP server only invokes this
|
||||||
|
-- function and returns its structured result.
|
||||||
|
CREATE OR REPLACE FUNCTION sg_game_query_plan(
|
||||||
|
p_question IN CLOB,
|
||||||
|
p_top_k IN PLS_INTEGER DEFAULT 5
|
||||||
|
) RETURN CLOB AUTHID DEFINER
|
||||||
|
IS
|
||||||
|
TYPE t_seen_game_keys IS TABLE OF BOOLEAN INDEX BY VARCHAR2(128);
|
||||||
|
|
||||||
|
v_extract_raw CLOB;
|
||||||
|
v_extract JSON_OBJECT_T;
|
||||||
|
v_mentions JSON_ARRAY_T;
|
||||||
|
v_scope_type VARCHAR2(30);
|
||||||
|
v_supported JSON_ARRAY_T := JSON_ARRAY_T();
|
||||||
|
v_unmatched JSON_ARRAY_T := JSON_ARRAY_T();
|
||||||
|
v_mention_results JSON_ARRAY_T := JSON_ARRAY_T();
|
||||||
|
v_seen_game_keys t_seen_game_keys;
|
||||||
|
v_result JSON_OBJECT_T := JSON_OBJECT_T();
|
||||||
|
|
||||||
|
v_mention VARCHAR2(1000);
|
||||||
|
v_candidates JSON_ARRAY_T;
|
||||||
|
v_candidate JSON_OBJECT_T;
|
||||||
|
v_decision_raw CLOB;
|
||||||
|
v_decision JSON_OBJECT_T;
|
||||||
|
v_decision_status VARCHAR2(30);
|
||||||
|
v_selected_key VARCHAR2(128);
|
||||||
|
v_reason VARCHAR2(4000);
|
||||||
|
v_matched_candidate JSON_OBJECT_T;
|
||||||
|
v_mention_result JSON_OBJECT_T;
|
||||||
|
v_unmatched_item JSON_OBJECT_T;
|
||||||
|
|
||||||
|
v_cursor SYS_REFCURSOR;
|
||||||
|
v_game_key VARCHAR2(128);
|
||||||
|
v_game_id VARCHAR2(128);
|
||||||
|
v_game_prefix VARCHAR2(128);
|
||||||
|
v_game_nm VARCHAR2(512);
|
||||||
|
v_game_alias_nm VARCHAR2(512);
|
||||||
|
v_cosine_distance NUMBER;
|
||||||
|
BEGIN
|
||||||
|
v_extract_raw := sg_game_extract_mentions(p_question);
|
||||||
|
v_extract := JSON_OBJECT_T.parse(v_extract_raw);
|
||||||
|
v_mentions := v_extract.get_array('game_mentions');
|
||||||
|
v_scope_type := NVL(v_extract.get_string('scope_hint'), 'UNKNOWN');
|
||||||
|
|
||||||
|
FOR i IN 0 .. v_mentions.get_size - 1 LOOP
|
||||||
|
v_mention := v_mentions.get_string(i);
|
||||||
|
v_candidates := JSON_ARRAY_T();
|
||||||
|
v_cursor := sg_game_catalog_search(
|
||||||
|
v_mention,
|
||||||
|
LEAST(GREATEST(NVL(p_top_k, 5), 1), 20)
|
||||||
|
);
|
||||||
|
|
||||||
|
LOOP
|
||||||
|
FETCH v_cursor INTO
|
||||||
|
v_game_key,
|
||||||
|
v_game_id,
|
||||||
|
v_game_prefix,
|
||||||
|
v_game_nm,
|
||||||
|
v_game_alias_nm,
|
||||||
|
v_cosine_distance;
|
||||||
|
EXIT WHEN v_cursor%NOTFOUND;
|
||||||
|
|
||||||
|
v_candidate := JSON_OBJECT_T();
|
||||||
|
v_candidate.put('gameKey', v_game_key);
|
||||||
|
v_candidate.put('gameId', v_game_id);
|
||||||
|
v_candidate.put('gamePrefix', v_game_prefix);
|
||||||
|
v_candidate.put('gameName', v_game_nm);
|
||||||
|
v_candidate.put('aliases', v_game_alias_nm);
|
||||||
|
v_candidate.put('cosineDistance', v_cosine_distance);
|
||||||
|
v_candidates.append(v_candidate);
|
||||||
|
END LOOP;
|
||||||
|
CLOSE v_cursor;
|
||||||
|
|
||||||
|
v_decision_raw := sg_game_match_candidate(
|
||||||
|
v_mention,
|
||||||
|
v_candidates.to_clob
|
||||||
|
);
|
||||||
|
v_decision := JSON_OBJECT_T.parse(v_decision_raw);
|
||||||
|
v_decision_status := UPPER(
|
||||||
|
NVL(v_decision.get_string('status'), 'UNMATCHED')
|
||||||
|
);
|
||||||
|
v_selected_key := v_decision.get_string('game_key');
|
||||||
|
v_reason := v_decision.get_string('reason');
|
||||||
|
v_matched_candidate := NULL;
|
||||||
|
|
||||||
|
IF v_decision_status = 'MATCHED' AND v_selected_key IS NOT NULL THEN
|
||||||
|
FOR j IN 0 .. v_candidates.get_size - 1 LOOP
|
||||||
|
v_candidate := TREAT(v_candidates.get(j) AS JSON_OBJECT_T);
|
||||||
|
IF v_candidate.get_string('gameKey') = v_selected_key THEN
|
||||||
|
v_matched_candidate := v_candidate;
|
||||||
|
EXIT;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_mention_result := JSON_OBJECT_T();
|
||||||
|
v_mention_result.put('mention', v_mention);
|
||||||
|
v_mention_result.put('candidateGames', v_candidates);
|
||||||
|
v_mention_result.put('reason', v_reason);
|
||||||
|
|
||||||
|
IF v_matched_candidate IS NOT NULL THEN
|
||||||
|
v_mention_result.put('status', 'MATCHED');
|
||||||
|
v_mention_result.put('reasonCode', 'LLM_CANDIDATE_MATCH');
|
||||||
|
v_mention_result.put('matchedGameKey', v_selected_key);
|
||||||
|
IF NOT v_seen_game_keys.EXISTS(v_selected_key) THEN
|
||||||
|
v_supported.append(v_matched_candidate);
|
||||||
|
v_seen_game_keys(v_selected_key) := TRUE;
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
v_mention_result.put('status', 'UNMATCHED');
|
||||||
|
IF v_decision_status = 'MATCHED' THEN
|
||||||
|
v_mention_result.put(
|
||||||
|
'reasonCode',
|
||||||
|
'LLM_SELECTED_UNKNOWN_CANDIDATE'
|
||||||
|
);
|
||||||
|
ELSE
|
||||||
|
v_mention_result.put('reasonCode', 'LLM_NO_CANDIDATE_MATCH');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_unmatched_item := JSON_OBJECT_T();
|
||||||
|
v_unmatched_item.put('mention', v_mention);
|
||||||
|
v_unmatched_item.put(
|
||||||
|
'reasonCode',
|
||||||
|
v_mention_result.get_string('reasonCode')
|
||||||
|
);
|
||||||
|
v_unmatched_item.put('reason', v_reason);
|
||||||
|
v_unmatched.append(v_unmatched_item);
|
||||||
|
END IF;
|
||||||
|
v_mention_results.append(v_mention_result);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
v_result.put('scopeType', v_scope_type);
|
||||||
|
IF v_supported.get_size = 0 THEN
|
||||||
|
v_result.put('status', 'NO_MATCH');
|
||||||
|
ELSIF v_unmatched.get_size > 0 THEN
|
||||||
|
v_result.put('status', 'PARTIAL');
|
||||||
|
ELSE
|
||||||
|
v_result.put('status', 'SUPPORTED');
|
||||||
|
END IF;
|
||||||
|
v_result.put('matchedGames', v_supported);
|
||||||
|
v_result.put('mentionResults', v_mention_results);
|
||||||
|
v_result.put('supportedGames', v_supported);
|
||||||
|
v_result.put('unmatchedGames', v_unmatched);
|
||||||
|
|
||||||
|
IF v_supported.get_size = 0 THEN
|
||||||
|
v_result.put('nextAction', 'STOP_NO_MATCH');
|
||||||
|
ELSIF v_scope_type = 'MULTI_GAME' THEN
|
||||||
|
v_result.put('nextAction', 'CALL_FEWSHOT_PER_GAME');
|
||||||
|
ELSE
|
||||||
|
v_result.put('nextAction', 'CALL_FEWSHOT');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
CASE v_scope_type
|
||||||
|
WHEN 'SINGLE_GAME' THEN v_result.put('executionMode', 'SINGLE');
|
||||||
|
WHEN 'MULTI_GAME' THEN v_result.put('executionMode', 'FAN_OUT');
|
||||||
|
WHEN 'ALL_GAMES' THEN v_result.put('executionMode', 'GROUPED');
|
||||||
|
ELSE v_result.put('executionMode', 'BLOCKED');
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
RETURN v_result.to_clob;
|
||||||
|
END;
|
||||||
|
/
|
||||||
@@ -50,10 +50,13 @@ Smilegate view는 전체 게임 마스터와 alias를 기준으로 하고, 현
|
|||||||
|
|
||||||
- ADB Chat이 반환한 `game_mentions`의 각 항목은 서로 독립적으로 판정한다. 벡터 검색 결과를
|
- ADB Chat이 반환한 `game_mentions`의 각 항목은 서로 독립적으로 판정한다. 벡터 검색 결과를
|
||||||
하나의 목록으로 합쳐 모든 후보를 지원 게임으로 취급하지 않는다.
|
하나의 목록으로 합쳐 모든 후보를 지원 게임으로 취급하지 않는다.
|
||||||
- 벡터 검색은 후보를 찾는 단계다. 후보의 카탈로그 명칭, 별칭, `GAME_ID`, `GAME_PREFIX` 중
|
- 벡터 검색은 후보를 찾는 단계다. ADB OCI GenAI가 추출 명칭과 후보의 카탈로그 명칭,
|
||||||
추출 명칭과 정규화 매칭되는 항목만 `supportedGames`에 넣는다.
|
별칭, `GAME_ID`, `GAME_PREFIX`를 비교해 후보 중 하나를 선택하거나 전체를 거절한다.
|
||||||
- 추출 명칭과 매칭되는 카탈로그 항목이 없으면 해당 원문 명칭을 `unmatchedGames`에 남긴다.
|
- 애플리케이션은 모델이 반환한 `gameKey`가 실제 후보 목록에 있을 때만
|
||||||
유사도 순위가 높다는 이유만으로 다른 게임에 대입하지 않는다.
|
`supportedGames`에 넣는다. 후보 목록에 없는 식별자는 거절한다.
|
||||||
|
- 모델이 모든 후보를 거절하면 해당 원문 명칭을 `unmatchedGames`에 남긴다. 유사도 순위가
|
||||||
|
높다는 이유만으로 다른 게임에 대입하지 않는다.
|
||||||
|
- 게임 판정에 정규식, 부분문자열 매칭, 유사도 임계값을 사용하지 않는다.
|
||||||
- 동일 게임이 여러 명칭으로 검색되더라도 `supportedGames`는 `gameKey` 기준으로 중복을
|
- 동일 게임이 여러 명칭으로 검색되더라도 `supportedGames`는 `gameKey` 기준으로 중복을
|
||||||
제거한다. `matchedGames`와 `unmatchedGames`에는 mention별 판정 근거를 유지한다.
|
제거한다. `matchedGames`와 `unmatchedGames`에는 mention별 판정 근거를 유지한다.
|
||||||
- 일부만 지원되는 복수 게임 질문은 `status=PARTIAL`로 반환하고, 지원 게임의 worker 실행과
|
- 일부만 지원되는 복수 게임 질문은 `status=PARTIAL`로 반환하고, 지원 게임의 worker 실행과
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||||
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 com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import java.sql.CallableStatement;
|
import java.sql.CallableStatement;
|
||||||
import java.sql.Clob;
|
import java.sql.Clob;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@@ -11,15 +12,17 @@ import java.sql.ResultSet;
|
|||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Types;
|
import java.sql.Types;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
@Service
|
||||||
public class GameCatalogVectorService {
|
public class GameCatalogVectorService {
|
||||||
|
|
||||||
@@ -37,76 +40,38 @@ public class GameCatalogVectorService {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public ObjectNode queryPlan(
|
||||||
* Resolves each extracted game mention independently.
|
String token, String question, int topK
|
||||||
*
|
) {
|
||||||
* <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) {
|
|
||||||
requireActiveToken(token);
|
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();
|
BackofficeProperties.SelectAi database = requiredDatabase();
|
||||||
try (Connection connection = DriverManager.getConnection(
|
try (Connection connection = DriverManager.getConnection(
|
||||||
database.dbUrl(), database.dbUsername(), database.dbPassword());
|
database.dbUrl(), database.dbUsername(), database.dbPassword());
|
||||||
CallableStatement statement = connection.prepareCall(
|
CallableStatement statement = connection.prepareCall(
|
||||||
"{ ? = call sg_game_extract_mentions(?) }")) {
|
"{ ? = call sg_game_query_plan(?, ?) }")) {
|
||||||
statement.registerOutParameter(1, Types.CLOB);
|
statement.registerOutParameter(1, Types.CLOB);
|
||||||
statement.setString(2, question);
|
statement.setString(2, question);
|
||||||
|
statement.setInt(3, boundedTopK(topK));
|
||||||
statement.execute();
|
statement.execute();
|
||||||
Clob value = (Clob) statement.getObject(1);
|
Clob value = (Clob) statement.getObject(1);
|
||||||
return value == null
|
String raw = value == null ? "" :
|
||||||
? "{\"game_mentions\":[],\"scope_hint\":\"UNKNOWN\"}"
|
value.getSubString(1, (int) value.length());
|
||||||
: 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) {
|
} catch (SQLException exception) {
|
||||||
throw new AppException(
|
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(?, ?) }")) {
|
"{ ? = call sg_game_catalog_search(?, ?) }")) {
|
||||||
statement.registerOutParameter(1, Types.REF_CURSOR);
|
statement.registerOutParameter(1, Types.REF_CURSOR);
|
||||||
statement.setString(2, question);
|
statement.setString(2, question);
|
||||||
statement.setInt(3, Math.min(Math.max(topK, 1), 20));
|
statement.setInt(3, boundedTopK(topK));
|
||||||
statement.execute();
|
statement.execute();
|
||||||
try (ResultSet rows = (ResultSet) statement.getObject(1)) {
|
try (ResultSet rows = (ResultSet) statement.getObject(1)) {
|
||||||
while (rows.next()) {
|
while (rows.next()) {
|
||||||
@@ -143,40 +108,8 @@ public class GameCatalogVectorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Optional<GameCandidate> selectMatchedCandidate(
|
static int boundedTopK(int topK) {
|
||||||
String mention, List<GameCandidate> candidates
|
return Math.min(Math.max(topK, 1), 20);
|
||||||
) {
|
|
||||||
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() {
|
private BackofficeProperties.SelectAi requiredDatabase() {
|
||||||
@@ -204,23 +137,4 @@ public class GameCatalogVectorService {
|
|||||||
String aliases,
|
String aliases,
|
||||||
double similarity
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,12 +200,12 @@ public class McpSseService {
|
|||||||
} else if (gameScopeTool) {
|
} else if (gameScopeTool) {
|
||||||
response = gameScopeResponse(gameScopeService.resolve(
|
response = gameScopeResponse(gameScopeService.resolve(
|
||||||
token, arguments.path("question").asText("")));
|
token, arguments.path("question").asText("")));
|
||||||
} else if (gameCatalogTool) {
|
} else if (gameCatalogTool || gameQueryPlanTool) {
|
||||||
response = gameCatalogVectorResponse(gameCatalogVectorService.resolve(
|
response = gameCatalogVectorService.queryPlan(
|
||||||
token, arguments.path("question").asText(""), arguments.path("topK").asInt(5)));
|
token,
|
||||||
} else if (gameQueryPlanTool) {
|
arguments.path("question").asText(""),
|
||||||
response = gameQueryPlanResponse(gameCatalogVectorService.resolve(
|
arguments.path("topK").asInt(5)
|
||||||
token, arguments.path("question").asText(""), arguments.path("topK").asInt(5)));
|
);
|
||||||
} else {
|
} else {
|
||||||
String prompt = arguments.path("prompt").asText("");
|
String prompt = arguments.path("prompt").asText("");
|
||||||
response = queryTool
|
response = queryTool
|
||||||
@@ -258,50 +258,6 @@ public class McpSseService {
|
|||||||
return response;
|
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) {
|
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");
|
||||||
@@ -409,44 +365,6 @@ public class McpSseService {
|
|||||||
-1L, "게임 질의 계획", SELECT_AI_TOOL_PATH);
|
-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() {
|
private String selectAiProfile() {
|
||||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||||
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
|
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
|
||||||
|
|||||||
@@ -2,51 +2,14 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class GameCatalogVectorServiceTest {
|
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
|
@Test
|
||||||
void acceptsCandidateWhoseCatalogIdentityContainsTheExtractedMention() {
|
void boundsCandidateCountPassedToTheAdbPlanningTool() {
|
||||||
var match = GameCatalogVectorService.selectMatchedCandidate(
|
assertThat(GameCatalogVectorService.boundedTopK(0)).isEqualTo(1);
|
||||||
"카탈로그 게임", List.of(CATALOG_GAME));
|
assertThat(GameCatalogVectorService.boundedTopK(3)).isEqualTo(3);
|
||||||
|
assertThat(GameCatalogVectorService.boundedTopK(100)).isEqualTo(20);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ class McpSseServiceTest {
|
|||||||
.contains("\"status\" : \"PARTIAL\"")
|
.contains("\"status\" : \"PARTIAL\"")
|
||||||
.contains("\"gameKey\" : \"SUPPORTED_GAME\"")
|
.contains("\"gameKey\" : \"SUPPORTED_GAME\"")
|
||||||
.contains("\"mention\" : \"Unknown game\"")
|
.contains("\"mention\" : \"Unknown game\"")
|
||||||
.contains("\"reasonCode\" : \"NO_CATALOG_IDENTITY_MATCH\"");
|
.contains("\"reasonCode\" : \"LLM_NO_CANDIDATE_MATCH\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode request(int id, String method) {
|
private ObjectNode request(int id, String method) {
|
||||||
@@ -370,41 +370,24 @@ class McpSseServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Resolution resolve(String token, String question, int topK) {
|
public ObjectNode queryPlan(String token, String question, int topK) {
|
||||||
GameCandidate supported = new GameCandidate(
|
ObjectNode plan = new ObjectMapper().createObjectNode();
|
||||||
"SUPPORTED_GAME",
|
plan.put("scopeType", "MULTI_GAME");
|
||||||
"SUPPORTED_GAME_ID",
|
plan.put("status", "PARTIAL");
|
||||||
"SUPPORTED_PREFIX",
|
plan.putArray("matchedGames")
|
||||||
"Supported game",
|
.addObject()
|
||||||
"Supported",
|
.put("gameKey", "SUPPORTED_GAME");
|
||||||
0.1
|
plan.putArray("mentionResults");
|
||||||
);
|
plan.putArray("supportedGames")
|
||||||
GameCandidate nearestUnknownCandidate = new GameCandidate(
|
.addObject()
|
||||||
"OTHER_GAME",
|
.put("gameKey", "SUPPORTED_GAME");
|
||||||
"OTHER_GAME_ID",
|
plan.putArray("unmatchedGames")
|
||||||
"OTHER_PREFIX",
|
.addObject()
|
||||||
"Other game",
|
.put("mention", "Unknown game")
|
||||||
"Other",
|
.put("reasonCode", "LLM_NO_CANDIDATE_MATCH");
|
||||||
0.6
|
plan.put("nextAction", "CALL_FEWSHOT_PER_GAME");
|
||||||
);
|
plan.put("executionMode", "FAN_OUT");
|
||||||
return new Resolution(
|
return plan;
|
||||||
"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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user