diff --git a/docs/design/708-smilegate-select-ai-oci-genai/README.md b/docs/design/708-smilegate-select-ai-oci-genai/README.md index 75adf4a..24b0553 100644 --- a/docs/design/708-smilegate-select-ai-oci-genai/README.md +++ b/docs/design/708-smilegate-select-ai-oci-genai/README.md @@ -67,3 +67,46 @@ credential, region, compartment, object list와 metadata instructions를 복제 형식을 기록한다. 품질·지연시간 결과를 검토하기 전에는 `sg_game_extract_mentions`와 `sg_game_match_candidate`의 운영 프로파일을 바꾸지 않는다. + +후보 모델 비교는 `sql/adb/114_sgmp_create_scope_chat_candidate_profiles.sql`과 +`sql/adb/115_sgmp_scope_chat_candidate_benchmark.sql`으로 수행한다. 후보는 +운영과 분리된 `SGMP_SCOPE_*` 프로파일로 생성한다. 각 모델은 `NONE`, `SINGLE`, +`MULTI`, `ALL` 질문에서 다음을 기록한다. + +- 초 단위 Chat 응답시간 +- 설명문·Markdown 없이 raw JSON만 반환했는지 +- `scope_hint`와 `game_mentions` 수가 기대값과 일치하는지 +- 생성 또는 Chat 호출 오류 + +## 게임명 추출과 벡터 점수 판정 + +게임명과 조회 범위 추출은 짧은 JSON Chat 작업이므로 운영 프로파일 +`SGMP_POC_OCI_COHERE_COMMAND` (`cohere.command-latest`)을 사용한다. +게임 카탈로그의 `ALIASES_JSON`에는 게임명, 영문명, 약칭, 게임 ID, prefix와 +운영 등록 별칭을 JSON 배열로 저장한다. 이 JSON 배열 전체를 게임당 하나의 +임베딩으로 생성한다. 추출된 각 게임명은 해당 게임 벡터의 cosine distance와 +`SG_GAME_SCOPE_POLICY`에 저장된 최대값을 비교해 `MATCHED` 또는 `UNMATCHED`로 +판정한다. 후보 동일성 확인을 위한 별도 LLM 호출은 사용하지 않는다. + +정책값은 코드가 아니라 DB 설정 테이블에 저장한다. 게임 카탈로그·임베딩 모델이 +변경되면 운영자가 그 기준값을 조정할 수 있다. `NONE`/`SINGLE`/`MULTI`/`ALL` +계약, Few-shot NL2SQL 전달 형식, Text2SQL 프로파일은 유지한다. + +### 검증 기준 + +- 추출 결과는 JSON 파싱 가능하고 `game_mentions`, `scope_hint`만 반환한다. +- `NONE`, `SINGLE`, `MULTI`, `ALL`에서 canonical `targets`, + `dataEligibleTargets`, `unresolvedTargets`가 유지된다. +- 동일성 판정을 위한 추가 OCI Chat 호출이 발생하지 않는다. + +### 후보 프로파일 비교 결과 + +동일한 JSON 추출 프롬프트로 `NONE`, `SINGLE`, `MULTI`, `ALL`을 호출한 결과, +`cohere.command-latest`, `cohere.command-plus-latest`, `cohere.command-a-vision`은 +기본 4건에서 모두 범위와 게임명 수를 맞췄다. `google.gemini-2.5-flash-lite`는 +JSON 파싱이 일관되지 않았고, Grok과 Llama Maverick은 게임 미지정 또는 전체 게임 +질의에서 범위 오류가 있었다. + +게임명 추출은 짧은 단일 JSON 요청이므로 `cohere.command-latest`를 선택했다. +이 측정값은 ADB 내부 Chat 호출 시간이며, Portal의 MCP·ReAct·답변 합성 시간을 +포함한 전체 체감시간과는 별도로 관리한다. diff --git a/docs/design/739-game-target-contract/README.md b/docs/design/739-game-target-contract/README.md index db58402..b222ec4 100644 --- a/docs/design/739-game-target-contract/README.md +++ b/docs/design/739-game-target-contract/README.md @@ -105,9 +105,17 @@ - 운영 registry에만 존재하는 게임도 카탈로그에 동기화하고 임베딩을 생성한다. - 게임별 물리 객체가 없으면 컬럼은 `null`이다. +`SG_GAME_CATALOG.ALIASES_JSON`에는 게임명, 영문명, 약칭, 게임 ID, prefix와 +운영 registry 별칭을 JSON 배열로 저장한다. 배열 전체를 게임당 하나의 벡터로 +임베딩한다. `SG_GAME_SCOPE_POLICY.GAME_ALIAS_MAX_COSINE_DISTANCE`는 벡터 검색 +점수의 신뢰도 기준이며, 후보·점수·원 질문은 `queryPlan`에 함께 보존해 다음 +Select AI가 이름 근거를 보조 판단할 수 있게 한다. 별도 LLM 후보 동일성 판정은 +사용하지 않는다. + `SG_GAME_CATALOG_SEARCH`는 물리 객체명을 후보 결과에 포함한다. -`SG_GAME_QUERY_PLAN`은 추출된 mention마다 후보 선택 결과를 `targets`로 만들며, -`ALL`은 벡터 선택 없이 전체 카탈로그를 사용한다. +`SG_GAME_QUERY_PLAN`은 추출된 mention마다 벡터 점수 기반 후보 결과를 `targets`와 +`mentionResults.candidateGames`로 만들며, `ALL`은 벡터 선택 없이 전체 카탈로그를 +사용한다. ## 5. MCP와 NL2SQL 설계 diff --git a/sql/adb/114_sgmp_create_scope_chat_candidate_profiles.sql b/sql/adb/114_sgmp_create_scope_chat_candidate_profiles.sql new file mode 100644 index 0000000..fce0828 --- /dev/null +++ b/sql/adb/114_sgmp_create_scope_chat_candidate_profiles.sql @@ -0,0 +1,90 @@ +-- Creates non-operational OCI profiles for game-scope extraction benchmarks. +-- Every profile inherits the active GPT profile's OCI credential, region, +-- object list, and metadata. Only model is varied. + +DECLARE + v_credential_name VARCHAR2(128); + v_region VARCHAR2(128); + v_compartment_id VARCHAR2(4000); + v_attributes CLOB; + v_attribute_json JSON_OBJECT_T; + v_exists PLS_INTEGER; + + PROCEDURE create_candidate( + p_profile_name IN VARCHAR2, + p_model IN VARCHAR2 + ) IS + BEGIN + SELECT COUNT(*) + INTO v_exists + FROM user_cloud_ai_profiles + WHERE profile_name = p_profile_name; + + IF v_exists > 0 THEN + DBMS_CLOUD_AI.DROP_PROFILE(profile_name => p_profile_name, force => TRUE); + END IF; + + v_attribute_json := JSON_OBJECT_T(); + v_attribute_json.put('provider', 'oci'); + v_attribute_json.put('credential_name', v_credential_name); + v_attribute_json.put('model', p_model); + 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 => p_profile_name, + attributes => v_attributes, + description => 'Non-operational Smilegate game-scope benchmark profile' + ); + + 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 => p_profile_name, + attribute_name => source_attribute.attribute_name, + attribute_value => source_attribute.attribute_value + ); + END LOOP; + END; +BEGIN + 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'; + + create_candidate('SGMP_SCOPE_COHERE_VISION', 'cohere.command-a-vision'); + create_candidate('SGMP_SCOPE_COHERE_COMMAND', 'cohere.command-latest'); + create_candidate('SGMP_SCOPE_COHERE_PLUS', 'cohere.command-plus-latest'); + create_candidate('SGMP_SCOPE_GEMINI_FLASH', 'google.gemini-2.5-flash-lite'); + create_candidate('SGMP_SCOPE_LLAMA_MAV', 'meta.llama-4-maverick-17b-128e-instruct-fp8'); + create_candidate('SGMP_SCOPE_GROK_NONR', 'xai.grok-4.20-non-reasoning'); +END; +/ + +SELECT profile_name, attribute_value AS model + FROM user_cloud_ai_profile_attributes + WHERE profile_name LIKE 'SGMP_SCOPE_%' + AND attribute_name = 'model' + ORDER BY profile_name; diff --git a/sql/adb/115_sgmp_scope_chat_candidate_benchmark.sql b/sql/adb/115_sgmp_scope_chat_candidate_benchmark.sql new file mode 100644 index 0000000..14e4614 --- /dev/null +++ b/sql/adb/115_sgmp_scope_chat_candidate_benchmark.sql @@ -0,0 +1,112 @@ +-- Read-only benchmark for candidate game-scope extraction profiles. +-- Korean test questions use UTF-8 base64 reconstruction for SQLcl safety. + +set serveroutput on size unlimited +prompt SG_SCOPE_PROFILE_BENCHMARK_LOADED + +DECLARE + TYPE t_case IS RECORD ( + case_name VARCHAR2(12), + question CLOB, + expected_scope VARCHAR2(20), + expected_mentions PLS_INTEGER + ); + TYPE t_cases IS TABLE OF t_case INDEX BY PLS_INTEGER; + v_cases t_cases; + v_prompt_prefix CLOB := + '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_result CLOB; + v_json JSON_OBJECT_T; + v_started PLS_INTEGER; + v_elapsed NUMBER; + v_scope VARCHAR2(20); + v_mentions PLS_INTEGER; + v_raw_json VARCHAR2(5); + + PROCEDURE run_case( + p_profile_name IN VARCHAR2, + p_case t_case + ) IS + BEGIN + v_started := DBMS_UTILITY.GET_TIME; + v_result := DBMS_CLOUD_AI.GENERATE( + prompt => v_prompt_prefix || p_case.question, + profile_name => p_profile_name, + action => 'chat' + ); + v_elapsed := (DBMS_UTILITY.GET_TIME - v_started) / 100; + v_json := JSON_OBJECT_T.parse(v_result); + v_raw_json := 'TRUE'; + v_scope := v_json.get_string('scope_hint'); + v_mentions := v_json.get_array('game_mentions').get_size; + DBMS_OUTPUT.PUT_LINE( + p_profile_name || '|' || p_case.case_name + || '|seconds=' || TO_CHAR(v_elapsed, 'FM9990D00') + || '|raw_json=' || v_raw_json + || '|scope=' || NVL(v_scope, 'NULL') + || '|mentions=' || v_mentions + || '|expected=' || p_case.expected_scope || '/' || p_case.expected_mentions + ); + EXCEPTION + WHEN OTHERS THEN + DBMS_OUTPUT.PUT_LINE( + p_profile_name || '|' || p_case.case_name + || '|seconds=' || TO_CHAR(v_elapsed, 'FM9990D00') + || '|ERROR=' || SQLCODE || '|' || SUBSTR(SQLERRM, 1, 180) + ); + END; +BEGIN + DBMS_OUTPUT.PUT_LINE('benchmark_started'); + v_cases(1).case_name := 'NONE'; + v_cases(1).question := utl_i18n.raw_to_char(utl_encode.base64_decode( + utl_raw.cast_to_raw('7KCE7LK0IOunpOy2nCDslYzroKTspJgu')), 'AL32UTF8'); + v_cases(1).expected_scope := 'GLOBAL'; + v_cases(1).expected_mentions := 0; + + v_cases(2).case_name := 'SINGLE'; + v_cases(2).question := utl_i18n.raw_to_char(utl_encode.base64_decode( + utl_raw.cast_to_raw('7Lm07KCc64KYIOy1nOyLoCBBVSDslYzroKTspJgu')), 'AL32UTF8'); + v_cases(2).expected_scope := 'SINGLE_GAME'; + v_cases(2).expected_mentions := 1; + + v_cases(3).case_name := 'MULTI'; + v_cases(3).question := utl_i18n.raw_to_char(utl_encode.base64_decode( + utl_raw.cast_to_raw('66Gc65Oc64KY7J247J20656RIOy5tOygnOuCmOydmCAyMDI264WEIDfsm5QgMTXsnbwgQVXrpbwg6rCB6rCBIOyVjOugpOykmC4=')), 'AL32UTF8'); + v_cases(3).expected_scope := 'MULTI_GAME'; + v_cases(3).expected_mentions := 2; + + v_cases(4).case_name := 'ALL'; + v_cases(4).question := utl_i18n.raw_to_char(utl_encode.base64_decode( + utl_raw.cast_to_raw('7KCE7LK0IOqyjOyehOydmCDrp6Tstpwg7JWM66Ck7KSYLg==')), 'AL32UTF8'); + v_cases(4).expected_scope := 'ALL_GAMES'; + v_cases(4).expected_mentions := 0; + + FOR profile_row IN ( + SELECT profile_name + FROM user_cloud_ai_profiles + WHERE profile_name IN ( + 'SGMP_POC_OCI_GPT54MINI', + 'SGMP_SCOPE_COHERE_VISION', + 'SGMP_SCOPE_COHERE_COMMAND', + 'SGMP_SCOPE_COHERE_PLUS', + 'SGMP_SCOPE_GEMINI_FLASH', + 'SGMP_SCOPE_LLAMA_MAV', + 'SGMP_SCOPE_GROK_NONR' + ) + ORDER BY profile_name + ) LOOP + DBMS_OUTPUT.PUT_LINE('profile=' || profile_row.profile_name); + FOR i IN 1 .. 4 LOOP + run_case(profile_row.profile_name, v_cases(i)); + END LOOP; + END LOOP; +END; +/ +prompt SG_SCOPE_PROFILE_BENCHMARK_COMPLETED diff --git a/sql/adb/116_sg_game_catalog_alias_embeddings.sql b/sql/adb/116_sg_game_catalog_alias_embeddings.sql new file mode 100644 index 0000000..5754854 --- /dev/null +++ b/sql/adb/116_sg_game_catalog_alias_embeddings.sql @@ -0,0 +1,122 @@ +-- Store all game-name variants as one JSON array per game and embed that JSON +-- as the canonical game-search vector. No customer game name is hardcoded. + +DECLARE + v_column_count PLS_INTEGER; +BEGIN + SELECT COUNT(*) + INTO v_column_count + FROM user_tab_columns + WHERE table_name = 'SG_GAME_CATALOG' + AND column_name = 'ALIASES_JSON'; + + IF v_column_count = 0 THEN + EXECUTE IMMEDIATE 'ALTER TABLE sg_game_catalog ADD (aliases_json CLOB)'; + END IF; +END; +/ + +UPDATE sg_game_catalog + SET aliases_json = '[]' + WHERE aliases_json IS NULL; +/ + +DECLARE + v_constraint_count PLS_INTEGER; +BEGIN + SELECT COUNT(*) + INTO v_constraint_count + FROM user_constraints + WHERE table_name = 'SG_GAME_CATALOG' + AND constraint_name = 'SG_GAME_CATALOG_ALIASES_JS_CK'; + + IF v_constraint_count = 0 THEN + EXECUTE IMMEDIATE + 'ALTER TABLE sg_game_catalog ADD CONSTRAINT sg_game_catalog_aliases_js_ck ' + || 'CHECK (aliases_json IS JSON)'; + END IF; +END; +/ + +MERGE INTO sg_game_catalog c +USING ( + WITH source_alias AS ( + SELECT game_id AS game_key, game_nm AS alias_name + FROM comn_game_alias_bas + WHERE use_yn = 'Y' AND game_nm IS NOT NULL + UNION ALL + SELECT game_id, game_alias_nm + FROM comn_game_alias_bas + WHERE use_yn = 'Y' AND game_alias_nm IS NOT NULL + UNION ALL + SELECT game_id, game_id + FROM comn_game_alias_bas + WHERE use_yn = 'Y' AND game_id IS NOT NULL + UNION ALL + SELECT game_id, game_prefix + FROM comn_game_alias_bas + WHERE use_yn = 'Y' AND game_prefix IS NOT NULL + UNION ALL + SELECT game_key, display_name + FROM sg_game_scope_registry + WHERE active_yn = 'Y' AND display_name IS NOT NULL + UNION ALL + SELECT game_key, game_alias + FROM sg_game_scope_registry + WHERE active_yn = 'Y' AND game_alias IS NOT NULL + ), + deduplicated_alias AS ( + SELECT game_key, alias_name + FROM source_alias + WHERE TRIM(alias_name) IS NOT NULL + GROUP BY game_key, alias_name + ) + SELECT game_key, + JSON_ARRAYAGG(alias_name ORDER BY alias_name RETURNING CLOB) AS aliases_json + FROM deduplicated_alias + GROUP BY game_key +) s +ON (c.game_key = s.game_key) +WHEN MATCHED THEN UPDATE SET + c.aliases_json = s.aliases_json, + c.updated_at = SYSTIMESTAMP; +/ + +-- A game has one canonical vector made from its complete JSON alias array. +UPDATE sg_game_catalog c + SET c.embedding = DBMS_VECTOR.UTL_TO_EMBEDDING( + c.aliases_json, + JSON(sg_qa_vector_params('search_document')) + ), + c.updated_at = SYSTIMESTAMP + WHERE c.active_yn = 'Y'; +/ + +COMMENT ON COLUMN sg_game_catalog.aliases_json IS + 'Canonical JSON string array of every game-name variant used as the embedding input.'; +COMMENT ON COLUMN sg_game_catalog.embedding IS + 'One vector per game, generated from the complete aliases_json array.'; +/ + +CREATE OR REPLACE FUNCTION sg_game_catalog_search( + p_question IN CLOB, + p_top_k IN PLS_INTEGER DEFAULT 5 +) RETURN SYS_REFCURSOR AUTHID DEFINER IS + v_query VECTOR; + v_result SYS_REFCURSOR; +BEGIN + v_query := DBMS_VECTOR.UTL_TO_EMBEDDING( + p_question, + JSON(sg_qa_vector_params('search_query')) + ); + 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 + ORDER BY VECTOR_DISTANCE(embedding, v_query, COSINE), priority, game_key + FETCH FIRST LEAST(GREATEST(NVL(p_top_k, 5), 1), 20) ROWS ONLY; + RETURN v_result; +END; +/ diff --git a/sql/adb/117_sg_game_scope_policy.sql b/sql/adb/117_sg_game_scope_policy.sql new file mode 100644 index 0000000..55e7a56 --- /dev/null +++ b/sql/adb/117_sg_game_scope_policy.sql @@ -0,0 +1,44 @@ +-- Customer-managed score policy for vector-only game identity resolution. + +BEGIN + EXECUTE IMMEDIATE q'[ + CREATE TABLE sg_game_scope_policy ( + policy_key VARCHAR2(128) PRIMARY KEY, + number_value NUMBER, + text_value VARCHAR2(4000), + description VARCHAR2(1000) NOT NULL, + active_yn CHAR(1) DEFAULT 'Y' NOT NULL, + updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT sg_game_scope_policy_active_ck CHECK (active_yn IN ('Y', 'N')) + )]'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN RAISE; END IF; +END; +/ + +MERGE INTO sg_game_scope_policy t +USING ( + SELECT 'GAME_ALIAS_MAX_COSINE_DISTANCE' AS policy_key, + 0.500000 AS number_value, + CAST(NULL AS VARCHAR2(4000)) AS text_value, + 'Maximum cosine distance for accepting the closest independently embedded game alias.' AS description + FROM dual +) s +ON (t.policy_key = s.policy_key) +WHEN MATCHED THEN UPDATE SET + t.description = s.description, + t.active_yn = 'Y', + t.updated_at = SYSTIMESTAMP +WHEN NOT MATCHED THEN INSERT ( + policy_key, number_value, text_value, description, active_yn +) VALUES ( + s.policy_key, s.number_value, s.text_value, s.description, 'Y' +); +/ + +COMMENT ON TABLE sg_game_scope_policy IS + 'Customer-managed game scope policy values; changing a value requires no application deployment.'; +COMMENT ON COLUMN sg_game_scope_policy.number_value IS + 'Numeric policy value. GAME_ALIAS_MAX_COSINE_DISTANCE applies to the closest alias vector.'; +/ diff --git a/sql/adb/118_sgmp_scope_command_profile.sql b/sql/adb/118_sgmp_scope_command_profile.sql new file mode 100644 index 0000000..68007bb --- /dev/null +++ b/sql/adb/118_sgmp_scope_command_profile.sql @@ -0,0 +1,78 @@ +-- Operational OCI Cohere profile for short game-name and scope extraction. +-- It inherits the active GPT profile's OCI credential, region, and metadata. + +DECLARE + v_credential_name VARCHAR2(128); + v_region VARCHAR2(128); + v_compartment_id VARCHAR2(4000); + v_attributes CLOB; + v_attribute_json JSON_OBJECT_T; + v_exists PLS_INTEGER; +BEGIN + SELECT COUNT(*) + INTO v_exists + FROM user_cloud_ai_profiles + WHERE profile_name = 'SGMP_POC_OCI_COHERE_COMMAND'; + + IF v_exists > 0 THEN + DBMS_CLOUD_AI.DROP_PROFILE( + profile_name => 'SGMP_POC_OCI_COHERE_COMMAND', + 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 := JSON_OBJECT_T(); + v_attribute_json.put('provider', 'oci'); + v_attribute_json.put('credential_name', v_credential_name); + v_attribute_json.put('model', 'cohere.command-latest'); + 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_COHERE_COMMAND', + attributes => v_attributes, + description => 'Smilegate operational OCI Cohere Command profile for game scope extraction' + ); + + 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_COHERE_COMMAND', + attribute_name => source_attribute.attribute_name, + attribute_value => source_attribute.attribute_value + ); + END LOOP; +END; +/ + +SELECT profile_name, attribute_value AS model + FROM user_cloud_ai_profile_attributes + WHERE profile_name = 'SGMP_POC_OCI_COHERE_COMMAND' + AND attribute_name = 'model'; diff --git a/sql/adb/80_sg_game_mention_extract.sql b/sql/adb/80_sg_game_mention_extract.sql index 871c7a7..795b887 100644 --- a/sql/adb/80_sg_game_mention_extract.sql +++ b/sql/adb/80_sg_game_mention_extract.sql @@ -1,20 +1,34 @@ --- OCI GenAI chat extraction. This function returns JSON only; it never chooses tables. +-- One short OCI Chat call extracts game-name mentions. Identity resolution is +-- performed later by catalog vector score, not by another LLM call. CREATE OR REPLACE FUNCTION sg_game_extract_mentions(p_question IN CLOB) RETURN CLOB AUTHID DEFINER IS v_prompt CLOB; v_result CLOB; + v_extract JSON_OBJECT_T; 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. Question: ' + || 'Do not resolve names to IDs and do not generate SQL. ' + || 'Return raw JSON only: no prose, no Markdown, and no code fence. Question: ' || DBMS_LOB.SUBSTR(p_question, 4000, 1); + v_result := DBMS_CLOUD_AI.GENERATE( prompt => v_prompt, - profile_name => 'SGMP_POC_OCI_GPT54MINI', + profile_name => 'SGMP_POC_OCI_COHERE_COMMAND', action => 'chat' ); + v_extract := JSON_OBJECT_T.parse(v_result); + IF NOT v_extract.has('game_mentions') OR NOT v_extract.has('scope_hint') THEN + RAISE_APPLICATION_ERROR( + -20091, + 'Game mention extraction must return game_mentions and scope_hint JSON keys.' + ); + END IF; RETURN v_result; END; / diff --git a/sql/adb/81_sg_game_query_plan.sql b/sql/adb/81_sg_game_query_plan.sql index e3d42f0..412811f 100644 --- a/sql/adb/81_sg_game_query_plan.sql +++ b/sql/adb/81_sg_game_query_plan.sql @@ -1,32 +1,57 @@ --- OCI GenAI candidate selection. Vector distance only orders candidates; the --- model may select one supplied game_key or explicitly reject all candidates. +-- Vector-score candidate selection. The closest catalog vector is accepted +-- only when it is within the customer-managed policy threshold. CREATE OR REPLACE FUNCTION sg_game_match_candidate( p_mention IN CLOB, p_candidates_json IN CLOB ) RETURN CLOB AUTHID DEFINER IS - v_prompt CLOB; - v_result CLOB; + v_candidates JSON_ARRAY_T; + v_candidate JSON_OBJECT_T; + v_result JSON_OBJECT_T := JSON_OBJECT_T(); + v_threshold NUMBER; + v_distance NUMBER; + v_game_key VARCHAR2(128); BEGIN - v_prompt := 'Decide whether the extracted game-name mention refers to exactly ' - || 'one game in the supplied candidate array. Candidate vector distance is ' - || 'retrieval evidence only and must not establish identity. Compare the ' - || 'mention with candidate gameName, aliases, gameId, and gamePrefix. ' - || 'If one candidate clearly identifies the same game, return exactly ' - || '{"status":"MATCHED","game_key":"candidate key","reason":"short reason"}. ' - || 'If none clearly identifies the same game, return exactly ' - || '{"status":"UNMATCHED","game_key":null,"reason":"short reason"}. ' - || 'Never invent a game_key and never return a key absent from the supplied ' - || 'candidate array. Mention: ' - || DBMS_LOB.SUBSTR(p_mention, 1000, 1) - || ' Candidates: ' - || DBMS_LOB.SUBSTR(p_candidates_json, 12000, 1); - v_result := DBMS_CLOUD_AI.GENERATE( - prompt => v_prompt, - profile_name => 'SGMP_POC_OCI_GPT54MINI', - action => 'chat' - ); - RETURN v_result; + SELECT number_value + INTO v_threshold + FROM sg_game_scope_policy + WHERE policy_key = 'GAME_ALIAS_MAX_COSINE_DISTANCE' + AND active_yn = 'Y'; + + v_candidates := JSON_ARRAY_T.parse(p_candidates_json); + IF v_candidates.get_size = 0 THEN + v_result.put('status', 'UNMATCHED'); + v_result.put_null('game_key'); + v_result.put('reason', 'No active game catalog vector candidate was returned.'); + RETURN v_result.to_clob; + END IF; + + v_candidate := TREAT(v_candidates.get(0) AS JSON_OBJECT_T); + v_distance := v_candidate.get_number('cosineDistance'); + v_game_key := v_candidate.get_string('gameKey'); + + IF v_distance <= v_threshold THEN + v_result.put('status', 'MATCHED'); + v_result.put('game_key', v_game_key); + v_result.put( + 'reason', + 'Closest catalog vector distance ' + || TO_CHAR(v_distance, 'FM0D000000') + || ' is within configured maximum ' + || TO_CHAR(v_threshold, 'FM0D000000') || '.' + ); + ELSE + v_result.put('status', 'UNMATCHED'); + v_result.put_null('game_key'); + v_result.put( + 'reason', + 'Closest catalog vector distance ' + || TO_CHAR(v_distance, 'FM0D000000') + || ' exceeds configured maximum ' + || TO_CHAR(v_threshold, 'FM0D000000') || '.' + ); + END IF; + RETURN v_result.to_clob; END; / @@ -255,14 +280,14 @@ BEGIN 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('reasonCode', 'VECTOR_SCORE_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('matchStatus', 'MATCHED'); - v_target.put('reasonCode', 'LLM_CANDIDATE_MATCH'); + v_target.put('reasonCode', 'VECTOR_SCORE_MATCH'); v_target.put('reason', v_reason); v_matched_count := v_matched_count + 1; @@ -282,14 +307,7 @@ BEGIN 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_mention_result.put('reasonCode', 'VECTOR_SCORE_OVER_THRESHOLD'); v_target := JSON_OBJECT_T(); v_target.put('mention', v_mention); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java index 4d57674..6b47a2b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameCatalogVectorService.java @@ -18,8 +18,8 @@ import org.springframework.stereotype.Service; /** * MCP database gateway for the ADB-owned game query planning functions. * - *

Game mention extraction, vector candidate retrieval, and OCI GenAI - * candidate selection run inside {@code SG_GAME_QUERY_PLAN}. Java validates + *

Game mention extraction, JSON-alias vector retrieval, and score-based + * scope resolution run inside {@code SG_GAME_QUERY_PLAN}. Java validates * authentication and transports the structured JSON result without applying * game-name matching rules.

*/