From d13d6d48ca2d7bd1c92abca2aacfb1c11704a233 Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 28 Jul 2026 10:09:12 +0900 Subject: [PATCH] refs #739: unify Smilegate game target planning --- .../design/739-game-target-contract/README.md | 123 +++++++ sql/adb/79_sg_game_catalog_vector.sql | 121 ++++++- sql/adb/81_sg_game_query_plan.sql | 303 ++++++++++++------ .../service/GameCatalogVectorService.java | 4 + .../service/GameScopeService.java | 19 +- .../vpdbackoffice/service/McpSseService.java | 20 +- .../service/SelectAiService.java | 151 ++++++++- .../service/McpSseServiceTest.java | 58 +++- .../service/SelectAiFewShotPromptTest.java | 3 +- 9 files changed, 664 insertions(+), 138 deletions(-) create mode 100644 docs/design/739-game-target-contract/README.md diff --git a/docs/design/739-game-target-contract/README.md b/docs/design/739-game-target-contract/README.md new file mode 100644 index 0000000..615fe5d --- /dev/null +++ b/docs/design/739-game-target-contract/README.md @@ -0,0 +1,123 @@ +# Smilegate 게임 대상 계약 통합 설계 + +## 1. 배경 + +현재 `game_query_plan`은 게임을 찾지 못하면 `STOP_NO_MATCH`, 여러 게임이면 +`FAN_OUT`을 반환한다. Portal의 ReAct 모델이 이 지시를 종료 조건으로 해석하거나 +일부 게임만 다음 도구로 전달하면서 다음 문제가 발생했다. + +- 게임명이 없는 전체 매출 질의가 SQL 생성 전에 중단된다. +- 여러 게임 중 일부만 매칭되면 매칭된 게임의 조회도 생략될 수 있다. +- 계획 결과를 전달해도 NL2SQL worker가 질문 전체를 다시 단일 게임 벡터검색하여 + 범위를 축소할 수 있다. +- 게임별 사용자 마스터 물리 객체를 모델이 추측해야 한다. + +## 2. 목표 + +질문의 게임 대상을 `NONE`, `SINGLE`, `MULTI`, `ALL` 네 가지로 통일하고, +모든 유형에서 원 질문과 전체 계획 결과를 `smilegate_fewshot_nl2sql`에 한 번 +전달한다. 계획 단계는 조회 중단이나 SQL 조립을 결정하지 않는다. + +## 3. 계약 + +`game_query_plan`은 다음 필드를 반환한다. + +| 필드 | 내용 | +|---|---| +| `targetType` | `NONE`, `SINGLE`, `MULTI`, `ALL` | +| `status` | `NO_TARGET`, `SUPPORTED`, `PARTIAL`, `UNMATCHED` | +| `targets` | 언급 순서를 보존한 게임 대상 배열 | +| `nextAction` | 항상 `CALL_FEWSHOT` | +| `executionMode` | `UNSCOPED`, `SINGLE`, `COMBINED`, `ALL` | + +각 `targets` 항목은 다음 값을 가진다. + +| 필드 | 내용 | +|---|---| +| `mention` | 질문에서 추출한 게임명. `NONE`은 `null` | +| `status` | `NONE`, `MATCHED`, `UNMATCHED`, `CATALOG` | +| `gameKey`, `gameId` | DB 카탈로그가 반환한 게임 식별자 | +| `gamePrefix` | DB 카탈로그가 반환한 prefix | +| `gameName`, `aliases` | 표시명과 별칭 | +| `userMasterObjectName` | 현재 Select AI 승인 객체 중 해당 게임의 사용자 마스터 물리 객체. 없으면 `null` | +| `cosineDistance` | 후보 검색 거리. 적용되지 않으면 `null` | +| `reasonCode`, `reason` | 매칭 또는 미매칭 근거 | + +유형별 처리 방식은 다음과 같다. + +- `NONE`: 게임 대상 항목 하나를 모든 게임 필드가 `null`인 상태로 전달한다. + 공통 거래·기준 객체 질의는 계속하되 prefix 전용 객체를 임의 선택하지 않는다. +- `SINGLE`: 질문에서 언급된 대상 한 건을 전달한다. 미매칭이면 게임 필드가 + `null`인 대상도 그대로 다음 도구에 전달한다. +- `MULTI`: 언급된 대상 모두를 전달한다. 매칭·미매칭을 함께 보존한다. +- `ALL`: 게임 카탈로그의 활성 게임을 모두 반환한다. 사용자 마스터 물리 객체가 + 없는 게임도 `null`로 보존한다. + +## 4. DB 설계 + +`SG_GAME_CATALOG`에 `USER_MASTER_OBJECT_NAME`을 추가한다. + +- 값은 `COMN_GAME_ALIAS_BAS`/운영 게임 registry의 `GAME_PREFIX`와 현재 + `USER_CLOUD_AI_PROFILE_ATTRIBUTES.object_list`, `USER_OBJECTS`를 조합해 계산한다. +- 공통 정책이나 애플리케이션 코드에는 특정 게임명, prefix, 테이블명을 넣지 않는다. +- 운영 registry에만 존재하는 게임도 카탈로그에 동기화하고 임베딩을 생성한다. +- 게임별 물리 객체가 없으면 컬럼은 `null`이다. + +`SG_GAME_CATALOG_SEARCH`는 물리 객체명을 후보 결과에 포함한다. +`SG_GAME_QUERY_PLAN`은 추출된 mention마다 후보 선택 결과를 `targets`로 만들며, +`ALL`은 벡터 선택 없이 전체 카탈로그를 사용한다. + +## 5. MCP와 NL2SQL 설계 + +`game_query_plan` 도구 설명은 “항상 먼저 호출하고 결과를 원 질문과 함께 다음 +도구에 전달”하도록 바꾼다. `smilegate_fewshot_nl2sql`의 입력은 다음과 같다. + +- `prompt`: 사용자가 입력한 원 질문 +- `queryPlan`: 바로 앞 `game_query_plan`의 전체 JSON +- `scopeGameKey`: 하위 호환용 선택 값. 새 흐름에서는 사용하지 않는다. + +NL2SQL worker는 전달된 `queryPlan`을 권위 있는 게임 범위로 사용한다. +계획이 있으면 질문 전체를 다시 게임 카탈로그 벡터검색하지 않는다. + +- 공통 객체에 게임 식별 컬럼이 있으면 질문 의미에 따라 `IN`과 `GROUP BY`를 + 사용할 수 있다. +- 대상별 물리 객체가 다르면 제공된 객체만 사용해 `UNION ALL`할 수 있다. +- 물리 객체가 `null`인 대상을 임의 객체로 대체하지 않는다. +- `NONE`에서 생성 SQL이 prefix 전용 객체를 참조하면 실행 전에 차단한다. +- 최종 SQL은 기존과 같이 단일 `SELECT`/`WITH`, read-only transaction, + 최대 행수와 timeout 제한을 적용한다. + +## 6. Portal ReAct 설계 + +Portal은 도구 호출 순서를 Java/Python 조건문으로 강제하지 않는다. 외부 환경변수의 +system prompt와 MCP 도구 설명으로 다음 절차를 안내한다. + +1. `game_query_plan`을 호출한다. +2. `targetType`과 상관없이 원 질문, 전체 `queryPlan`으로 + `smilegate_fewshot_nl2sql`을 한 번 호출한다. +3. SQL, 실행 결과, 매칭·미매칭 대상을 함께 설명한다. + +## 7. 검증 + +운영 MCP에서 다음 네 질문군을 실제 호출한다. + +| 유형 | 검증 내용 | +|---|---| +| `NONE` | 게임명이 없는 전체 매출 질의가 공통 테이블로 실행되고 게임 필터가 임의 추가되지 않는다. | +| `SINGLE` | 한 게임의 AU 질의가 해당 게임 식별자/물리 객체로 실행된다. | +| `MULTI` | 지원·미지원 게임을 함께 질문해 지원 결과와 미지원 상태가 한 답변에 보존된다. | +| `ALL` | 전체 게임 질의가 전체 카탈로그 대상을 받아 공통 객체 그룹 또는 게임별 객체 결합 SQL로 실행된다. | + +추가 검증: + +- Maven 테스트 +- Portal Python 테스트 +- MCP `tools/list` schema/description 확인 +- 생성 SQL에서 `NONE`의 prefix 전용 객체 차단 확인 +- 운영 서비스 health와 Portal ReAct 실제 응답 확인 + +## 8. 변경 이력 + +- Redmine: `#739` +- Backoffice branch: `smilegate` +- Portal branch: `main` diff --git a/sql/adb/79_sg_game_catalog_vector.sql b/sql/adb/79_sg_game_catalog_vector.sql index 19d4c03..064cee7 100644 --- a/sql/adb/79_sg_game_catalog_vector.sql +++ b/sql/adb/79_sg_game_catalog_vector.sql @@ -4,16 +4,17 @@ BEGIN EXECUTE IMMEDIATE q'[ CREATE TABLE sg_game_catalog ( - game_key VARCHAR2(128) PRIMARY KEY, - game_id VARCHAR2(128) NOT NULL, - game_prefix VARCHAR2(128), - game_nm VARCHAR2(512), - game_alias_nm VARCHAR2(512), - search_text CLOB NOT NULL, - embedding VECTOR(1536, FLOAT32), - active_yn CHAR(1) DEFAULT 'Y' NOT NULL, - priority NUMBER DEFAULT 100 NOT NULL, - updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL + game_key VARCHAR2(128) PRIMARY KEY, + game_id VARCHAR2(128) NOT NULL, + game_prefix VARCHAR2(128), + game_nm VARCHAR2(512), + game_alias_nm VARCHAR2(512), + user_master_object_name VARCHAR2(128), + search_text CLOB NOT NULL, + embedding VECTOR(1536, FLOAT32), + active_yn CHAR(1) DEFAULT 'Y' NOT NULL, + priority NUMBER DEFAULT 100 NOT NULL, + updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL )]'; EXCEPTION WHEN OTHERS THEN @@ -21,6 +22,20 @@ EXCEPTION END; / +DECLARE + v_count PLS_INTEGER; +BEGIN + SELECT COUNT(*) INTO v_count + FROM user_tab_columns + WHERE table_name = 'SG_GAME_CATALOG' + AND column_name = 'USER_MASTER_OBJECT_NAME'; + IF v_count = 0 THEN + EXECUTE IMMEDIATE + 'ALTER TABLE sg_game_catalog ADD (user_master_object_name VARCHAR2(128))'; + END IF; +END; +/ + MERGE INTO sg_game_catalog c USING ( SELECT @@ -51,10 +66,93 @@ VALUES (s.game_key, s.game_id, s.game_prefix, s.game_nm, s.game_alias_nm, s.search_text); / +-- Keep operator-maintained games in the same canonical catalog. These rows +-- remain observable even when their current approved physical object is null. +MERGE INTO sg_game_catalog c +USING ( + SELECT + r.game_key, + r.game_key AS game_id, + MAX(r.game_prefix) AS game_prefix, + MAX(r.display_name) AS game_nm, + LISTAGG(r.game_alias, ' ') WITHIN GROUP (ORDER BY r.game_alias) AS game_alias_nm, + r.game_key || ' ' || MAX(NVL(r.display_name, '')) || ' ' + || LISTAGG(NVL(r.game_alias, ''), ' ') WITHIN GROUP (ORDER BY r.game_alias) + || ' ' || MAX(NVL(r.game_prefix, '')) AS search_text + FROM sg_game_scope_registry r + WHERE r.active_yn = 'Y' + AND NOT EXISTS ( + SELECT 1 + FROM comn_game_alias_bas a + WHERE a.use_yn = 'Y' + AND a.game_id = r.game_key + ) + GROUP BY r.game_key +) s +ON (c.game_key = s.game_key) +WHEN MATCHED THEN UPDATE SET + c.game_id = s.game_id, + c.game_prefix = s.game_prefix, + c.game_nm = s.game_nm, + c.game_alias_nm = s.game_alias_nm, + c.search_text = s.search_text, + c.active_yn = 'Y', + c.updated_at = SYSTIMESTAMP +WHEN NOT MATCHED THEN INSERT + (game_key, game_id, game_prefix, game_nm, game_alias_nm, search_text) +VALUES + (s.game_key, s.game_id, s.game_prefix, s.game_nm, s.game_alias_nm, s.search_text); +/ + +-- Resolve the physical user-master object from current valid objects and the +-- current Select AI object lists. No game, prefix, or object name is embedded +-- in this policy. +MERGE INTO sg_game_catalog c +USING ( + WITH approved_objects AS ( + SELECT DISTINCT UPPER(j.object_name) AS object_name + FROM user_cloud_ai_profile_attributes p, + JSON_TABLE( + p.attribute_value, + '$[*]' COLUMNS (object_name VARCHAR2(128) PATH '$.name') + ) j + INNER JOIN user_objects o + ON o.object_name = UPPER(j.object_name) + AND o.object_type IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW') + AND o.status = 'VALID' + WHERE p.attribute_name = 'object_list' + ) + SELECT c2.game_key, + MIN(a.object_name) AS user_master_object_name + FROM sg_game_catalog c2 + LEFT JOIN approved_objects a + ON c2.game_prefix IS NOT NULL + AND a.object_name = UPPER(c2.game_prefix || '_COMN_USER_MST') + GROUP BY c2.game_key +) s +ON (c.game_key = s.game_key) +WHEN MATCHED THEN UPDATE SET + c.user_master_object_name = s.user_master_object_name, + c.updated_at = SYSTIMESTAMP; +/ + +-- The catalog is small. Recompute embeddings after synchronization so changed +-- aliases and registry-only games cannot retain a stale or null vector. +UPDATE sg_game_catalog c + SET c.embedding = DBMS_VECTOR.UTL_TO_EMBEDDING( + c.search_text, + JSON(sg_qa_vector_params('search_document')) + ), + c.updated_at = SYSTIMESTAMP + WHERE c.active_yn = 'Y'; +/ + COMMENT ON TABLE sg_game_catalog IS 'Customer-owned game catalog used to resolve query scope before NL2SQL.'; COMMENT ON COLUMN sg_game_catalog.embedding IS - 'Vector representation of game names and aliases; populated by the configured embedding job.'; + 'Vector representation of game names and aliases, generated with the configured vector credential.'; +COMMENT ON COLUMN sg_game_catalog.user_master_object_name IS + 'Current valid Select AI-approved physical user-master object for this game; null when unavailable.'; / CREATE OR REPLACE FUNCTION sg_game_catalog_search( @@ -70,6 +168,7 @@ BEGIN ); OPEN v_result FOR SELECT game_key, game_id, game_prefix, game_nm, game_alias_nm, + user_master_object_name, VECTOR_DISTANCE(embedding, v_query, COSINE) AS cosine_distance FROM sg_game_catalog WHERE active_yn = 'Y' AND embedding IS NOT NULL diff --git a/sql/adb/81_sg_game_query_plan.sql b/sql/adb/81_sg_game_query_plan.sql index 8de3ef2..8f09aeb 100644 --- a/sql/adb/81_sg_game_query_plan.sql +++ b/sql/adb/81_sg_game_query_plan.sql @@ -43,6 +43,8 @@ IS v_extract JSON_OBJECT_T; v_mentions JSON_ARRAY_T; v_scope_type VARCHAR2(30); + v_target_type VARCHAR2(20); + v_targets JSON_ARRAY_T := JSON_ARRAY_T(); 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(); @@ -59,6 +61,7 @@ IS v_reason VARCHAR2(4000); v_matched_candidate JSON_OBJECT_T; v_mention_result JSON_OBJECT_T; + v_target JSON_OBJECT_T; v_unmatched_item JSON_OBJECT_T; v_cursor SYS_REFCURSOR; @@ -67,6 +70,7 @@ IS v_game_prefix VARCHAR2(128); v_game_nm VARCHAR2(512); v_game_alias_nm VARCHAR2(512); + v_user_master_object_name VARCHAR2(128); v_cosine_distance NUMBER; BEGIN v_extract_raw := sg_game_extract_mentions(p_question); @@ -74,119 +78,218 @@ BEGIN 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) - ); + IF v_scope_type = 'ALL_GAMES' THEN + v_target_type := 'ALL'; + ELSIF v_mentions.get_size = 0 THEN + v_target_type := 'NONE'; + ELSIF v_mentions.get_size = 1 THEN + v_target_type := 'SINGLE'; + ELSE + v_target_type := 'MULTI'; + END IF; - 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') + IF v_target_type = 'ALL' THEN + FOR game_row IN ( + SELECT game_key, game_id, game_prefix, game_nm, game_alias_nm, + user_master_object_name + FROM sg_game_catalog + WHERE active_yn = 'Y' + ORDER BY priority, game_key + ) LOOP + v_target := JSON_OBJECT_T(); + v_target.put_null('mention'); + v_target.put('status', 'CATALOG'); + v_target.put('gameKey', game_row.game_key); + v_target.put('gameId', game_row.game_id); + v_target.put('gamePrefix', game_row.game_prefix); + v_target.put('gameName', game_row.game_nm); + v_target.put('aliases', game_row.game_alias_nm); + v_target.put( + 'userMasterObjectName', + game_row.user_master_object_name + ); + v_target.put( + 'objectStatus', + CASE WHEN game_row.user_master_object_name IS NULL + THEN 'UNAVAILABLE' ELSE 'AVAILABLE' END + ); + v_target.put_null('cosineDistance'); + v_target.put('reasonCode', 'ALL_GAMES_CATALOG'); + v_target.put('reason', 'Active game returned from the database catalog.'); + v_targets.append(v_target); + IF game_row.user_master_object_name IS NOT NULL THEN + v_supported.append(v_target); + END IF; + END LOOP; + ELSIF v_target_type = 'NONE' THEN + v_target := JSON_OBJECT_T(); + v_target.put_null('mention'); + v_target.put('status', 'NONE'); + v_target.put_null('gameKey'); + v_target.put_null('gameId'); + v_target.put_null('gamePrefix'); + v_target.put_null('gameName'); + v_target.put_null('aliases'); + v_target.put_null('userMasterObjectName'); + v_target.put('objectStatus', 'NOT_APPLICABLE'); + v_target.put_null('cosineDistance'); + v_target.put('reasonCode', 'NO_GAME_TARGET'); + v_target.put( + 'reason', + 'The question does not select a particular game.' + ); + v_targets.append(v_target); + ELSE + 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) ); - 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'); + LOOP + FETCH v_cursor INTO + v_game_key, + v_game_id, + v_game_prefix, + v_game_nm, + v_game_alias_nm, + v_user_master_object_name, + 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( + 'userMasterObjectName', + v_user_master_object_name + ); + v_candidate.put( + 'objectStatus', + CASE WHEN v_user_master_object_name IS NULL + THEN 'UNAVAILABLE' ELSE 'AVAILABLE' END + ); + 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); + + v_target := JSON_OBJECT_T.parse(v_matched_candidate.to_clob); + v_target.put('mention', v_mention); + v_target.put('status', 'MATCHED'); + v_target.put('reasonCode', 'LLM_CANDIDATE_MATCH'); + v_target.put('reason', v_reason); + v_targets.append(v_target); + + 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_target := JSON_OBJECT_T(); + v_target.put('mention', v_mention); + v_target.put('status', 'UNMATCHED'); + v_target.put_null('gameKey'); + v_target.put_null('gameId'); + v_target.put_null('gamePrefix'); + v_target.put_null('gameName'); + v_target.put_null('aliases'); + v_target.put_null('userMasterObjectName'); + v_target.put('objectStatus', 'UNAVAILABLE'); + v_target.put_null('cosineDistance'); + v_target.put( + 'reasonCode', + v_mention_result.get_string('reasonCode') + ); + v_target.put('reason', v_reason); + v_targets.append(v_target); + + 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; + END IF; + + v_result.put('targetType', v_target_type); + v_result.put('scopeType', v_target_type); + v_result.put('extractScopeHint', v_scope_type); + IF v_target_type = 'NONE' THEN + v_result.put('status', 'NO_TARGET'); + ELSIF v_target_type = 'ALL' THEN + v_result.put('status', 'SUPPORTED'); + ELSIF v_supported.get_size = 0 THEN + v_result.put('status', 'UNMATCHED'); ELSIF v_unmatched.get_size > 0 THEN v_result.put('status', 'PARTIAL'); ELSE v_result.put('status', 'SUPPORTED'); END IF; + v_result.put('targets', v_targets); 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); + v_result.put('nextAction', 'CALL_FEWSHOT'); - 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'); + CASE v_target_type + WHEN 'NONE' THEN v_result.put('executionMode', 'UNSCOPED'); + WHEN 'SINGLE' THEN v_result.put('executionMode', 'SINGLE'); + WHEN 'MULTI' THEN v_result.put('executionMode', 'COMBINED'); + WHEN 'ALL' THEN v_result.put('executionMode', 'ALL'); END CASE; RETURN v_result.to_clob; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java index a77675c..4d57674 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java @@ -59,6 +59,8 @@ public class GameCatalogVectorService { JsonNode parsed = objectMapper.readTree(raw); if (!(parsed instanceof ObjectNode plan) || !plan.hasNonNull("status") + || !plan.hasNonNull("targetType") + || !plan.path("targets").isArray() || !plan.path("supportedGames").isArray() || !plan.path("unmatchedGames").isArray()) { throw new AppException("ADB 게임 질의 계획 응답 형식이 올바르지 않습니다."); @@ -97,6 +99,7 @@ public class GameCatalogVectorService { rows.getString("GAME_PREFIX"), rows.getString("GAME_NM"), rows.getString("GAME_ALIAS_NM"), + rows.getString("USER_MASTER_OBJECT_NAME"), rows.getDouble("COSINE_DISTANCE") )); } @@ -135,6 +138,7 @@ public class GameCatalogVectorService { String gamePrefix, String gameName, String aliases, + String userMasterObjectName, double similarity ) {} } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameScopeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameScopeService.java index 42645c2..a02766f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameScopeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameScopeService.java @@ -16,6 +16,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; import org.springframework.stereotype.Service; @@ -109,6 +110,16 @@ public class GameScopeService { * name is embedded in application code. */ public boolean referencesGameScopedObject(String bearerToken, String sql) { + return referencesGameScopedObjectOutsidePrefixes(bearerToken, sql, Set.of()); + } + + /** + * Returns true when SQL references a DB-declared game-prefix object whose + * prefix is not present in the authoritative query plan. + */ + public boolean referencesGameScopedObjectOutsidePrefixes( + String bearerToken, String sql, Set allowedPrefixes + ) { requireActiveToken(bearerToken); if (!gameScopeProperties.configured() || sql == null || sql.isBlank()) { return false; @@ -124,11 +135,17 @@ public class GameScopeService { statement.setString(1, selectAi.profile()); try (ResultSet rows = statement.executeQuery()) { String normalizedSql = sql.toUpperCase(Locale.ROOT); + Set normalizedAllowed = allowedPrefixes == null ? Set.of() + : allowedPrefixes.stream() + .filter(value -> value != null && !value.isBlank()) + .map(value -> value.trim().toUpperCase(Locale.ROOT)) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); while (rows.next()) { String prefix = rows.getString("GAME_PREFIX"); if (prefix != null && Pattern.compile( "(? allowedPrefixes, + QueryPlanContext queryPlan + ) { EnrichedPrompt enrichedPrompt = enrichWithFewShot( - bearerToken, selectAi, gameContext.prompt(scope.prompt())); + bearerToken, selectAi, executionPrompt); String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql"); String normalizedSql = validateReadOnlySql(generatedSql); + if (allowedPrefixes != null && gameScopeService != null + && gameScopeService.configured() + && gameScopeService.referencesGameScopedObjectOutsidePrefixes( + bearerToken, normalizedSql, allowedPrefixes)) { + throw new AppException( + "Select AI 생성 SQL이 게임 질의 계획에 없는 prefix 전용 객체를 참조했습니다."); + } QueryExecution execution = executeReadOnly(selectAi, normalizedSql); ObjectNode response = objectMapper.createObjectNode(); response.put("status", "SHOWSQL_AND_EXECUTED"); response.put("profile", selectAi.profile()); - response.put("originalPrompt", normalizedPrompt); - response.put("gameScopeType", gameContext.scopeType()); - response.put("gameScopeStatus", gameContext.status()); - response.put("gameCandidateCount", gameContext.candidates().size()); + response.put("originalPrompt", originalPrompt); + response.put("gameScopeType", gameScopeType); + response.put("gameScopeStatus", gameScopeStatus); + response.put("gameCandidateCount", gameCandidateCount); + if (queryPlan != null) { + response.put("queryPlanTargetType", queryPlan.targetType()); + response.put("queryPlanStatus", queryPlan.status()); + response.put("queryPlanTargetCount", queryPlan.targetCount()); + } if (scope.gameKey() != null) { response.put("scopeGameKey", scope.gameKey()); response.put("scopeDisplayName", scope.displayName()); - response.put("scopeStatus", "DB_REVALIDATED"); + response.put("scopeStatus", queryPlan == null + ? "DB_REVALIDATED" : "QUERY_PLAN_VALIDATED"); } response.put("fewShotStatus", enrichedPrompt.status()); response.put("fewShotExampleCount", enrichedPrompt.exampleCount()); @@ -115,11 +184,27 @@ public class SelectAiService { return response; } - public JsonNode generateAndExecute( - String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) { - String context = priorToolContext == null || priorToolContext.isMissingNode() - || priorToolContext.isNull() ? "" : "\n[PREVIOUS TOOL RESULT]\n" + priorToolContext; - return generateAndExecute(bearerToken, prompt + context, scopeGameKey); + private QueryPlanContext requiredQueryPlan(JsonNode plan) { + String targetType = plan.path("targetType").asText("").trim().toUpperCase(Locale.ROOT); + if (!Set.of("NONE", "SINGLE", "MULTI", "ALL").contains(targetType) + || !plan.path("targets").isArray()) { + throw new AppException( + "queryPlan은 targetType(NONE/SINGLE/MULTI/ALL)과 targets 배열이 필요합니다."); + } + Set allowedPrefixes = new LinkedHashSet<>(); + for (JsonNode target : plan.path("targets")) { + String prefix = target.path("gamePrefix").asText("").trim(); + if (!prefix.isEmpty()) { + allowedPrefixes.add(prefix.toUpperCase(Locale.ROOT)); + } + } + return new QueryPlanContext( + targetType, + plan.path("status").asText(""), + plan.path("targets").size(), + Set.copyOf(allowedPrefixes), + plan.deepCopy() + ); } private GameContext resolveGameContext(String bearerToken, String question) { @@ -280,8 +365,8 @@ public class SelectAiService { return POLICY_PREFIX + "Resolve business terms and game names from the approved game-alias metadata before selecting a " + "game-scoped object. A generic term such as common user means no particular game. If no game alias " - + "is resolved, do not substitute an arbitrary game-scoped object. Return a read-only no-match result " - + "and preserve the resolver status for the answer layer; do not invent a user-facing explanation.\n" + + "is resolved, do not substitute an arbitrary game-scoped object. Keep common-object questions " + + "game-neutral and preserve the resolver status for the answer layer.\n" + "Original user question:\n" + prompt; } @@ -425,6 +510,42 @@ public class SelectAiService { private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {} + private record QueryPlanContext( + String targetType, + String status, + int targetCount, + Set allowedPrefixes, + JsonNode plan + ) { + String prompt(String originalQuestion) { + return "Use the authoritative game query plan below without independently rematching " + + "the game scope. Always answer the original question with one read-only SQL statement. " + + "For NONE, keep the query game-neutral and never select a prefix-specific object. " + + "For SINGLE, use only the matched target metadata. For MULTI or ALL, use the supplied " + + "target identifiers with IN and GROUP BY when a common object fits the question, or " + + "combine only the supplied non-null physical objects with UNION ALL when separate " + + "objects are required. A target with a null physical object must remain unresolved and " + + "must never be substituted with another target's object. Do not invent identifiers, " + + "prefixes, or physical object names.\n" + + "[AUTHORITATIVE GAME QUERY PLAN]\n" + plan + + "\n[ORIGINAL USER QUESTION]\n" + originalQuestion; + } + + ResolvedExecutionScope scope(String requestedGameKey) { + String requested = requestedGameKey == null ? "" : requestedGameKey.trim(); + if (requested.isEmpty()) { + return new ResolvedExecutionScope("", null, null); + } + for (JsonNode target : plan.path("targets")) { + if (requested.equals(target.path("gameKey").asText(""))) { + return new ResolvedExecutionScope( + "", requested, target.path("gameName").asText(requested)); + } + } + throw new AppException("scopeGameKey가 queryPlan targets에 없습니다."); + } + } + private record GameContext( String scopeType, String status, List candidates) { String prompt(String original) { @@ -438,6 +559,8 @@ public class SelectAiService { .append("game_prefix: ").append(candidate.gamePrefix()).append('\n') .append("game_name: ").append(candidate.gameName()).append('\n') .append("matched_aliases: ").append(candidate.aliases()).append('\n') + .append("user_master_object_name: ") + .append(candidate.userMasterObjectName()).append('\n') .append("cosine_distance: ").append(candidate.similarity()).append('\n'); } context.append("[GAME SCOPE METADATA]\n") diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index de2ceb6..4d61673 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -98,7 +98,12 @@ class McpSseServiceTest { var fewShot = tools.get(4); assertThat(fewShot.path("name").asText()) .isEqualTo("oracle.select_ai.test_fewshot_nl2sql"); - assertThat(fewShot.path("description").asText()).contains("Few-shot"); + assertThat(fewShot.path("description").asText()) + .contains("Few-shot") + .contains("NONE, SINGLE, MULTI, ALL"); + assertThat(fewShot.path("inputSchema").path("properties") + .path("queryPlan").path("description").asText()) + .contains("NONE, SINGLE, MULTI, ALL"); var gameScope = tools.get(5); assertThat(gameScope.path("name").asText()) @@ -110,6 +115,10 @@ class McpSseServiceTest { .isEqualTo("oracle.select_ai.game_catalog_resolve"); assertThat(tools.get(7).path("name").asText()) .isEqualTo("oracle.select_ai.game_query_plan"); + assertThat(tools.get(7).path("description").asText()) + .contains("항상 먼저 호출") + .contains("NONE, SINGLE, MULTI, ALL") + .contains("smilegate_fewshot_nl2sql"); } @Test @@ -161,10 +170,22 @@ class McpSseServiceTest { ObjectNode request = request(7, "tools/call"); ObjectNode params = (ObjectNode) request.putObject("params"); params.put("name", "oracle.select_ai.test_fewshot_nl2sql"); - params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘"); + ObjectNode arguments = params.putObject("arguments"); + arguments.put("prompt", "전체 매출을 조회해 줘"); + ObjectNode plan = arguments.putObject("queryPlan"); + plan.put("targetType", "NONE"); + plan.put("status", "NO_TARGET"); + plan.putArray("targets").addObject() + .put("status", "NONE") + .putNull("gameKey") + .putNull("gamePrefix") + .putNull("userMasterObjectName"); ObjectNode response = service.handle("default", request, "user-bearer"); + CapturingSelectAiService agentService = (CapturingSelectAiService) selectAiService; + assertThat(agentService.prompt).isEqualTo("전체 매출을 조회해 줘"); + assertThat(agentService.queryPlan.path("targetType").asText()).isEqualTo("NONE"); assertThat(response.path("result").path("isError").asBoolean()).isFalse(); String payload = response.path("result").path("content").get(0).path("text").asText(); assertThat(payload) @@ -262,7 +283,9 @@ class McpSseServiceTest { .contains("\"status\" : \"PARTIAL\"") .contains("\"gameKey\" : \"SUPPORTED_GAME\"") .contains("\"mention\" : \"Unknown game\"") - .contains("\"reasonCode\" : \"LLM_NO_CANDIDATE_MATCH\""); + .contains("\"reasonCode\" : \"LLM_NO_CANDIDATE_MATCH\"") + .contains("\"targetType\" : \"MULTI\"") + .contains("\"nextAction\" : \"CALL_FEWSHOT\""); } private ObjectNode request(int id, String method) { @@ -277,6 +300,7 @@ class McpSseServiceTest { private String bearerToken; private String prompt; + private JsonNode queryPlan; private boolean showpromptCalled; private CapturingSelectAiService() { @@ -302,6 +326,13 @@ class McpSseServiceTest { return generateAndExecute(bearerToken, prompt); } + @Override + public JsonNode generateAndExecute( + String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) { + this.queryPlan = priorToolContext; + return generateAndExecute(bearerToken, prompt); + } + @Override public JsonNode generatePrompt(String bearerToken, String prompt) { this.bearerToken = bearerToken; @@ -372,8 +403,23 @@ class McpSseServiceTest { @Override public ObjectNode queryPlan(String token, String question, int topK) { ObjectNode plan = new ObjectMapper().createObjectNode(); - plan.put("scopeType", "MULTI_GAME"); + plan.put("targetType", "MULTI"); + plan.put("scopeType", "MULTI"); plan.put("status", "PARTIAL"); + plan.putArray("targets") + .addObject() + .put("mention", "Supported game") + .put("status", "MATCHED") + .put("gameKey", "SUPPORTED_GAME") + .put("gamePrefix", "SUPPORTED") + .put("userMasterObjectName", "SUPPORTED_COMN_USER_MST"); + plan.withArray("targets") + .addObject() + .put("mention", "Unknown game") + .put("status", "UNMATCHED") + .putNull("gameKey") + .putNull("gamePrefix") + .putNull("userMasterObjectName"); plan.putArray("matchedGames") .addObject() .put("gameKey", "SUPPORTED_GAME"); @@ -385,8 +431,8 @@ class McpSseServiceTest { .addObject() .put("mention", "Unknown game") .put("reasonCode", "LLM_NO_CANDIDATE_MATCH"); - plan.put("nextAction", "CALL_FEWSHOT_PER_GAME"); - plan.put("executionMode", "FAN_OUT"); + plan.put("nextAction", "CALL_FEWSHOT"); + plan.put("executionMode", "COMBINED"); return plan; } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java index 57df87b..a5b964d 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiFewShotPromptTest.java @@ -26,6 +26,7 @@ class SelectAiFewShotPromptTest { .contains("SELECT COUNT(*) AS AU_COUNT FROM APP_USER") .contains("Original user question:\ncurrent active users") .contains("current approved object list and policy") - .contains("do not infer a default game"); + .contains("do not infer a default game") + .contains("Continue a game-neutral question with approved common objects"); } }