From 271f764f458d3ff89e4d79071dd4ba4442104b39 Mon Sep 17 00:00:00 2001 From: devmrko Date: Wed, 29 Jul 2026 09:52:19 +0900 Subject: [PATCH] refs #739: formalize game query plan contract --- .../design/739-game-target-contract/README.md | 59 +++++++++++-- sql/adb/81_sg_game_query_plan.sql | 83 +++++++++++++++++-- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/docs/design/739-game-target-contract/README.md b/docs/design/739-game-target-contract/README.md index a086bbc..db58402 100644 --- a/docs/design/739-game-target-contract/README.md +++ b/docs/design/739-game-target-contract/README.md @@ -20,22 +20,64 @@ ## 3. 계약 -`game_query_plan`은 다음 필드를 반환한다. +`game_query_plan`은 ReAct가 판단에 사용할 고정 JSON 계약을 반환한다. 자연어 +설명, 후보 검색 원문, 기존 호환 필드는 진단 영역으로 분리하며 실행 판단은 아래 +권위 필드만 사용한다. + +```json +{ + "contractVersion": "1.0", + "scopeType": "MULTI", + "targets": [ + { + "mention": "사용자가 언급한 게임명", + "matchStatus": "MATCHED", + "dataStatus": "AVAILABLE", + "gameKey": "카탈로그 키 또는 null", + "gameId": "카탈로그 식별자 또는 null", + "userMasterObjectName": "승인 객체 또는 null" + } + ], + "dataEligibleTargets": [], + "unresolvedTargets": [] +} +``` + +고정 enum은 다음과 같다. + +- `scopeType`: `NONE`, `SINGLE`, `MULTI`, `ALL` +- `matchStatus`: `NOT_APPLICABLE`, `MATCHED`, `UNMATCHED` +- `dataStatus`: `NOT_APPLICABLE`, `AVAILABLE`, `UNAVAILABLE` + +`scopeType`은 질문에 언급된 범위만 나타낸다. 실행 가능성이나 SQL 조립 방식을 +암시하지 않는다. `targets`는 언급 순서를 유지하고, `dataEligibleTargets`는 현재 +질의에 사용할 수 있는 카탈로그 대상만, `unresolvedTargets`는 미매칭 또는 데이터 +객체가 없는 대상을 보존한다. ReAct는 이 계약 전체를 관찰한 뒤 공통 팩트 단일 +호출, 대상별 호출, 또는 빈 결과 응답을 선택한다. + +기존 API 호환 기간에는 아래 필드도 반환할 수 있지만 ReAct의 실행 판단에 사용하지 +않는다: `targetType`, `status`, `matchedGames`, `supportedGames`, +`mentionResults`, `dataIneligibleTargets`, `unmatchedGames`, `nextAction`, +`executionMode`. + +기존 필드의 의미는 다음과 같다. | 필드 | 내용 | |---|---| -| `targetType` | `NONE`, `SINGLE`, `MULTI`, `ALL` | -| `status` | `NO_TARGET`, `SUPPORTED`, `PARTIAL`, `UNMATCHED` | +| `targetType` | 호환용 `scopeType` 별칭 | +| `status` | 호환용 요약 상태. ReAct 실행 분기에 사용하지 않음 | | `targets` | 언급 순서를 보존한 게임 대상 배열 | -| `nextAction` | 항상 `CALL_FEWSHOT` | -| `executionMode` | `UNSCOPED`, `SINGLE`, `COMBINED`, `ALL` | +| `nextAction` | 호환용 안내. 실행을 강제하지 않음 | +| `executionMode` | 호환용 안내. 실행을 강제하지 않음 | 각 `targets` 항목은 다음 값을 가진다. | 필드 | 내용 | |---|---| | `mention` | 질문에서 추출한 게임명. `NONE`은 `null` | -| `status` | `NONE`, `MATCHED`, `UNMATCHED`, `CATALOG` | +| `status` | 호환용 대상 상태 | +| `matchStatus` | `NOT_APPLICABLE`, `MATCHED`, `UNMATCHED` | +| `dataStatus` | `NOT_APPLICABLE`, `AVAILABLE`, `UNAVAILABLE` | | `gameKey`, `gameId` | DB 카탈로그가 반환한 게임 식별자 | | `gamePrefix` | DB 카탈로그가 반환한 prefix | | `gameName`, `aliases` | 표시명과 별칭 | @@ -193,3 +235,8 @@ read-only·객체 범위 검증만 담당한다. 답변 이력 `422`는 `PASS`로 기록됐다. 직접 정답 실행 분기는 두지 않는다. - 2026-07-28: `NONE`을 질의 차단으로 해석하지 않도록 경계 예제에 논리 객체 역할을 부여하고, 공통 객체 질의에는 경계 SQL을 일반화하지 않는 프롬프트 규칙을 추가했다. +- 2026-07-29: ReAct가 자유 설명문이나 중복 요약 필드를 해석하지 않도록, 게임 범위 + 계획의 권위 JSON 계약을 `contractVersion`, `scopeType`, `targets[*].matchStatus`, + `targets[*].dataStatus`, `dataEligibleTargets`, `unresolvedTargets`로 정리했다. + 이 계약은 게임명·prefix·물리 객체를 애플리케이션에 고정하지 않으며, 다음 도구 호출 + 횟수와 방식은 ReAct가 현재 질문과 계획 결과로 판단한다. diff --git a/sql/adb/81_sg_game_query_plan.sql b/sql/adb/81_sg_game_query_plan.sql index 8f09aeb..e3d42f0 100644 --- a/sql/adb/81_sg_game_query_plan.sql +++ b/sql/adb/81_sg_game_query_plan.sql @@ -30,6 +30,23 @@ BEGIN END; / +-- A game can be known to the scope registry while not being an active alias +-- source for a common fact query. Keep that availability separate from a +-- role-specific physical-object availability such as a user-master table. +CREATE OR REPLACE FUNCTION sg_game_fact_scope_status( + p_game_id IN VARCHAR2 +) RETURN VARCHAR2 AUTHID DEFINER IS + v_exists NUMBER; +BEGIN + SELECT COUNT(*) + INTO v_exists + FROM SGMP_POC.comn_game_alias_bas + WHERE use_yn = 'Y' + AND game_id = p_game_id; + RETURN CASE WHEN v_exists > 0 THEN 'ACTIVE_ALIAS' ELSE 'REGISTRY_ONLY' END; +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( @@ -46,10 +63,16 @@ IS v_target_type VARCHAR2(20); v_targets JSON_ARRAY_T := JSON_ARRAY_T(); v_supported JSON_ARRAY_T := JSON_ARRAY_T(); + v_data_eligible JSON_ARRAY_T := JSON_ARRAY_T(); + v_data_ineligible JSON_ARRAY_T := JSON_ARRAY_T(); + v_unresolved 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_matched_count PLS_INTEGER := 0; + v_data_eligible_count PLS_INTEGER := 0; + v_unmatched_count PLS_INTEGER := 0; v_mention VARCHAR2(1000); v_candidates JSON_ARRAY_T; @@ -71,6 +94,7 @@ IS v_game_nm VARCHAR2(512); v_game_alias_nm VARCHAR2(512); v_user_master_object_name VARCHAR2(128); + v_fact_scope_status VARCHAR2(30); v_cosine_distance NUMBER; BEGIN v_extract_raw := sg_game_extract_mentions(p_question); @@ -92,13 +116,14 @@ BEGIN FOR game_row IN ( SELECT game_key, game_id, game_prefix, game_nm, game_alias_nm, user_master_object_name - FROM sg_game_catalog + FROM SGMP_POC.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('matchStatus', 'MATCHED'); v_target.put('gameKey', game_row.game_key); v_target.put('gameId', game_row.game_id); v_target.put('gamePrefix', game_row.game_prefix); @@ -113,18 +138,34 @@ BEGIN CASE WHEN game_row.user_master_object_name IS NULL THEN 'UNAVAILABLE' ELSE 'AVAILABLE' END ); + v_fact_scope_status := sg_game_fact_scope_status(game_row.game_id); + v_target.put('factScopeStatus', v_fact_scope_status); + v_target.put( + 'dataStatus', + CASE WHEN v_fact_scope_status = 'ACTIVE_ALIAS' + THEN 'AVAILABLE' ELSE 'UNAVAILABLE' 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); + v_matched_count := v_matched_count + 1; IF game_row.user_master_object_name IS NOT NULL THEN v_supported.append(v_target); END IF; + IF v_fact_scope_status = 'ACTIVE_ALIAS' THEN + v_data_eligible.append(v_target); + v_data_eligible_count := v_data_eligible_count + 1; + ELSE + v_data_ineligible.append(v_target); + v_unresolved.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('matchStatus', 'NOT_APPLICABLE'); v_target.put_null('gameKey'); v_target.put_null('gameId'); v_target.put_null('gamePrefix'); @@ -132,6 +173,8 @@ BEGIN v_target.put_null('aliases'); v_target.put_null('userMasterObjectName'); v_target.put('objectStatus', 'NOT_APPLICABLE'); + v_target.put('factScopeStatus', 'NOT_APPLICABLE'); + v_target.put('dataStatus', 'NOT_APPLICABLE'); v_target.put_null('cosineDistance'); v_target.put('reasonCode', 'NO_GAME_TARGET'); v_target.put( @@ -143,7 +186,7 @@ BEGIN 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_cursor := SGMP_POC.sg_game_catalog_search( v_mention, LEAST(GREATEST(NVL(p_top_k, 5), 1), 20) ); @@ -174,6 +217,10 @@ BEGIN CASE WHEN v_user_master_object_name IS NULL THEN 'UNAVAILABLE' ELSE 'AVAILABLE' END ); + v_candidate.put( + 'factScopeStatus', + sg_game_fact_scope_status(v_game_id) + ); v_candidate.put('cosineDistance', v_cosine_distance); v_candidates.append(v_candidate); END LOOP; @@ -214,12 +261,23 @@ BEGIN 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('matchStatus', 'MATCHED'); v_target.put('reasonCode', 'LLM_CANDIDATE_MATCH'); v_target.put('reason', v_reason); - v_targets.append(v_target); + v_matched_count := v_matched_count + 1; + IF v_matched_candidate.get_string('factScopeStatus') = 'ACTIVE_ALIAS' THEN + v_target.put('dataStatus', 'AVAILABLE'); + v_data_eligible.append(v_target); + v_data_eligible_count := v_data_eligible_count + 1; + ELSE + v_target.put('dataStatus', 'UNAVAILABLE'); + v_data_ineligible.append(v_target); + v_unresolved.append(v_target); + END IF; + v_targets.append(v_target); IF NOT v_seen_game_keys.EXISTS(v_selected_key) THEN - v_supported.append(v_matched_candidate); + v_supported.append(v_target); v_seen_game_keys(v_selected_key) := TRUE; END IF; ELSE @@ -236,6 +294,7 @@ BEGIN v_target := JSON_OBJECT_T(); v_target.put('mention', v_mention); v_target.put('status', 'UNMATCHED'); + v_target.put('matchStatus', 'UNMATCHED'); v_target.put_null('gameKey'); v_target.put_null('gameId'); v_target.put_null('gamePrefix'); @@ -243,6 +302,8 @@ BEGIN v_target.put_null('aliases'); v_target.put_null('userMasterObjectName'); v_target.put('objectStatus', 'UNAVAILABLE'); + v_target.put('factScopeStatus', 'UNAVAILABLE'); + v_target.put('dataStatus', 'UNAVAILABLE'); v_target.put_null('cosineDistance'); v_target.put( 'reasonCode', @@ -259,21 +320,24 @@ BEGIN ); v_unmatched_item.put('reason', v_reason); v_unmatched.append(v_unmatched_item); + v_unresolved.append(v_target); + v_unmatched_count := v_unmatched_count + 1; END IF; v_mention_results.append(v_mention_result); END LOOP; END IF; + v_result.put('contractVersion', '1.0'); 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 + ELSIF v_matched_count = 0 THEN v_result.put('status', 'UNMATCHED'); - ELSIF v_unmatched.get_size > 0 THEN + ELSIF v_data_eligible_count = 0 THEN + v_result.put('status', 'UNAVAILABLE'); + ELSIF v_unmatched_count > 0 THEN v_result.put('status', 'PARTIAL'); ELSE v_result.put('status', 'SUPPORTED'); @@ -282,6 +346,9 @@ BEGIN v_result.put('matchedGames', v_supported); v_result.put('mentionResults', v_mention_results); v_result.put('supportedGames', v_supported); + v_result.put('dataEligibleTargets', v_data_eligible); + v_result.put('unresolvedTargets', v_unresolved); + v_result.put('dataIneligibleTargets', v_data_ineligible); v_result.put('unmatchedGames', v_unmatched); v_result.put('nextAction', 'CALL_FEWSHOT');