Compare commits

...

2 Commits

Author SHA1 Message Date
devmrko
8b04fb4e89 refs #708: benchmark Llama game scope profile 2026-07-29 10:07:20 +09:00
devmrko
271f764f45 refs #739: formalize game query plan contract 2026-07-29 10:07:20 +09:00
5 changed files with 290 additions and 14 deletions

View File

@@ -46,3 +46,24 @@ Smilegate DATA & AI PoC는 Autonomous Database의 게임 데이터에서 Select
- `DBMS_CLOUD_AI.GENERATE(..., 'showsql')`이 읽기 전용 `SELECT` 또는 `WITH` SQL을 생성한다.
- 해당 SQL을 읽기 전용 트랜잭션에서 실행해 결과를 반환한다.
- 운영 MCP 응답의 `profile``SGMP_POC_OCI_GPT54MINI`이다.
## Llama 4 Scout 범위 판정 성능 비교
게임 범위 판정은 Text2SQL 실행 전 OCI Chat을 사용한다. 이 단계의 응답시간을
비교하기 위해 운영 프로파일을 교체하지 않고 별도 테스트 프로파일
`SGMP_POC_OCI_LLAMA4SCOUT`을 만든다.
| 구분 | 운영 | 비교 대상 |
| --- | --- | --- |
| 프로파일 | `SGMP_POC_OCI_GPT54MINI` | `SGMP_POC_OCI_LLAMA4SCOUT` |
| 모델 | `openai.gpt-5.4-mini` | `meta.llama-4-scout-17b-16e-instruct` |
| 인증·리전 | 기존 OCI credential·inference region 유지 | 동일 |
| object list·metadata | 운영 값 | 운영 값 복제 |
| 운영 트래픽 | 사용 | 사용하지 않음 |
`sql/adb/112_sgmp_select_ai_oci_llama4scout_profile.sql`은 기존 OCI 프로파일의
credential, region, compartment, object list와 metadata instructions를 복제하고
모델만 교체한다. 같은 질문을 두 프로파일에 각각 Chat 호출해 경과시간과 반환 JSON
형식을 기록한다. 품질·지연시간 결과를 검토하기 전에는
`sg_game_extract_mentions``sg_game_match_candidate`의 운영 프로파일을 바꾸지
않는다.

View File

@@ -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가 현재 질문과 계획 결과로 판단한다.

View File

@@ -0,0 +1,81 @@
-- Creates a non-operational comparison profile for Smilegate game-scope chat.
-- The active GPT profile remains unchanged. Provider credentials and profile
-- metadata are copied from it so the only comparison variable is the model.
DECLARE
v_exists PLS_INTEGER;
v_credential_name VARCHAR2(128);
v_region VARCHAR2(128);
v_compartment_id VARCHAR2(4000);
v_attributes CLOB;
v_attribute_json JSON_OBJECT_T := JSON_OBJECT_T();
BEGIN
SELECT COUNT(*)
INTO v_exists
FROM user_cloud_ai_profiles
WHERE profile_name = 'SGMP_POC_OCI_LLAMA4SCOUT';
IF v_exists > 0 THEN
DBMS_CLOUD_AI.DROP_PROFILE(
profile_name => 'SGMP_POC_OCI_LLAMA4SCOUT',
force => TRUE
);
END IF;
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
INTO v_credential_name
FROM user_cloud_ai_profile_attributes
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
AND attribute_name = 'credential_name';
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
INTO v_region
FROM user_cloud_ai_profile_attributes
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
AND attribute_name = 'region';
SELECT DBMS_LOB.SUBSTR(attribute_value, 4000, 1)
INTO v_compartment_id
FROM user_cloud_ai_profile_attributes
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
AND attribute_name = 'oci_compartment_id';
v_attribute_json.put('provider', 'oci');
v_attribute_json.put('credential_name', v_credential_name);
v_attribute_json.put('model', 'meta.llama-4-scout-17b-16e-instruct');
v_attribute_json.put('region', v_region);
v_attribute_json.put('oci_compartment_id', v_compartment_id);
v_attributes := v_attribute_json.to_clob;
DBMS_CLOUD_AI.CREATE_PROFILE(
profile_name => 'SGMP_POC_OCI_LLAMA4SCOUT',
attributes => v_attributes,
description => 'Non-operational Smilegate game-scope latency comparison'
);
FOR source_attribute IN (
SELECT attribute_name, attribute_value
FROM user_cloud_ai_profile_attributes
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
AND attribute_name NOT IN (
'credential_name', 'model', 'provider', 'provider_endpoint',
'region', 'oci_compartment_id', 'oci_endpoint_id',
'oci_apiformat', 'oci_runtimetype'
)
) LOOP
DBMS_CLOUD_AI.SET_ATTRIBUTE(
profile_name => 'SGMP_POC_OCI_LLAMA4SCOUT',
attribute_name => source_attribute.attribute_name,
attribute_value => source_attribute.attribute_value
);
END LOOP;
END;
/
SELECT attribute_name, attribute_value
FROM user_cloud_ai_profile_attributes
WHERE profile_name = 'SGMP_POC_OCI_LLAMA4SCOUT'
AND attribute_name IN (
'provider', 'model', 'credential_name', 'region', 'oci_compartment_id'
)
ORDER BY attribute_name;

View File

@@ -0,0 +1,60 @@
-- Read-only latency and JSON-shape comparison for the game-mention extraction
-- stage. Korean input is reconstructed from UTF-8 base64 for SQLcl safety.
set serveroutput on size unlimited
DECLARE
v_question CLOB := utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw(
'66Gc65Oc64KY7J247J20656RIOy5tOygnOuCmOydmCAyMDI264WEIDfsm5QgMTXsnbwgQVXrpbwg6rCB6rCBIOyVjOugpOykmC4='
)),
'AL32UTF8'
);
v_prompt CLOB;
v_result CLOB;
v_json JSON_OBJECT_T;
v_started PLS_INTEGER;
v_elapsed_seconds NUMBER;
PROCEDURE run_profile(p_profile_name IN VARCHAR2) IS
BEGIN
v_started := DBMS_UTILITY.GET_TIME;
v_result := DBMS_CLOUD_AI.GENERATE(
prompt => v_prompt,
profile_name => p_profile_name,
action => 'chat'
);
v_elapsed_seconds := (DBMS_UTILITY.GET_TIME - v_started) / 100;
v_json := JSON_OBJECT_T.parse(v_result);
DBMS_OUTPUT.PUT_LINE(
p_profile_name
|| '|elapsed_seconds=' || TO_CHAR(v_elapsed_seconds, 'FM9990D00')
|| '|scope_hint=' || NVL(v_json.get_string('scope_hint'), 'NULL')
|| '|mention_count=' || v_json.get_array('game_mentions').get_size
);
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE(
p_profile_name || '|elapsed_seconds='
|| TO_CHAR(v_elapsed_seconds, 'FM9990D00')
|| '|ERROR|' || SQLCODE || '|' || SUBSTR(SQLERRM, 1, 300)
);
DBMS_OUTPUT.PUT_LINE(
p_profile_name || '|raw_response=' || DBMS_LOB.SUBSTR(v_result, 1000, 1)
);
END;
BEGIN
v_prompt := 'Extract only game-name mentions from the user question. '
|| 'Metrics, acronyms, dates, filters, and database object or column names are not game names unless they are themselves an explicit game title. '
|| 'When a title-like noun directly qualifies a game data request such as user master, character, sales, AU, NRU, server, or game log, preserve that noun as a game-name mention even when it is not in a catalog. '
|| 'Do not discard an unknown title merely because it cannot be resolved. General scope words such as common, overall, all, total, or every are not game-name mentions unless they are part of an explicit title. '
|| 'Return exactly one JSON object with keys game_mentions (array of strings) '
|| 'and scope_hint (GLOBAL, SINGLE_GAME, MULTI_GAME, ALL_GAMES, UNKNOWN). '
|| 'Do not resolve names to IDs and do not generate SQL. '
|| 'Return raw JSON only: no prose, no Markdown, and no code fence. Question: '
|| v_question;
run_profile('SGMP_POC_OCI_GPT54MINI');
run_profile('SGMP_POC_OCI_LLAMA4SCOUT');
END;
/

View File

@@ -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');