diff --git a/database/adb/77_sgmp_game_scope_resolver.sql b/database/adb/77_sgmp_game_scope_resolver.sql new file mode 100644 index 0000000..49a6d9f --- /dev/null +++ b/database/adb/77_sgmp_game_scope_resolver.sql @@ -0,0 +1,175 @@ +-- DB-backed game query scope contract for MCP orchestration. +-- +-- This script deliberately keeps game facts in database rows, not in application +-- code or Select AI instructions. The view combines the active game alias source +-- with an operator-maintained registry for known games that currently have no +-- approved query object. A zero-row business result is still queryable; only the +-- absence of an approved object makes a game scope unavailable. + +DECLARE + v_count PLS_INTEGER; +BEGIN + SELECT COUNT(*) INTO v_count + FROM user_tables + WHERE table_name = 'SG_GAME_SCOPE_REGISTRY'; + + IF v_count = 0 THEN + EXECUTE IMMEDIATE q'[ + CREATE TABLE sg_game_scope_registry ( + game_key VARCHAR2(128) NOT NULL, + display_name VARCHAR2(200) NOT NULL, + game_alias VARCHAR2(200) NOT NULL, + game_prefix VARCHAR2(30), + active_yn VARCHAR2(1) DEFAULT 'Y' NOT NULL, + alias_priority NUMBER(10) DEFAULT 100 NOT NULL, + source_type VARCHAR2(30) DEFAULT 'OPERATOR' NOT NULL, + work_dtm TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT sg_game_scope_registry_pk PRIMARY KEY (game_key, game_alias), + CONSTRAINT sg_game_scope_registry_active_ck CHECK (active_yn IN ('Y', 'N')) + )]'; + END IF; +END; +/ + +-- Seed only database facts needed to recognise currently unavailable games in +-- the customer QA catalogue. Customer game-master synchronization can replace +-- these rows without an application deployment. +MERGE INTO sg_game_scope_registry t +USING ( + SELECT 'LORDNINE' AS game_key, + utl_i18n.raw_to_char( + utl_encode.base64_decode(utl_raw.cast_to_raw('66Gc65Oc64KY7J24')), + 'AL32UTF8' + ) AS display_name, + utl_i18n.raw_to_char( + utl_encode.base64_decode(utl_raw.cast_to_raw('66Gc65Oc64KY7J24')), + 'AL32UTF8' + ) AS game_alias, + CAST(NULL AS VARCHAR2(30)) AS game_prefix, + 100 AS alias_priority + FROM dual + UNION ALL + SELECT 'BUBBLYZ', 'Bubblyz', 'Bubblyz', CAST(NULL AS VARCHAR2(30)), 100 FROM dual +) s +ON (t.game_key = s.game_key AND t.game_alias = s.game_alias) +WHEN MATCHED THEN UPDATE SET + t.display_name = s.display_name, + t.game_prefix = s.game_prefix, + t.active_yn = 'Y', + t.alias_priority = s.alias_priority, + t.source_type = 'OPERATOR', + t.work_dtm = SYSTIMESTAMP +WHEN NOT MATCHED THEN INSERT ( + game_key, display_name, game_alias, game_prefix, active_yn, alias_priority, source_type +) VALUES ( + s.game_key, s.display_name, s.game_alias, s.game_prefix, 'Y', s.alias_priority, 'OPERATOR' +); +/ + +CREATE OR REPLACE VIEW sg_game_query_scope_v AS +WITH existing_query_objects AS ( + -- USER_OBJECTS is the authoritative current-schema inventory. The profile + -- object list alone is not enough because a stale entry must not make a game + -- executable after its table or view has been removed or invalidated. + SELECT object_name + FROM user_objects + WHERE object_type IN ('TABLE', 'VIEW') + AND status = 'VALID' +), +profile_names AS ( + SELECT DISTINCT profile_name + FROM user_cloud_ai_profile_attributes + WHERE attribute_name = 'object_list' +), +profile_objects AS ( + SELECT DISTINCT p.profile_name, o.object_name + FROM user_cloud_ai_profile_attributes p, + JSON_TABLE( + p.attribute_value, + '$[*]' COLUMNS (object_name VARCHAR2(128) PATH '$.name') + ) o + INNER JOIN existing_query_objects e + ON e.object_name = o.object_name + WHERE p.attribute_name = 'object_list' +), +source_alias AS ( + SELECT game_id AS game_key, + game_nm AS display_name, + game_alias_nm AS game_alias, + game_prefix, + use_yn AS active_yn, + NVL(sort_order, 100) AS alias_priority, + 'GAME_ALIAS' AS source_type + FROM comn_game_alias_bas +), +all_alias AS ( + SELECT game_key, display_name, game_alias, game_prefix, active_yn, alias_priority, source_type + FROM source_alias + UNION ALL + SELECT r.game_key, r.display_name, r.game_alias, r.game_prefix, + r.active_yn, r.alias_priority, r.source_type + FROM sg_game_scope_registry r + WHERE NOT EXISTS ( + SELECT 1 + FROM source_alias a + WHERE a.game_key = r.game_key + AND a.game_alias = r.game_alias + ) +), +scope_object AS ( + SELECT n.profile_name, + a.game_key, + a.game_alias, + COUNT(p.object_name) AS approved_object_count + FROM profile_names n + CROSS JOIN all_alias a + LEFT JOIN profile_objects p + ON p.profile_name = n.profile_name + AND a.game_prefix IS NOT NULL + AND SUBSTR(p.object_name, 1, LENGTH(a.game_prefix) + 1) = a.game_prefix || '_' + GROUP BY n.profile_name, a.game_key, a.game_alias +) +SELECT o.profile_name, + a.game_key, + a.display_name, + a.game_alias, + a.game_prefix, + a.active_yn, + NVL(o.approved_object_count, 0) AS approved_object_count, + CASE + WHEN a.active_yn <> 'Y' THEN 'N' + WHEN NVL(o.approved_object_count, 0) > 0 THEN 'Y' + ELSE 'N' + END AS query_allowed_yn, + CASE + WHEN a.active_yn <> 'Y' THEN 'GAME_INACTIVE' + WHEN NVL(o.approved_object_count, 0) > 0 THEN 'APPROVED_OBJECT_AVAILABLE' + ELSE 'OBJECT_LIST_NOT_AVAILABLE' + END AS reason_code, + a.alias_priority, + a.source_type, + TO_CHAR(MAX(a.alias_priority) OVER (PARTITION BY a.game_key), 'FM999999990') AS scope_version + FROM all_alias a + LEFT JOIN scope_object o + ON o.game_key = a.game_key + AND o.game_alias = a.game_alias; +/ + +COMMENT ON TABLE sg_game_scope_registry IS + 'Operator-managed game aliases retained for scope resolution when a game has no approved query object.'; +COMMENT ON COLUMN sg_game_scope_registry.game_key IS + 'Stable game identifier used only by the DB-backed scope contract.'; +COMMENT ON COLUMN sg_game_scope_registry.game_alias IS + 'Question text alias matched by the resolver before any SQL worker is called.'; +COMMENT ON COLUMN sg_game_scope_registry.game_prefix IS + 'Optional data-object prefix. The scope view derives approved object availability from it.'; +COMMENT ON COLUMN sg_game_scope_registry.active_yn IS + 'Whether the game is eligible for scope resolution; inactive games are never executable.'; +COMMENT ON COLUMN sg_game_scope_registry.alias_priority IS + 'Database-defined ordering used to resolve overlapping aliases without application constants.'; +COMMENT ON COLUMN sg_game_query_scope_v.profile_name IS + 'Select AI profile whose current approved object list was used for this scope decision.'; +COMMENT ON COLUMN sg_game_query_scope_v.query_allowed_yn IS + 'Y only when the active game has at least one current Select AI approved and valid prefix-specific table or view.'; +COMMENT ON COLUMN sg_game_query_scope_v.reason_code IS + 'Database-derived explanation for scope availability returned to the MCP agent.'; diff --git a/database/adb/78_sgmp_game_alias_logical_joins.sql b/database/adb/78_sgmp_game_alias_logical_joins.sql new file mode 100644 index 0000000..d8f1592 --- /dev/null +++ b/database/adb/78_sgmp_game_alias_logical_joins.sql @@ -0,0 +1,36 @@ +-- Logical game-alias join guidance for Select AI. +-- +-- COMN_GAME_ALIAS_BAS intentionally has multiple alias rows per GAME_ID, so +-- GAME_ID cannot be modelled as a physical foreign key to that table. These +-- annotations describe the safe semantic relationship without asserting a +-- false database constraint or creating fan-out aggregation errors. + +DECLARE v_result VARCHAR2(4000); BEGIN + v_result := sgmp_set_annotation( + 'SGMP_POC', 'TABLE', 'COMN_SALES_TXN', NULL, + 'Logical game filter: COMN_SALES_TXN.GAME_ID is resolved through COMN_GAME_ALIAS_BAS. GAME_ID is not unique in the alias table because one game can have multiple aliases. For a game-name filter, use EXISTS against active aliases or join a DISTINCT GAME_ID alias subquery. Do not directly join all alias rows before SUM or COUNT because that can multiply fact rows.', + 'GAME_ALIAS_JOIN' + ); + dbms_output.put_line(v_result); +END; +/ + +DECLARE v_result VARCHAR2(4000); BEGIN + v_result := sgmp_set_annotation( + 'SGMP_POC', 'TABLE', 'COMN_REFUND_TXN', NULL, + 'Logical game filter: COMN_REFUND_TXN.GAME_ID is resolved through COMN_GAME_ALIAS_BAS. GAME_ID is not unique in the alias table because one game can have multiple aliases. For a game-name filter, use EXISTS against active aliases or join a DISTINCT GAME_ID alias subquery. Do not directly join all alias rows before SUM or COUNT because that can multiply fact rows.', + 'GAME_ALIAS_JOIN' + ); + dbms_output.put_line(v_result); +END; +/ + +DECLARE v_result VARCHAR2(4000); BEGIN + v_result := sgmp_set_annotation( + 'SGMP_POC', 'TABLE', 'COMN_SALES_PRODUCT_DISP_BAS', NULL, + 'Logical product scope: COMN_SALES_PRODUCT_DISP_BAS is keyed by GAME_ID and PRODUCT_ID. Resolve a natural-language game through COMN_GAME_ALIAS_BAS using EXISTS or a DISTINCT GAME_ID alias subquery before joining product data to transaction facts. The alias table has multiple aliases per GAME_ID and is not a physical foreign-key parent.', + 'GAME_ALIAS_JOIN' + ); + dbms_output.put_line(v_result); +END; +/ diff --git a/docs/design/731-sgmp-game-scope-resolver/README.md b/docs/design/731-sgmp-game-scope-resolver/README.md new file mode 100644 index 0000000..ea561d9 --- /dev/null +++ b/docs/design/731-sgmp-game-scope-resolver/README.md @@ -0,0 +1,69 @@ +# SGMP DB 기반 게임 범위 Resolver (#731) + +## 목표 + +복수 게임이 포함된 데이터 질문에서 애플리케이션 코드나 에이전트 지시문에 게임명, prefix, +테이블명을 넣지 않는다. DB가 제공하는 게임 범위 뷰를 먼저 조회하고, 조회 가능으로 판정된 +게임에만 기존 Few-shot NL2SQL MCP를 호출한다. + +## 범위와 원칙 + +- 기존 `oracle.select_ai.smilegate_fewshot_nl2sql`은 예제 검색, SQL 생성, 읽기 전용 실행을 + 담당하는 worker로 유지한다. +- 새 `game_scope_resolve` MCP는 SQL을 생성하거나 실행하지 않는다. +- 공통 백오피스는 환경변수로 지정된 DB view 이름과 MCP tool 이름만 안다. +- 게임명, alias, GAME_ID, GAME_PREFIX, 대상 object는 DB view의 데이터로만 결정한다. +- 지원 여부는 대상 날짜의 행 수가 아니라, 현재 승인된 조회 object가 존재하는지로 판정한다. + 데이터가 0건인 날도 정상 조회 범위다. + +## DB 공통 계약 + +고객 DB는 환경변수 `BACKOFFICE_GAME_SCOPE_VIEW`로 지정된 view를 제공한다. view는 아래 +별칭(column alias)을 반환한다. + +| Column | 의미 | +|---|---| +| `GAME_KEY` | 내부 게임 식별자 | +| `PROFILE_NAME` | 승인 object list를 판정한 Select AI profile | +| `DISPLAY_NAME` | 화면 표시용 정식 게임명 | +| `GAME_ALIAS` | 질문에서 찾을 게임명 또는 별칭 | +| `QUERY_ALLOWED_YN` | 승인된 조회 object 존재 여부 (`Y`/`N`) | +| `REASON_CODE` | 미지원 또는 보류 사유 코드 | +| `ALIAS_PRIORITY` | 동일/중첩 alias 정렬 우선순위 | +| `SCOPE_VERSION` | object list 변경 시 함께 갱신되는 버전 | + +Smilegate view는 전체 게임 마스터와 alias를 기준으로 하고, 현재 Select AI profile별 승인 object list와 +실제 object 존재 여부를 조합해 `QUERY_ALLOWED_YN`을 계산한다. 따라서 등록 게임이지만 현재 +조회 object가 없는 게임도 `N`으로 반환된다. + +## MCP와 ReAct 계약 + +1. 포털 ReAct는 게임 데이터 질의 전에 `game_scope_resolve(question)`를 호출한다. +2. resolver는 질문 문자열과 `GAME_ALIAS`를 정규화해 포함 관계를 찾고, 우선순위와 alias 길이로 + 중복을 제거한다. 동일 우선순위의 복수 게임은 `AMBIGUOUS`로 반환한다. +3. `QUERY_ALLOWED_YN=Y`인 scope에는 서명·만료된 opaque `scopeToken`과 worker tool 이름을 반환한다. +4. ReAct는 `nextAction=CALL_WORKER`인 항목만 Few-shot NL2SQL에 전달한다. `UNSUPPORTED`와 + `AMBIGUOUS`는 SQL 실행 없이 결과에 표시한다. +5. Few-shot worker는 scope token을 검증하고, token에 담긴 DB scope로만 prompt를 보강한다. + +## 검증 + +- view가 지원 게임과 object list 미연결 게임을 각각 반환하는지 확인한다. +- resolver MCP의 결과에 구체 게임/테이블 하드코딩이 없는지 확인한다. +- STD-06에서 미지원 게임은 worker가 호출되지 않고, 지원 게임 결과에는 few-shot 예제, 생성 SQL, + 실행 결과가 포함되는지 확인한다. +- 기존 단일 게임 Few-shot NL2SQL 및 미게임명 거절 guardrail 회귀를 확인한다. + +## 논리 조인 메타데이터 + +`COMN_GAME_ALIAS_BAS`는 하나의 게임에 여러 alias 행을 갖기 때문에 `GAME_ID`가 유일키가 +아니다. 따라서 공통 transaction table의 `GAME_ID`에 물리 FK를 추가하지 않는다. 대신 +`78_sgmp_game_alias_logical_joins.sql`이 fact table에 `GAME_ALIAS_JOIN` annotation을 추가한다. +이 annotation은 게임명 필터에서 `EXISTS` 또는 `DISTINCT GAME_ID` alias subquery를 사용하고, +alias 원본을 직접 조인해 집계 행을 늘리지 않도록 설명한다. Prefix 전용 테이블은 가짜 FK 없이 +기존 alias/prefix 소유 범위 annotation을 유지한다. + +## 보류 항목 + +전체 게임 마스터에 미지원 게임이 없다면 DB만으로 그 이름을 게임으로 식별할 수 없다. 이 경우 +고객 원천 게임 마스터를 view에 연결하는 작업이 선행되어야 하며, 모델 추측으로 보완하지 않는다.