refs #739: unify Smilegate game target planning
This commit is contained in:
@@ -9,6 +9,7 @@ BEGIN
|
|||||||
game_prefix VARCHAR2(128),
|
game_prefix VARCHAR2(128),
|
||||||
game_nm VARCHAR2(512),
|
game_nm VARCHAR2(512),
|
||||||
game_alias_nm VARCHAR2(512),
|
game_alias_nm VARCHAR2(512),
|
||||||
|
user_master_object_name VARCHAR2(128),
|
||||||
search_text CLOB NOT NULL,
|
search_text CLOB NOT NULL,
|
||||||
embedding VECTOR(1536, FLOAT32),
|
embedding VECTOR(1536, FLOAT32),
|
||||||
active_yn CHAR(1) DEFAULT 'Y' NOT NULL,
|
active_yn CHAR(1) DEFAULT 'Y' NOT NULL,
|
||||||
@@ -21,6 +22,20 @@ EXCEPTION
|
|||||||
END;
|
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
|
MERGE INTO sg_game_catalog c
|
||||||
USING (
|
USING (
|
||||||
SELECT
|
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);
|
(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
|
COMMENT ON TABLE sg_game_catalog IS
|
||||||
'Customer-owned game catalog used to resolve query scope before NL2SQL.';
|
'Customer-owned game catalog used to resolve query scope before NL2SQL.';
|
||||||
COMMENT ON COLUMN sg_game_catalog.embedding IS
|
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(
|
CREATE OR REPLACE FUNCTION sg_game_catalog_search(
|
||||||
@@ -70,6 +168,7 @@ BEGIN
|
|||||||
);
|
);
|
||||||
OPEN v_result FOR
|
OPEN v_result FOR
|
||||||
SELECT game_key, game_id, game_prefix, game_nm, game_alias_nm,
|
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
|
VECTOR_DISTANCE(embedding, v_query, COSINE) AS cosine_distance
|
||||||
FROM sg_game_catalog
|
FROM sg_game_catalog
|
||||||
WHERE active_yn = 'Y' AND embedding IS NOT NULL
|
WHERE active_yn = 'Y' AND embedding IS NOT NULL
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ IS
|
|||||||
v_extract JSON_OBJECT_T;
|
v_extract JSON_OBJECT_T;
|
||||||
v_mentions JSON_ARRAY_T;
|
v_mentions JSON_ARRAY_T;
|
||||||
v_scope_type VARCHAR2(30);
|
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_supported JSON_ARRAY_T := JSON_ARRAY_T();
|
||||||
v_unmatched JSON_ARRAY_T := JSON_ARRAY_T();
|
v_unmatched JSON_ARRAY_T := JSON_ARRAY_T();
|
||||||
v_mention_results 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_reason VARCHAR2(4000);
|
||||||
v_matched_candidate JSON_OBJECT_T;
|
v_matched_candidate JSON_OBJECT_T;
|
||||||
v_mention_result JSON_OBJECT_T;
|
v_mention_result JSON_OBJECT_T;
|
||||||
|
v_target JSON_OBJECT_T;
|
||||||
v_unmatched_item JSON_OBJECT_T;
|
v_unmatched_item JSON_OBJECT_T;
|
||||||
|
|
||||||
v_cursor SYS_REFCURSOR;
|
v_cursor SYS_REFCURSOR;
|
||||||
@@ -67,6 +70,7 @@ IS
|
|||||||
v_game_prefix VARCHAR2(128);
|
v_game_prefix VARCHAR2(128);
|
||||||
v_game_nm VARCHAR2(512);
|
v_game_nm VARCHAR2(512);
|
||||||
v_game_alias_nm VARCHAR2(512);
|
v_game_alias_nm VARCHAR2(512);
|
||||||
|
v_user_master_object_name VARCHAR2(128);
|
||||||
v_cosine_distance NUMBER;
|
v_cosine_distance NUMBER;
|
||||||
BEGIN
|
BEGIN
|
||||||
v_extract_raw := sg_game_extract_mentions(p_question);
|
v_extract_raw := sg_game_extract_mentions(p_question);
|
||||||
@@ -74,6 +78,68 @@ BEGIN
|
|||||||
v_mentions := v_extract.get_array('game_mentions');
|
v_mentions := v_extract.get_array('game_mentions');
|
||||||
v_scope_type := NVL(v_extract.get_string('scope_hint'), 'UNKNOWN');
|
v_scope_type := NVL(v_extract.get_string('scope_hint'), 'UNKNOWN');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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
|
FOR i IN 0 .. v_mentions.get_size - 1 LOOP
|
||||||
v_mention := v_mentions.get_string(i);
|
v_mention := v_mentions.get_string(i);
|
||||||
v_candidates := JSON_ARRAY_T();
|
v_candidates := JSON_ARRAY_T();
|
||||||
@@ -89,6 +155,7 @@ BEGIN
|
|||||||
v_game_prefix,
|
v_game_prefix,
|
||||||
v_game_nm,
|
v_game_nm,
|
||||||
v_game_alias_nm,
|
v_game_alias_nm,
|
||||||
|
v_user_master_object_name,
|
||||||
v_cosine_distance;
|
v_cosine_distance;
|
||||||
EXIT WHEN v_cursor%NOTFOUND;
|
EXIT WHEN v_cursor%NOTFOUND;
|
||||||
|
|
||||||
@@ -98,6 +165,15 @@ BEGIN
|
|||||||
v_candidate.put('gamePrefix', v_game_prefix);
|
v_candidate.put('gamePrefix', v_game_prefix);
|
||||||
v_candidate.put('gameName', v_game_nm);
|
v_candidate.put('gameName', v_game_nm);
|
||||||
v_candidate.put('aliases', v_game_alias_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_candidate.put('cosineDistance', v_cosine_distance);
|
||||||
v_candidates.append(v_candidate);
|
v_candidates.append(v_candidate);
|
||||||
END LOOP;
|
END LOOP;
|
||||||
@@ -134,6 +210,14 @@ BEGIN
|
|||||||
v_mention_result.put('status', 'MATCHED');
|
v_mention_result.put('status', 'MATCHED');
|
||||||
v_mention_result.put('reasonCode', 'LLM_CANDIDATE_MATCH');
|
v_mention_result.put('reasonCode', 'LLM_CANDIDATE_MATCH');
|
||||||
v_mention_result.put('matchedGameKey', v_selected_key);
|
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
|
IF NOT v_seen_game_keys.EXISTS(v_selected_key) THEN
|
||||||
v_supported.append(v_matched_candidate);
|
v_supported.append(v_matched_candidate);
|
||||||
v_seen_game_keys(v_selected_key) := TRUE;
|
v_seen_game_keys(v_selected_key) := TRUE;
|
||||||
@@ -149,6 +233,24 @@ BEGIN
|
|||||||
v_mention_result.put('reasonCode', 'LLM_NO_CANDIDATE_MATCH');
|
v_mention_result.put('reasonCode', 'LLM_NO_CANDIDATE_MATCH');
|
||||||
END IF;
|
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 := JSON_OBJECT_T();
|
||||||
v_unmatched_item.put('mention', v_mention);
|
v_unmatched_item.put('mention', v_mention);
|
||||||
v_unmatched_item.put(
|
v_unmatched_item.put(
|
||||||
@@ -160,33 +262,34 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
v_mention_results.append(v_mention_result);
|
v_mention_results.append(v_mention_result);
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
|
||||||
v_result.put('scopeType', v_scope_type);
|
v_result.put('targetType', v_target_type);
|
||||||
IF v_supported.get_size = 0 THEN
|
v_result.put('scopeType', v_target_type);
|
||||||
v_result.put('status', 'NO_MATCH');
|
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
|
ELSIF v_unmatched.get_size > 0 THEN
|
||||||
v_result.put('status', 'PARTIAL');
|
v_result.put('status', 'PARTIAL');
|
||||||
ELSE
|
ELSE
|
||||||
v_result.put('status', 'SUPPORTED');
|
v_result.put('status', 'SUPPORTED');
|
||||||
END IF;
|
END IF;
|
||||||
|
v_result.put('targets', v_targets);
|
||||||
v_result.put('matchedGames', v_supported);
|
v_result.put('matchedGames', v_supported);
|
||||||
v_result.put('mentionResults', v_mention_results);
|
v_result.put('mentionResults', v_mention_results);
|
||||||
v_result.put('supportedGames', v_supported);
|
v_result.put('supportedGames', v_supported);
|
||||||
v_result.put('unmatchedGames', v_unmatched);
|
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');
|
v_result.put('nextAction', 'CALL_FEWSHOT');
|
||||||
END IF;
|
|
||||||
|
|
||||||
CASE v_scope_type
|
CASE v_target_type
|
||||||
WHEN 'SINGLE_GAME' THEN v_result.put('executionMode', 'SINGLE');
|
WHEN 'NONE' THEN v_result.put('executionMode', 'UNSCOPED');
|
||||||
WHEN 'MULTI_GAME' THEN v_result.put('executionMode', 'FAN_OUT');
|
WHEN 'SINGLE' THEN v_result.put('executionMode', 'SINGLE');
|
||||||
WHEN 'ALL_GAMES' THEN v_result.put('executionMode', 'GROUPED');
|
WHEN 'MULTI' THEN v_result.put('executionMode', 'COMBINED');
|
||||||
ELSE v_result.put('executionMode', 'BLOCKED');
|
WHEN 'ALL' THEN v_result.put('executionMode', 'ALL');
|
||||||
END CASE;
|
END CASE;
|
||||||
|
|
||||||
RETURN v_result.to_clob;
|
RETURN v_result.to_clob;
|
||||||
|
|||||||
123
docs/design/739-game-target-contract/README.md
Normal file
123
docs/design/739-game-target-contract/README.md
Normal file
@@ -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`
|
||||||
@@ -59,6 +59,8 @@ public class GameCatalogVectorService {
|
|||||||
JsonNode parsed = objectMapper.readTree(raw);
|
JsonNode parsed = objectMapper.readTree(raw);
|
||||||
if (!(parsed instanceof ObjectNode plan)
|
if (!(parsed instanceof ObjectNode plan)
|
||||||
|| !plan.hasNonNull("status")
|
|| !plan.hasNonNull("status")
|
||||||
|
|| !plan.hasNonNull("targetType")
|
||||||
|
|| !plan.path("targets").isArray()
|
||||||
|| !plan.path("supportedGames").isArray()
|
|| !plan.path("supportedGames").isArray()
|
||||||
|| !plan.path("unmatchedGames").isArray()) {
|
|| !plan.path("unmatchedGames").isArray()) {
|
||||||
throw new AppException("ADB 게임 질의 계획 응답 형식이 올바르지 않습니다.");
|
throw new AppException("ADB 게임 질의 계획 응답 형식이 올바르지 않습니다.");
|
||||||
@@ -97,6 +99,7 @@ public class GameCatalogVectorService {
|
|||||||
rows.getString("GAME_PREFIX"),
|
rows.getString("GAME_PREFIX"),
|
||||||
rows.getString("GAME_NM"),
|
rows.getString("GAME_NM"),
|
||||||
rows.getString("GAME_ALIAS_NM"),
|
rows.getString("GAME_ALIAS_NM"),
|
||||||
|
rows.getString("USER_MASTER_OBJECT_NAME"),
|
||||||
rows.getDouble("COSINE_DISTANCE")
|
rows.getDouble("COSINE_DISTANCE")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -135,6 +138,7 @@ public class GameCatalogVectorService {
|
|||||||
String gamePrefix,
|
String gamePrefix,
|
||||||
String gameName,
|
String gameName,
|
||||||
String aliases,
|
String aliases,
|
||||||
|
String userMasterObjectName,
|
||||||
double similarity
|
double similarity
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -109,6 +110,16 @@ public class GameScopeService {
|
|||||||
* name is embedded in application code.
|
* name is embedded in application code.
|
||||||
*/
|
*/
|
||||||
public boolean referencesGameScopedObject(String bearerToken, String sql) {
|
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<String> allowedPrefixes
|
||||||
|
) {
|
||||||
requireActiveToken(bearerToken);
|
requireActiveToken(bearerToken);
|
||||||
if (!gameScopeProperties.configured() || sql == null || sql.isBlank()) {
|
if (!gameScopeProperties.configured() || sql == null || sql.isBlank()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -124,11 +135,17 @@ public class GameScopeService {
|
|||||||
statement.setString(1, selectAi.profile());
|
statement.setString(1, selectAi.profile());
|
||||||
try (ResultSet rows = statement.executeQuery()) {
|
try (ResultSet rows = statement.executeQuery()) {
|
||||||
String normalizedSql = sql.toUpperCase(Locale.ROOT);
|
String normalizedSql = sql.toUpperCase(Locale.ROOT);
|
||||||
|
Set<String> 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()) {
|
while (rows.next()) {
|
||||||
String prefix = rows.getString("GAME_PREFIX");
|
String prefix = rows.getString("GAME_PREFIX");
|
||||||
if (prefix != null && Pattern.compile(
|
if (prefix != null && Pattern.compile(
|
||||||
"(?<![A-Z0-9_$#])" + Pattern.quote(prefix.toUpperCase(Locale.ROOT))
|
"(?<![A-Z0-9_$#])" + Pattern.quote(prefix.toUpperCase(Locale.ROOT))
|
||||||
+ "_[A-Z0-9_$#]+(?![A-Z0-9_$#])").matcher(normalizedSql).find()) {
|
+ "_[A-Z0-9_$#]+(?![A-Z0-9_$#])").matcher(normalizedSql).find()
|
||||||
|
&& !normalizedAllowed.contains(prefix.toUpperCase(Locale.ROOT))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,11 +143,13 @@ public class McpSseService {
|
|||||||
addStringProperty(properties, "prompt", promptDescription(), 4000);
|
addStringProperty(properties, "prompt", promptDescription(), 4000);
|
||||||
if (fewShotNl2SqlToolName().equals(toolView.name())) {
|
if (fewShotNl2SqlToolName().equals(toolView.name())) {
|
||||||
addStringProperty(properties, "scopeGameKey",
|
addStringProperty(properties, "scopeGameKey",
|
||||||
"선택 사항입니다. game_scope_resolve가 반환한 SUPPORTED gameKey만 전달하세요.", 128);
|
"하위 호환용 선택 값입니다. 새 흐름에서는 queryPlan 전체를 전달하고 비워 둡니다.", 128);
|
||||||
ObjectNode plan = properties.putObject("queryPlan");
|
ObjectNode plan = properties.putObject("queryPlan");
|
||||||
plan.put("type", "object");
|
plan.put("type", "object");
|
||||||
plan.put("description", "앞 단계 game_query_plan의 구조화된 결과입니다.");
|
plan.put("description",
|
||||||
plan.putObject("additionalProperties").put("type", "object");
|
"바로 앞 game_query_plan의 전체 결과입니다. targetType이 NONE, SINGLE, MULTI, ALL 중 "
|
||||||
|
+ "어느 값이어도 원 질문과 함께 그대로 전달합니다.");
|
||||||
|
plan.put("additionalProperties", true);
|
||||||
}
|
}
|
||||||
required.add("prompt");
|
required.add("prompt");
|
||||||
}
|
}
|
||||||
@@ -342,7 +344,12 @@ public class McpSseService {
|
|||||||
|
|
||||||
private McpToolView fewShotNl2SqlView() {
|
private McpToolView fewShotNl2SqlView() {
|
||||||
return new McpToolView(
|
return new McpToolView(
|
||||||
fewShotNl2SqlToolName(), fewShotNl2SqlToolDescription(), -1L,
|
fewShotNl2SqlToolName(),
|
||||||
|
fewShotNl2SqlToolDescription()
|
||||||
|
+ " game_query_plan의 targetType이 NONE, SINGLE, MULTI, ALL 중 어느 값이어도 "
|
||||||
|
+ "원 질문과 전체 queryPlan을 한 번 받아 단일 읽기 전용 SQL을 생성·실행합니다. "
|
||||||
|
+ "queryPlan에 없는 게임, prefix, 물리 객체를 추측하지 않습니다.",
|
||||||
|
-1L,
|
||||||
fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH);
|
fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +368,10 @@ public class McpSseService {
|
|||||||
|
|
||||||
private McpToolView gameQueryPlanView() {
|
private McpToolView gameQueryPlanView() {
|
||||||
return new McpToolView(GAME_QUERY_PLAN_TOOL,
|
return new McpToolView(GAME_QUERY_PLAN_TOOL,
|
||||||
"질문에서 게임 범위와 실행 모드를 판정합니다. SQL은 실행하지 않습니다.",
|
"항상 먼저 호출해 질문의 게임 대상을 NONE, SINGLE, MULTI, ALL로 판정합니다. "
|
||||||
|
+ "targets에는 DB 카탈로그의 게임 식별자와 승인된 사용자 마스터 물리 객체명이 포함됩니다. "
|
||||||
|
+ "어떤 targetType도 종료 조건이 아닙니다. 원 질문과 이 도구의 전체 결과를 "
|
||||||
|
+ "smilegate_fewshot_nl2sql의 prompt와 queryPlan에 한 번 전달하세요. SQL은 실행하지 않습니다.",
|
||||||
-1L, "게임 질의 계획", SELECT_AI_TOOL_PATH);
|
-1L, "게임 질의 계획", SELECT_AI_TOOL_PATH);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import java.sql.Statement;
|
|||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -36,7 +39,9 @@ public class SelectAiService {
|
|||||||
private static final String POLICY_PREFIX =
|
private static final String POLICY_PREFIX =
|
||||||
"Answer the original user question using the current approved object list and policy. "
|
"Answer the original user question using the current approved object list and policy. "
|
||||||
+ "If no game identifier resolves through game-alias metadata, do not select a prefix-specific object "
|
+ "If no game identifier resolves through game-alias metadata, do not select a prefix-specific object "
|
||||||
+ "and do not infer a default game. State that the required game identifier is missing instead.\n\n";
|
+ "and do not infer a default game. Continue a game-neutral question with approved common objects. "
|
||||||
|
+ "Only report a missing game identifier when the requested operation inherently requires a "
|
||||||
|
+ "game-specific object.\n\n";
|
||||||
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||||
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||||
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
||||||
@@ -84,22 +89,86 @@ public class SelectAiService {
|
|||||||
ResolvedExecutionScope scope = resolveExecutionScope(bearerToken, normalizedPrompt, scopeGameKey);
|
ResolvedExecutionScope scope = resolveExecutionScope(bearerToken, normalizedPrompt, scopeGameKey);
|
||||||
|
|
||||||
GameContext gameContext = resolveGameContext(bearerToken, normalizedPrompt);
|
GameContext gameContext = resolveGameContext(bearerToken, normalizedPrompt);
|
||||||
|
return generateAndExecutePrepared(
|
||||||
|
bearerToken,
|
||||||
|
normalizedPrompt,
|
||||||
|
selectAi,
|
||||||
|
gameContext.prompt(scope.prompt()),
|
||||||
|
gameContext.scopeType(),
|
||||||
|
gameContext.status(),
|
||||||
|
gameContext.candidates().size(),
|
||||||
|
scope,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode generateAndExecute(
|
||||||
|
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) {
|
||||||
|
if (priorToolContext == null || priorToolContext.isMissingNode()
|
||||||
|
|| priorToolContext.isNull()) {
|
||||||
|
return generateAndExecute(bearerToken, prompt, scopeGameKey);
|
||||||
|
}
|
||||||
|
requireActiveToken(bearerToken);
|
||||||
|
String normalizedPrompt = requiredPrompt(prompt);
|
||||||
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||||
|
QueryPlanContext queryPlan = requiredQueryPlan(priorToolContext);
|
||||||
|
ResolvedExecutionScope scope = queryPlan.scope(scopeGameKey);
|
||||||
|
return generateAndExecutePrepared(
|
||||||
|
bearerToken,
|
||||||
|
normalizedPrompt,
|
||||||
|
selectAi,
|
||||||
|
queryPlan.prompt(normalizedPrompt),
|
||||||
|
queryPlan.targetType(),
|
||||||
|
queryPlan.status(),
|
||||||
|
queryPlan.targetCount(),
|
||||||
|
scope,
|
||||||
|
queryPlan.allowedPrefixes(),
|
||||||
|
queryPlan
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode generateAndExecutePrepared(
|
||||||
|
String bearerToken,
|
||||||
|
String originalPrompt,
|
||||||
|
BackofficeProperties.SelectAi selectAi,
|
||||||
|
String executionPrompt,
|
||||||
|
String gameScopeType,
|
||||||
|
String gameScopeStatus,
|
||||||
|
int gameCandidateCount,
|
||||||
|
ResolvedExecutionScope scope,
|
||||||
|
Set<String> allowedPrefixes,
|
||||||
|
QueryPlanContext queryPlan
|
||||||
|
) {
|
||||||
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
|
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
|
||||||
bearerToken, selectAi, gameContext.prompt(scope.prompt()));
|
bearerToken, selectAi, executionPrompt);
|
||||||
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
|
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
|
||||||
String normalizedSql = validateReadOnlySql(generatedSql);
|
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);
|
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
||||||
ObjectNode response = objectMapper.createObjectNode();
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
response.put("status", "SHOWSQL_AND_EXECUTED");
|
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||||
response.put("profile", selectAi.profile());
|
response.put("profile", selectAi.profile());
|
||||||
response.put("originalPrompt", normalizedPrompt);
|
response.put("originalPrompt", originalPrompt);
|
||||||
response.put("gameScopeType", gameContext.scopeType());
|
response.put("gameScopeType", gameScopeType);
|
||||||
response.put("gameScopeStatus", gameContext.status());
|
response.put("gameScopeStatus", gameScopeStatus);
|
||||||
response.put("gameCandidateCount", gameContext.candidates().size());
|
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) {
|
if (scope.gameKey() != null) {
|
||||||
response.put("scopeGameKey", scope.gameKey());
|
response.put("scopeGameKey", scope.gameKey());
|
||||||
response.put("scopeDisplayName", scope.displayName());
|
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("fewShotStatus", enrichedPrompt.status());
|
||||||
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
|
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
|
||||||
@@ -115,11 +184,27 @@ public class SelectAiService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public JsonNode generateAndExecute(
|
private QueryPlanContext requiredQueryPlan(JsonNode plan) {
|
||||||
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) {
|
String targetType = plan.path("targetType").asText("").trim().toUpperCase(Locale.ROOT);
|
||||||
String context = priorToolContext == null || priorToolContext.isMissingNode()
|
if (!Set.of("NONE", "SINGLE", "MULTI", "ALL").contains(targetType)
|
||||||
|| priorToolContext.isNull() ? "" : "\n[PREVIOUS TOOL RESULT]\n" + priorToolContext;
|
|| !plan.path("targets").isArray()) {
|
||||||
return generateAndExecute(bearerToken, prompt + context, scopeGameKey);
|
throw new AppException(
|
||||||
|
"queryPlan은 targetType(NONE/SINGLE/MULTI/ALL)과 targets 배열이 필요합니다.");
|
||||||
|
}
|
||||||
|
Set<String> 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) {
|
private GameContext resolveGameContext(String bearerToken, String question) {
|
||||||
@@ -280,8 +365,8 @@ public class SelectAiService {
|
|||||||
return POLICY_PREFIX
|
return POLICY_PREFIX
|
||||||
+ "Resolve business terms and game names from the approved game-alias metadata before selecting a "
|
+ "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 "
|
+ "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 "
|
+ "is resolved, do not substitute an arbitrary game-scoped object. Keep common-object questions "
|
||||||
+ "and preserve the resolver status for the answer layer; do not invent a user-facing explanation.\n"
|
+ "game-neutral and preserve the resolver status for the answer layer.\n"
|
||||||
+ "Original user question:\n" + prompt;
|
+ "Original user question:\n" + prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +510,42 @@ public class SelectAiService {
|
|||||||
|
|
||||||
private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {}
|
private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {}
|
||||||
|
|
||||||
|
private record QueryPlanContext(
|
||||||
|
String targetType,
|
||||||
|
String status,
|
||||||
|
int targetCount,
|
||||||
|
Set<String> 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(
|
private record GameContext(
|
||||||
String scopeType, String status, List<GameCatalogVectorService.GameCandidate> candidates) {
|
String scopeType, String status, List<GameCatalogVectorService.GameCandidate> candidates) {
|
||||||
String prompt(String original) {
|
String prompt(String original) {
|
||||||
@@ -438,6 +559,8 @@ public class SelectAiService {
|
|||||||
.append("game_prefix: ").append(candidate.gamePrefix()).append('\n')
|
.append("game_prefix: ").append(candidate.gamePrefix()).append('\n')
|
||||||
.append("game_name: ").append(candidate.gameName()).append('\n')
|
.append("game_name: ").append(candidate.gameName()).append('\n')
|
||||||
.append("matched_aliases: ").append(candidate.aliases()).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');
|
.append("cosine_distance: ").append(candidate.similarity()).append('\n');
|
||||||
}
|
}
|
||||||
context.append("[GAME SCOPE METADATA]\n")
|
context.append("[GAME SCOPE METADATA]\n")
|
||||||
|
|||||||
@@ -98,7 +98,12 @@ class McpSseServiceTest {
|
|||||||
var fewShot = tools.get(4);
|
var fewShot = tools.get(4);
|
||||||
assertThat(fewShot.path("name").asText())
|
assertThat(fewShot.path("name").asText())
|
||||||
.isEqualTo("oracle.select_ai.test_fewshot_nl2sql");
|
.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);
|
var gameScope = tools.get(5);
|
||||||
assertThat(gameScope.path("name").asText())
|
assertThat(gameScope.path("name").asText())
|
||||||
@@ -110,6 +115,10 @@ class McpSseServiceTest {
|
|||||||
.isEqualTo("oracle.select_ai.game_catalog_resolve");
|
.isEqualTo("oracle.select_ai.game_catalog_resolve");
|
||||||
assertThat(tools.get(7).path("name").asText())
|
assertThat(tools.get(7).path("name").asText())
|
||||||
.isEqualTo("oracle.select_ai.game_query_plan");
|
.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
|
@Test
|
||||||
@@ -161,10 +170,22 @@ class McpSseServiceTest {
|
|||||||
ObjectNode request = request(7, "tools/call");
|
ObjectNode request = request(7, "tools/call");
|
||||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
ObjectNode params = (ObjectNode) request.putObject("params");
|
||||||
params.put("name", "oracle.select_ai.test_fewshot_nl2sql");
|
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");
|
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();
|
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
||||||
String payload = response.path("result").path("content").get(0).path("text").asText();
|
String payload = response.path("result").path("content").get(0).path("text").asText();
|
||||||
assertThat(payload)
|
assertThat(payload)
|
||||||
@@ -262,7 +283,9 @@ 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\" : \"LLM_NO_CANDIDATE_MATCH\"");
|
.contains("\"reasonCode\" : \"LLM_NO_CANDIDATE_MATCH\"")
|
||||||
|
.contains("\"targetType\" : \"MULTI\"")
|
||||||
|
.contains("\"nextAction\" : \"CALL_FEWSHOT\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode request(int id, String method) {
|
private ObjectNode request(int id, String method) {
|
||||||
@@ -277,6 +300,7 @@ class McpSseServiceTest {
|
|||||||
|
|
||||||
private String bearerToken;
|
private String bearerToken;
|
||||||
private String prompt;
|
private String prompt;
|
||||||
|
private JsonNode queryPlan;
|
||||||
private boolean showpromptCalled;
|
private boolean showpromptCalled;
|
||||||
|
|
||||||
private CapturingSelectAiService() {
|
private CapturingSelectAiService() {
|
||||||
@@ -302,6 +326,13 @@ class McpSseServiceTest {
|
|||||||
return generateAndExecute(bearerToken, prompt);
|
return generateAndExecute(bearerToken, prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public JsonNode generateAndExecute(
|
||||||
|
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) {
|
||||||
|
this.queryPlan = priorToolContext;
|
||||||
|
return generateAndExecute(bearerToken, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JsonNode generatePrompt(String bearerToken, String prompt) {
|
public JsonNode generatePrompt(String bearerToken, String prompt) {
|
||||||
this.bearerToken = bearerToken;
|
this.bearerToken = bearerToken;
|
||||||
@@ -372,8 +403,23 @@ class McpSseServiceTest {
|
|||||||
@Override
|
@Override
|
||||||
public ObjectNode queryPlan(String token, String question, int topK) {
|
public ObjectNode queryPlan(String token, String question, int topK) {
|
||||||
ObjectNode plan = new ObjectMapper().createObjectNode();
|
ObjectNode plan = new ObjectMapper().createObjectNode();
|
||||||
plan.put("scopeType", "MULTI_GAME");
|
plan.put("targetType", "MULTI");
|
||||||
|
plan.put("scopeType", "MULTI");
|
||||||
plan.put("status", "PARTIAL");
|
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")
|
plan.putArray("matchedGames")
|
||||||
.addObject()
|
.addObject()
|
||||||
.put("gameKey", "SUPPORTED_GAME");
|
.put("gameKey", "SUPPORTED_GAME");
|
||||||
@@ -385,8 +431,8 @@ class McpSseServiceTest {
|
|||||||
.addObject()
|
.addObject()
|
||||||
.put("mention", "Unknown game")
|
.put("mention", "Unknown game")
|
||||||
.put("reasonCode", "LLM_NO_CANDIDATE_MATCH");
|
.put("reasonCode", "LLM_NO_CANDIDATE_MATCH");
|
||||||
plan.put("nextAction", "CALL_FEWSHOT_PER_GAME");
|
plan.put("nextAction", "CALL_FEWSHOT");
|
||||||
plan.put("executionMode", "FAN_OUT");
|
plan.put("executionMode", "COMBINED");
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class SelectAiFewShotPromptTest {
|
|||||||
.contains("SELECT COUNT(*) AS AU_COUNT FROM APP_USER")
|
.contains("SELECT COUNT(*) AS AU_COUNT FROM APP_USER")
|
||||||
.contains("Original user question:\ncurrent active users")
|
.contains("Original user question:\ncurrent active users")
|
||||||
.contains("current approved object list and policy")
|
.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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user