From 00a939f9a1b7d4d9e9fff091ee365b787b7b64d6 Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 27 Jul 2026 16:39:58 +0900 Subject: [PATCH] refs #737: move game query planning into ADB MCP tool --- .../731-sgmp-game-scope-resolver/README.md | 11 +- sql/adb/81_sg_game_query_plan.sql | 194 ++++++++++++++++++ .../service/GameCatalogVectorService.java | 152 +++----------- .../vpdbackoffice/service/McpSseService.java | 94 +-------- .../service/GameCatalogVectorServiceTest.java | 45 +--- .../service/McpSseServiceTest.java | 55 ++--- 6 files changed, 263 insertions(+), 288 deletions(-) create mode 100644 sql/adb/81_sg_game_query_plan.sql diff --git a/docs/design/731-sgmp-game-scope-resolver/README.md b/docs/design/731-sgmp-game-scope-resolver/README.md index a19dd8f..cfb96bc 100644 --- a/docs/design/731-sgmp-game-scope-resolver/README.md +++ b/docs/design/731-sgmp-game-scope-resolver/README.md @@ -50,10 +50,13 @@ Smilegate view는 전체 게임 마스터와 alias를 기준으로 하고, 현 - ADB Chat이 반환한 `game_mentions`의 각 항목은 서로 독립적으로 판정한다. 벡터 검색 결과를 하나의 목록으로 합쳐 모든 후보를 지원 게임으로 취급하지 않는다. -- 벡터 검색은 후보를 찾는 단계다. 후보의 카탈로그 명칭, 별칭, `GAME_ID`, `GAME_PREFIX` 중 - 추출 명칭과 정규화 매칭되는 항목만 `supportedGames`에 넣는다. -- 추출 명칭과 매칭되는 카탈로그 항목이 없으면 해당 원문 명칭을 `unmatchedGames`에 남긴다. - 유사도 순위가 높다는 이유만으로 다른 게임에 대입하지 않는다. +- 벡터 검색은 후보를 찾는 단계다. ADB OCI GenAI가 추출 명칭과 후보의 카탈로그 명칭, + 별칭, `GAME_ID`, `GAME_PREFIX`를 비교해 후보 중 하나를 선택하거나 전체를 거절한다. +- 애플리케이션은 모델이 반환한 `gameKey`가 실제 후보 목록에 있을 때만 + `supportedGames`에 넣는다. 후보 목록에 없는 식별자는 거절한다. +- 모델이 모든 후보를 거절하면 해당 원문 명칭을 `unmatchedGames`에 남긴다. 유사도 순위가 + 높다는 이유만으로 다른 게임에 대입하지 않는다. +- 게임 판정에 정규식, 부분문자열 매칭, 유사도 임계값을 사용하지 않는다. - 동일 게임이 여러 명칭으로 검색되더라도 `supportedGames`는 `gameKey` 기준으로 중복을 제거한다. `matchedGames`와 `unmatchedGames`에는 mention별 판정 근거를 유지한다. - 일부만 지원되는 복수 게임 질문은 `status=PARTIAL`로 반환하고, 지원 게임의 worker 실행과 diff --git a/sql/adb/81_sg_game_query_plan.sql b/sql/adb/81_sg_game_query_plan.sql new file mode 100644 index 0000000..8de3ef2 --- /dev/null +++ b/sql/adb/81_sg_game_query_plan.sql @@ -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; +/ diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java index 9db83ad..a77675c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java @@ -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. + * + *

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.

+ */ @Service public class GameCatalogVectorService { @@ -37,76 +40,38 @@ public class GameCatalogVectorService { 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) { + 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 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(?) }")) { + "{ ? = 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 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; + 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 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 f6d413b..9f7f648 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -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 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()) { diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java index 8ceee78..d61139c 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorServiceTest.java @@ -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); } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index 69e1717..de2ceb6 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -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; } } }