69 Commits

Author SHA1 Message Date
devmrko
72b88c43fc refs #739: add Smilegate MCP operations guide 2026-08-03 13:36:23 +09:00
devmrko
03bf0e096c refs #739: align Smilegate with application repository layout 2026-08-03 12:37:38 +09:00
devmrko
4d2964b5c6 refs #739: preserve Smilegate changes before repository layout migration 2026-08-03 12:31:51 +09:00
devmrko
022ae7f9d2 refs #739: document DB-owned task and few-shot policy 2026-08-03 12:31:51 +09:00
devmrko
7bf8199343 refs #708 #739: use Cohere command and vector game scope 2026-08-03 12:31:51 +09:00
devmrko
ccc7d9e7ec refs #708: benchmark Llama game scope profile 2026-08-03 12:31:50 +09:00
devmrko
e0ec0c9340 refs #739: formalize game query plan contract 2026-08-03 12:31:50 +09:00
devmrko
e1fd486b01 refs #739: separate catalog and fact availability 2026-08-03 12:31:50 +09:00
devmrko
5ca02e646a refs #739: generalize game target profile guidance 2026-08-03 12:31:50 +09:00
devmrko
0a27ce9cd5 refs #739: guide no-target few-shot SQL generation 2026-08-03 12:31:50 +09:00
devmrko
5ac9f5d591 refs #739: seed verified Smilegate QA few-shot candidates 2026-08-03 12:31:50 +09:00
devmrko
ae37235258 refs #739: govern Smilegate few-shot references 2026-08-03 12:31:49 +09:00
devmrko
d932782c9f refs #739: normalize MCP query plan envelopes 2026-08-03 12:31:49 +09:00
devmrko
7bc13446bc refs #739: unify Smilegate game target planning 2026-08-03 12:31:49 +09:00
devmrko
9f15ef6be1 refs #737: align STD-06 partial success baseline 2026-08-03 12:31:49 +09:00
devmrko
8298ecb511 refs #737: move game query planning into ADB MCP tool 2026-08-03 12:31:49 +09:00
devmrko
5a3b03060d refs #737: preserve unmatched game mentions in query plan 2026-08-03 12:31:49 +09:00
devmrko
8ee7fe262b refs #737: align MCP tests with game planning tools 2026-08-03 12:31:48 +09:00
devmrko
471c50a40f refs #731: document game scope deployment contract 2026-08-03 12:31:48 +09:00
devmrko
9816088453 remove unconfigured scope bypass 2026-08-03 12:31:48 +09:00
devmrko
5dc9572210 allow plan context when optional scope view unavailable 2026-08-03 12:31:48 +09:00
devmrko
af311f60f8 return structured next action for game query plan 2026-08-03 12:31:48 +09:00
devmrko
698baee583 pass previous tool context into few-shot query 2026-08-03 12:31:48 +09:00
devmrko
c4643eca8b mark approved game plan candidates as supported 2026-08-03 12:31:48 +09:00
devmrko
eee87b364d remove hardcoded game scope policy from prompt 2026-08-03 12:31:48 +09:00
devmrko
f16d3e5798 add game query plan MCP tool 2026-08-03 12:31:48 +09:00
devmrko
4afa4b219e chain ADB chat game extraction before vector resolution 2026-08-03 12:31:48 +09:00
devmrko
9b2c54d27f add OCI GenAI game mention extraction function 2026-08-03 12:31:48 +09:00
devmrko
caa2df3e5d inject game catalog context into few-shot prompt 2026-08-03 12:31:48 +09:00
devmrko
5d4af888f3 implement game catalog vector MCP resolver 2026-08-03 12:31:48 +09:00
devmrko
9089897d88 add game catalog resolver MCP tool 2026-08-03 12:31:48 +09:00
devmrko
a1685f533e add metadata-driven game catalog vector schema 2026-08-03 12:31:48 +09:00
devmrko
b69abe0f3a refs #731: expose database game scope MCP 2026-08-03 12:31:47 +09:00
devmrko
e77b3e0543 refs #731: add DB game scope metadata 2026-08-03 12:31:47 +09:00
devmrko
859840e9bd derive unresolved scope response from resolver status 2026-08-03 12:31:29 +09:00
devmrko
593b1750f5 add metadata-backed game scope resolver 2026-08-03 12:31:29 +09:00
devmrko
0662c4140c generalize game scope guidance through metadata few-shot 2026-08-03 12:31:10 +09:00
devmrko
0cbac8d23b refs #736: route Smilegate portal to few-shot MCP 2026-08-03 12:31:10 +09:00
devmrko
caa7d55085 refs #735: add few-shot NL2SQL MCP tool 2026-08-03 12:31:10 +09:00
devmrko
b738708528 refs #731: guard missing game identifiers in few-shot prompt 2026-08-03 12:31:10 +09:00
devmrko
48476961ed refs #731: preserve Select AI configuration binding 2026-08-03 12:31:10 +09:00
devmrko
745c091113 refs #734: add validated annotation PL/SQL API 2026-08-03 12:31:10 +09:00
devmrko
7175460314 refs #731: enrich Text2SQL prompts with QA examples 2026-08-03 12:31:10 +09:00
devmrko
c90c43facf refs #731: fix autonomous vector example storage 2026-08-03 12:31:09 +09:00
devmrko
2407c1bcc8 refs #731: allow QA vector MCP tools in portal 2026-08-03 12:31:09 +09:00
devmrko
e7213eabb5 refs #731: add QA vector MCP tools 2026-08-03 12:31:09 +09:00
devmrko
88a292d711 feat: add dedicated Cohere Embed 4 QA vector retrieval 2026-08-03 12:31:09 +09:00
devmrko
2efd1559aa refs #726: add Select AI SHOWPROMPT diagnostic tool 2026-08-03 12:30:27 +09:00
devmrko
f452b05209 refs #722: externalize backoffice customer configuration 2026-08-03 12:30:27 +09:00
devmrko
a579501d6e refs #708: report configured Smilegate Select AI profile 2026-08-03 11:16:40 +09:00
devmrko
14b237a734 refs #710: remove POC4 URL remember tokens 2026-08-03 11:16:40 +09:00
devmrko
c3aca16a09 refs #708: move Smilegate Select AI to OCI GPT 5.4 Mini 2026-08-03 11:16:40 +09:00
devmrko
ddd487c96a refs #703: avoid blocking Smilegate backoffice rendering 2026-08-03 11:15:44 +09:00
devmrko
983bd5d4cb refs #703: force Smilegate PoC HTTP 1.1 2026-08-03 11:15:44 +09:00
devmrko
a66fdb2413 refs #703: use gzip for Smilegate backoffice 2026-08-03 11:15:44 +09:00
devmrko
ec6a0304b2 refs #703: fix Smilegate annotation metadata query 2026-08-03 11:15:44 +09:00
devmrko
87b26225b6 refs #703: log schema metadata lookup failures 2026-08-03 11:15:44 +09:00
devmrko
94c7bc2e07 refs #703: route schema metadata through MyBatis 2026-08-03 11:15:44 +09:00
devmrko
a7ea010f5c refs #706: add Smilegate QA history benchmark 2026-08-03 11:15:27 +09:00
devmrko
b21d7ad01b refs #703: cover Streamlit expander dark theme internals 2026-08-03 11:14:58 +09:00
devmrko
e843bc9b31 refs #703: fix dark mode expander contrast 2026-08-03 11:14:58 +09:00
devmrko
aa9ea48b24 refs #703: enforce chat answer contrast in dark mode 2026-08-03 11:14:58 +09:00
devmrko
5d6754df05 refs #703: fix dark mode secondary button contrast 2026-08-03 11:14:58 +09:00
devmrko
68ad2f81e9 refs #703: preserve read-only transaction for Text2SQL 2026-08-03 11:14:58 +09:00
devmrko
de4a2c4be7 refs #703: execute validated Smilegate Text2SQL 2026-08-03 11:14:58 +09:00
devmrko
820f026e17 refs #703: configure OCI GenAI root compartment 2026-08-03 11:14:58 +09:00
devmrko
55b5b7fd9d refs #703: fix MCP detail contrast 2026-08-03 11:14:34 +09:00
devmrko
068b8fba2d refs #703: configure smilegate mcp token presets 2026-08-03 11:14:34 +09:00
devmrko
3304b22bc4 refs #703 #704: finalize smilegate game data poc 2026-08-03 11:14:33 +09:00
170 changed files with 13370 additions and 1531 deletions

View File

@@ -52,42 +52,49 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
export BACKOFFICE_ORDS_DB_PASSWORD=""
# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다.
# Select AI 프로파일 소유 스키마로 별도 접속합니다.
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}"
export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}"
export BACKOFFICE_SELECT_AI_DB_USERNAME=""
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
export BACKOFFICE_SELECT_AI_PROFILE=""
# 생성 SQL은 반드시 EXEMPT ACCESS POLICY가 없는 별도 계정으로 실행합니다.
# 런타임 비밀번호는 Git에 저장하지 말고 배포 서버 secret 환경 파일에만 넣으세요.
export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS"
export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=""
# Optional deployment-specific JSON contract. Keep project rules out of Java.
export BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE=""
export BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED="true"
export BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K="3"
# Customer-owned DB view: game aliases, approved profile objects, and valid DB objects.
export BACKOFFICE_GAME_SCOPE_ENABLED="false"
export BACKOFFICE_GAME_SCOPE_VIEW=""
export BACKOFFICE_GAME_SCOPE_MAX_SCOPES="8"
# --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 ---
# 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다.
# 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다.
# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
export BACKOFFICE_CATALOG_OBJECTS='[{"key":"employees","tableName":"EMPLOYEES","objectType":"TABLE","businessName":"직원","description":"직원 기본 정보"}]'
export BACKOFFICE_CATALOG_OBJECTS='[{"key":"customers","tableName":"CUSTOMER","objectType":"TABLE","businessName":"고객","description":"고객 기본 정보"}]'
export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
# 단일 Select AI 도구 호환 설정. 여러 Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
# AGENT_TOOL targetName은 서버 시작 시 USER_AI_AGENT_TOOLS의 ENABLED 상태를 검증합니다.
export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp"
export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice"
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL"
export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다."
export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다."
export BACKOFFICE_MCP_TOOLS=''
# Data Redaction 관리 대상과 보안 SQL 화면 allowlist. 빈 값이면 관리/노출하지 않습니다.
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME="oracle.select_ai.data_showprompt"
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL="업무 데이터 SHOWPROMPT"
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION="Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다."
# Select AI few-shot 예제 SQL 조회·저장 MCP. 운영 환경은 고객별 도구명과 안내문만 변경합니다.
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_NAME="oracle.select_ai.qa_vector_search"
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_LABEL="Select AI 예제 SQL 조회"
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION="현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다."
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME="oracle.select_ai.qa_vector_store"
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL="Select AI 예제 SQL 저장"
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION="검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다."
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_NAME="oracle.select_ai.game_scope_resolve"
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_LABEL="게임 조회 범위 확인"
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_DESCRIPTION="질문의 게임 별칭을 DB 범위 view로 확인하고, SUPPORTED 결과에만 Few-shot NL2SQL을 호출하도록 안내합니다."
# 마스킹 관리 대상. objectName/policyName JSON 배열이며, 비우면 어떤 DB 정책도 관리하지 않습니다.
export BACKOFFICE_MASKING_POLICIES=''
# 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다.
export BACKOFFICE_SECURITY_SQL_SCRIPTS=''
# --- (2d) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
export BACKOFFICE_AI_ENABLED="false"
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com

4
.gitignore vendored
View File

@@ -17,11 +17,9 @@ logs/
# Java / Maven
target/
# Python
# Python / Streamlit
__pycache__/
*.py[cod]
.venv/
data/
# Locally downloaded development tools (for example SQLcl)
.tools/

View File

@@ -183,7 +183,7 @@ def build_mcp_tool_arguments(
elif "limit" in properties:
args["limit"] = limit
return args
return {"prompt": question, "limit": limit}
return {}
__all__ = [

View File

@@ -26,7 +26,10 @@ ALLOWED_OCI_SETTINGS = frozenset(
"OCI_PROFILE",
}
)
_COMPARTMENT_ID = re.compile(r"^ocid1\.compartment\.[A-Za-z0-9._-]+$")
# OCI permits the tenancy OCID when the root compartment is selected.
_COMPARTMENT_OR_ROOT_ID = re.compile(
r"^ocid1\.(?:compartment|tenancy)\.[A-Za-z0-9._-]+$"
)
class CompletionClient(Protocol):
@@ -95,7 +98,7 @@ def load_oci_settings() -> OCISettings:
raise ValueError("unsupported OCI authentication mode")
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
if not _COMPARTMENT_ID.fullmatch(compartment_id):
if not _COMPARTMENT_OR_ROOT_ID.fullmatch(compartment_id):
raise ValueError("OCI Generative AI compartment is not configured")
return OCISettings(
auth_type=auth_type,

View File

@@ -35,12 +35,64 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
background:#fff !important; border:1px solid var(--console-border) !important;
border-radius:4px !important; box-shadow:none !important; }}
/* Streamlit JSON uses an independently styled code surface. Keep the
MCP detail payload readable even when the browser/system prefers a
dark code theme. */
[data-testid="stJson"], [data-testid="stJson"] > div,
[data-testid="stJson"] .react-json-view, [data-testid="stJson"] pre {{
background:#f8fafc !important; color:var(--console-text) !important;
border-color:var(--console-border) !important; color-scheme:light !important; }}
[data-testid="stJson"] *, [data-testid="stJson"] pre *,
[data-testid="stJson"] code {{
background:transparent !important; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
/* Baseline answers and generated SQL use Streamlit's separate code
surface. Keep it readable when the browser prefers dark mode. */
[data-testid="stCode"], [data-testid="stCode"] pre,
[data-testid="stCode"] code, [data-testid="stCodeBlock"],
[data-testid="stCodeBlock"] pre, [data-testid="stCodeBlock"] code {{
background:#f8fafc !important; color:var(--console-text) !important;
border-color:var(--console-border) !important; color-scheme:light !important;
-webkit-text-fill-color:var(--console-text) !important; }}
[data-testid="stCode"] *, [data-testid="stCodeBlock"] * {{
color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
/* Chat responses are rendered in a separate Streamlit surface. Without
these rules a dark browser theme can leave the answer card dark while
its Markdown keeps the light-theme text color. */
div[data-testid="stChatMessage"], div[data-testid="stChatMessageContent"] {{
background:#fff !important; color:var(--console-text) !important;
border-color:var(--console-border) !important; color-scheme:light !important; }}
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"],
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] *,
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"],
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"] * {{
color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
/* Streamlit expanders use a dark summary bar in dark browser themes. */
details, details > summary {{
background:#fff !important; color:var(--console-text) !important;
border-color:var(--console-border) !important; color-scheme:light !important; }}
details > summary *, details > summary::marker {{
color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
[data-testid="stExpander"] > details,
[data-testid="stExpander"] > details > summary,
[data-testid="stExpander"] > details > summary > div,
[data-testid="stExpander"] > details > summary > div > div {{
background:#fff !important; color:var(--console-text) !important;
border-color:var(--console-border) !important; }}
[data-testid="stExpander"] > details > summary *,
[data-testid="stExpander"] > details > summary svg {{
color:var(--console-text) !important; fill:var(--console-text) !important;
stroke:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
background:#fff !important; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important;
border:1px solid var(--console-border) !important;
border-radius:4px !important; box-shadow:none !important; }}
div[data-testid="stButton"] > button *,
div[data-testid="stFormSubmitButton"] > button * {{
border-radius:4px !important; box-shadow:none !important; color-scheme:light !important; }}
div[data-testid="stButton"] > button *, div[data-testid="stFormSubmitButton"] > button * {{
color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }}
div[data-testid="stButton"] > button[kind="primary"],
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{

View File

@@ -0,0 +1,242 @@
"""Customer QA benchmark parsing and deterministic SQL evaluation."""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
from pathlib import Path
import re
from typing import Any, Mapping
class QaBenchmarkError(RuntimeError):
"""Raised when the QA benchmark source cannot be used safely."""
@dataclass(frozen=True)
class QaQuestion:
question_id: int | None
question_code: str
category: str
title: str
question_text: str
source_document: str
source_sheet: str
source_row: int | None
source_scenario: str
sample_sql: str
expected_focus: str
baseline_sql: str
baseline_answer: str
support_level: str
evaluation_rule: Mapping[str, Any]
last_judgment_status: str = ""
last_evaluated_at: str = ""
@dataclass(frozen=True)
class QaJudgment:
status: str
reason: str
def question_fingerprint(question_text: str) -> str:
normalized = " ".join(str(question_text or "").split()).casefold()
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def _compact_text(value: object) -> str:
return str(value or "").strip()
def _string_list(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
return tuple(_compact_text(item) for item in value if _compact_text(item))
def load_benchmark_questions(path: Path) -> tuple[QaQuestion, ...]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, ValueError) as exc:
raise QaBenchmarkError(f"질답 기준 파일을 읽지 못했습니다: {path}") from exc
rows = payload.get("scenarios") if isinstance(payload, Mapping) else None
if not isinstance(rows, list):
raise QaBenchmarkError("질답 기준 파일에 scenarios 배열이 필요합니다.")
questions: list[QaQuestion] = []
seen_codes: set[str] = set()
for row in rows:
if not isinstance(row, Mapping):
raise QaBenchmarkError("질답 기준의 각 시나리오는 객체여야 합니다.")
source = row.get("source") if isinstance(row.get("source"), Mapping) else {}
history = (
row.get("historical_answer")
if isinstance(row.get("historical_answer"), Mapping)
else {}
)
code = _compact_text(row.get("case_id")).upper()
question_text = _compact_text(row.get("question"))
if not code or not question_text:
raise QaBenchmarkError("각 질답 기준에는 case_id와 question이 필요합니다.")
if code in seen_codes:
raise QaBenchmarkError(f"중복된 질답 case_id입니다: {code}")
evaluation_rule = row.get("evaluation_rule")
if not isinstance(evaluation_rule, Mapping):
evaluation_rule = {}
questions.append(
QaQuestion(
question_id=None,
question_code=code,
category=_compact_text(row.get("category")) or "GENERAL",
title=_compact_text(row.get("title")) or code,
question_text=question_text,
source_document=_compact_text(source.get("workbook")),
source_sheet=_compact_text(source.get("sheet")),
source_row=_number_or_none(source.get("excel_row")),
source_scenario=_compact_text(source.get("scenario")),
sample_sql=_compact_text(source.get("sample_query")),
expected_focus=_compact_text(row.get("expected_focus")),
baseline_sql=_compact_text(history.get("generated_sql")),
baseline_answer=_compact_text(history.get("answer_text")),
support_level=_compact_text(row.get("support_level")).upper() or "UNKNOWN",
evaluation_rule={
"required_sql_terms": list(
_string_list(evaluation_rule.get("required_sql_terms"))
),
"recommended_sql_terms": list(
_string_list(evaluation_rule.get("recommended_sql_terms"))
),
},
)
)
seen_codes.add(code)
return tuple(questions)
def question_from_record(record: Mapping[str, Any]) -> QaQuestion:
rule = record.get("evaluation_rule")
if isinstance(rule, str):
try:
rule = json.loads(rule)
except ValueError:
rule = {}
if not isinstance(rule, Mapping):
rule = {}
return QaQuestion(
question_id=_number_or_none(record.get("question_id")),
question_code=_compact_text(record.get("question_code")),
category=_compact_text(record.get("category")) or "GENERAL",
title=_compact_text(record.get("title")) or _compact_text(record.get("question_code")),
question_text=_compact_text(record.get("question_text")),
source_document=_compact_text(record.get("source_document")),
source_sheet=_compact_text(record.get("source_sheet")),
source_row=_number_or_none(record.get("source_row")),
source_scenario=_compact_text(record.get("source_scenario")),
sample_sql=_compact_text(record.get("sample_sql")),
expected_focus=_compact_text(record.get("expected_focus")),
baseline_sql=_compact_text(record.get("baseline_sql")),
baseline_answer=_compact_text(record.get("baseline_answer")),
support_level=_compact_text(record.get("support_level")).upper() or "UNKNOWN",
evaluation_rule={
"required_sql_terms": list(
_string_list(rule.get("required_sql_terms"))
),
"recommended_sql_terms": list(
_string_list(rule.get("recommended_sql_terms"))
),
},
last_judgment_status=_compact_text(record.get("last_judgment_status")),
last_evaluated_at=_compact_text(record.get("last_evaluated_at")),
)
def _number_or_none(value: object) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _append_issue(issues: list[str], condition: bool, message: str) -> None:
if condition:
issues.append(message)
def evaluate_sql(
question: QaQuestion | None,
generated_sql: str,
*,
execution_succeeded: bool,
error_text: str = "",
game_plan_status: str = "",
) -> QaJudgment:
"""Evaluate the generated SQL against the customer-approved benchmark rule."""
if question is None or not question.question_code:
return QaJudgment(
status="REVIEW",
reason="자유 질의입니다. 고객 기준 정답 시나리오와 연결되지 않아 수동 검토가 필요합니다.",
)
sql = _compact_text(generated_sql)
upper_sql = sql.upper()
lower_sql = sql.lower()
execution_status = "PASS" if execution_succeeded else "FAIL_EXECUTION"
issues: list[str] = []
failure_markers = ("could not be generated", "exception encountered", "invalid identifier", "ora-")
has_failure_text = any(marker in lower_sql for marker in failure_markers)
required = _string_list(question.evaluation_rule.get("required_sql_terms"))
recommended = _string_list(question.evaluation_rule.get("recommended_sql_terms"))
missing_required = [term for term in required if term.upper() not in upper_sql]
missing_recommended = [term for term in recommended if term.upper() not in upper_sql]
if not execution_succeeded:
issues.append(f"실행 상태가 {execution_status}입니다.")
if not sql:
issues.append("생성 SQL이 없습니다.")
if has_failure_text:
issues.append("생성 SQL에 오류 또는 생성 실패 문구가 포함되어 있습니다.")
if missing_required:
issues.append("필수 SQL 요소 누락: " + ", ".join(missing_required))
if missing_recommended:
issues.append("권장 SQL 요소 누락: " + ", ".join(missing_recommended))
_append_issue(
issues,
bool(re.search(r'_[A-Z0-9]*YN"\s*=\s*\'1\'', sql, flags=re.IGNORECASE)),
"*_YN 컬럼은 샘플 메타데이터의 Y/N 값으로 비교해야 합니다.",
)
_append_issue(
issues,
bool(re.search(r'_[A-Z0-9]*FLAG"\s*=\s*\'Y\'', sql, flags=re.IGNORECASE)),
"*_FLAG 컬럼은 샘플 메타데이터의 0/1 값으로 비교해야 합니다.",
)
support = question.support_level
if support == "UNSUPPORTED":
plan_status = _compact_text(game_plan_status).upper()
safe_empty_result = bool(
re.search(r"\bFROM\s+DUAL\b", upper_sql)
and re.search(r"\bWHERE\s+1\s*=\s*0\b", upper_sql)
)
if plan_status in {"UNAVAILABLE", "UNMATCHED"} and execution_succeeded and safe_empty_result:
return QaJudgment(
"PASS",
"게임 계획이 데이터 미지원 또는 미매칭으로 판정됐고, 임의 객체 선택 없이 빈 결과를 반환했습니다.",
)
if not sql and any(marker in error_text.lower() for marker in failure_markers):
return QaJudgment("PASS", "미지원 게임 질문이 실행 가능한 SQL로 변환되지 않았습니다. 기대한 안전 차단입니다.")
return QaJudgment("FAIL", "미지원 게임이 게임 계획의 안전한 빈 결과로 처리되지 않았거나 실행에 실패했습니다.")
if not execution_succeeded or not sql or has_failure_text or missing_required:
return QaJudgment("FAIL", "\n".join(issues) or "필수 SQL 또는 실행 검증에 실패했습니다.")
if any(issue.startswith("필수") for issue in issues):
return QaJudgment("FAIL", "\n".join(issues))
if support == "PARTIAL":
issues.append("지원 범위가 일부인 질문이므로 결과 범위를 함께 검토해야 합니다.")
if issues:
return QaJudgment("WARN", "\n".join(issues))
return QaJudgment("PASS", "고객 기준의 필수 SQL 요소와 실행 결과를 확인했습니다.")

View File

@@ -0,0 +1,583 @@
"""Oracle ADB persistence for the Smilegate customer QA benchmark."""
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime, timezone
import json
import os
from pathlib import Path
from typing import Any, Iterator, Mapping
from urllib.parse import parse_qs
import oracledb
from src.poc4.qa_history import QaQuestion, load_benchmark_questions, question_fingerprint, question_from_record
class QaHistoryStoreError(RuntimeError):
"""A safe user-facing persistence error."""
QUESTION_TABLE = "SG_AI_QA_QUESTION"
ANSWER_TABLE = "SG_AI_QA_ANSWER"
HISTORICAL_RUN_KEY = "HISTORICAL:2026-07-21:term-dict-final-v2"
def _env_value(name: str, env_file: Path | None = None) -> str:
value = os.environ.get(name, "").strip()
if value or env_file is None or not env_file.is_file():
return value
try:
lines = env_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return ""
for line in lines:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[7:].lstrip()
key, raw = line.split("=", 1)
if key.strip() != name:
continue
raw = raw.strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {"'", '"'}:
raw = raw[1:-1]
return raw.strip()
return ""
def _normalize_oracle_dsn(raw_dsn: str) -> tuple[str, str]:
value = str(raw_dsn or "").strip()
if value.startswith("jdbc:oracle:thin:@"):
value = value[len("jdbc:oracle:thin:@"):]
if "?" not in value:
return value, ""
dsn, query = value.split("?", 1)
parsed = parse_qs(query, keep_blank_values=False)
wallet_dir = (parsed.get("TNS_ADMIN") or parsed.get("tns_admin") or [""])[0]
return dsn.strip(), wallet_dir.strip()
def _read_lob(value: Any) -> Any:
return value.read() if hasattr(value, "read") else value
def _record_from_cursor(cursor: Any, row: Any) -> dict[str, Any]:
names = [column[0].lower() for column in cursor.description]
return {name: _read_lob(value) for name, value in zip(names, row)}
def _to_json(value: Mapping[str, Any] | None) -> str:
payload = dict(value or {})
text = json.dumps(payload, ensure_ascii=False, default=str)
if len(text) <= 120_000:
return text
return json.dumps(
{
"truncated": True,
"preview": text[:119_800],
},
ensure_ascii=False,
)
def _answer_record(row: Mapping[str, Any]) -> dict[str, Any]:
result_json = str(row.get("result_json") or "").strip()
try:
result = json.loads(result_json) if result_json else {}
except ValueError:
result = {"raw": result_json}
return {
"answer_seq": row.get("answer_seq"),
"question_id": row.get("question_id"),
"answer_kind": str(row.get("answer_kind") or ""),
"run_key": str(row.get("run_key") or ""),
"conversation_id": str(row.get("conversation_id") or ""),
"requested_by": str(row.get("requested_by") or ""),
"requested_at": str(row.get("requested_at") or ""),
"model_profile": str(row.get("model_profile") or ""),
"generated_sql": str(row.get("generated_sql") or ""),
"answer_text": str(row.get("answer_text") or ""),
"result": result,
"execution_output": str(row.get("execution_output") or ""),
"execution_status": str(row.get("execution_status") or ""),
"judgment_status": str(row.get("judgment_status") or ""),
"judgment_reason": str(row.get("judgment_reason") or ""),
"duration_ms": row.get("duration_ms"),
"created_at": str(row.get("created_at") or ""),
}
class QaHistoryStore:
def __init__(self, *, env_file: Path | None = None) -> None:
self._env_file = env_file
self._pool: Any | None = None
def _config(self) -> dict[str, str]:
username = (
_env_value("POC4_QA_DB_USERNAME", self._env_file)
or _env_value("BACKOFFICE_SELECT_AI_DB_USERNAME", self._env_file)
or _env_value("BACKOFFICE_DB_USERNAME", self._env_file)
)
password = (
_env_value("POC4_QA_DB_PASSWORD", self._env_file)
or _env_value("BACKOFFICE_SELECT_AI_DB_PASSWORD", self._env_file)
or _env_value("BACKOFFICE_DB_PASSWORD", self._env_file)
)
raw_dsn = (
_env_value("POC4_QA_DB_DSN", self._env_file)
or _env_value("BACKOFFICE_SELECT_AI_DB_URL", self._env_file)
or _env_value("BACKOFFICE_DB_URL", self._env_file)
)
dsn, wallet_from_dsn = _normalize_oracle_dsn(raw_dsn)
wallet_dir = (
_env_value("POC4_QA_DB_WALLET_DIR", self._env_file)
or wallet_from_dsn
or _env_value("ORACLE_WALLET_DIR", self._env_file)
)
if not username or not password or not dsn:
raise QaHistoryStoreError("질답 이력 DB 접속 설정을 확인해 주세요.")
return {
"username": username,
"password": password,
"dsn": dsn,
"wallet_dir": wallet_dir,
}
def _get_pool(self) -> Any:
if self._pool is not None:
return self._pool
config = self._config()
kwargs: dict[str, Any] = {
"user": config["username"],
"password": config["password"],
"dsn": config["dsn"],
"min": 1,
"max": 3,
"increment": 1,
"getmode": oracledb.POOL_GETMODE_WAIT,
}
wallet_dir = Path(config["wallet_dir"]).expanduser()
if config["wallet_dir"]:
if not wallet_dir.is_dir():
raise QaHistoryStoreError("질답 이력 DB Wallet 경로를 확인해 주세요.")
kwargs["config_dir"] = str(wallet_dir)
try:
self._pool = oracledb.create_pool(**kwargs)
return self._pool
except (oracledb.Error, OSError, ValueError) as exc:
raise QaHistoryStoreError("질답 이력 DB에 연결하지 못했습니다.") from exc
@contextmanager
def _connection(self) -> Iterator[Any]:
try:
with self._get_pool().acquire() as connection:
yield connection
except QaHistoryStoreError:
raise
except (oracledb.Error, OSError, ValueError) as exc:
raise QaHistoryStoreError("질답 이력 DB 작업에 실패했습니다.") from exc
def list_questions(self, *, limit: int = 200) -> list[QaQuestion]:
sql = f"""
SELECT q.question_id, q.question_code, q.category, q.title,
q.question_text, q.source_document, q.source_sheet,
q.source_row, q.source_scenario, q.sample_sql,
q.expected_focus, q.baseline_sql, q.baseline_answer,
q.support_level, q.evaluation_rule_json,
latest.judgment_status AS last_judgment_status,
TO_CHAR(latest.evaluated_at AT TIME ZONE 'Asia/Seoul',
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS last_evaluated_at
FROM {QUESTION_TABLE} q
LEFT JOIN (
SELECT question_id, judgment_status, evaluated_at
FROM (
SELECT question_id, judgment_status, evaluated_at,
ROW_NUMBER() OVER (
PARTITION BY question_id ORDER BY answer_seq DESC
) AS row_no
FROM {ANSWER_TABLE}
)
WHERE row_no = 1
) latest ON latest.question_id = q.question_id
WHERE q.active_yn = 'Y'
ORDER BY q.category, q.question_code
FETCH FIRST :row_limit ROWS ONLY
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, {"row_limit": int(limit)})
rows = [_record_from_cursor(cursor, row) for row in cursor]
return [question_from_record(row) for row in rows]
def get_question(self, question_id: int) -> QaQuestion | None:
sql = f"""
SELECT question_id, question_code, category, title, question_text,
source_document, source_sheet, source_row, source_scenario,
sample_sql, expected_focus, baseline_sql, baseline_answer,
support_level, evaluation_rule_json
FROM {QUESTION_TABLE}
WHERE question_id = :question_id AND active_yn = 'Y'
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, {"question_id": int(question_id)})
row = cursor.fetchone()
return question_from_record(_record_from_cursor(cursor, row)) if row else None
def get_question_by_code(self, question_code: str) -> QaQuestion | None:
sql = f"""
SELECT question_id, question_code, category, title, question_text,
source_document, source_sheet, source_row, source_scenario,
sample_sql, expected_focus, baseline_sql, baseline_answer,
support_level, evaluation_rule_json
FROM {QUESTION_TABLE}
WHERE question_code = :question_code AND active_yn = 'Y'
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, {"question_code": str(question_code).upper()})
row = cursor.fetchone()
return question_from_record(_record_from_cursor(cursor, row)) if row else None
def list_answers(self, question_id: int, *, limit: int = 30) -> list[dict[str, Any]]:
sql = f"""
SELECT answer_seq, question_id, answer_kind, run_key, conversation_id,
requested_by,
TO_CHAR(requested_at AT TIME ZONE 'Asia/Seoul',
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS requested_at,
model_profile, generated_sql, answer_text, result_json,
execution_output, execution_status, judgment_status,
judgment_reason, duration_ms,
TO_CHAR(created_at AT TIME ZONE 'Asia/Seoul',
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS created_at
FROM {ANSWER_TABLE}
WHERE question_id = :question_id
ORDER BY answer_seq DESC
FETCH FIRST :row_limit ROWS ONLY
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, {"question_id": int(question_id), "row_limit": int(limit)})
rows = [_record_from_cursor(cursor, row) for row in cursor]
return [_answer_record(row) for row in rows]
def find_or_create_free_text_question(self, question_text: str) -> QaQuestion:
normalized = str(question_text or "").strip()
if not normalized:
raise QaHistoryStoreError("자유 질의가 비어 있습니다.")
fingerprint = question_fingerprint(normalized)
code = f"ADHOC-{fingerprint[:12].upper()}"
merge_sql = f"""
MERGE INTO {QUESTION_TABLE} target
USING (SELECT :question_hash AS question_hash FROM dual) source
ON (target.question_hash = source.question_hash)
WHEN NOT MATCHED THEN INSERT (
question_code, question_source, question_hash, category, title,
question_text, support_level, evaluation_rule_json, active_yn
) VALUES (
:question_code, 'FREE_TEXT', :question_hash, 'FREE_TEXT',
:title, :question_text, 'REVIEW', '{{}}', 'Y'
)
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
merge_sql,
{
"question_hash": fingerprint,
"question_code": code,
"title": normalized[:180],
"question_text": normalized,
},
)
connection.commit()
cursor.execute(
f"""SELECT question_id FROM {QUESTION_TABLE}
WHERE question_hash = :question_hash""",
{"question_hash": fingerprint},
)
row = cursor.fetchone()
if not row:
raise QaHistoryStoreError("자유 질의 마스터를 저장하지 못했습니다.")
question = self.get_question(int(row[0]))
if question is None:
raise QaHistoryStoreError("자유 질의 마스터를 다시 읽지 못했습니다.")
return question
def record_answer(
self,
*,
question_id: int,
answer_kind: str,
conversation_id: str,
requested_by: str,
model_profile: str,
generated_sql: str,
answer_text: str,
result: Mapping[str, Any] | None,
execution_output: str,
execution_status: str,
judgment_status: str,
judgment_reason: str,
duration_ms: int | None,
run_key: str = "",
) -> None:
sql = f"""
INSERT INTO {ANSWER_TABLE} (
question_id, answer_kind, run_key, conversation_id, requested_by,
requested_at, model_profile, generated_sql, answer_text, result_json,
execution_output, execution_status, judgment_status,
judgment_reason, duration_ms
) VALUES (
:question_id, :answer_kind, :run_key, :conversation_id,
:requested_by, SYSTIMESTAMP, :model_profile, :generated_sql,
:answer_text, :result_json, :execution_output, :execution_status,
:judgment_status, :judgment_reason, :duration_ms
)
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
sql,
{
"question_id": int(question_id),
"answer_kind": str(answer_kind)[:20],
"run_key": str(run_key)[:100] or None,
"conversation_id": str(conversation_id)[:100] or None,
"requested_by": str(requested_by)[:100] or None,
"model_profile": str(model_profile)[:100] or None,
"generated_sql": str(generated_sql or ""),
"answer_text": str(answer_text or ""),
"result_json": _to_json(result),
"execution_output": str(execution_output or ""),
"execution_status": str(execution_status)[:40] or None,
"judgment_status": str(judgment_status)[:20],
"judgment_reason": str(judgment_reason or ""),
"duration_ms": duration_ms,
},
)
connection.commit()
def seed_benchmark(self, benchmark_file: Path) -> tuple[int, int]:
questions = load_benchmark_questions(benchmark_file)
raw = json.loads(benchmark_file.read_text(encoding="utf-8"))
raw_by_code = {
str(item.get("case_id") or "").upper(): item
for item in raw.get("scenarios", [])
if isinstance(item, Mapping)
}
seeded_questions = 0
seeded_answers = 0
for question in questions:
question_id = self._upsert_benchmark_question(question)
seeded_questions += 1
raw_item = raw_by_code[question.question_code]
history = raw_item.get("historical_answer") if isinstance(raw_item.get("historical_answer"), Mapping) else {}
inserted = self._seed_historical_answer(question_id, history, raw)
seeded_answers += 1 if inserted else 0
return seeded_questions, seeded_answers
def _upsert_benchmark_question(self, question: QaQuestion) -> int:
sql = f"""
MERGE INTO {QUESTION_TABLE} target
USING (SELECT :question_code AS question_code FROM dual) source
ON (target.question_code = source.question_code)
WHEN MATCHED THEN UPDATE SET
question_source = 'CUSTOMER_EXCEL',
question_hash = :question_hash,
category = :category,
title = :title,
question_text = :question_text,
source_document = :source_document,
source_sheet = :source_sheet,
source_row = :source_row,
source_scenario = :source_scenario,
sample_sql = :sample_sql,
expected_focus = :expected_focus,
baseline_sql = :baseline_sql,
baseline_answer = :baseline_answer,
support_level = :support_level,
evaluation_rule_json = :evaluation_rule_json,
active_yn = 'Y',
updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (
question_code, question_source, question_hash, category, title,
question_text, source_document, source_sheet, source_row,
source_scenario, sample_sql, expected_focus, baseline_sql,
baseline_answer, support_level, evaluation_rule_json, active_yn
) VALUES (
:question_code, 'CUSTOMER_EXCEL', :question_hash, :category,
:title, :question_text, :source_document, :source_sheet,
:source_row, :source_scenario, :sample_sql, :expected_focus,
:baseline_sql, :baseline_answer, :support_level,
:evaluation_rule_json, 'Y'
)
"""
binds = {
"question_code": question.question_code,
"question_hash": question_fingerprint(question.question_text),
"category": question.category[:30],
"title": question.title[:200],
"question_text": question.question_text,
"source_document": question.source_document[:255] or None,
"source_sheet": question.source_sheet[:255] or None,
"source_row": question.source_row,
"source_scenario": question.source_scenario,
"sample_sql": question.sample_sql,
"expected_focus": question.expected_focus,
"baseline_sql": question.baseline_sql,
"baseline_answer": question.baseline_answer,
"support_level": question.support_level[:20],
"evaluation_rule_json": json.dumps(question.evaluation_rule, ensure_ascii=False),
}
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, binds)
connection.commit()
cursor.execute(
f"SELECT question_id FROM {QUESTION_TABLE} WHERE question_code = :question_code",
{"question_code": question.question_code},
)
row = cursor.fetchone()
if not row:
raise QaHistoryStoreError(f"질문 마스터를 적재하지 못했습니다: {question.question_code}")
return int(row[0])
def _seed_historical_answer(
self,
question_id: int,
history: Mapping[str, Any],
benchmark: Mapping[str, Any],
) -> bool:
exists_sql = f"""
SELECT COUNT(*) FROM {ANSWER_TABLE}
WHERE question_id = :question_id AND run_key = :run_key
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(exists_sql, {"question_id": question_id, "run_key": HISTORICAL_RUN_KEY})
if int(cursor.fetchone()[0]) > 0:
return False
result = {
"source_report": str(benchmark.get("source_report") or ""),
"source_redmine": benchmark.get("source_redmine"),
"historical_execution_output": str(history.get("execution_output") or ""),
}
self.record_answer(
question_id=question_id,
answer_kind="HISTORICAL",
run_key=HISTORICAL_RUN_KEY,
conversation_id="",
requested_by="customer-excel-baseline",
model_profile=str(history.get("profile") or ""),
generated_sql=str(history.get("generated_sql") or ""),
answer_text=str(history.get("answer_text") or ""),
result=result,
execution_output=str(history.get("execution_output") or ""),
execution_status=str(history.get("execution_status") or ""),
judgment_status=str(history.get("judgment_status") or "REVIEW"),
judgment_reason=str(history.get("judgment_reason") or ""),
duration_ms=int(history.get("duration_ms") or 0),
)
return True
def schema_statements() -> tuple[str, ...]:
return (
f"""
CREATE TABLE {QUESTION_TABLE} (
question_id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY,
question_code VARCHAR2(30) UNIQUE,
question_source VARCHAR2(30) NOT NULL,
question_hash VARCHAR2(64) NOT NULL UNIQUE,
category VARCHAR2(30) NOT NULL,
title VARCHAR2(200) NOT NULL,
question_text CLOB NOT NULL,
source_document VARCHAR2(255),
source_sheet VARCHAR2(255),
source_row NUMBER,
source_scenario CLOB,
sample_sql CLOB,
expected_focus CLOB,
baseline_sql CLOB,
baseline_answer CLOB,
support_level VARCHAR2(20) NOT NULL,
evaluation_rule_json CLOB CHECK (evaluation_rule_json IS JSON),
active_yn CHAR(1) DEFAULT 'Y' NOT NULL CHECK (active_yn IN ('Y', 'N')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT sg_ai_qa_question_source_ck
CHECK (question_source IN ('CUSTOMER_EXCEL', 'FREE_TEXT'))
)
""",
f"""
CREATE TABLE {ANSWER_TABLE} (
answer_seq NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
question_id NUMBER NOT NULL,
answer_kind VARCHAR2(20) NOT NULL,
run_key VARCHAR2(100),
conversation_id VARCHAR2(100),
requested_by VARCHAR2(100),
requested_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
model_profile VARCHAR2(100),
generated_sql CLOB,
answer_text CLOB,
result_json CLOB CHECK (result_json IS JSON),
execution_output CLOB,
execution_status VARCHAR2(40),
judgment_status VARCHAR2(20) NOT NULL,
judgment_reason CLOB,
duration_ms NUMBER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT sg_ai_qa_answer_question_fk
FOREIGN KEY (question_id)
REFERENCES {QUESTION_TABLE} (question_id)
ON DELETE CASCADE,
CONSTRAINT sg_ai_qa_answer_kind_ck
CHECK (answer_kind IN ('HISTORICAL', 'LIVE')),
CONSTRAINT sg_ai_qa_answer_judgment_ck
CHECK (judgment_status IN ('PASS', 'WARN', 'FAIL', 'REVIEW'))
)
""",
f"""
CREATE INDEX sg_ai_qa_answer_question_ix
ON {ANSWER_TABLE} (question_id, answer_seq DESC)
""",
f"""
CREATE UNIQUE INDEX sg_ai_qa_answer_run_uk
ON {ANSWER_TABLE} (question_id, run_key)
""",
)
def ensure_schema(store: QaHistoryStore) -> None:
objects = (QUESTION_TABLE, ANSWER_TABLE)
with store._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"SELECT table_name FROM user_tables WHERE table_name IN (:q, :a)",
{"q": objects[0], "a": objects[1]},
)
existing = {str(row[0]) for row in cursor}
statements = schema_statements()
if QUESTION_TABLE not in existing:
cursor.execute(statements[0])
if ANSWER_TABLE not in existing:
cursor.execute(statements[1])
cursor.execute(
"SELECT index_name FROM user_indexes WHERE index_name IN (:ix1, :ix2)",
{"ix1": "SG_AI_QA_ANSWER_QUESTION_IX", "ix2": "SG_AI_QA_ANSWER_RUN_UK"},
)
indexes = {str(row[0]) for row in cursor}
if "SG_AI_QA_ANSWER_QUESTION_IX" not in indexes:
cursor.execute(statements[2])
if "SG_AI_QA_ANSWER_RUN_UK" not in indexes:
cursor.execute(statements[3])
connection.commit()
def timestamp_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")

View File

@@ -0,0 +1,5 @@
"""Smilegate demo modules.
The portal is assembled from small modules so each feature can be reviewed and
released independently.
"""

View File

@@ -0,0 +1 @@
"""Presentation modules for the Smilegate demo."""

View File

@@ -0,0 +1,28 @@
"""Blank presentation shell.
No authentication, data access, MCP call, persistence, or customer text belongs
in this module. It exists only to prove the minimal Streamlit runtime path.
"""
from __future__ import annotations
from typing import Any
def render_blank_shell(st: Any) -> None:
"""Render the intentionally empty first review screen."""
st.set_page_config(page_title="Smilegate Demo", layout="wide")
st.markdown(
"""
<style>
[data-testid="stHeader"],
[data-testid="stToolbar"],
#MainMenu,
footer { display: none; }
[data-testid="stAppViewContainer"],
.stApp { background: #ffffff; }
.block-container { padding: 0; max-width: none; }
</style>
""",
unsafe_allow_html=True,
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,21 @@
{
"version": 1,
"product": {
"name": "AI 업무 에이전트",
"short_name": "AGENT",
"page_title": "AI 업무 에이전트",
"name": "SMILEGATE DATA & AI POC",
"short_name": "SMILEGATE",
"page_title": "SMILEGATE DATA & AI POC",
"page_icon": "🤖",
"header_title": "AI 업무 에이전트",
"header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.",
"login_kicker": "DATA & AI DEMO",
"login_title": "AI 업무 에이전트",
"login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.",
"login_footer": "인된 DEMO 사용자만 접근할 수 있습니다."
"header_title": "스마일게이트 게임 데이터 AI 에이전트",
"header_description": "게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.",
"login_kicker": "SMILEGATE DATA & AI POC",
"login_title": "스마일게이트 게임 데이터 AI 에이전트",
"login_description": "사용자 인증 후 게임 데이터 AI 질의와 보안 관리 기능을 이용할 수 있습니다.",
"login_footer": "인된 Data & AI PoC 사용자만 접근할 수 있습니다."
},
"theme": {
"primary_color": "#003b70",
"text_color": "#172b3a",
"muted_color": "#667785",
"border_color": "#dfe7ed"
"primary_color": "#113F67",
"text_color": "#15283B",
"muted_color": "#5D6C7C",
"border_color": "#D7E0E8"
}
}

View File

@@ -1,23 +1,58 @@
{
"default_server_id": "hmm_hr_mcp",
"default_server_id": "smilegate_game_data_mcp",
"servers": [
{
"id": "hmm_hr_mcp",
"id": "smilegate_game_data_mcp",
"enabled": true,
"provider": "hmm_compat_mcp",
"provider": "smilegate_select_ai_mcp",
"transport": "http",
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
"timeout_seconds_env": "AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS",
"default_tool": "search_hr_data",
"endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp",
"auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN",
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
"default_tool": "oracle.select_ai.smilegate_fewshot_nl2sql",
"router_model_profile": "gpt54_mini_oci",
"tool_allowlist": [
"search_hr_data",
"resolve_hr_term",
"search_hr_policy",
"search_carrier_performance"
"oracle.select_ai.fewshot_preflight",
"oracle.select_ai.game_query_plan",
"oracle.select_ai.game_daily_au_lookup",
"oracle.select_ai.smilegate_fewshot_nl2sql",
"oracle.select_ai.smilegate_game_text2sql",
"oracle.select_ai.qa_vector_search",
"oracle.select_ai.qa_vector_store"
],
"description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server"
"tool_workflow": [
{
"tool": "oracle.select_ai.fewshot_preflight",
"prelude": true
},
{
"tool": "oracle.select_ai.game_query_plan",
"prelude": true
},
{
"tool": "oracle.select_ai.smilegate_fewshot_nl2sql",
"arguments_from": [
{
"argument": "fewShotPreflight",
"tool": "oracle.select_ai.fewshot_preflight"
},
{
"argument": "queryPlan",
"tool": "oracle.select_ai.game_query_plan"
}
]
},
{
"tool": "oracle.select_ai.game_daily_au_lookup",
"arguments_from": [
{
"argument": "queryPlan",
"tool": "oracle.select_ai.game_query_plan"
}
]
}
],
"description": "Smilegate game-data Text2SQL MCP server"
}
]
}

View File

@@ -0,0 +1,41 @@
{
"version": 1,
"description": "Smilegate Data & AI PoC 화면에 표시할 게임 데이터 질의 샘플입니다.",
"scenarios": [
{
"id": "GAME-01",
"enabled": true,
"category": "활성 사용자",
"title": "카제나 최신 AU",
"question": "카제나 최신 기준 활성 사용자 수(AU)를 알려줘"
},
{
"id": "GAME-02",
"enabled": true,
"category": "매출",
"title": "게임별 판매 현황",
"question": "최신 기준 게임별 판매 건수와 판매 금액을 보여줘"
},
{
"id": "GAME-03",
"enabled": true,
"category": "환불",
"title": "최근 환불 현황",
"question": "최신 기준 게임별 환불 건수와 환불 금액을 보여줘"
},
{
"id": "GAME-04",
"enabled": true,
"category": "게임·서버",
"title": "게임 서버 구성",
"question": "등록된 게임과 게임 서버 정보를 보여줘"
},
{
"id": "GAME-05",
"enabled": true,
"category": "사용자 분석",
"title": "신규 사용자 현황",
"question": "최신 월 기준 게임별 신규 사용자 수를 보여줘"
}
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,52 +1,24 @@
{
"version": 2,
"description": "HMM HR 데모 사용자 선택 목록입니다. 파일명은 기존 배포 호환성을 위해 유지합니다. token 원문은 저장하지 않고 mcp_token_env의 서버 환경변수만 참조합니다.",
"presets": [
{
"enabled": true,
"default": true,
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
"user_id": "E1001",
"name": "Kim Minseo",
"role": "HR Team Manager",
"team": "HMM HR Demo Team",
"scope": "팀원 6명의 휴가·근태 현황을 확인하는 관리자 데모"
"user_id": "1001",
"name": "Data & AI TF 팀장",
"role": "DATA_AI_POC_ADMIN",
"channel": "DATA_AI_TF",
"scope": "SGMP_POC 게임 데이터 전체",
"token_env": "SMILEGATE_TEAMLEAD_BEARER_TOKEN"
},
{
"enabled": true,
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
"user_id": "E1002",
"name": "Lee Jiwon",
"role": "HR Operations Specialist",
"team": "HMM HR Demo Team",
"scope": "본인 휴가 잔여·신청·근태를 확인하는 팀원 데모"
},
{
"enabled": true,
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
"user_id": "E1003",
"name": "Park Dohyun",
"role": "People Analytics Analyst",
"team": "HMM HR Demo Team",
"scope": "본인 휴가·근태와 팀 인력 현황을 확인하는 분석 담당 데모"
},
{
"enabled": true,
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
"user_id": "E1005",
"name": "Han Seojun",
"role": "Recruiting Specialist",
"team": "HMM HR Demo Team",
"scope": "대기 중인 2일 연차 신청을 확인하는 팀원 데모"
},
{
"enabled": true,
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
"user_id": "E1007",
"name": "Kang Minho",
"role": "HR Coordinator",
"team": "HMM HR Demo Team",
"scope": "대기 중인 1일 연차 신청과 휴가 근태를 확인하는 팀원 데모"
"default": false,
"user_id": "1002",
"name": "Data & AI TF 팀원",
"role": "DATA_AI_POC_ADMIN",
"channel": "DATA_AI_TF",
"scope": "SGMP_POC 게임 데이터 전체",
"token_env": "SMILEGATE_TEAMMEMBER_BEARER_TOKEN"
}
]
}

View File

@@ -0,0 +1,128 @@
"""Small same-origin authentication gateway for the Smilegate Streamlit portal.
The gateway issues a signed HttpOnly cookie after validating the configured
PBKDF2 password. The Streamlit application verifies the signature and expiry
from the incoming request, so browser refreshes and WebSocket reconnects do not
require a new login.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
COOKIE_NAME = "poc4_portal_auth"
MAX_BODY_BYTES = 8_192
COOKIE_TTL_SECONDS = int(os.environ.get("POC4_LOGIN_COOKIE_TTL_SECONDS", "43200"))
def _password_matches(password: str, encoded_password: str) -> bool:
try:
scheme, iterations_text, salt_hex, expected_hex = encoded_password.split("$", 3)
iterations = int(iterations_text)
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(expected_hex)
except (TypeError, ValueError):
return False
if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000:
return False
candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(candidate, expected)
def _cookie_value(username: str) -> str:
secret = os.environ["POC4_LOGIN_REMEMBER_SECRET"]
claims = {"v": 1, "u": username, "e": int(time.time()) + COOKIE_TTL_SECONDS}
encoded = base64.urlsafe_b64encode(
json.dumps(claims, separators=(",", ":")).encode("utf-8")
).decode("ascii").rstrip("=")
signature = hmac.new(secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256).hexdigest()
return f"{encoded}.{signature}"
def _set_cookie(handler: BaseHTTPRequestHandler, value: str, max_age: int) -> None:
attributes = [
f"{COOKIE_NAME}={value}",
"Path=/",
f"Max-Age={max_age}",
"HttpOnly",
"Secure",
"SameSite=Lax",
]
handler.send_header("Set-Cookie", "; ".join(attributes))
class PortalAuthHandler(BaseHTTPRequestHandler):
server_version = "SmilegatePortalAuth/1.0"
def log_message(self, _format: str, *_args: object) -> None:
# Do not log form data or authentication details.
return
def _redirect(self, location: str, cookie_value: str | None = None, max_age: int = 0) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
if cookie_value is not None:
_set_cookie(self, cookie_value, max_age)
self.send_header("Location", location)
self.send_header("Cache-Control", "no-store")
self.end_headers()
def do_GET(self) -> None: # noqa: N802
if self.path == "/health":
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(b"ok\n")
return
if self.path == "/logout":
self._redirect("/", "", 0)
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None: # noqa: N802
if self.path != "/login":
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
content_length = 0
if content_length <= 0 or content_length > MAX_BODY_BYTES:
self._redirect("/?login=failed")
return
form = parse_qs(self.rfile.read(content_length).decode("utf-8"), keep_blank_values=True)
username = form.get("username", [""])[0].strip()
password = form.get("password", [""])[0]
expected_username = os.environ.get("POC4_LOGIN_USER", "").strip()
encoded_password = os.environ.get("POC4_LOGIN_PASSWORD_PBKDF2", "").strip()
if (
expected_username
and hmac.compare_digest(username, expected_username)
and _password_matches(password, encoded_password)
):
self._redirect("/", _cookie_value(username), COOKIE_TTL_SECONDS)
return
self._redirect("/?login=failed")
def main() -> None:
address = os.environ.get("POC4_AUTH_BIND", "127.0.0.1")
port = int(os.environ.get("POC4_AUTH_PORT", "8623"))
required = ("POC4_LOGIN_USER", "POC4_LOGIN_PASSWORD_PBKDF2", "POC4_LOGIN_REMEMBER_SECRET")
missing = [name for name in required if not os.environ.get(name, "").strip()]
if missing:
raise RuntimeError("missing required portal auth configuration")
ThreadingHTTPServer((address, port), PortalAuthHandler).serve_forever()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Create and seed the Smilegate customer QA benchmark history tables."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.poc4.qa_history_store import QaHistoryStore, ensure_schema
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--benchmark",
type=Path,
default=ROOT / "config" / "smilegate_qa_benchmark.json",
help="Customer Excel benchmark JSON generated from the approved QA report.",
)
parser.add_argument(
"--env-file",
type=Path,
default=None,
help="Optional environment file containing the QA DB connection settings.",
)
args = parser.parse_args()
if not args.benchmark.is_file():
raise SystemExit(f"Benchmark file not found: {args.benchmark}")
store = QaHistoryStore(env_file=args.env_file)
ensure_schema(store)
question_count, historical_insert_count = store.seed_benchmark(args.benchmark)
print(
"qa_history_sync"
f" questions={question_count}"
f" historical_answers_inserted={historical_insert_count}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,20 @@
"""Minimal Smilegate Streamlit demo entrypoint.
This entrypoint intentionally wires only the blank presentation shell. Feature
modules such as authentication, MCP querying, and history are added separately
after each review.
"""
from __future__ import annotations
import streamlit as st
from ai_web_agent_console.smilegate_demo.ui.shell import render_blank_shell
def main() -> None:
render_blank_shell(st)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,51 @@
"""OCI GenAI configuration validation tests."""
from __future__ import annotations
import os
import unittest
from src.oci_genai_sdk import ALLOWED_OCI_SETTINGS, load_oci_settings
class OCISettingsTest(unittest.TestCase):
def setUp(self) -> None:
self._previous = {key: os.environ.get(key) for key in ALLOWED_OCI_SETTINGS}
os.environ.update(
{
"OCI_AUTH_TYPE": "config_file",
"OCI_CONFIG_FILE": "/home/opc/.oci/config",
"OCI_PROFILE": "DEFAULT",
}
)
def tearDown(self) -> None:
for key, value in self._previous.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def test_accepts_a_child_compartment_ocid(self) -> None:
os.environ["OCI_GENAI_COMPARTMENT_ID"] = "ocid1.compartment.oc1..example"
settings = load_oci_settings()
self.assertEqual("ocid1.compartment.oc1..example", settings.compartment_id)
def test_accepts_a_tenancy_ocid_for_the_root_compartment(self) -> None:
os.environ["OCI_GENAI_COMPARTMENT_ID"] = "ocid1.tenancy.oc1..example"
settings = load_oci_settings()
self.assertEqual("ocid1.tenancy.oc1..example", settings.compartment_id)
def test_rejects_an_invalid_compartment_identifier(self) -> None:
os.environ["OCI_GENAI_COMPARTMENT_ID"] = "not-an-ocid"
with self.assertRaisesRegex(ValueError, "compartment is not configured"):
load_oci_settings()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from pathlib import Path
import unittest
from src.poc4.qa_history import evaluate_sql, load_benchmark_questions
from src.poc4.qa_history_store import _normalize_oracle_dsn, schema_statements
class QaHistoryTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
benchmark = Path(__file__).parents[1] / "config" / "smilegate_qa_benchmark.json"
cls.questions = {item.question_code: item for item in load_benchmark_questions(benchmark)}
def test_customer_excel_benchmark_contains_all_47_cases(self) -> None:
self.assertEqual(47, len(self.questions))
self.assertIn("STD-01", self.questions)
self.assertIn("CZN-19", self.questions)
def test_supported_query_passes_when_required_terms_are_present(self) -> None:
judgment = evaluate_sql(
self.questions["STD-13"],
"SELECT SUM(PAYMT_AMT) FROM COMN_SALES_TXN",
execution_succeeded=True,
)
self.assertEqual("PASS", judgment.status)
def test_monthly_au_with_au_flag_fails(self) -> None:
judgment = evaluate_sql(
self.questions["STD-27"],
"""
SELECT COUNT(*)
FROM CZN_COMN_USER_MST
WHERE AU_FLAG = 1
AND BASE_DT = (SELECT MAX(BASE_DT) FROM CZN_COMN_USER_MST)
AND LAST_CONN_DT >= ADD_MONTHS(BASE_DT, -1)
AND STD_USER_YN = 'Y'
AND EXPT_USER_YN = 'N'
""",
execution_succeeded=True,
)
self.assertEqual("FAIL", judgment.status)
self.assertIn("AU_FLAG", judgment.reason)
def test_unsupported_game_requires_safe_alias_lookup(self) -> None:
safe = evaluate_sql(
self.questions["STD-02"],
"SELECT GAME_ID FROM COMN_GAME_ALIAS_BAS WHERE GAME_NM LIKE '%버블리즈%'",
execution_succeeded=True,
)
unsafe = evaluate_sql(
self.questions["STD-02"],
"SELECT COUNT(*) FROM CZN_COMN_USER_MST WHERE GAME_ID = 'STOVE_CHAOSZERO'",
execution_succeeded=True,
)
self.assertEqual("PASS", safe.status)
self.assertEqual("FAIL", unsafe.status)
def test_free_text_is_review_not_automatic_pass(self) -> None:
judgment = evaluate_sql(None, "SELECT 1 FROM DUAL", execution_succeeded=True)
self.assertEqual("REVIEW", judgment.status)
def test_jdbc_url_wallet_is_normalized_for_python_driver(self) -> None:
self.assertEqual(
("sgmpaipoc_medium", "/home/opc/wallet/sgmpaipoc"),
_normalize_oracle_dsn(
"jdbc:oracle:thin:@sgmpaipoc_medium?TNS_ADMIN=/home/opc/wallet/sgmpaipoc"
),
)
def test_schema_defines_two_history_tables_and_indexes(self) -> None:
statements = "\n".join(schema_statements())
self.assertIn("CREATE TABLE SG_AI_QA_QUESTION", statements)
self.assertIn("CREATE TABLE SG_AI_QA_ANSWER", statements)
self.assertIn("answer_seq NUMBER GENERATED ALWAYS AS IDENTITY", statements)
if __name__ == "__main__":
unittest.main()

View File

@@ -6,134 +6,44 @@ import tempfile
import unittest
from unittest.mock import patch
from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios
from ai_web_agent_console.profile import load_app_profile
from ai_web_agent_console.mcp_tool_router import McpTool, build_mcp_tool_arguments
from ai_web_agent_console.mcp_result import (
has_actionable_text_result,
status_result_evidence,
status_result_summary,
)
from ai_web_agent_console.model_registry import load_model_registry
def _load_console_query_helpers():
"""Load the Streamlit entrypoint only when its optional runtime is installed."""
try:
from app import _prepare_hmm_hr_tool_query
except ModuleNotFoundError:
return None
return _prepare_hmm_hr_tool_query
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
from src.agent_console.profile import load_app_profile
class DemoScenarioConfigTest(unittest.TestCase):
def test_model_registry_uses_console_names(self) -> None:
registry = load_model_registry()
self.assertEqual(
registry.registry_name,
"AI_WEB_AGENT_CONSOLE_MODEL_PROFILES",
)
self.assertTrue(registry.default_profile.default_for_console)
def test_profile_environment_overrides_json_defaults(self) -> None:
path = Path(__file__).parents[1] / "config" / "app_profile.json"
with patch.dict(
"os.environ",
{
"AGENT_CONSOLE_SHORT_NAME": "HMM",
"AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트",
"AGENT_CONSOLE_PRIMARY_COLOR": "#003b70",
"AGENT_CONSOLE_SHORT_NAME": "SMILEGATE",
"AGENT_CONSOLE_PAGE_TITLE": "SMILEGATE DATA & AI POC",
"AGENT_CONSOLE_PRIMARY_COLOR": "#113F67",
},
clear=False,
):
profile = load_app_profile(path)
self.assertEqual(profile.short_name, "HMM")
self.assertEqual(profile.page_title, "HMM AI 업무 에이전트")
self.assertEqual(profile.primary_color, "#003b70")
self.assertEqual(profile.short_name, "SMILEGATE")
self.assertEqual(profile.page_title, "SMILEGATE DATA & AI POC")
self.assertEqual(profile.primary_color, "#113F67")
def test_profile_reads_dotenv_values(self) -> None:
path = Path(__file__).parents[1] / "config" / "app_profile.json"
with tempfile.TemporaryDirectory() as temp_dir:
env_file = Path(temp_dir) / ".env"
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=HMM\n", encoding="utf-8")
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=SMILEGATE\n", encoding="utf-8")
profile = load_app_profile(path, env_file)
self.assertEqual(profile.short_name, "HMM")
self.assertEqual(profile.short_name, "SMILEGATE")
def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None:
path = Path(__file__).parents[1] / "ai_web_agent_console" / "presentation.py"
source = path.read_text(encoding="utf-8")
self.assertIn('[data-testid="stAppViewContainer"] li', source)
self.assertIn('[data-testid="stExpander"] summary', source)
self.assertIn('div[data-testid="stButton"] > button', source)
self.assertIn('[data-baseweb="tab-list"] [role="tab"]', source)
self.assertIn('[data-testid="stTab"]', source)
self.assertIn('[role="tab"][aria-selected="true"]', source)
def test_audit_tab_uses_hmm_access_audit_loaders(self) -> None:
root = Path(__file__).parents[1]
entrypoint = (root / "app.py").read_text(
encoding="utf-8"
)
renderer = (root / "ai_web_agent_console" / "audit.py").read_text(
encoding="utf-8"
)
self.assertIn("FROM ADMIN.HMM_ACCESS_AUDIT", entrypoint)
self.assertIn("_load_hmm_audit_inventory", entrypoint)
self.assertIn("(protocol=tcps)(port=1521)", entrypoint)
self.assertIn(
"AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD",
entrypoint,
)
self.assertIn("HMM 접근 관리", renderer)
self.assertNotIn('AUDIT_SCHEMA = "POC_2"', entrypoint)
def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"
def test_smilegate_scenarios_are_enabled_and_unique(self) -> None:
path = Path(__file__).parents[1] / "config" / "smilegate_demo_scenarios.json"
scenarios = load_demo_scenarios(path)
self.assertGreaterEqual(len(scenarios), 3)
self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios}))
self.assertTrue(all(item.question.strip() for item in scenarios))
by_id = {item.scenario_id: item for item in scenarios}
self.assertEqual(
{"FED-01", "FED-02", "FED-03"},
{"FED-01", "FED-02", "FED-03"} & set(by_id),
)
self.assertTrue(
all("선사" in by_id[scenario_id].question for scenario_id in (
"FED-01", "FED-02", "FED-03"
))
)
def test_hmm_mcp_allows_carrier_federation_tool(self) -> None:
path = Path(__file__).parents[1] / "config" / "mcp_servers.json"
payload = json.loads(path.read_text(encoding="utf-8"))
server = next(
item for item in payload["servers"] if item["id"] == "hmm_hr_mcp"
)
self.assertIn(
"search_carrier_performance",
server["tool_allowlist"],
)
def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None:
path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json"
payload = json.loads(path.read_text(encoding="utf-8"))
presets = payload["presets"]
self.assertEqual(payload["version"], 2)
self.assertEqual({item["user_id"] for item in presets}, {
"E1001", "E1002", "E1003", "E1005", "E1007"
})
self.assertTrue(all(item["mcp_token_env"] == "HMM_MCP_BEARER_TOKEN" for item in presets))
self.assertTrue(all("token" not in item for item in presets))
def test_duplicate_id_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
@@ -152,87 +62,6 @@ class DemoScenarioConfigTest(unittest.TestCase):
with self.assertRaises(ScenarioConfigError):
load_demo_scenarios(path)
def test_default_mcp_tool_arguments_follow_discovered_query_schema(self) -> None:
tool = McpTool(
name="search_hr_data",
description="",
schema={
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
read_only=True,
)
arguments = build_mcp_tool_arguments(
tool, "직원 E1005의 휴가 신청 내역", 50, preferred_tool="search_hr_data"
)
self.assertEqual(arguments, {"query": "직원 E1005의 휴가 신청 내역"})
def test_term_tool_arguments_follow_discovered_term_schema(self) -> None:
tool = McpTool(
name="resolve_hr_term",
description="",
schema={
"type": "object",
"properties": {"term": {"type": "string"}},
"required": ["term"],
},
read_only=True,
)
arguments = build_mcp_tool_arguments(
tool, "반차", 50, preferred_tool="search_hr_data"
)
self.assertEqual(arguments, {"term": "반차"})
def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None:
result = {
"status": "success",
"result": (
"HR_POLICY_SEARCH_RESULT\n"
"EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준"
),
}
summary = status_result_summary(result, excerpt_chars=40)
evidence = status_result_evidence(result)
self.assertEqual(summary["status"], "success")
self.assertGreater(summary["result_chars"], 40)
self.assertIn("KR_Leave_Policy.pdf", evidence["result"])
self.assertTrue(has_actionable_text_result(result))
def test_no_data_text_is_not_actionable(self) -> None:
self.assertFalse(
has_actionable_text_result({"status": "success", "result": "No data found"})
)
@unittest.skipIf(_load_console_query_helpers() is None, "Streamlit runtime is optional")
def test_policy_query_does_not_include_demo_user_context(self) -> None:
prepare = _load_console_query_helpers()
assert prepare is not None
tool = McpTool(
name="search_hr_policy",
description="Search policy documents",
schema={"properties": {"query": {"type": "string"}}},
read_only=True,
)
query = prepare(
question="연차 휴가 이월 기준과 제한을 알려줘",
tool=tool,
model_profile_key="gpt54_mini_oci",
selected_user_id="E1001",
selected_user_role="HR Team Manager",
selected_user_team="HMM HR Demo Team",
selected_user_scope="팀원 6명 관리",
)
self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,22 @@
-- Approve the reviewed customer QA example for a grouped business-AU query.
-- Empty result sets remain valid executed query results.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
answer_text = 'Expected focus: aggregate business AU by the user-master country attribute. '
|| 'Join CZN_CUSTOM_BIZ_USER_TXN to CZN_COMN_USER_MST by GUID and BASE_DT; filter BIZ_AU_FLAG=1 and EXPT_USER_YN=''N'', then group by LAST_CONN_COUNTRY_CD. '
|| 'A successfully executed query with no country rows is a valid result, not a SQL failure. '
|| 'Historical answer: no result rows.',
inspection_note = 'Customer QA verified: country business-AU is a grouped join; an empty result is a valid query outcome.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-05';
COMMIT;
SELECT example_id, reference_status, inspection_status, source_case_id, answer_text
FROM sg_qa_vector_example
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-05';

View File

@@ -0,0 +1,16 @@
-- Approve the reviewed customer QA example for a grouped standard-AU query.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
answer_text = 'Expected focus: aggregate standard AU by user-master country, joining COMN_COUNTRY_BAS only for the country display name. '
|| 'Use CZN_COMN_USER_MST with AU_FLAG=1 and EXPT_USER_YN=''N'', grouped by LAST_CONN_COUNTRY_CD and COUNTRY_KR_NM. '
|| 'The label standard AU does not imply STD_USER_YN. A successfully executed empty result is valid. '
|| 'Historical answer: no result rows.',
inspection_note = 'Customer QA verified: country standard-AU is grouped AU_FLAG aggregation; empty output is valid.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-06';
COMMIT;

View File

@@ -0,0 +1,15 @@
-- Approve the reviewed customer QA example for daily in-game currency holdings.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
answer_text = 'Expected focus: daily crystal holdings use CZN_CUSTOM_GOODS_HAVE_TXN joined to CZN_COMN_USER_MST and CZN_COMN_SVC_DIM_BAS. '
|| 'Filter the goods dimension to crystal, nonzero HAVE_CNT, eligible returning-user population, and the requested date range; group by BASE_DT. '
|| 'A successfully executed empty result is valid. Historical answer: no result rows.',
inspection_note = 'Customer QA verified: daily crystal holdings are a date-grouped goods/user/dimension join; empty output is valid.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-07';
COMMIT;

View File

@@ -0,0 +1,15 @@
-- Approve the exact customer QA for standard-AU crystal holdings per user.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
answer_text = 'Use the verified customer SQL template for crystal holdings among standard AU. '
|| 'The standard-AU population uses AU_FLAG=1 and EXPT_USER_YN=''N''; do not add STD_USER_YN unless explicitly requested. '
|| 'Use the template population denominator for the per-user average. Null aggregate values are valid when the qualifying set is empty.',
inspection_note = 'Customer QA verified: retain the approved standard-AU population and average denominator semantics.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-08';
COMMIT;

View File

@@ -0,0 +1,21 @@
-- Customer-provided CZN benchmark examples are the approved reference corpus
-- for exact-question Few-shot retrieval. Their SQL and expected-answer text
-- remain the source of metric semantics; no runtime game/table branching is added.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
inspection_note = 'Customer QA benchmark approved for exact-question Few-shot retrieval.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id LIKE 'CZN-%'
AND reference_status <> 'APPROVED';
COMMIT;
SELECT source_case_id, reference_status, inspection_status
FROM sg_qa_vector_example
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id LIKE 'CZN-%'
ORDER BY source_case_id;

View File

@@ -0,0 +1,11 @@
-- Preserve customer QA output semantics for empty numeric aggregates.
UPDATE sg_qa_vector_example
SET answer_text = NVL(answer_text, '') || ' For this approved metric, normalize an empty numeric aggregate to 0 in the returned result. Preserve the template join from CZN_CUSTOM_GOODS_CHANGE_TXN to CZN_COMN_USER_MST, apply u.EXPT_USER_YN=''N'', and count distinct u.GUID.',
inspection_note = 'Customer QA verified: empty total Ether usage is reported as numeric zero with the template user-master join, excluded-user filter, and user population.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-13';
COMMIT;

View File

@@ -0,0 +1,21 @@
-- Approve the remaining customer-provided standard QA references for exact-question Few-shot retrieval.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
inspection_note = 'Customer QA benchmark approved for exact-question Few-shot retrieval.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id LIKE 'STD-%'
AND reference_status <> 'APPROVED';
UPDATE sg_qa_vector_example
SET answer_text = NVL(answer_text, '') || ' This unavailable-object case must not fabricate a DUAL/NULL result row. Return no result rows and explain that no approved physical object is available for the resolved game.',
inspection_note = 'Customer QA verified: unavailable game objects return no result rows; no synthetic DUAL result.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'STD-01';
COMMIT;

View File

@@ -0,0 +1,64 @@
-- Customer QA is evaluation data, never production Few-shot context.
-- Preserve it for SG_AI_QA_* baseline/history audit while retiring its vector copies.
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
inspection_note = 'Evaluation-only customer QA. Excluded from production Few-shot retrieval.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_EVALUATION_SEPARATION'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK';
COMMIT;
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR
AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
BEGIN
IF p_question IS NULL THEN
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'target_type must be NONE, SINGLE, MULTI, ALL, or ANY.');
END IF;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question,
JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id,
question,
answer_sql,
answer_text,
embedding_model,
reference_kind,
target_type,
object_role,
source_case_id,
source_type,
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND (source_type IS NULL OR source_type <> 'CUSTOMER_QA_BENCHMARK')
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
ORDER BY vector_distance(embedding, v_query_vector, COSINE), example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
SELECT source_type, reference_status, COUNT(*) AS example_count
FROM sg_qa_vector_example
GROUP BY source_type, reference_status
ORDER BY source_type, reference_status;

View File

@@ -0,0 +1,165 @@
-- Build one generalized runtime Few-shot pattern for every customer QA case.
-- The source benchmark remains evaluation-only; this derived record contains
-- no customer game name, date literal, expected result, or physical CZN object.
DECLARE
v_pattern_question CLOB;
v_pattern_sql CLOB;
v_embedding_input CLOB;
v_embedding VECTOR;
v_object_role VARCHAR2(64);
v_exists NUMBER;
FUNCTION generalized_question(p_question CLOB) RETURN CLOB IS
v_value CLOB := p_question;
BEGIN
-- Resolved names/aliases become a semantic game placeholder.
FOR token IN (
SELECT column_value AS value
FROM TABLE(sys.odcivarchar2list(
'카오스 제로 나이트메어', '카오스제로나이트메어', 'Chaos Zero Nightmare',
'STOVE_CHAOSZERO', '카제나', 'CZN', 'Bubblyz', '버블리즈',
'로드나인', '로나', '테스트게임', 'BUBBLYZ', 'LORDNINE'
))
) LOOP
v_value := REPLACE(v_value, token.value, '<게임>');
END LOOP;
v_value := REGEXP_REPLACE(v_value, '[0-9]{4}년[[:space:]]*[0-9]{1,2}월[[:space:]]*[0-9]{1,2}일', '<기준일>');
v_value := REGEXP_REPLACE(v_value, '[0-9]{4}-[0-9]{2}-[0-9]{2}', '<기준일>');
RETURN v_value;
END;
FUNCTION generalized_sql(p_sql CLOB) RETURN CLOB IS
v_value CLOB := p_sql;
BEGIN
-- Physical game objects become logical roles. Common dimensions remain
-- logical as well so the current metadata/plan selects real objects.
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_COMN_USER_MST"', '<RESOLVED_GAME_USER_MASTER>');
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_COMN_CHARACTER_MST"', '<RESOLVED_GAME_CHARACTER_MASTER>');
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_CUSTOM_GOODS_HAVE_TXN"', '<RESOLVED_GAME_GOODS_HOLDINGS>');
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_CUSTOM_GOODS_CHANGE_TXN"', '<RESOLVED_GAME_GOODS_CHANGE>');
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_CUSTOM_BIZ_USER_TXN"', '<RESOLVED_GAME_BUSINESS_USER>');
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_CUSTOM_USER_GOODS_TXN"', '<RESOLVED_GAME_USER_GOODS>');
v_value := REPLACE(v_value, '"SGMP_POC"."COMN_SALES_TXN"', '<APPROVED_SALES_TRANSACTION>');
v_value := REPLACE(v_value, '"SGMP_POC"."COMN_REFUND_TXN"', '<APPROVED_REFUND_TRANSACTION>');
v_value := REPLACE(v_value, '"SGMP_POC"."COMN_GAME_ALIAS_BAS"', '<GAME_ALIAS_CATALOG>');
v_value := REPLACE(v_value, '"SGMP_POC"."COMN_COUNTRY_BAS"', '<COUNTRY_DIMENSION>');
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_COMN_SVC_DIM_BAS"', '<RESOLVED_GAME_SERVICE_DIMENSION>');
v_value := REPLACE(v_value, 'STOVE_CHAOSZERO', '<RESOLVED_GAME_ID>');
v_value := REPLACE(v_value, '''카제나''', '<RESOLVED_GAME_NAME>');
v_value := REPLACE(v_value, '''CZN''', '<RESOLVED_GAME_PREFIX>');
v_value := REGEXP_REPLACE(v_value, 'CZN_[A-Z0-9_]+', '<RESOLVED_GAME_OBJECT>');
v_value := REPLACE(v_value, '카제나', '<RESOLVED_GAME_NAME>');
v_value := REPLACE(v_value, '카오스 제로 나이트메어', '<RESOLVED_GAME_NAME>');
v_value := REPLACE(v_value, '카오스제로나이트메어', '<RESOLVED_GAME_NAME>');
v_value := REPLACE(v_value, 'CZN', '<RESOLVED_GAME_PREFIX>');
v_value := REPLACE(v_value, 'BUBBLYZ', '<RESOLVED_GAME_ID>');
v_value := REPLACE(v_value, 'Bubblyz', '<RESOLVED_GAME_NAME>');
v_value := REPLACE(v_value, '버블리즈', '<RESOLVED_GAME_NAME>');
v_value := REPLACE(v_value, 'LORDNINE', '<RESOLVED_GAME_ID>');
v_value := REPLACE(v_value, '로드나인', '<RESOLVED_GAME_NAME>');
v_value := REPLACE(v_value, '테스트게임', '<RESOLVED_GAME_NAME>');
v_value := REGEXP_REPLACE(v_value, 'TO_DATE\(''[0-9]{4}-[0-9]{2}-[0-9]{2}'', ''YYYY-MM-DD''\)', '<BUSINESS_DATE>');
v_value := REGEXP_REPLACE(v_value, 'TO_DATE\(''[0-9]{8}'', ''YYYYMMDD''\)', '<BUSINESS_DATE>');
v_value := REGEXP_REPLACE(v_value, 'DATE ''[0-9]{4}-[0-9]{2}-[0-9]{2}''', '<BUSINESS_DATE>');
RETURN v_value;
END;
FUNCTION role_of(p_sql CLOB) RETURN VARCHAR2 IS
BEGIN
IF DBMS_LOB.INSTR(p_sql, 'CZN_COMN_CHARACTER_MST') > 0 THEN
RETURN 'GAME_CHARACTER_MASTER';
ELSIF DBMS_LOB.INSTR(p_sql, 'CZN_CUSTOM_GOODS_HAVE_TXN') > 0 THEN
RETURN 'GAME_GOODS_HOLDINGS';
ELSIF DBMS_LOB.INSTR(p_sql, 'CZN_CUSTOM_GOODS_CHANGE_TXN') > 0 THEN
RETURN 'GAME_GOODS_CHANGE';
ELSIF DBMS_LOB.INSTR(p_sql, 'CZN_CUSTOM_BIZ_USER_TXN') > 0 THEN
RETURN 'GAME_BUSINESS_USER';
ELSIF DBMS_LOB.INSTR(p_sql, 'COMN_SALES_TXN') > 0 THEN
RETURN 'SALES_TRANSACTION';
ELSIF DBMS_LOB.INSTR(p_sql, 'COMN_REFUND_TXN') > 0 THEN
RETURN 'REFUND_TRANSACTION';
ELSIF DBMS_LOB.INSTR(p_sql, 'CZN_COMN_USER_MST') > 0 THEN
RETURN 'GAME_USER_MASTER';
END IF;
RETURN 'METADATA_OR_OPERATION';
END;
BEGIN
FOR source_row IN (
SELECT example_id, source_case_id, question, answer_sql
FROM sg_qa_vector_example
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
ORDER BY source_case_id
) LOOP
v_pattern_question := generalized_question(source_row.question);
v_pattern_sql := generalized_sql(source_row.answer_sql);
v_object_role := role_of(source_row.answer_sql);
v_embedding_input := TO_CLOB('Generalized question pattern: ') || v_pattern_question
|| CHR(10) || 'Logical object role: ' || v_object_role
|| CHR(10) || 'Structural SQL template: ' || v_pattern_sql
|| CHR(10) || 'Use only current game scope metadata and replace placeholders from the current request.';
-- A generalized runtime pattern must not contain known customer answer
-- identifiers or fixed business-date literals.
IF REGEXP_LIKE(v_pattern_question,
'카제나|버블리즈|Bubblyz|로드나인|테스트게임|[0-9]{4}년|[0-9]{4}-[0-9]{2}-[0-9]{2}', 'i')
OR REGEXP_LIKE(v_pattern_sql,
'CZN_|STOVE_CHAOSZERO|카제나|버블리즈|Bubblyz|[0-9]{4}-[0-9]{2}-[0-9]{2}', 'i') THEN
RAISE_APPLICATION_ERROR(-20061, 'Generalization leak in ' || source_row.source_case_id);
END IF;
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_embedding_input,
JSON(sg_qa_vector_params('search_document'))
);
SELECT COUNT(*)
INTO v_exists
FROM sg_qa_vector_example
WHERE source_type = 'GENERALIZED_QUESTION_PATTERN'
AND source_case_id = 'PAT-' || source_row.source_case_id;
IF v_exists = 0 THEN
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, object_role,
inspection_status, inspection_note, verified_at, verified_by,
source_case_id, source_type
) VALUES (
v_pattern_question,
v_pattern_sql,
'Question-specific generalized Few-shot. Structural only: it contains no customer game, date, result, or executable answer. First decide whether this pattern is applicable; then apply the authoritative NONE/SINGLE/MULTI/ALL game plan and replace placeholders from current metadata.',
v_embedding_input,
v_embedding,
'cohere.embed-v4.0',
'APPROVED', 'SQL_TEMPLATE', 'ANY', v_object_role,
'VERIFIED',
'Derived from a customer QA structure after game/date/result/object leakage validation; runtime uses this generalized pattern only.',
SYSTIMESTAMP, 'SGMP_POC_PATTERN_REVIEW',
'PAT-' || source_row.source_case_id, 'GENERALIZED_QUESTION_PATTERN'
);
ELSE
UPDATE sg_qa_vector_example
SET question = v_pattern_question,
answer_sql = v_pattern_sql,
answer_text = 'Question-specific generalized Few-shot. Structural only: it contains no customer game, date, result, or executable answer. First decide whether this pattern is applicable; then apply the authoritative NONE/SINGLE/MULTI/ALL game plan and replace placeholders from current metadata.',
embedding_input = v_embedding_input,
embedding = v_embedding,
object_role = v_object_role,
reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
inspection_note = 'Derived from a customer QA structure after game/date/result/object leakage validation; runtime uses this generalized pattern only.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_PATTERN_REVIEW'
WHERE source_type = 'GENERALIZED_QUESTION_PATTERN'
AND source_case_id = 'PAT-' || source_row.source_case_id;
END IF;
END LOOP;
COMMIT;
END;
/
SELECT source_type, reference_status, COUNT(*) AS example_count
FROM sg_qa_vector_example
GROUP BY source_type, reference_status
ORDER BY source_type, reference_status;

View File

@@ -0,0 +1,358 @@
-- Generate one reusable, question-specific Few-shot pattern per customer QA
-- benchmark without promoting the benchmark answer itself. Game identity is
-- deliberately not inferred here: sg_game_query_plan owns that through OCI
-- GenAI chat + the current game catalog.
CREATE OR REPLACE FUNCTION sg_qa_genai_generalize_pattern(
p_question IN CLOB,
p_answer_sql IN CLOB,
p_target_type IN VARCHAR2
) RETURN CLOB AUTHID DEFINER
IS
v_prompt CLOB;
v_result CLOB;
BEGIN
v_prompt :=
'Create one reusable, question-specific Few-shot SQL pattern from the source example. '
|| 'This is training guidance, never an answer key. Return exactly these tagged sections and nothing else: '
|| '[[PATTERN_QUESTION]], [[STRUCTURAL_SQL_PATTERN]], [[OBJECT_ROLE]], [[TARGET_TYPE]], '
|| '[[APPLICABILITY]], [[END]]. '
|| 'Preserve only the query intent and structural operations such as aggregation, joins, '
|| 'grouping, ordering, date semantics, and filters. Replace every game name, alias, game ID, '
|| 'schema name, physical object name, column name, literal date, literal number, user ID, '
|| 'currency amount, and expected output with semantic placeholders such as <GAME_SCOPE>, '
|| '<LOGICAL_FACT>, <LOGICAL_DIMENSION>, <METRIC>, <AS_OF_DATE>, <FILTER>, and <GROUPING>. '
|| 'In STRUCTURAL_SQL_PATTERN, every non-SQL identifier must be an angle-bracket placeholder: '
|| 'do not retain any source column, alias, table, schema, literal, code, or business value. '
|| 'Do not include executable SQL. Do not include a game name or a customer answer. '
|| 'The current game scope is supplied separately at runtime by a database OCI GenAI chat '
|| 'resolver, therefore never choose or imply a game. The TARGET_TYPE section must be one of NONE, '
|| 'SINGLE, MULTI, ALL, ANY and must describe applicability, not a game identity. '
|| 'Source target type from the current resolver: ' || NVL(p_target_type, 'ANY') || CHR(10)
|| 'Source question:' || CHR(10) || DBMS_LOB.SUBSTR(p_question, 4000, 1) || CHR(10)
|| 'Source SQL (structure only; do not copy identifiers or values):' || CHR(10)
|| DBMS_LOB.SUBSTR(p_answer_sql, 12000, 1);
v_result := DBMS_CLOUD_AI.GENERATE(
prompt => v_prompt,
profile_name => 'SGMP_POC_OCI_GPT54MINI',
action => 'chat'
);
RETURN v_result;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_genai_validate_pattern(
p_pattern_json IN CLOB
) RETURN CLOB AUTHID DEFINER
IS
v_prompt CLOB;
v_result CLOB;
BEGIN
v_prompt :=
'Inspect only concrete-answer leakage in this reusable Few-shot pattern. Return exactly '
|| '[[CONCRETE_LEAKAGE]] YES or NO, then [[REASON]] and a short reason, then [[END]]. '
|| 'Return YES only when a customer answer, concrete game identity, physical schema/table/column '
|| 'identifier, literal date, literal business result, or executable SQL against a real object remains. '
|| 'Return NO when all such references are semantic angle-bracket placeholders. A pseudo-SQL pattern '
|| 'using SELECT/FROM/JOIN/GROUP BY, generic game-scope checks, EXISTS, UNION, or equality with '
|| 'angle-bracket placeholders is not concrete leakage and must return NO. Do not judge usefulness or '
|| 'completeness; classify leakage only. '
|| 'Candidate:' || CHR(10) || DBMS_LOB.SUBSTR(p_pattern_json, 16000, 1);
v_result := DBMS_CLOUD_AI.GENERATE(
prompt => v_prompt,
profile_name => 'SGMP_POC_OCI_GPT54MINI',
action => 'chat'
);
RETURN v_result;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_generate_generalized_patterns
RETURN NUMBER AUTHID DEFINER
IS
PRAGMA AUTONOMOUS_TRANSACTION;
v_plan_raw CLOB;
v_plan JSON_OBJECT_T;
v_target_type VARCHAR2(16);
v_pattern_raw CLOB;
v_validation_raw CLOB;
v_status VARCHAR2(16);
v_validation_note CLOB;
v_question CLOB;
v_sql_pattern CLOB;
v_answer_text CLOB;
v_object_role VARCHAR2(64);
v_embedding_input CLOB;
v_embedding VECTOR;
v_count NUMBER := 0;
FUNCTION parse_json_result(p_value CLOB) RETURN JSON_OBJECT_T IS
v_text CLOB := TRIM(p_value);
BEGIN
IF DBMS_LOB.SUBSTR(v_text, 7, 1) = '```json' THEN
v_text := REGEXP_REPLACE(v_text, '^```json[[:space:]]*', '');
v_text := REGEXP_REPLACE(v_text, '[[:space:]]*```[[:space:]]*$', '');
ELSIF DBMS_LOB.SUBSTR(v_text, 3, 1) = '```' THEN
v_text := REGEXP_REPLACE(v_text, '^```[[:space:]]*', '');
v_text := REGEXP_REPLACE(v_text, '[[:space:]]*```[[:space:]]*$', '');
END IF;
RETURN JSON_OBJECT_T.parse(v_text);
END;
FUNCTION section_value(
p_raw IN CLOB, p_start_tag IN VARCHAR2, p_end_tag IN VARCHAR2
) RETURN CLOB IS
v_start PLS_INTEGER;
v_end PLS_INTEGER;
BEGIN
v_start := DBMS_LOB.INSTR(p_raw, p_start_tag, 1, 1);
IF v_start = 0 THEN
RAISE_APPLICATION_ERROR(-20071, 'OCI GenAI response is missing ' || p_start_tag);
END IF;
v_start := v_start + LENGTH(p_start_tag);
v_end := DBMS_LOB.INSTR(p_raw, p_end_tag, v_start, 1);
IF v_end = 0 OR v_end <= v_start THEN
RAISE_APPLICATION_ERROR(-20072, 'OCI GenAI response is missing ' || p_end_tag);
END IF;
RETURN TRIM(DBMS_LOB.SUBSTR(p_raw, LEAST(v_end - v_start, 32767), v_start));
END;
PROCEDURE upsert_pattern(
p_case_id IN VARCHAR2,
p_status IN VARCHAR2,
p_note IN CLOB
) IS
BEGIN
UPDATE sg_qa_vector_example
SET question = v_question,
answer_sql = v_sql_pattern,
answer_text = v_answer_text,
embedding_input = v_embedding_input,
embedding = v_embedding,
embedding_model = 'cohere.embed-v4.0',
reference_status = p_status,
reference_kind = 'SQL_PATTERN',
target_type = v_target_type,
object_role = v_object_role,
inspection_status = CASE WHEN p_status = 'APPROVED' THEN 'GENAI_VERIFIED' ELSE 'GENAI_REJECTED' END,
inspection_note = p_note,
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_OCI_GENAI_PATTERN'
WHERE source_type = 'GENERALIZED_QUESTION_PATTERN'
AND source_case_id = 'PAT-' || p_case_id;
IF SQL%ROWCOUNT = 0 THEN
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, object_role, inspection_status,
inspection_note, verified_at, verified_by, source_case_id, source_type
) VALUES (
v_question, v_sql_pattern, v_answer_text, v_embedding_input, v_embedding, 'cohere.embed-v4.0',
p_status, 'SQL_PATTERN', v_target_type, v_object_role,
CASE WHEN p_status = 'APPROVED' THEN 'GENAI_VERIFIED' ELSE 'GENAI_REJECTED' END,
p_note, SYSTIMESTAMP, 'SGMP_POC_OCI_GENAI_PATTERN',
'PAT-' || p_case_id, 'GENERALIZED_QUESTION_PATTERN'
);
END IF;
END;
BEGIN
FOR source_row IN (
SELECT source.source_case_id, source.question, source.answer_sql
FROM sg_qa_vector_example source
WHERE source.source_type = 'CUSTOMER_QA_BENCHMARK'
AND NOT EXISTS (
SELECT 1
FROM sg_qa_vector_example pattern
WHERE pattern.source_type = 'GENERALIZED_QUESTION_PATTERN'
AND pattern.source_case_id = 'PAT-' || source.source_case_id
AND pattern.reference_status = 'APPROVED'
)
ORDER BY source_case_id
) LOOP
BEGIN
-- The target category comes from the existing OCI GenAI game resolver;
-- no alias, prefix, table, or name is transformed in this migration.
v_plan_raw := sg_game_query_plan(source_row.question, 5);
v_plan := parse_json_result(v_plan_raw);
v_target_type := UPPER(NVL(v_plan.get_string('targetType'), 'ANY'));
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL') THEN
v_target_type := 'ANY';
END IF;
v_pattern_raw := sg_qa_genai_generalize_pattern(
source_row.question, source_row.answer_sql, v_target_type
);
v_question := section_value(v_pattern_raw, '[[PATTERN_QUESTION]]', '[[STRUCTURAL_SQL_PATTERN]]');
v_sql_pattern := section_value(v_pattern_raw, '[[STRUCTURAL_SQL_PATTERN]]', '[[OBJECT_ROLE]]');
v_object_role := SUBSTR(section_value(v_pattern_raw, '[[OBJECT_ROLE]]', '[[TARGET_TYPE]]'), 1, 64);
IF section_value(v_pattern_raw, '[[TARGET_TYPE]]', '[[APPLICABILITY]]')
IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
v_target_type := section_value(v_pattern_raw, '[[TARGET_TYPE]]', '[[APPLICABILITY]]');
END IF;
v_answer_text := TO_CLOB('Generalized, question-specific structural pattern. '
|| 'Current game scope must be supplied only by sg_game_query_plan. Applicability: ')
|| section_value(v_pattern_raw, '[[APPLICABILITY]]', '[[END]]');
v_embedding_input := TO_CLOB('Question-specific generalized Few-shot pattern:' || CHR(10))
|| v_question || CHR(10) || 'Logical role: ' || v_object_role || CHR(10)
|| 'Structural SQL pattern:' || CHR(10) || v_sql_pattern;
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_embedding_input, JSON(sg_qa_vector_params('search_document'))
);
v_validation_raw := sg_qa_genai_validate_pattern(v_pattern_raw);
v_status := CASE
WHEN REGEXP_SUBSTR(
UPPER(section_value(v_validation_raw, '[[CONCRETE_LEAKAGE]]', '[[REASON]]')),
'[A-Z]+'
) = 'NO'
THEN 'APPROVE'
ELSE 'REJECT'
END;
v_validation_note := section_value(v_validation_raw, '[[REASON]]', '[[END]]');
IF v_status = 'APPROVE' THEN
upsert_pattern(source_row.source_case_id, 'APPROVED',
'ADB OCI GenAI generated and independently validated a generalized pattern. '
|| 'The original customer QA remains evaluation-only. ' || v_validation_note);
v_count := v_count + 1;
ELSE
upsert_pattern(source_row.source_case_id, 'DRAFT',
'ADB OCI GenAI rejected the generalized pattern: ' || v_validation_note);
END IF;
EXCEPTION
WHEN OTHERS THEN
-- Persist an auditable non-runtime draft and continue with the other
-- customer questions; one malformed LLM response must not block all 47.
v_question := source_row.question;
v_sql_pattern := TO_CLOB('<PATTERN_GENERATION_FAILED>');
v_answer_text := TO_CLOB('No runtime Few-shot pattern: OCI GenAI generalization failed.');
v_object_role := 'UNSPECIFIED';
v_target_type := 'ANY';
v_embedding_input := TO_CLOB('Failed generalized pattern: ') || source_row.question;
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_embedding_input, JSON(sg_qa_vector_params('search_document'))
);
upsert_pattern(source_row.source_case_id, 'DRAFT',
'OCI GenAI pattern generation error: ' || SQLERRM);
END;
END LOOP;
COMMIT;
RETURN v_count;
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE;
END;
/
-- Re-run only the independent OCI Chat safety review after its policy changes.
-- It never reads a customer benchmark and never changes the generated pattern.
CREATE OR REPLACE FUNCTION sg_qa_revalidate_generalized_patterns
RETURN NUMBER AUTHID DEFINER
IS
PRAGMA AUTONOMOUS_TRANSACTION;
v_raw CLOB;
v_status VARCHAR2(16);
v_reason CLOB;
v_start PLS_INTEGER;
v_end PLS_INTEGER;
v_count NUMBER := 0;
FUNCTION section_value(
p_raw IN CLOB, p_start_tag IN VARCHAR2, p_end_tag IN VARCHAR2
) RETURN CLOB IS
v_from PLS_INTEGER;
v_to PLS_INTEGER;
BEGIN
v_from := DBMS_LOB.INSTR(p_raw, p_start_tag, 1, 1);
IF v_from = 0 THEN RAISE_APPLICATION_ERROR(-20073, 'Missing ' || p_start_tag); END IF;
v_from := v_from + LENGTH(p_start_tag);
v_to := DBMS_LOB.INSTR(p_raw, p_end_tag, v_from, 1);
IF v_to = 0 OR v_to <= v_from THEN RAISE_APPLICATION_ERROR(-20074, 'Missing ' || p_end_tag); END IF;
RETURN TRIM(DBMS_LOB.SUBSTR(p_raw, LEAST(v_to - v_from, 32767), v_from));
END;
BEGIN
FOR item IN (
SELECT example_id, question, answer_sql, answer_text
FROM sg_qa_vector_example
WHERE source_type = 'GENERALIZED_QUESTION_PATTERN'
ORDER BY source_case_id
) LOOP
BEGIN
v_raw := sg_qa_genai_validate_pattern(
TO_CLOB('[[PATTERN_QUESTION]]') || item.question
|| TO_CLOB(CHR(10) || '[[STRUCTURAL_SQL_PATTERN]]') || item.answer_sql
|| TO_CLOB(CHR(10) || '[[APPLICABILITY]]') || item.answer_text || CHR(10) || '[[END]]'
);
v_status := CASE
WHEN REGEXP_SUBSTR(
UPPER(section_value(v_raw, '[[CONCRETE_LEAKAGE]]', '[[REASON]]')),
'[A-Z]+'
) = 'NO'
THEN 'APPROVE'
ELSE 'REJECT'
END;
v_reason := section_value(v_raw, '[[REASON]]', '[[END]]');
UPDATE sg_qa_vector_example
SET reference_status = CASE WHEN v_status = 'APPROVE' THEN 'APPROVED' ELSE 'DRAFT' END,
inspection_status = CASE WHEN v_status = 'APPROVE' THEN 'GENAI_VERIFIED' ELSE 'GENAI_REJECTED' END,
inspection_note = 'ADB OCI GenAI independent revalidation: ' || v_reason,
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_OCI_GENAI_PATTERN'
WHERE example_id = item.example_id;
IF v_status = 'APPROVE' THEN v_count := v_count + 1; END IF;
EXCEPTION
WHEN OTHERS THEN
v_reason := TO_CLOB('OCI GenAI revalidation error: ' || SQLERRM);
UPDATE sg_qa_vector_example
SET reference_status = 'DRAFT',
inspection_status = 'GENAI_REJECTED',
inspection_note = v_reason,
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_OCI_GENAI_PATTERN'
WHERE example_id = item.example_id;
END;
END LOOP;
COMMIT;
RETURN v_count;
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE;
END;
/
-- Production retrieval accepts only independently generalized patterns or
-- policy templates. Customer QA benchmarks remain evaluation-only forever.
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
BEGIN
IF p_question IS NULL THEN RAISE_APPLICATION_ERROR(-20003, 'question is required.'); END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'invalid target type.');
END IF;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question, JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND source_type IN ('GENERALIZED_QUESTION_PATTERN', 'POLICY_TEMPLATE')
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
ORDER BY vector_distance(embedding, v_query_vector, COSINE), example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/

View File

@@ -0,0 +1,9 @@
-- The MCP DB account owns the OCI GenAI planning functions while the game
-- catalog is owned by the data schema. Definer-rights PL/SQL needs direct
-- object grants; role grants are not sufficient at compile time.
BEGIN
EXECUTE IMMEDIATE 'GRANT SELECT ON SGMP_POC.SG_GAME_CATALOG TO ADMIN';
EXECUTE IMMEDIATE 'GRANT SELECT ON SGMP_POC.COMN_GAME_ALIAS_BAS TO ADMIN';
EXECUTE IMMEDIATE 'GRANT EXECUTE ON SGMP_POC.SG_GAME_CATALOG_SEARCH TO ADMIN';
END;
/

View File

@@ -0,0 +1,148 @@
-- Deterministic daily-AU lookup for an already resolved game query plan.
-- Physical user-master objects are selected only from SG_GAME_CATALOG.
-- No game name, alias, prefix, or object name is embedded in this function.
CREATE OR REPLACE FUNCTION sg_game_daily_au_lookup(
p_query_plan IN CLOB,
p_base_date IN DATE DEFAULT NULL
) RETURN CLOB AUTHID DEFINER IS
v_plan JSON_OBJECT_T;
v_targets JSON_ARRAY_T;
v_target JSON_OBJECT_T;
v_result JSON_OBJECT_T := JSON_OBJECT_T();
v_items JSON_ARRAY_T := JSON_ARRAY_T();
v_item JSON_OBJECT_T;
v_game_key VARCHAR2(128);
v_game_id VARCHAR2(128);
v_game_name VARCHAR2(512);
v_object_name VARCHAR2(128);
v_safe_object_name VARCHAR2(128);
v_effective_date DATE;
v_au_count NUMBER;
v_column_count PLS_INTEGER;
v_object_count PLS_INTEGER;
v_seen SYS.ODCIVARCHAR2LIST := SYS.ODCIVARCHAR2LIST();
v_target_count PLS_INTEGER := 0;
FUNCTION is_seen(p_game_key IN VARCHAR2) RETURN BOOLEAN IS
BEGIN
FOR i IN 1 .. v_seen.COUNT LOOP
IF v_seen(i) = p_game_key THEN
RETURN TRUE;
END IF;
END LOOP;
RETURN FALSE;
END;
PROCEDURE add_status(
p_game_key IN VARCHAR2,
p_status IN VARCHAR2,
p_reason IN VARCHAR2
) IS
BEGIN
v_item := JSON_OBJECT_T();
v_item.put('gameKey', p_game_key);
v_item.put('status', p_status);
v_item.put('reason', p_reason);
v_items.append(v_item);
END;
BEGIN
IF p_query_plan IS NULL THEN
RAISE_APPLICATION_ERROR(-20001, 'queryPlan is required');
END IF;
v_plan := JSON_OBJECT_T.parse(p_query_plan);
v_targets := v_plan.get_array('dataEligibleTargets');
IF v_targets IS NULL THEN
v_targets := v_plan.get_array('targets');
END IF;
IF v_targets IS NOT NULL AND v_targets.get_size > 0 THEN
FOR i IN 0 .. v_targets.get_size - 1 LOOP
v_target := TREAT(v_targets.get(i) AS JSON_OBJECT_T);
IF v_target IS NULL OR NOT v_target.has('gameKey') THEN
CONTINUE;
END IF;
v_game_key := v_target.get_string('gameKey');
IF v_game_key IS NULL OR is_seen(v_game_key) THEN
CONTINUE;
END IF;
v_seen.EXTEND;
v_seen(v_seen.COUNT) := v_game_key;
v_target_count := v_target_count + 1;
BEGIN
SELECT game_id, game_nm, user_master_object_name
INTO v_game_id, v_game_name, v_object_name
FROM sg_game_catalog
WHERE game_key = v_game_key
AND active_yn = 'Y';
EXCEPTION
WHEN NO_DATA_FOUND THEN
add_status(v_game_key, 'UNAVAILABLE', 'Catalog target is not active.');
CONTINUE;
END;
IF v_object_name IS NULL THEN
add_status(v_game_key, 'UNAVAILABLE', 'No approved user-master object is registered.');
CONTINUE;
END IF;
v_safe_object_name := DBMS_ASSERT.SIMPLE_SQL_NAME(UPPER(v_object_name));
SELECT COUNT(*) INTO v_object_count
FROM user_objects
WHERE object_name = v_safe_object_name
AND object_type IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW')
AND status = 'VALID';
SELECT COUNT(*) INTO v_column_count
FROM user_tab_columns
WHERE table_name = v_safe_object_name
AND column_name IN ('GUID', 'BASE_DT', 'AU_FLAG', 'EXPT_USER_YN');
IF v_object_count = 0 OR v_column_count <> 4 THEN
add_status(v_game_key, 'UNAVAILABLE', 'Approved user-master object is not query-ready.');
CONTINUE;
END IF;
IF p_base_date IS NULL THEN
EXECUTE IMMEDIATE 'SELECT MAX(BASE_DT) FROM ' || v_safe_object_name
INTO v_effective_date;
ELSE
v_effective_date := TRUNC(p_base_date);
END IF;
IF v_effective_date IS NULL THEN
add_status(v_game_key, 'NO_DATA', 'No available base date in the selected object.');
CONTINUE;
END IF;
EXECUTE IMMEDIATE
'SELECT COUNT(DISTINCT GUID) FROM ' || v_safe_object_name
|| ' WHERE BASE_DT = :1 AND AU_FLAG = 1 AND EXPT_USER_YN = ''N'''
INTO v_au_count USING v_effective_date;
v_item := JSON_OBJECT_T();
v_item.put('gameKey', v_game_key);
v_item.put('gameId', v_game_id);
v_item.put('gameName', v_game_name);
v_item.put('objectName', v_safe_object_name);
v_item.put('baseDate', TO_CHAR(v_effective_date, 'YYYY-MM-DD'));
v_item.put('auCount', v_au_count);
v_item.put('status', 'READY');
v_item.put('sqlTemplate',
'SELECT COUNT(DISTINCT GUID) AS AU_COUNT FROM <catalog_user_master_object> '
|| 'WHERE BASE_DT = :baseDate AND AU_FLAG = 1 AND EXPT_USER_YN = ''N''');
v_items.append(v_item);
END LOOP;
END IF;
v_result.put('status', CASE WHEN v_target_count = 0 THEN 'NO_GAME_TARGET' ELSE 'GAME_AU_LOOKUP' END);
v_result.put('targetType', NVL(v_plan.get_string('targetType'), 'NONE'));
IF p_base_date IS NULL THEN
v_result.put_null('requestedBaseDate');
ELSE
v_result.put('requestedBaseDate', TO_CHAR(TRUNC(p_base_date), 'YYYY-MM-DD'));
END IF;
v_result.put('targetCount', v_target_count);
v_result.put('items', v_items);
RETURN v_result.to_clob;
END;
/

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

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

View File

@@ -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

View File

@@ -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;
/

View File

@@ -0,0 +1,68 @@
-- 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
UNION ALL
SELECT 'SCOPE_GUIDANCE_NONE', NULL,
'{"mode":"GAME_UNSPECIFIED","allowGameScopedObjects":false,"targetExecution":"COMMON_OBJECTS_OR_ZERO_ROW","instruction":"No game was selected. Do not use a game-scoped object. Use only a game-neutral common object when it answers the question; otherwise return a zero-row result."}',
'Prompt guidance for a question without a selected game.'
FROM dual
UNION ALL
SELECT 'SCOPE_GUIDANCE_SINGLE', NULL,
'{"mode":"EXACT_TARGETS","allowGameScopedObjects":true,"targetExecution":"ONLY_RESOLVED_TARGETS","instruction":"Use only the resolved target in targets. Do not select another game-scoped object."}',
'Prompt guidance for exactly one resolved game target.'
FROM dual
UNION ALL
SELECT 'SCOPE_GUIDANCE_MULTI', NULL,
'{"mode":"MULTIPLE_TARGETS","allowGameScopedObjects":true,"targetExecution":"ALL_RESOLVED_TARGETS","instruction":"Return results for all resolved available targets. Preserve unresolved targets as unavailable; do not replace them with another game."}',
'Prompt guidance for multiple game targets.'
FROM dual
UNION ALL
SELECT 'SCOPE_GUIDANCE_ALL', NULL,
'{"mode":"ALL_CATALOG_TARGETS","allowGameScopedObjects":true,"targetExecution":"ALL_AVAILABLE_CATALOG_TARGETS","instruction":"Use all available catalog targets. Do not invent games or game-scoped objects outside the catalog."}',
'Prompt guidance for every catalog game.'
FROM dual
) s
ON (t.policy_key = s.policy_key)
WHEN MATCHED THEN UPDATE SET
t.text_value = CASE
WHEN s.policy_key LIKE 'SCOPE_GUIDANCE_%' THEN s.text_value
ELSE t.text_value
END,
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.';
/

View File

@@ -0,0 +1,82 @@
-- Customer-managed quality floor for runtime Few-shot retrieval.
-- Lower cosine distance is more similar. The value is data, not application code.
MERGE INTO sg_game_scope_policy t
USING (
SELECT 'QA_VECTOR_MAX_COSINE_DISTANCE' AS policy_key,
0.650000 AS number_value,
'Maximum cosine distance accepted for a runtime approved Few-shot example.' AS description
FROM dual
) s
ON (t.policy_key = s.policy_key)
WHEN MATCHED THEN UPDATE SET
t.number_value = s.number_value,
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, NULL, s.description, 'Y'
);
/
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
v_max_cosine_distance NUMBER;
BEGIN
IF p_question IS NULL THEN
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'invalid target type.');
END IF;
SELECT number_value
INTO v_max_cosine_distance
FROM sg_game_scope_policy
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE'
AND active_yn = 'Y'
AND number_value IS NOT NULL;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question, JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
cosine_distance
FROM (
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
VECTOR_DISTANCE(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
-- A generated structural pattern is review material, not a runtime
-- Few-shot. Runtime examples must have human verification and an
-- executable SQL body rather than unresolved logical placeholders.
AND inspection_status = 'VERIFIED'
AND answer_sql IS NOT NULL
AND NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i')
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
)
WHERE cosine_distance <= v_max_cosine_distance
ORDER BY cosine_distance, example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
COMMENT ON TABLE sg_game_scope_policy IS
'Customer-managed game scope and runtime retrieval policy values; changing a value requires no application deployment.';
/

View File

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

View File

@@ -0,0 +1,38 @@
-- STD-12 is a multi-target orchestration case, not a reusable SQL few-shot.
-- Preserve SG_AI_QA_QUESTION as the customer benchmark; remove only its
-- invalid vector-example row so it cannot be managed as a few-shot.
DECLARE
v_count PLS_INTEGER;
BEGIN
SELECT COUNT(*)
INTO v_count
FROM sg_qa_vector_example
WHERE example_id = 51
AND source_case_id = 'STD-12'
AND source_type = 'CUSTOMER_QA_BENCHMARK';
IF v_count <> 1 THEN
RAISE_APPLICATION_ERROR(-20051, 'Expected exactly one invalid STD-12 few-shot row.');
END IF;
DELETE FROM sg_qa_vector_example
WHERE example_id = 51
AND source_case_id = 'STD-12'
AND source_type = 'CUSTOMER_QA_BENCHMARK';
IF SQL%ROWCOUNT <> 1 THEN
RAISE_APPLICATION_ERROR(-20052, 'Invalid STD-12 few-shot row was not deleted.');
END IF;
COMMIT;
END;
/
SELECT COUNT(*) AS remaining_fewshot_rows
FROM sg_qa_vector_example
WHERE example_id = 51;
SELECT COUNT(*) AS preserved_question_rows
FROM sg_ai_qa_question
WHERE question_code = 'STD-12';

View File

@@ -0,0 +1,84 @@
-- One DB-owned JSON identity document per game. No game value is hardcoded.
-- The relational alias child makes aliases a nested Duality View array.
BEGIN
EXECUTE IMMEDIATE q'[
CREATE TABLE sg_game_catalog_identity_alias (
game_key VARCHAR2(128) NOT NULL,
alias_value VARCHAR2(512) NOT NULL,
CONSTRAINT sg_game_catalog_identity_alias_pk PRIMARY KEY (game_key, alias_value),
CONSTRAINT sg_game_catalog_identity_alias_fk FOREIGN KEY (game_key)
REFERENCES sg_game_catalog (game_key)
)]';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN RAISE; END IF;
END;
/
MERGE INTO sg_game_catalog_identity_alias target
USING (
SELECT c.game_key, aliases.alias_value
FROM sg_game_catalog c,
JSON_TABLE(
c.aliases_json,
'$[*]' COLUMNS (alias_value VARCHAR2(512) PATH '$')
) aliases
WHERE c.active_yn = 'Y'
) source
ON (target.game_key = source.game_key AND target.alias_value = source.alias_value)
WHEN NOT MATCHED THEN INSERT (game_key, alias_value)
VALUES (source.game_key, source.alias_value);
/
DELETE FROM sg_game_catalog_identity_alias target
WHERE NOT EXISTS (
SELECT 1
FROM sg_game_catalog c,
JSON_TABLE(
c.aliases_json,
'$[*]' COLUMNS (alias_value VARCHAR2(512) PATH '$')
) aliases
WHERE c.game_key = target.game_key
AND c.active_yn = 'Y'
AND aliases.alias_value = target.alias_value
);
/
CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW sg_game_catalog_identity_dv AS
SELECT JSON {
'_id' : c.game_key,
'gameId' : c.game_id,
'gamePrefix' : c.game_prefix,
'gameName' : c.game_nm,
'gameAliases' : [
SELECT JSON {
'_id' : { 'gameKey' : a.game_key, 'value' : a.alias_value }
}
FROM sg_game_catalog_identity_alias a
WHERE a.game_key = c.game_key
]
}
FROM sg_game_catalog c
WHERE c.active_yn = 'Y'
WITH CHECK OPTION;
/
-- Serialize the DB JSON document itself before embedding. GAME_ID, GAME_PREFIX,
-- names and every alias therefore share one vector search document.
UPDATE sg_game_catalog c
SET c.embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
(
SELECT JSON_SERIALIZE(d.data RETURNING CLOB)
FROM sg_game_catalog_identity_dv d
WHERE JSON_VALUE(d.data, '$._id') = c.game_key
),
JSON(sg_qa_vector_params('search_document'))
),
c.updated_at = SYSTIMESTAMP
WHERE c.active_yn = 'Y';
/
COMMENT ON TABLE sg_game_catalog_identity_dv IS
'DB JSON identity document for each active game; the canonical embedding source for game-name, alias, GAME_ID and GAME_PREFIX resolution.';
/

View File

@@ -0,0 +1,144 @@
-- STD-18 asks for an aggregate over qualifying orders, not an individual
-- transaction list. Remove the invalid customer-derived references and keep
-- one reusable, data-neutral aggregate pattern for runtime retrieval.
DELETE FROM sg_qa_vector_example
WHERE source_case_id IN ('STD-18', 'PAT-STD-18')
AND source_type IN ('CUSTOMER_QA_BENCHMARK', 'GENERALIZED_QUESTION_PATTERN');
/
DECLARE
v_input CLOB;
v_embedding VECTOR;
v_exists NUMBER;
BEGIN
v_input := TO_CLOB('질문 패턴: 전체 매출에서 금액 조건을 만족하는 주문을 집계해줘.')
|| CHR(10) || 'Question pattern: summarize whole-scope sales after a payment amount filter.'
|| CHR(10) || 'Logical object role: SALES_TRANSACTION'
|| CHR(10) || 'Required result grain: one aggregate row with total sales amount, distinct buyer count, and order count.'
|| CHR(10) || 'A reference to orders does not by itself request individual order detail.';
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
SELECT COUNT(*)
INTO v_exists
FROM sg_qa_vector_example
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'FILTERED_SALES_AGGREGATE';
IF v_exists = 0 THEN
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, object_role,
inspection_status, inspection_note, verified_at, verified_by,
source_case_id, source_type
) VALUES (
'전체 매출에서 금액 조건을 만족하는 주문을 집계해줘.',
TO_CLOB('SELECT SUM(CAST(s."PAYMT_AMT" AS NUMBER)) AS "TOTAL_SALES_AMOUNT",' || CHR(10)
|| ' COUNT(DISTINCT s."GUID") AS "BUYER_COUNT",' || CHR(10)
|| ' COUNT(*) AS "ORDER_COUNT"' || CHR(10)
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" s' || CHR(10)
|| 'WHERE s."PAYMT_DTM" >= <BUSINESS_DATE_START>' || CHR(10)
|| ' AND s."PAYMT_DTM" < <BUSINESS_DATE_END>' || CHR(10)
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) <AMOUNT_CONDITION>' || CHR(10)
|| ' AND s."EXPT_USER_YN" = ''N'''),
'Structural Few-shot: return one aggregate row containing total sales amount, distinct buyer count, and order count after the requested payment-amount filter. Do not return individual orders unless the user explicitly asks for a list or detail rows.',
v_input,
v_embedding,
'cohere.embed-v4.0',
'APPROVED', 'SQL_TEMPLATE', 'NONE', 'SALES_TRANSACTION',
'VERIFIED',
'Reusable whole-scope filtered-sales aggregate. No customer date, amount, game, or expected result is stored.',
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW',
'FILTERED_SALES_AGGREGATE', 'POLICY_TEMPLATE'
);
ELSE
UPDATE sg_qa_vector_example
SET question = '전체 매출에서 금액 조건을 만족하는 주문을 집계해줘.',
answer_sql = TO_CLOB('SELECT SUM(CAST(s."PAYMT_AMT" AS NUMBER)) AS "TOTAL_SALES_AMOUNT",' || CHR(10)
|| ' COUNT(DISTINCT s."GUID") AS "BUYER_COUNT",' || CHR(10)
|| ' COUNT(*) AS "ORDER_COUNT"' || CHR(10)
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" s' || CHR(10)
|| 'WHERE s."PAYMT_DTM" >= <BUSINESS_DATE_START>' || CHR(10)
|| ' AND s."PAYMT_DTM" < <BUSINESS_DATE_END>' || CHR(10)
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) <AMOUNT_CONDITION>' || CHR(10)
|| ' AND s."EXPT_USER_YN" = ''N'''),
answer_text = 'Structural Few-shot: return one aggregate row containing total sales amount, distinct buyer count, and order count after the requested payment-amount filter. Do not return individual orders unless the user explicitly asks for a list or detail rows.',
embedding_input = v_input,
embedding = v_embedding,
reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'NONE',
object_role = 'SALES_TRANSACTION',
inspection_status = 'VERIFIED',
inspection_note = 'Reusable whole-scope filtered-sales aggregate. No customer date, amount, game, or expected result is stored.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'FILTERED_SALES_AGGREGATE';
END IF;
COMMIT;
END;
/
-- A SQL template is prompt context, never an executable statement. Permit
-- reviewed policy templates to retain logical placeholders while continuing
-- to require executable SQL for automatically generalized patterns.
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
v_max_cosine_distance NUMBER;
BEGIN
IF p_question IS NULL THEN RAISE_APPLICATION_ERROR(-20003, 'question is required.'); END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'invalid target type.');
END IF;
SELECT number_value INTO v_max_cosine_distance
FROM sg_game_scope_policy
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE'
AND active_yn = 'Y'
AND number_value IS NOT NULL;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question, JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
cosine_distance
FROM (
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
VECTOR_DISTANCE(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND inspection_status = 'VERIFIED'
AND answer_sql IS NOT NULL
AND (source_type = 'POLICY_TEMPLATE'
OR NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i'))
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
)
WHERE cosine_distance <= v_max_cosine_distance
ORDER BY cosine_distance, example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
SELECT example_id, source_case_id, source_type, reference_status, inspection_status
FROM sg_qa_vector_example
WHERE source_case_id IN ('STD-18', 'PAT-STD-18', 'FILTERED_SALES_AGGREGATE')
ORDER BY example_id;

View File

@@ -0,0 +1,33 @@
-- This customer question is game-unscoped. Keep the existing NONE-compatible
-- Few-shot path; game_query_plan remains responsible for scope resolution.
UPDATE sg_qa_vector_example
SET question = '매출에서 금액 조건을 만족하는 주문의 총액, 구매자 수, 주문 수를 집계해줘.',
answer_sql = TO_CLOB('SELECT' || CHR(10)
|| ' SUM(CAST(s."PAYMT_AMT" AS NUMBER)) AS "TOTAL_SALES_AMOUNT",' || CHR(10)
|| ' COUNT(DISTINCT s."GUID") AS "BUYER_COUNT",' || CHR(10)
|| ' COUNT(*) AS "ORDER_COUNT"' || CHR(10)
|| ' FROM <COMMON_SALES_TRANSACTION> s' || CHR(10)
|| ' WHERE s."PAYMT_DTM" >= <BUSINESS_DATE_START>' || CHR(10)
|| ' AND s."PAYMT_DTM" < <BUSINESS_DATE_END>' || CHR(10)
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) <AMOUNT_CONDITION>' || CHR(10)
|| ' AND s."EXPT_USER_YN" = ''N'''),
answer_text = 'Aggregate result-shape reference: return one row with total sales amount, distinct buyer count, and order count. The game plan separately supplies any game scope; use this pattern only when the question is semantically similar.',
embedding_input = TO_CLOB('Question pattern: summarize sales after a payment amount condition.' || CHR(10)
|| 'Logical object role: SALES_TRANSACTION' || CHR(10)
|| 'Result grain: one aggregate row with total sales amount, distinct buyer count, and order count.'),
embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
TO_CLOB('Question pattern: summarize sales after a payment amount condition.' || CHR(10)
|| 'Logical object role: SALES_TRANSACTION' || CHR(10)
|| 'Result grain: one aggregate row with total sales amount, distinct buyer count, and order count.'),
JSON(sg_qa_vector_params('search_document'))),
target_type = 'NONE',
source_case_id = 'PORTAL-STD-18',
inspection_note = 'Generalized aggregate pattern for the current game-unscoped question; game_query_plan controls scope separately.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE example_id = 141
AND source_type = 'POLICY_TEMPLATE';
/
COMMIT;
/

View File

@@ -0,0 +1,23 @@
-- The reviewed customer benchmark answer is part of this Few-shot guidance.
-- It clarifies that the requested result is one aggregate row, not detail rows.
UPDATE sg_qa_vector_example
SET answer_text = TO_CLOB('Expected answer shape: return exactly one aggregate row, not individual order rows.' || CHR(10)
|| 'Expected answer:' || CHR(10)
|| 'TOTAL_SALES_AMOUNT BUYER_COUNT ORDER_COUNT' || CHR(10)
|| '------------------ ----------- -----------' || CHR(10)
|| ' 204720 6 6'),
embedding_input = TO_CLOB('Question pattern: summarize sales after a payment amount condition.' || CHR(10)
|| 'Expected output: TOTAL_SALES_AMOUNT, BUYER_COUNT, ORDER_COUNT as one aggregate row.' || CHR(10)
|| 'Expected result example: 204720, 6, 6.'),
embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
TO_CLOB('Question pattern: summarize sales after a payment amount condition.' || CHR(10)
|| 'Expected output: TOTAL_SALES_AMOUNT, BUYER_COUNT, ORDER_COUNT as one aggregate row.' || CHR(10)
|| 'Expected result example: 204720, 6, 6.'),
JSON(sg_qa_vector_params('search_document'))),
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE example_id = 141
AND source_type = 'POLICY_TEMPLATE';
/
COMMIT;
/

View File

@@ -0,0 +1,29 @@
-- STD-25 is a period AU metric, not the daily AU_FLAG metric.
-- Keep the evaluation evidence explicit so the LLM judge accepts the valid
-- weekly result shape produced by the NL2SQL tool.
UPDATE sg_ai_qa_question
SET expected_focus = 'Weekly AU: use one as-of snapshot (BASE_DT=2026-07-15), '
|| 'count DISTINCT GUID whose LAST_CONN_DT is in the inclusive seven-day window '
|| '(2026-07-09 through 2026-07-15), with STD_USER_YN=''Y'' and EXPT_USER_YN=''N''. '
|| 'This is one aggregate result, not daily rows. Do not substitute daily AU_FLAG=1 for the period definition.',
baseline_sql = TO_CLOB('SELECT COUNT(DISTINCT u."GUID") AS "RECENT_7DAY_AU"' || CHR(10)
|| 'FROM "SGMP_POC"."CZN_COMN_USER_MST" u' || CHR(10)
|| 'WHERE u."BASE_DT" = DATE ''2026-07-15''' || CHR(10)
|| ' AND u."LAST_CONN_DT" BETWEEN DATE ''2026-07-09'' AND DATE ''2026-07-15''' || CHR(10)
|| ' AND u."STD_USER_YN" = ''Y''' || CHR(10)
|| ' AND u."EXPT_USER_YN" = ''N'''),
baseline_answer = 'RECENT_7DAY_AU=0',
evaluation_rule_json = '{"required_sql_terms":["CZN_COMN_USER_MST","LAST_CONN_DT","STD_USER_YN","EXPT_USER_YN","COUNT"],"recommended_sql_terms":["BASE_DT"],"forbidden_sql_terms":["AU_FLAG"],"required_result_shape":"SINGLE_AGGREGATE"}',
updated_at = SYSTIMESTAMP
WHERE question_code = 'STD-25';
/
COMMIT;
/
SELECT question_code, expected_focus, baseline_sql, baseline_answer, evaluation_rule_json
FROM sg_ai_qa_question
WHERE question_code = 'STD-25';

View File

@@ -0,0 +1,40 @@
-- CZN-02 customer sample marks STD_USER_YN='Y' as optional for daily
-- standard-AU reporting. It must not turn an otherwise correct AU query into
-- a failure merely because the condition is present.
UPDATE sg_ai_qa_question
SET expected_focus = 'Daily standard AU: COUNT(DISTINCT GUID) from CZN_COMN_USER_MST '
|| 'for BASE_DT=2026-07-15 with AU_FLAG=1 and EXPT_USER_YN=''N''. '
|| 'STD_USER_YN=''Y'' is an allowed optional cohort filter in the customer sample; '
|| 'its presence or absence is not a contradiction to this baseline.',
evaluation_rule_json = '{"required_sql_terms":["CZN_COMN_USER_MST","AU_FLAG","EXPT_USER_YN","COUNT"],"recommended_sql_terms":["BASE_DT","STD_USER_YN"],"optional_sql_terms":["STD_USER_YN"]}',
updated_at = SYSTIMESTAMP
WHERE question_code = 'CZN-02';
/
UPDATE sg_qa_vector_example
SET answer_text = 'Expected focus: daily standard AU uses CZN_COMN_USER_MST, BASE_DT=2026-07-15, '
|| 'AU_FLAG=1 and EXPT_USER_YN=''N''. The customer sample permits STD_USER_YN=''Y'' '
|| 'as an optional standard-user cohort filter; do not treat its presence as a conflicting condition. '
|| 'Historical answer: STD_AU_COUNT=0',
inspection_note = 'Customer sample permits optional STD_USER_YN filtering for daily standard AU; AU_FLAG and excluded-user filtering remain mandatory.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-02'
AND reference_status = 'APPROVED';
/
COMMIT;
/
SELECT q.question_code, q.expected_focus, q.evaluation_rule_json,
e.example_id, e.answer_text
FROM sg_ai_qa_question q
LEFT JOIN sg_qa_vector_example e
ON e.source_type = 'CUSTOMER_QA_BENCHMARK'
AND e.source_case_id = q.question_code
WHERE q.question_code = 'CZN-02';

View File

@@ -0,0 +1,90 @@
-- Reusable SINGLE-scope pattern: two AU populations must be aggregated
-- independently before comparison. A user-master LEFT JOIN may erase valid
-- business-user rows and must not define the business population.
UPDATE sg_ai_qa_question
SET expected_focus = 'Compare standard AU and business AU as two independent single-row aggregates for the same as-of date. '
|| 'Standard AU uses the resolved game user master with AU_FLAG=1 and EXPT_USER_YN=''N''. '
|| 'Business AU uses the resolved game business-user fact with BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''. '
|| 'Do not make the business count depend on a LEFT JOIN from the user-master population. '
|| 'STD_USER_YN=''Y'' is an allowed optional cohort filter, not a reason to reject the result.',
evaluation_rule_json = '{"required_sql_terms":["CZN_COMN_USER_MST","CZN_CUSTOM_BIZ_USER_TXN","AU_FLAG","BIZ_AU_FLAG","EXPT_USER_YN","COUNT"],"recommended_sql_terms":["BASE_DT","STD_USER_YN"],"optional_sql_terms":["STD_USER_YN"],"required_result_shape":"SINGLE_COMPARISON"}',
updated_at = SYSTIMESTAMP
WHERE question_code = 'CZN-03';
/
DECLARE
v_input CLOB;
v_embedding VECTOR;
v_exists NUMBER;
BEGIN
v_input := TO_CLOB('Question pattern: compare daily standard active users and business active users for one resolved game and one business date.')
|| CHR(10) || 'Question pattern Korean: 한 게임의 기준일 일간 표준 AU와 사업 AU를 비교해줘.'
|| CHR(10) || 'Logical object role: USER_BUSINESS_AU_COMPARISON'
|| CHR(10) || 'Required result shape: one row with two independent aggregate metrics.'
|| CHR(10) || 'Business population must be aggregated independently; a LEFT JOIN from the user-master population may not define it.';
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input, JSON(sg_qa_vector_params('search_document'))
);
SELECT COUNT(*) INTO v_exists
FROM sg_qa_vector_example
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'STD_BIZ_AU_COMPARE';
IF v_exists = 0 THEN
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, object_role,
inspection_status, inspection_note, verified_at, verified_by,
source_case_id, source_type
) VALUES (
'한 게임의 기준일 일간 표준 AU와 사업 AU를 비교해줘.',
TO_CLOB('SELECT' || CHR(10)
|| ' (SELECT COUNT(DISTINCT u."GUID")' || CHR(10)
|| ' FROM <RESOLVED_GAME_USER_MASTER> u' || CHR(10)
|| ' WHERE u."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|| ' AND u."AU_FLAG" = 1' || CHR(10)
|| ' AND u."EXPT_USER_YN" = ''N'') AS "STANDARD_AU_COUNT",' || CHR(10)
|| ' (SELECT COUNT(DISTINCT b."GUID")' || CHR(10)
|| ' FROM <RESOLVED_GAME_BUSINESS_USER> b' || CHR(10)
|| ' WHERE b."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|| ' AND b."BIZ_AU_FLAG" = 1' || CHR(10)
|| ' AND b."EXPT_USER_YN" = ''N'') AS "BUSINESS_AU_COUNT"' || CHR(10)
|| 'FROM DUAL'),
'Applicable metric reference: for this daily AU comparison, the standard metric must use AU_FLAG=1 and EXPT_USER_YN=''N''; do not replace it with LAST_CONN_DT period logic or STD_USER_YN alone. The business metric must use BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''. Produce one row from two independent aggregate subqueries, and do not count business users through a LEFT JOIN from the standard-user population. STD_USER_YN may be added only as an optional cohort filter.',
v_input, v_embedding, 'cohere.embed-v4.0',
'APPROVED', 'SQL_TEMPLATE', 'SINGLE', 'USER_BUSINESS_AU_COMPARISON',
'VERIFIED',
'Reusable comparison pattern with logical placeholders only; no customer game, date, result, or physical object is embedded.',
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW',
'STD_BIZ_AU_COMPARE', 'POLICY_TEMPLATE'
);
ELSE
UPDATE sg_qa_vector_example
SET question = '한 게임의 기준일 일간 표준 AU와 사업 AU를 비교해줘.',
answer_sql = TO_CLOB('SELECT' || CHR(10)
|| ' (SELECT COUNT(DISTINCT u."GUID")' || CHR(10)
|| ' FROM <RESOLVED_GAME_USER_MASTER> u' || CHR(10)
|| ' WHERE u."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|| ' AND u."AU_FLAG" = 1' || CHR(10)
|| ' AND u."EXPT_USER_YN" = ''N'') AS "STANDARD_AU_COUNT",' || CHR(10)
|| ' (SELECT COUNT(DISTINCT b."GUID")' || CHR(10)
|| ' FROM <RESOLVED_GAME_BUSINESS_USER> b' || CHR(10)
|| ' WHERE b."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|| ' AND b."BIZ_AU_FLAG" = 1' || CHR(10)
|| ' AND b."EXPT_USER_YN" = ''N'') AS "BUSINESS_AU_COUNT"' || CHR(10)
|| 'FROM DUAL'),
answer_text = 'Applicable metric reference: for this daily AU comparison, the standard metric must use AU_FLAG=1 and EXPT_USER_YN=''N''; do not replace it with LAST_CONN_DT period logic or STD_USER_YN alone. The business metric must use BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''. Produce one row from two independent aggregate subqueries, and do not count business users through a LEFT JOIN from the standard-user population. STD_USER_YN may be added only as an optional cohort filter.',
embedding_input = v_input,
embedding = v_embedding,
reference_status = 'APPROVED', inspection_status = 'VERIFIED',
verified_at = SYSTIMESTAMP, verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'STD_BIZ_AU_COMPARE';
END IF;
COMMIT;
END;
/

View File

@@ -0,0 +1,58 @@
-- Reusable SINGLE-scope pattern for country-grouped daily AU.
UPDATE sg_ai_qa_question
SET expected_focus = 'Country-grouped daily standard AU uses the resolved game user master with BASE_DT, AU_FLAG=1 and EXPT_USER_YN=''N''. '
|| 'Group by LAST_CONN_COUNTRY_CD and use the approved country dimension only for display/classification. '
|| 'STD_USER_YN is optional and cannot replace AU_FLAG for the daily metric.',
evaluation_rule_json = '{"required_sql_terms":["CZN_COMN_USER_MST","AU_FLAG","EXPT_USER_YN","LAST_CONN_COUNTRY_CD","COUNT"],"recommended_sql_terms":["BASE_DT","COMN_COUNTRY_BAS","STD_USER_YN"],"optional_sql_terms":["STD_USER_YN"],"required_result_shape":"COUNTRY_GROUPED"}',
updated_at = SYSTIMESTAMP
WHERE question_code = 'CZN-06';
/
DECLARE
v_input CLOB;
v_embedding VECTOR;
v_exists NUMBER;
BEGIN
v_input := TO_CLOB('Question pattern: show daily active-user counts by country for one resolved game and one business date.')
|| CHR(10) || 'Question pattern Korean: 한 게임의 기준일 주요 국가별 표준 AU 수를 알려줘.'
|| CHR(10) || 'Logical object role: COUNTRY_GROUPED_DAILY_AU'
|| CHR(10) || 'Required metric: AU_FLAG=1 and excluded-user filtering; group by the last connection country.';
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(v_input, JSON(sg_qa_vector_params('search_document')));
SELECT COUNT(*) INTO v_exists FROM sg_qa_vector_example
WHERE source_type = 'POLICY_TEMPLATE' AND source_case_id = 'COUNTRY_DAILY_AU';
IF v_exists = 0 THEN
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, object_role,
inspection_status, inspection_note, verified_at, verified_by, source_case_id, source_type
) VALUES (
'한 게임의 기준일 주요 국가별 표준 AU 수를 알려줘.',
TO_CLOB('SELECT u."LAST_CONN_COUNTRY_CD" AS "COUNTRY_CD",' || CHR(10)
|| ' c."COUNTRY_KR_NM" AS "COUNTRY_NAME",' || CHR(10)
|| ' COUNT(DISTINCT u."GUID") AS "STANDARD_AU_COUNT"' || CHR(10)
|| 'FROM <RESOLVED_GAME_USER_MASTER> u' || CHR(10)
|| 'LEFT JOIN <APPROVED_COUNTRY_DIMENSION> c' || CHR(10)
|| ' ON c."COUNTRY_2CHAR_CD" = u."LAST_CONN_COUNTRY_CD"' || CHR(10)
|| 'WHERE u."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|| ' AND u."AU_FLAG" = 1' || CHR(10)
|| ' AND u."EXPT_USER_YN" = ''N''' || CHR(10)
|| 'GROUP BY u."LAST_CONN_COUNTRY_CD", c."COUNTRY_KR_NM"'),
'Applicable metric reference: country-grouped daily AU must use AU_FLAG=1 and EXPT_USER_YN=''N''; do not replace AU_FLAG with STD_USER_YN alone. Group by LAST_CONN_COUNTRY_CD. Use an approved country dimension for country display or a current approved major-country classification when the request requires it.',
v_input, v_embedding, 'cohere.embed-v4.0',
'APPROVED', 'SQL_TEMPLATE', 'SINGLE', 'COUNTRY_GROUPED_DAILY_AU',
'VERIFIED', 'Reusable country-grouped daily-AU pattern; no customer game, date, result, or physical object is embedded.',
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW', 'COUNTRY_DAILY_AU', 'POLICY_TEMPLATE'
);
ELSE
UPDATE sg_qa_vector_example
SET embedding_input = v_input, embedding = v_embedding,
reference_status = 'APPROVED', inspection_status = 'VERIFIED',
verified_at = SYSTIMESTAMP, verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'POLICY_TEMPLATE' AND source_case_id = 'COUNTRY_DAILY_AU';
END IF;
COMMIT;
END;
/

View File

@@ -0,0 +1,17 @@
-- CZN-06 is a verified, exact customer question/SQL pair. It must be a
-- runtime Few-shot when approved; RETIRED is the DB switch that excludes it.
-- This is a single-game reference, so keep the retrieval scope explicit.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
object_role = 'COUNTRY_GROUPED_DAILY_AU',
inspection_status = 'VERIFIED',
inspection_note = 'Verified exact CZN-06 Few-shot restored for runtime retrieval. Daily country AU requires AU_FLAG=1 and EXPT_USER_YN=''N''; STD_USER_YN alone is insufficient.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-06'
/
COMMIT

View File

@@ -0,0 +1,69 @@
-- Runtime Few-shots remain semantic vector retrieval. Customer examples are
-- governed by their DB approval state, not restricted to exact text matches.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
object_role = 'GAME_GOODS_HOLDINGS',
inspection_status = 'VERIFIED',
inspection_note = 'Verified CZN-07 semantic Few-shot. Use goods holdings, crystal dimension, RU_FLAG=1, excluded-user filter, nonzero holdings, and daily grouping.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-07'
/
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
v_max_cosine_distance NUMBER;
BEGIN
IF p_question IS NULL THEN RAISE_APPLICATION_ERROR(-20003, 'question is required.'); END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'invalid target type.');
END IF;
SELECT number_value INTO v_max_cosine_distance
FROM sg_game_scope_policy
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE'
AND active_yn = 'Y'
AND number_value IS NOT NULL;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question, JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
cosine_distance
FROM (
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
VECTOR_DISTANCE(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND inspection_status = 'VERIFIED'
AND answer_sql IS NOT NULL
AND (source_type = 'POLICY_TEMPLATE'
OR NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i'))
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
)
WHERE cosine_distance <= v_max_cosine_distance
ORDER BY cosine_distance, example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
COMMIT

View File

@@ -0,0 +1,87 @@
-- Keep semantic vector retrieval, but do not inject weak trailing neighbours
-- when a materially stronger example has already been found.
MERGE INTO sg_game_scope_policy t
USING (
SELECT 'QA_VECTOR_NEIGHBOR_DISTANCE_MARGIN' AS policy_key,
0.120000 AS number_value,
'Maximum additional cosine distance from the best runtime Few-shot candidate.' AS description
FROM dual
) s
ON (t.policy_key = s.policy_key)
WHEN MATCHED THEN UPDATE SET
t.number_value = s.number_value,
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, NULL, s.description, 'Y'
)
/
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
v_max_cosine_distance NUMBER;
v_neighbor_margin NUMBER;
BEGIN
IF p_question IS NULL THEN RAISE_APPLICATION_ERROR(-20003, 'question is required.'); END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'invalid target type.');
END IF;
SELECT number_value INTO v_max_cosine_distance
FROM sg_game_scope_policy
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE'
AND active_yn = 'Y'
AND number_value IS NOT NULL;
SELECT number_value INTO v_neighbor_margin
FROM sg_game_scope_policy
WHERE policy_key = 'QA_VECTOR_NEIGHBOR_DISTANCE_MARGIN'
AND active_yn = 'Y'
AND number_value IS NOT NULL;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question, JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
cosine_distance
FROM (
SELECT c.*,
MIN(c.cosine_distance) OVER () AS best_cosine_distance
FROM (
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
VECTOR_DISTANCE(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND inspection_status = 'VERIFIED'
AND answer_sql IS NOT NULL
AND (source_type = 'POLICY_TEMPLATE'
OR NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i'))
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
) c
WHERE c.cosine_distance <= v_max_cosine_distance
)
WHERE cosine_distance <= best_cosine_distance + v_neighbor_margin
ORDER BY cosine_distance, example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
COMMIT

View File

@@ -0,0 +1,16 @@
-- CZN-08 is the reviewed semantic reference for daily standard-AU crystal
-- total and per-user average holdings.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
object_role = 'GAME_GOODS_HOLDINGS',
inspection_status = 'VERIFIED',
inspection_note = 'Verified CZN-08 Few-shot. Standard-AU crystal holdings require AU_FLAG=1, excluded-user filtering, and per-user average as SUM(HAVE_CNT) / COUNT(DISTINCT GUID), not AVG(HAVE_CNT).',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-08'
/
COMMIT

View File

@@ -0,0 +1,11 @@
-- Calibrate the semantic-neighbour window using reviewed CZN patterns:
-- retain close paraphrases, exclude adjacent metric shapes.
UPDATE sg_game_scope_policy
SET number_value = 0.100000,
description = 'Maximum additional cosine distance from the best runtime Few-shot candidate.',
active_yn = 'Y',
updated_at = SYSTIMESTAMP
WHERE policy_key = 'QA_VECTOR_NEIGHBOR_DISTANCE_MARGIN'
/
COMMIT

View File

@@ -0,0 +1,15 @@
-- CZN-05 is the reviewed reference for country-grouped business AU.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
object_role = 'COUNTRY_GROUPED_BUSINESS_AU',
inspection_status = 'VERIFIED',
inspection_note = 'Verified CZN-05 Few-shot. Join business-user data to user master on GUID and BASE_DT before grouping by user country; filter BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-05'
/
COMMIT

View File

@@ -0,0 +1,95 @@
-- Hybrid retrieval remains database-driven: dense vector similarity handles
-- paraphrases, while lexical similarity distinguishes decisive request terms.
MERGE INTO sg_game_scope_policy t
USING (
SELECT 'QA_VECTOR_LEXICAL_WEIGHT' AS policy_key, 0.350000 AS number_value,
'Weight of normalized lexical question similarity in runtime Few-shot reranking.' AS description
FROM dual
UNION ALL
SELECT 'QA_VECTOR_HYBRID_SCORE_MARGIN', 0.050000,
'Maximum hybrid-score difference from the best runtime Few-shot candidate.'
FROM dual
) s
ON (t.policy_key = s.policy_key)
WHEN MATCHED THEN UPDATE SET
t.number_value = s.number_value,
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, NULL, s.description, 'Y'
)
/
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
v_max_cosine_distance NUMBER;
v_lexical_weight NUMBER;
v_hybrid_margin NUMBER;
BEGIN
IF p_question IS NULL THEN RAISE_APPLICATION_ERROR(-20003, 'question is required.'); END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'invalid target type.');
END IF;
SELECT number_value INTO v_max_cosine_distance FROM sg_game_scope_policy
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE' AND active_yn = 'Y';
SELECT number_value INTO v_lexical_weight FROM sg_game_scope_policy
WHERE policy_key = 'QA_VECTOR_LEXICAL_WEIGHT' AND active_yn = 'Y';
SELECT number_value INTO v_hybrid_margin FROM sg_game_scope_policy
WHERE policy_key = 'QA_VECTOR_HYBRID_SCORE_MARGIN' AND active_yn = 'Y';
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question, JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
cosine_distance
FROM (
SELECT s.*,
MAX(s.hybrid_score) OVER () AS best_hybrid_score
FROM (
SELECT c.*,
((1 - v_lexical_weight) * (1 - c.cosine_distance)
+ v_lexical_weight * c.lexical_similarity) AS hybrid_score
FROM (
SELECT example_id, question, answer_sql, answer_text, embedding_model,
reference_kind, target_type, object_role, source_case_id, source_type,
VECTOR_DISTANCE(embedding, v_query_vector, COSINE) AS cosine_distance,
UTL_MATCH.JARO_WINKLER_SIMILARITY(
DBMS_LOB.SUBSTR(question, 4000, 1),
DBMS_LOB.SUBSTR(p_question, 4000, 1)
) / 100 AS lexical_similarity
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND inspection_status = 'VERIFIED'
AND answer_sql IS NOT NULL
AND (source_type = 'POLICY_TEMPLATE'
OR NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i'))
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
) c
WHERE c.cosine_distance <= v_max_cosine_distance
) s
)
WHERE hybrid_score >= best_hybrid_score - v_hybrid_margin
ORDER BY hybrid_score DESC, cosine_distance, example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
COMMIT

View File

@@ -0,0 +1,15 @@
-- CZN-13 is the reviewed reference for Ether usage and distinct users.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
object_role = 'GAME_GOODS_CHANGE',
inspection_status = 'VERIFIED',
inspection_note = 'Verified CZN-13 Few-shot. Ether usage requires goods-change data joined to the goods dimension and user master by GUID and BASE_DT, CHANGE_TYPE_CD=''USE'', active Ether dimension, excluded-user filter, and GOODS_CHANGE_CNT aggregation.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-13'
/
COMMIT

View File

@@ -0,0 +1,15 @@
-- CZN-16 is the reviewed reference for purchasers of a named package.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
object_role = 'SALES_PRODUCT_PURCHASER',
inspection_status = 'VERIFIED',
inspection_note = 'Verified CZN-16 Few-shot. Join sales transactions to product display by GAME_ID and PRODUCT_ID, filter the resolved package name and excluded users, and use the payment business date when counting distinct purchasers.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-16'
/
COMMIT

View File

@@ -0,0 +1,62 @@
-- Smilegate customer Excel QA benchmark history.
-- This script is also applied by poc4_active_source_20260714/scripts/
-- sync_smilegate_qa_history.py with existence checks for repeatable deployment.
CREATE TABLE SG_AI_QA_QUESTION (
question_id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY,
question_code VARCHAR2(30) UNIQUE,
question_source VARCHAR2(30) NOT NULL,
question_hash VARCHAR2(64) NOT NULL UNIQUE,
category VARCHAR2(30) NOT NULL,
title VARCHAR2(200) NOT NULL,
question_text CLOB NOT NULL,
source_document VARCHAR2(255),
source_sheet VARCHAR2(255),
source_row NUMBER,
source_scenario CLOB,
sample_sql CLOB,
expected_focus CLOB,
baseline_sql CLOB,
baseline_answer CLOB,
support_level VARCHAR2(20) NOT NULL,
evaluation_rule_json CLOB CHECK (evaluation_rule_json IS JSON),
active_yn CHAR(1) DEFAULT 'Y' NOT NULL CHECK (active_yn IN ('Y', 'N')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT sg_ai_qa_question_source_ck
CHECK (question_source IN ('CUSTOMER_EXCEL', 'FREE_TEXT'))
);
CREATE TABLE SG_AI_QA_ANSWER (
answer_seq NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
question_id NUMBER NOT NULL,
answer_kind VARCHAR2(20) NOT NULL,
run_key VARCHAR2(100),
conversation_id VARCHAR2(100),
requested_by VARCHAR2(100),
requested_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
model_profile VARCHAR2(100),
generated_sql CLOB,
answer_text CLOB,
result_json CLOB CHECK (result_json IS JSON),
execution_output CLOB,
execution_status VARCHAR2(40),
judgment_status VARCHAR2(20) NOT NULL,
judgment_reason CLOB,
duration_ms NUMBER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT sg_ai_qa_answer_question_fk
FOREIGN KEY (question_id)
REFERENCES SG_AI_QA_QUESTION (question_id)
ON DELETE CASCADE,
CONSTRAINT sg_ai_qa_answer_kind_ck
CHECK (answer_kind IN ('HISTORICAL', 'LIVE')),
CONSTRAINT sg_ai_qa_answer_judgment_ck
CHECK (judgment_status IN ('PASS', 'WARN', 'FAIL', 'REVIEW'))
);
CREATE INDEX sg_ai_qa_answer_question_ix
ON SG_AI_QA_ANSWER (question_id, answer_seq DESC);
CREATE UNIQUE INDEX sg_ai_qa_answer_run_uk
ON SG_AI_QA_ANSWER (question_id, run_key);

View File

@@ -0,0 +1,74 @@
-- 72_sgmp_select_ai_oci_genai_profile.sql
-- Run as SGMP_POC after creating SGMP_POC_OCI_DEFAULT_CRED from the local
-- ~/.oci/config DEFAULT API signing key. No private-key material belongs in
-- this script or the repository.
--
-- The source external profile is retained. Metadata attributes are copied
-- individually so object_list, comments, annotations and instructions remain
-- intact while external endpoint, credential and model settings are replaced.
DECLARE
v_exists PLS_INTEGER;
BEGIN
SELECT COUNT(*)
INTO v_exists
FROM user_cloud_ai_profiles
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI';
IF v_exists > 0 THEN
DBMS_CLOUD_AI.DROP_PROFILE(
profile_name => 'SGMP_POC_OCI_GPT54MINI',
force => TRUE
);
END IF;
DBMS_CLOUD_AI.CREATE_PROFILE(
profile_name => 'SGMP_POC_OCI_GPT54MINI',
attributes => '{
"provider": "oci",
"credential_name": "SGMP_POC_OCI_DEFAULT_CRED",
"model": "openai.gpt-5.4-mini",
"region": "us-chicago-1",
"oci_compartment_id": "<DEFAULT tenancy OCID>"
}',
description => 'Smilegate Text2SQL on OCI GenAI GPT-5.4 Mini'
);
FOR source_attribute IN (
SELECT attribute_name,
attribute_value
FROM user_cloud_ai_profile_attributes
WHERE profile_name = 'SGMP_POC_HAIKU45'
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_GPT54MINI',
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_GPT54MINI'
AND attribute_name IN (
'provider',
'model',
'credential_name',
'region',
'oci_compartment_id'
)
ORDER BY attribute_name;

View File

@@ -0,0 +1,199 @@
-- SGMP QA example vector store.
--
-- Run as SGMP_POC after scripts/setup-sgmp-qa-vector.sh has registered the
-- DBMS_VECTOR credential and granted the HTTPS ACL. No API key material is
-- stored in this file.
--
-- Cohere Embed 4 is intentionally fixed to 1536 dimensions. Stored examples
-- use search_document; incoming questions use search_query.
DECLARE
v_count PLS_INTEGER;
BEGIN
SELECT COUNT(*) INTO v_count
FROM user_tables
WHERE table_name = 'SG_QA_VECTOR_CONFIG';
IF v_count = 0 THEN
EXECUTE IMMEDIATE q'[
CREATE TABLE sg_qa_vector_config (
config_key VARCHAR2(64) PRIMARY KEY,
config_value VARCHAR2(4000) NOT NULL,
updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL
)]';
END IF;
END;
/
MERGE INTO sg_qa_vector_config c
USING (
SELECT 'CREDENTIAL_NAME' AS config_key, 'SGMP_POC_QA_VECTOR_CRED' AS config_value FROM dual
UNION ALL SELECT 'ENDPOINT_URL', 'https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/embedText' FROM dual
UNION ALL SELECT 'MODEL_NAME', 'cohere.embed-v4.0' FROM dual
UNION ALL SELECT 'DIMENSION', '1536' FROM dual
) s
ON (c.config_key = s.config_key)
WHEN MATCHED THEN UPDATE SET c.config_value = s.config_value, c.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN INSERT (config_key, config_value) VALUES (s.config_key, s.config_value);
/
DECLARE
v_count PLS_INTEGER;
BEGIN
SELECT COUNT(*) INTO v_count
FROM user_tables
WHERE table_name = 'SG_QA_VECTOR_EXAMPLE';
IF v_count = 0 THEN
EXECUTE IMMEDIATE q'[
CREATE TABLE sg_qa_vector_example (
example_id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
question CLOB NOT NULL,
answer_sql CLOB NOT NULL,
answer_text CLOB,
embedding_input CLOB NOT NULL,
embedding VECTOR(1536, FLOAT32) NOT NULL,
embedding_model VARCHAR2(128) DEFAULT 'cohere.embed-v4.0' NOT NULL,
created_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL
)]';
END IF;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_params(p_input_type IN VARCHAR2)
RETURN CLOB
AUTHID DEFINER
IS
v_credential VARCHAR2(4000);
v_endpoint VARCHAR2(4000);
v_model VARCHAR2(4000);
BEGIN
SELECT MAX(CASE WHEN config_key = 'CREDENTIAL_NAME' THEN config_value END),
MAX(CASE WHEN config_key = 'ENDPOINT_URL' THEN config_value END),
MAX(CASE WHEN config_key = 'MODEL_NAME' THEN config_value END)
INTO v_credential, v_endpoint, v_model
FROM sg_qa_vector_config;
IF v_credential IS NULL OR v_endpoint IS NULL OR v_model IS NULL THEN
RAISE_APPLICATION_ERROR(-20001, 'SG QA vector configuration is incomplete.');
END IF;
RETURN TO_CLOB('{"provider":"ocigenai","credential_name":"')
|| v_credential
|| '","url":"' || v_endpoint
|| '","model":"' || v_model
|| '","truncate":"END"}';
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_store(
p_question IN CLOB,
p_answer_sql IN CLOB,
p_answer IN CLOB DEFAULT NULL
) RETURN NUMBER
AUTHID DEFINER
IS
PRAGMA AUTONOMOUS_TRANSACTION;
v_input CLOB;
v_embedding VECTOR;
v_example_id NUMBER;
BEGIN
IF p_question IS NULL OR p_answer_sql IS NULL THEN
RAISE_APPLICATION_ERROR(-20002, 'question and answer_sql are required.');
END IF;
v_input := TO_CLOB('Question: ') || p_question
|| TO_CLOB(CHR(10) || 'Answer SQL: ') || p_answer_sql
|| CASE WHEN p_answer IS NULL THEN NULL ELSE TO_CLOB(CHR(10) || 'Answer: ') || p_answer END;
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model
) VALUES (
p_question, p_answer_sql, p_answer, v_input, v_embedding, 'cohere.embed-v4.0'
) RETURNING example_id INTO v_example_id;
COMMIT;
RETURN v_example_id;
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3
) RETURN SYS_REFCURSOR
AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
BEGIN
IF p_question IS NULL THEN
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question,
JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id,
question,
answer_sql,
answer_text,
embedding_model,
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
ORDER BY vector_distance(embedding, v_query_vector, COSINE), example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_context(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3
) RETURN CLOB
AUTHID DEFINER
IS
v_results SYS_REFCURSOR;
v_id NUMBER;
v_q CLOB;
v_sql CLOB;
v_answer CLOB;
v_model VARCHAR2(128);
v_dist NUMBER;
v_context CLOB := EMPTY_CLOB();
BEGIN
v_results := sg_qa_vector_search(p_question, p_top_k);
LOOP
FETCH v_results INTO v_id, v_q, v_sql, v_answer, v_model, v_dist;
EXIT WHEN v_results%NOTFOUND;
v_context := v_context
|| CASE WHEN DBMS_LOB.GETLENGTH(v_context) = 0 THEN NULL ELSE CHR(10) || CHR(10) END
|| '[Example ' || v_id || ', cosine_distance=' || TO_CHAR(v_dist, 'FM0D000000') || ']' || CHR(10)
|| 'Question: ' || v_q || CHR(10)
|| 'Answer SQL: ' || v_sql
|| CASE WHEN v_answer IS NULL THEN NULL ELSE CHR(10) || 'Answer: ' || v_answer END;
END LOOP;
CLOSE v_results;
RETURN v_context;
END;
/
COMMENT ON TABLE sg_qa_vector_example IS
'Question-to-SQL QA examples embedded with OCI GenAI Cohere Embed 4 for retrieval-augmented prompt context.';
COMMENT ON COLUMN sg_qa_vector_example.embedding IS
'1536-dimensional Cohere Embed 4 document embedding; generated through the dedicated SGMP vector API credential.';

View File

@@ -0,0 +1,148 @@
-- SGMP PoC: validated table/view and column annotation API
-- Issue: #734
CREATE OR REPLACE FUNCTION sgmp_set_annotation(
p_schema_name IN VARCHAR2,
p_target_kind IN VARCHAR2,
p_object_name IN VARCHAR2,
p_column_name IN VARCHAR2 DEFAULT NULL,
p_change_text IN VARCHAR2 DEFAULT NULL,
p_annotation_name IN VARCHAR2 DEFAULT 'AI_GUIDANCE'
) RETURN VARCHAR2
AUTHID DEFINER
IS
l_schema_name VARCHAR2(128);
l_object_name VARCHAR2(128);
l_column_name VARCHAR2(128);
l_annotation_name VARCHAR2(128);
l_target_kind VARCHAR2(20);
l_object_type VARCHAR2(30);
l_exists PLS_INTEGER := 0;
l_action VARCHAR2(20);
l_sql VARCHAR2(32767);
FUNCTION simple_name(p_value VARCHAR2, p_label VARCHAR2) RETURN VARCHAR2 IS
l_value VARCHAR2(128) := UPPER(TRIM(p_value));
BEGIN
IF l_value IS NULL OR NOT REGEXP_LIKE(l_value, '^[A-Z][A-Z0-9_$#]{0,127}$') THEN
RAISE_APPLICATION_ERROR(-20001, p_label || ' has an invalid format.');
END IF;
RETURN l_value;
END;
FUNCTION qname(p_value VARCHAR2) RETURN VARCHAR2 IS
BEGIN
RETURN DBMS_ASSERT.ENQUOTE_NAME(p_value, FALSE);
END;
FUNCTION literal(p_value VARCHAR2) RETURN VARCHAR2 IS
BEGIN
RETURN DBMS_ASSERT.ENQUOTE_LITERAL(p_value);
END;
BEGIN
l_schema_name := simple_name(p_schema_name, 'schema_name');
l_object_name := simple_name(p_object_name, 'object_name');
l_target_kind := UPPER(TRIM(p_target_kind));
IF l_target_kind NOT IN ('TABLE', 'COLUMN') THEN
RAISE_APPLICATION_ERROR(-20002, 'target_kind must be TABLE or COLUMN.');
END IF;
l_annotation_name := simple_name(p_annotation_name, 'annotation_name');
IF p_change_text IS NULL OR LENGTH(p_change_text) = 0 THEN
RAISE_APPLICATION_ERROR(-20003, 'change_text must not be empty.');
END IF;
IF LENGTH(p_change_text) > 4000 THEN
RAISE_APPLICATION_ERROR(-20004, 'change_text must be 4000 characters or less.');
END IF;
BEGIN
SELECT object_type
INTO l_object_type
FROM all_objects
WHERE owner = l_schema_name
AND object_name = l_object_name
AND object_type IN ('TABLE', 'VIEW')
AND ROWNUM = 1;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(-20005, 'TABLE or VIEW object was not found.');
END;
IF l_target_kind = 'COLUMN' THEN
IF p_column_name IS NULL THEN
RAISE_APPLICATION_ERROR(-20006, 'column_name is required for COLUMN target.');
END IF;
IF l_object_type = 'VIEW' THEN
RAISE_APPLICATION_ERROR(-20007, 'VIEW column annotations cannot be altered by Oracle.');
END IF;
l_column_name := simple_name(p_column_name, 'column_name');
BEGIN
SELECT 1 INTO l_exists
FROM all_tab_columns
WHERE owner = l_schema_name
AND table_name = l_object_name
AND column_name = l_column_name
AND ROWNUM = 1;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(-20008, 'column_name does not exist on the table.');
END;
ELSIF p_column_name IS NOT NULL THEN
RAISE_APPLICATION_ERROR(-20009, 'column_name is not allowed for TABLE target.');
END IF;
SELECT COUNT(*)
INTO l_exists
FROM all_annotations_usage
WHERE annotation_owner = l_schema_name
AND object_name = l_object_name
AND object_type = l_object_type
AND annotation_name = l_annotation_name
AND (l_target_kind = 'TABLE' AND column_name IS NULL
OR l_target_kind = 'COLUMN' AND column_name = l_column_name);
IF l_exists > 0 THEN
IF l_target_kind = 'COLUMN' THEN
l_sql := 'ALTER TABLE ' || qname(l_schema_name) || '.' || qname(l_object_name)
|| ' MODIFY ' || qname(l_column_name) || ' ANNOTATIONS (DROP '
|| qname(l_annotation_name) || ')';
ELSIF l_object_type = 'VIEW' THEN
l_sql := 'ALTER VIEW ' || qname(l_schema_name) || '.' || qname(l_object_name)
|| ' ANNOTATIONS (DROP ' || qname(l_annotation_name) || ')';
ELSE
l_sql := 'ALTER TABLE ' || qname(l_schema_name) || '.' || qname(l_object_name)
|| ' ANNOTATIONS (DROP ' || qname(l_annotation_name) || ')';
END IF;
EXECUTE IMMEDIATE l_sql;
l_action := 'REPLACED';
ELSE
l_action := 'ADDED';
END IF;
IF l_target_kind = 'COLUMN' THEN
l_sql := 'ALTER TABLE ' || qname(l_schema_name) || '.' || qname(l_object_name)
|| ' MODIFY ' || qname(l_column_name) || ' ANNOTATIONS (ADD '
|| qname(l_annotation_name) || ' ' || literal(p_change_text) || ')';
ELSIF l_object_type = 'VIEW' THEN
l_sql := 'ALTER VIEW ' || qname(l_schema_name) || '.' || qname(l_object_name)
|| ' ANNOTATIONS (ADD ' || qname(l_annotation_name) || ' '
|| literal(p_change_text) || ')';
ELSE
l_sql := 'ALTER TABLE ' || qname(l_schema_name) || '.' || qname(l_object_name)
|| ' ANNOTATIONS (ADD ' || qname(l_annotation_name) || ' '
|| literal(p_change_text) || ')';
END IF;
EXECUTE IMMEDIATE l_sql;
RETURN l_action || ': ' || l_schema_name || '.' || l_object_name
|| CASE WHEN l_column_name IS NULL THEN '' ELSE '.' || l_column_name END
|| ' [' || l_annotation_name || ']';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE BETWEEN -20099 AND -20000 THEN
RAISE;
END IF;
RAISE_APPLICATION_ERROR(-20099, 'annotation change failed: ' || SQLERRM);
END;
/
SHOW ERRORS FUNCTION sgmp_set_annotation;

View File

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

View File

@@ -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;
/

View File

@@ -0,0 +1,179 @@
-- Metadata-driven game catalog for deterministic scope resolution.
-- No customer game names or prefixes are embedded in this script.
BEGIN
EXECUTE IMMEDIATE q'[
CREATE TABLE sg_game_catalog (
game_key VARCHAR2(128) PRIMARY KEY,
game_id VARCHAR2(128) NOT NULL,
game_prefix VARCHAR2(128),
game_nm VARCHAR2(512),
game_alias_nm VARCHAR2(512),
user_master_object_name VARCHAR2(128),
search_text CLOB NOT NULL,
embedding VECTOR(1536, FLOAT32),
active_yn CHAR(1) DEFAULT 'Y' NOT NULL,
priority NUMBER DEFAULT 100 NOT NULL,
updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL
)]';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN RAISE; END IF;
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
USING (
SELECT
game_id AS game_key,
game_id,
MAX(game_prefix) AS game_prefix,
MAX(game_nm) AS game_nm,
LISTAGG(game_alias_nm, ' ') WITHIN GROUP (ORDER BY game_alias_nm) AS game_alias_nm,
game_id || ' ' || MAX(NVL(game_nm, '')) || ' '
|| LISTAGG(NVL(game_alias_nm, ''), ' ') WITHIN GROUP (ORDER BY game_alias_nm)
|| ' ' || MAX(NVL(game_prefix, '')) AS search_text
FROM comn_game_alias_bas
WHERE use_yn = 'Y'
GROUP BY game_id
) 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);
/
-- 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
'Customer-owned game catalog used to resolve query scope before NL2SQL.';
COMMENT ON COLUMN sg_game_catalog.embedding IS
'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(
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;
/

View File

@@ -0,0 +1,35 @@
-- 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 identity mentions from the user question. '
|| 'A registered game title, alias, GAME_ID, GAME_PREFIX, or catalog key is a game mention and must be preserved exactly as written. '
|| 'Metrics, acronyms, dates, filters, and database object or column names are not game mentions unless they are themselves an explicit registered game identity. '
|| '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 one game identity to another 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_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;
/

View File

@@ -0,0 +1,485 @@
-- 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_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
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;
/
-- 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(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 5
) RETURN CLOB AUTHID DEFINER
IS
TYPE t_seen_game_keys IS TABLE OF BOOLEAN INDEX BY VARCHAR2(128);
v_extract_raw CLOB;
v_extract JSON_OBJECT_T;
v_mentions JSON_ARRAY_T;
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_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_execution_tasks JSON_ARRAY_T := JSON_ARRAY_T();
v_seen_game_keys t_seen_game_keys;
v_result JSON_OBJECT_T := JSON_OBJECT_T();
v_reference_summary JSON_OBJECT_T := JSON_OBJECT_T();
v_select_ai_reference CLOB;
v_plan_status VARCHAR2(30);
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;
v_candidate JSON_OBJECT_T;
v_decision_raw CLOB;
v_decision JSON_OBJECT_T;
v_decision_status VARCHAR2(30);
v_selected_key VARCHAR2(128);
v_reason VARCHAR2(4000);
v_matched_candidate JSON_OBJECT_T;
v_mention_result JSON_OBJECT_T;
v_target JSON_OBJECT_T;
v_unmatched_item JSON_OBJECT_T;
v_execution_task JSON_OBJECT_T;
v_worker_arguments JSON_OBJECT_T;
v_fewshot_arguments JSON_OBJECT_T;
v_task_plan JSON_OBJECT_T;
v_task_reference JSON_OBJECT_T;
v_task_targets JSON_ARRAY_T;
v_excluded_mentions JSON_ARRAY_T;
v_task_question CLOB;
v_cursor SYS_REFCURSOR;
v_game_key VARCHAR2(128);
v_game_id VARCHAR2(128);
v_game_prefix VARCHAR2(128);
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);
v_extract := JSON_OBJECT_T.parse(v_extract_raw);
v_mentions := v_extract.get_array('game_mentions');
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 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);
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_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');
v_target.put_null('gameName');
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(
'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
v_mention := v_mentions.get_string(i);
v_candidates := JSON_ARRAY_T();
v_cursor := SGMP_POC.sg_game_catalog_search(
v_mention,
LEAST(GREATEST(NVL(p_top_k, 5), 1), 20)
);
LOOP
FETCH v_cursor INTO
v_game_key,
v_game_id,
v_game_prefix,
v_game_nm,
v_game_alias_nm,
v_user_master_object_name,
v_cosine_distance;
EXIT WHEN v_cursor%NOTFOUND;
v_candidate := JSON_OBJECT_T();
v_candidate.put('gameKey', v_game_key);
v_candidate.put('gameId', v_game_id);
v_candidate.put('gamePrefix', v_game_prefix);
v_candidate.put('gameName', v_game_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(
'factScopeStatus',
sg_game_fact_scope_status(v_game_id)
);
v_candidate.put('cosineDistance', v_cosine_distance);
v_candidates.append(v_candidate);
END LOOP;
CLOSE v_cursor;
v_decision_raw := sg_game_match_candidate(
v_mention,
v_candidates.to_clob
);
v_decision := JSON_OBJECT_T.parse(v_decision_raw);
v_decision_status := UPPER(
NVL(v_decision.get_string('status'), 'UNMATCHED')
);
v_selected_key := v_decision.get_string('game_key');
v_reason := v_decision.get_string('reason');
v_matched_candidate := NULL;
IF v_decision_status = 'MATCHED' AND v_selected_key IS NOT NULL THEN
FOR j IN 0 .. v_candidates.get_size - 1 LOOP
v_candidate := TREAT(v_candidates.get(j) AS JSON_OBJECT_T);
IF v_candidate.get_string('gameKey') = v_selected_key THEN
v_matched_candidate := v_candidate;
EXIT;
END IF;
END LOOP;
END IF;
v_mention_result := JSON_OBJECT_T();
v_mention_result.put('mention', v_mention);
v_mention_result.put('candidateGames', v_candidates);
v_mention_result.put('reason', v_reason);
IF v_matched_candidate IS NOT NULL THEN
v_mention_result.put('status', 'MATCHED');
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', 'VECTOR_SCORE_MATCH');
v_target.put('reason', v_reason);
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_target);
v_seen_game_keys(v_selected_key) := TRUE;
END IF;
ELSE
v_mention_result.put('status', 'UNMATCHED');
v_mention_result.put('reasonCode', 'VECTOR_SCORE_OVER_THRESHOLD');
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');
v_target.put_null('gameName');
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',
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.put('mention', v_mention);
v_unmatched_item.put(
'reasonCode',
v_mention_result.get_string('reasonCode')
);
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;
IF v_target_type = 'NONE' THEN
v_plan_status := 'NO_TARGET';
ELSIF v_matched_count = 0 THEN
v_plan_status := 'UNMATCHED';
ELSIF v_data_eligible_count = 0 THEN
v_plan_status := 'UNAVAILABLE';
ELSIF v_unmatched_count > 0 THEN
v_plan_status := 'PARTIAL';
ELSE
v_plan_status := 'SUPPORTED';
END IF;
-- This is the sole Select AI handoff contract. It is deliberately generic:
-- game values and physical objects come only from the database lookup above.
v_reference_summary.put('targetType', v_target_type);
v_reference_summary.put('status', v_plan_status);
v_reference_summary.put('gameTargets', v_supported);
v_select_ai_reference := '[GAME QUERY REFERENCE]' || CHR(10) || CHR(10)
|| v_reference_summary.to_clob()
|| CHR(10) || CHR(10)
|| 'Field meanings:' || CHR(10) || CHR(10)
|| '* targetType:' || CHR(10)
|| ' * NONE: No game was resolved.' || CHR(10)
|| ' * SINGLE: Exactly one game was resolved.' || CHR(10)
|| ' * MULTI: Multiple specific games were resolved.' || CHR(10)
|| ' * ALL: The query applies to all supported games.' || CHR(10)
|| '* status: The result of game-target resolution.' || CHR(10)
|| '* gameTargets: The exact games resolved by the DB lookup. Each item may include:' || CHR(10)
|| ' * gameKey: Canonical game identifier.' || CHR(10)
|| ' * gamePrefix: Prefix used for game-scoped objects.' || CHR(10)
|| ' * userMasterObjectName: Resolved user-master object for that game.' || CHR(10) || CHR(10)
|| 'Object-selection guidance:' || CHR(10) || CHR(10)
|| '* Use only the targets listed in gameTargets.' || CHR(10)
|| '* For NONE, use a game-neutral common object when it directly answers the question.' || CHR(10)
|| '* If no suitable common object exists, state that a game name is required.' || CHR(10)
|| '* Do not infer an unlisted game or game-scoped object.';
v_result.put('contractVersion', '2.0');
v_result.put('targetType', v_target_type);
v_result.put('scopeType', v_target_type);
v_result.put('selectAiReference', v_select_ai_reference);
v_result.put('extractScopeHint', v_scope_type);
v_result.put('status', v_plan_status);
-- `gameTargets` is the public downstream contract. The detailed `targets`
-- collection remains diagnostic evidence only for the MCP response.
v_result.put('gameTargets', v_supported);
v_result.put('targets', v_targets);
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);
-- Dynamic ReAct work items. Every game identity and availability state comes
-- from the catalog lookup above; clients must not infer their own targets.
FOR i IN 0 .. v_targets.get_size - 1 LOOP
v_target := TREAT(v_targets.get(i) AS JSON_OBJECT_T);
IF v_target.get_string('gameKey') IS NOT NULL THEN
v_execution_task := JSON_OBJECT_T();
v_execution_task.put(
'action',
CASE
WHEN v_target.get_string('dataStatus') = 'AVAILABLE'
AND v_target.get_string('userMasterObjectName') IS NOT NULL
THEN 'QUERY'
ELSE 'REPORT_UNAVAILABLE'
END
);
v_execution_task.put('scopeGameKey', v_target.get_string('gameKey'));
v_execution_task.put('target', v_target);
IF v_execution_task.get_string('action') = 'QUERY' THEN
v_excluded_mentions := JSON_ARRAY_T();
FOR j IN 0 .. v_targets.get_size - 1 LOOP
v_candidate := TREAT(v_targets.get(j) AS JSON_OBJECT_T);
IF v_candidate.get_string('gameKey') <> v_target.get_string('gameKey')
AND v_candidate.get_string('mention') IS NOT NULL THEN
v_excluded_mentions.append(v_candidate.get_string('mention'));
END IF;
END LOOP;
-- Preserve the original question verbatim. The target-specific
-- queryPlan below is the separate, authoritative scope contract.
v_task_question := p_question;
v_task_reference := JSON_OBJECT_T();
v_task_targets := JSON_ARRAY_T();
v_task_targets.append(v_target);
v_task_reference.put('targetType', 'SINGLE');
v_task_reference.put('status', 'SUPPORTED');
v_task_reference.put('gameTargets', v_task_targets);
v_task_plan := JSON_OBJECT_T();
v_task_plan.put('contractVersion', '2.0');
v_task_plan.put('targetType', 'SINGLE');
v_task_plan.put('status', 'SUPPORTED');
v_task_plan.put('gameTargets', v_task_targets);
v_task_plan.put('selectAiReference',
'[GAME QUERY REFERENCE]' || CHR(10) || CHR(10) || v_task_reference.to_clob());
v_worker_arguments := JSON_OBJECT_T();
v_worker_arguments.put('prompt', v_task_question);
v_worker_arguments.put('scopeGameKey', v_target.get_string('gameKey'));
v_worker_arguments.put('queryPlan', v_task_plan);
v_fewshot_arguments := JSON_OBJECT_T();
v_fewshot_arguments.put('question', v_task_question);
v_fewshot_arguments.put('topK', 3);
v_execution_task.put('workerTool', 'oracle.select_ai.smilegate_fewshot_nl2sql');
v_execution_task.put('workerArguments', v_worker_arguments);
v_execution_task.put('fewShotArguments', v_fewshot_arguments);
END IF;
v_execution_tasks.append(v_execution_task);
END IF;
END LOOP;
v_result.put('executionTasks', v_execution_tasks);
v_result.put('nextAction', 'CALL_FEWSHOT');
CASE v_target_type
WHEN 'NONE' THEN v_result.put('executionMode', 'UNSCOPED');
WHEN 'SINGLE' THEN v_result.put('executionMode', 'SINGLE');
WHEN 'MULTI' THEN v_result.put('executionMode', 'COMBINED');
WHEN 'ALL' THEN v_result.put('executionMode', 'ALL');
END CASE;
RETURN v_result.to_clob;
END;
/

View File

@@ -0,0 +1,219 @@
-- Governed Few-shot examples for the Smilegate Select AI MCP.
-- Existing rows are preserved for audit and only APPROVED rows are retrievable.
DECLARE
PROCEDURE add_column(p_definition IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE sg_qa_vector_example ADD (' || p_definition || ')';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1430 THEN
RAISE;
END IF;
END;
BEGIN
add_column('reference_status VARCHAR2(16) DEFAULT ''DRAFT'' NOT NULL');
add_column('reference_kind VARCHAR2(32) DEFAULT ''SQL_TEMPLATE'' NOT NULL');
add_column('target_type VARCHAR2(16) DEFAULT ''ANY'' NOT NULL');
add_column('object_role VARCHAR2(64)');
add_column('inspection_status VARCHAR2(16) DEFAULT ''PENDING'' NOT NULL');
add_column('inspection_note CLOB');
add_column('verified_at TIMESTAMP(6)');
add_column('verified_by VARCHAR2(128)');
END;
/
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
inspection_status = 'RETIRED',
inspection_note = 'Executed successfully but maps BUBBLYZ to the CZN physical user-master object. Conflicts with the approved game-target contract.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 2;
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
reference_kind = 'OBJECT_UNAVAILABLE',
target_type = 'SINGLE',
object_role = 'GAME_ALIAS_CATALOG',
inspection_status = 'RETIRED',
inspection_note = 'Executed successfully with no BUBBLYZ alias rows, but the game literal is not a reusable object-unavailable template. The current game query plan is the authoritative boundary source.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 3;
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'SINGLE',
object_role = 'GAME_USER_MASTER',
inspection_status = 'VERIFIED',
inspection_note = 'Logical placeholder template. The resolved physical object must come only from the current game query plan.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 4;
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
reference_kind = 'NO_TARGET',
target_type = 'NONE',
inspection_status = 'VERIFIED',
inspection_note = 'Executed successfully with no rows. Canonical boundary for an unscoped game-user-master request.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 5;
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
reference_kind = 'METADATA_POLICY',
target_type = 'ANY',
object_role = 'GAME_ALIAS_CATALOG',
inspection_status = 'RETIRED',
inspection_note = 'Not directly executable: requires an unbound GAME_TERM placeholder. Game resolution is now supplied by game_query_plan.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 6;
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
reference_kind = 'SQL_TEMPLATE',
target_type = 'NONE',
object_role = 'GAME_USER_MASTER',
inspection_status = 'RETIRED',
inspection_note = 'Executed successfully but chooses CZN_COMN_USER_MST for a game-unscoped question. Conflicts with the NONE target contract.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id IN (7, 8);
CREATE OR REPLACE FUNCTION sg_qa_vector_store(
p_question IN CLOB,
p_answer_sql IN CLOB,
p_answer IN CLOB DEFAULT NULL
) RETURN NUMBER
AUTHID DEFINER
IS
PRAGMA AUTONOMOUS_TRANSACTION;
v_input CLOB;
v_embedding VECTOR;
v_example_id NUMBER;
BEGIN
IF p_question IS NULL OR p_answer_sql IS NULL THEN
RAISE_APPLICATION_ERROR(-20002, 'question and answer_sql are required.');
END IF;
v_input := TO_CLOB('Question: ') || p_question
|| TO_CLOB(CHR(10) || 'Answer SQL: ') || p_answer_sql
|| CASE WHEN p_answer IS NULL THEN NULL ELSE TO_CLOB(CHR(10) || 'Answer: ') || p_answer END;
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, inspection_status
) VALUES (
p_question, p_answer_sql, p_answer, v_input, v_embedding, 'cohere.embed-v4.0',
'DRAFT', 'SQL_TEMPLATE', 'ANY', 'PENDING'
) RETURNING example_id INTO v_example_id;
COMMIT;
RETURN v_example_id;
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR
AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
BEGIN
IF p_question IS NULL THEN
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'target_type must be NONE, SINGLE, MULTI, ALL, or ANY.');
END IF;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question,
JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id,
question,
answer_sql,
answer_text,
embedding_model,
reference_kind,
target_type,
object_role,
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
ORDER BY vector_distance(embedding, v_query_vector, COSINE), example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_context(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN CLOB
AUTHID DEFINER
IS
v_results SYS_REFCURSOR;
v_id NUMBER;
v_q CLOB;
v_sql CLOB;
v_answer CLOB;
v_model VARCHAR2(128);
v_kind VARCHAR2(32);
v_target VARCHAR2(16);
v_role VARCHAR2(64);
v_dist NUMBER;
v_context CLOB := EMPTY_CLOB();
BEGIN
v_results := sg_qa_vector_search(p_question, p_top_k, p_target_type);
LOOP
FETCH v_results INTO v_id, v_q, v_sql, v_answer, v_model, v_kind, v_target, v_role, v_dist;
EXIT WHEN v_results%NOTFOUND;
v_context := v_context
|| CASE WHEN DBMS_LOB.GETLENGTH(v_context) = 0 THEN NULL ELSE CHR(10) || CHR(10) END
|| '[Example ' || v_id || ', kind=' || v_kind || ', target_type=' || v_target
|| ', cosine_distance=' || TO_CHAR(v_dist, 'FM0D000000') || ']' || CHR(10)
|| 'Question: ' || v_q || CHR(10)
|| 'Answer SQL: ' || v_sql
|| CASE WHEN v_answer IS NULL THEN NULL ELSE CHR(10) || 'Answer: ' || v_answer END;
END LOOP;
CLOSE v_results;
RETURN v_context;
END;
/
COMMENT ON COLUMN sg_qa_vector_example.reference_status IS
'Few-shot retrieval lifecycle: DRAFT, APPROVED, or RETIRED. Only APPROVED is retrievable.';
COMMENT ON COLUMN sg_qa_vector_example.reference_kind IS
'Few-shot semantic kind: SQL_TEMPLATE, NO_TARGET, OBJECT_UNAVAILABLE, or METADATA_POLICY.';
COMMENT ON COLUMN sg_qa_vector_example.target_type IS
'Applicable game query-plan target type: NONE, SINGLE, MULTI, ALL, or ANY.';
COMMENT ON COLUMN sg_qa_vector_example.object_role IS
'Logical object role; physical object names must be sourced from the current game query plan.';

View File

@@ -0,0 +1,304 @@
-- Import the 47 customer QA benchmark rows as governed Few-shot candidates.
-- This migration deliberately creates DRAFT records only. Retrieval continues
-- to use APPROVED rows only (see 82_sgmp_qa_fewshot_reference_governance.sql).
DECLARE
PROCEDURE add_column(p_definition IN VARCHAR2) IS
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE sg_qa_vector_example ADD (' || p_definition || ')';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -1430 THEN
RAISE;
END IF;
END;
BEGIN
add_column('source_case_id VARCHAR2(30)');
add_column('source_type VARCHAR2(30)');
END;
/
BEGIN
EXECUTE IMMEDIATE
'CREATE UNIQUE INDEX sg_qa_vector_example_source_uk '
|| 'ON sg_qa_vector_example (source_type, source_case_id)';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_seed_benchmark
RETURN NUMBER
AUTHID DEFINER
IS
PRAGMA AUTONOMOUS_TRANSACTION;
v_input CLOB;
v_embedding VECTOR;
v_answer_sql CLOB;
v_answer_text CLOB;
v_exists NUMBER;
v_inserted NUMBER := 0;
BEGIN
FOR item IN (
SELECT question_code,
question_text,
baseline_sql,
baseline_answer,
expected_focus,
support_level
FROM sg_ai_qa_question
WHERE question_source = 'CUSTOMER_EXCEL'
AND active_yn = 'Y'
ORDER BY question_code
) LOOP
SELECT CASE
WHEN EXISTS (
SELECT 1
FROM sg_qa_vector_example existing
WHERE existing.source_type = 'CUSTOMER_QA_BENCHMARK'
AND existing.source_case_id = item.question_code
) THEN 1 ELSE 0
END
INTO v_exists
FROM dual;
IF v_exists = 0 THEN
v_answer_sql := CASE
WHEN item.baseline_sql IS NULL OR DBMS_LOB.GETLENGTH(TRIM(item.baseline_sql)) = 0
THEN TO_CLOB('SELECT CAST(NULL AS NUMBER) AS "NO_BASELINE" FROM DUAL WHERE 1 = 0')
ELSE item.baseline_sql
END;
v_answer_text := TO_CLOB('Expected focus: ') || item.expected_focus
|| CASE WHEN item.baseline_answer IS NULL THEN NULL
ELSE TO_CLOB(CHR(10) || 'Historical answer: ') || item.baseline_answer END;
v_input := TO_CLOB('Customer QA case: ') || item.question_code
|| TO_CLOB(CHR(10) || 'Question: ') || item.question_text
|| TO_CLOB(CHR(10) || 'Expected focus: ') || item.expected_focus
|| TO_CLOB(CHR(10) || 'Candidate SQL: ') || v_answer_sql;
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
BEGIN
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, inspection_status,
inspection_note, source_case_id, source_type
) VALUES (
item.question_text, v_answer_sql, v_answer_text, v_input, v_embedding, 'cohere.embed-v4.0',
'DRAFT',
CASE WHEN item.support_level = 'UNSUPPORTED' THEN 'OBJECT_UNAVAILABLE'
ELSE 'SQL_TEMPLATE' END,
'ANY',
'PENDING',
'Imported from the customer benchmark. A candidate cannot be retrieved until scope, logical object role, and SQL safety are reviewed.',
item.question_code,
'CUSTOMER_QA_BENCHMARK'
);
v_inserted := v_inserted + 1;
EXCEPTION
WHEN DUP_VAL_ON_INDEX THEN
NULL;
END;
END IF;
END LOOP;
COMMIT;
RETURN v_inserted;
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_approve_verified_benchmark
RETURN NUMBER
AUTHID DEFINER
IS
PRAGMA AUTONOMOUS_TRANSACTION;
v_target_type VARCHAR2(16);
v_input CLOB;
v_answer_text CLOB;
v_embedding VECTOR;
v_approved NUMBER := 0;
BEGIN
FOR item IN (
WITH latest_answer AS (
SELECT answer.question_id,
answer.generated_sql,
answer.answer_text,
answer.result_json,
answer.judgment_status,
answer.execution_status,
ROW_NUMBER() OVER (
PARTITION BY answer.question_id ORDER BY answer.answer_seq DESC
) AS row_rank
FROM sg_ai_qa_answer answer
)
SELECT example.example_id,
question.question_text,
question.expected_focus,
question.support_level,
latest.generated_sql,
latest.answer_text AS live_answer,
latest.result_json,
latest.judgment_status,
latest.execution_status
FROM sg_qa_vector_example example
JOIN sg_ai_qa_question question
ON question.question_code = example.source_case_id
LEFT JOIN latest_answer latest
ON latest.question_id = question.question_id
AND latest.row_rank = 1
WHERE example.source_type = 'CUSTOMER_QA_BENCHMARK'
AND example.reference_status = 'DRAFT'
) LOOP
IF item.support_level = 'SUPPORTED'
AND item.judgment_status = 'PASS'
AND item.execution_status = 'COMPLETED'
AND item.generated_sql IS NOT NULL
AND DBMS_LOB.GETLENGTH(TRIM(item.generated_sql)) > 0 THEN
v_target_type := REGEXP_SUBSTR(
DBMS_LOB.SUBSTR(item.result_json, 32767, 1),
'"targetType"[[:space:]]*:[[:space:]]*"([A-Z]+)"',
1, 1, NULL, 1
);
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
v_target_type := 'ANY';
END IF;
v_answer_text := TO_CLOB('Expected focus: ') || item.expected_focus
|| CASE WHEN item.live_answer IS NULL THEN NULL
ELSE TO_CLOB(CHR(10) || 'Verified live answer: ') || item.live_answer END;
v_input := TO_CLOB('Customer QA question: ') || item.question_text
|| TO_CLOB(CHR(10) || 'Expected focus: ') || item.expected_focus
|| TO_CLOB(CHR(10) || 'Verified SQL template: ') || item.generated_sql;
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
UPDATE sg_qa_vector_example
SET answer_sql = item.generated_sql,
answer_text = v_answer_text,
embedding_input = v_input,
embedding = v_embedding,
reference_status = 'APPROVED',
reference_kind = 'SQL_TEMPLATE',
target_type = v_target_type,
inspection_status = 'VERIFIED',
inspection_note = 'Promoted only after the latest Portal ReAct run completed with PASS for this customer benchmark case.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_BENCHMARK_REVIEW'
WHERE example_id = item.example_id;
v_approved := v_approved + 1;
ELSE
UPDATE sg_qa_vector_example
SET inspection_status = 'REVIEW',
inspection_note = 'Not retrievable: latest customer benchmark execution is unsupported, incomplete, WARN, FAIL, or lacks executable SQL.'
WHERE example_id = item.example_id;
END IF;
END LOOP;
COMMIT;
RETURN v_approved;
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR
AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
BEGIN
IF p_question IS NULL THEN
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'target_type must be NONE, SINGLE, MULTI, ALL, or ANY.');
END IF;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question,
JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id,
question,
answer_sql,
answer_text,
embedding_model,
reference_kind,
target_type,
object_role,
source_case_id,
source_type,
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
ORDER BY vector_distance(embedding, v_query_vector, COSINE), example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/
CREATE OR REPLACE FUNCTION sg_qa_vector_context(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN CLOB
AUTHID DEFINER
IS
v_results SYS_REFCURSOR;
v_id NUMBER;
v_q CLOB;
v_sql CLOB;
v_answer CLOB;
v_model VARCHAR2(128);
v_kind VARCHAR2(32);
v_target VARCHAR2(16);
v_role VARCHAR2(64);
v_case_id VARCHAR2(30);
v_source VARCHAR2(30);
v_dist NUMBER;
v_context CLOB := EMPTY_CLOB();
BEGIN
v_results := sg_qa_vector_search(p_question, p_top_k, p_target_type);
LOOP
FETCH v_results INTO v_id, v_q, v_sql, v_answer, v_model, v_kind, v_target, v_role,
v_case_id, v_source, v_dist;
EXIT WHEN v_results%NOTFOUND;
v_context := v_context
|| CASE WHEN DBMS_LOB.GETLENGTH(v_context) = 0 THEN NULL ELSE CHR(10) || CHR(10) END
|| '[Example ' || v_id || ', kind=' || v_kind || ', target_type=' || v_target
|| CASE WHEN v_case_id IS NULL THEN NULL ELSE ', source_case_id=' || v_case_id END
|| ', cosine_distance=' || TO_CHAR(v_dist, 'FM0D000000') || ']' || CHR(10)
|| 'Question: ' || v_q || CHR(10)
|| 'Answer SQL: ' || v_sql
|| CASE WHEN v_answer IS NULL THEN NULL ELSE CHR(10) || 'Answer: ' || v_answer END;
END LOOP;
CLOSE v_results;
RETURN v_context;
END;
/
COMMENT ON COLUMN sg_qa_vector_example.source_case_id IS
'Customer QA benchmark case identifier, such as STD-01 or CZN-01.';
COMMENT ON COLUMN sg_qa_vector_example.source_type IS
'Candidate provenance. CUSTOMER_QA_BENCHMARK rows remain DRAFT until reviewed.';

View File

@@ -0,0 +1,31 @@
-- NONE means "no selected game", not "no executable query".
-- Keep the boundary example scoped to its logical object role so it cannot
-- be generalized to approved common-object questions.
UPDATE sg_qa_vector_example
SET object_role = 'GAME_USER_MASTER',
answer_text = 'This boundary applies only to a game-scoped user-master operation. '
|| 'When no game identifier is resolved, do not select a prefix-specific user-master object. '
|| 'This does not prohibit an approved common-object query.',
inspection_note = 'Canonical boundary for an unscoped game-user-master request. '
|| 'It applies only to GAME_USER_MASTER and must not suppress common-object queries.'
WHERE example_id = 5
AND reference_kind = 'NO_TARGET';
-- Populate logical roles from verified SQL. This is prompt metadata only;
-- runtime physical-object selection remains governed by the query plan.
UPDATE sg_qa_vector_example
SET object_role = CASE
WHEN REGEXP_LIKE(answer_sql, 'COMN_SALES_TXN', 'i') THEN 'SALES_TRANSACTION'
WHEN REGEXP_LIKE(answer_sql, 'COMN_REFUND_TXN', 'i') THEN 'REFUND_TRANSACTION'
WHEN REGEXP_LIKE(answer_sql, 'COMN_CHARACTER_MST', 'i') THEN 'GAME_CHARACTER_MASTER'
WHEN REGEXP_LIKE(answer_sql, 'COMN_USER_MST', 'i') THEN 'GAME_USER_MASTER'
ELSE object_role
END
WHERE reference_status = 'APPROVED'
AND object_role IS NULL;
COMMENT ON COLUMN sg_qa_vector_example.object_role IS
'Logical business object role used to bound Few-shot interpretation. It is not a runtime physical-object selector.';
COMMIT;

View File

@@ -0,0 +1,39 @@
-- A no-target aggregate is a successful empty-value result, not a no-row
-- execution failure. Keep the output alias from the verified logical metric.
DECLARE
v_input CLOB;
v_embedding VECTOR;
BEGIN
SELECT TO_CLOB('Question: ') || question
|| TO_CLOB(CHR(10) || 'Answer SQL: SELECT CAST(NULL AS NUMBER) AS "USER_COUNT" FROM DUAL')
|| TO_CLOB(CHR(10) || 'Answer: This boundary applies only to a game-scoped user-master operation. '
|| 'When no game identifier is resolved, do not select a prefix-specific user-master object. '
|| 'Return USER_COUNT as NULL. This does not prohibit an approved common-object query.')
INTO v_input
FROM sg_qa_vector_example
WHERE example_id = 5;
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
UPDATE sg_qa_vector_example
SET answer_sql = 'SELECT CAST(NULL AS NUMBER) AS "USER_COUNT" FROM DUAL',
answer_text = 'This boundary applies only to a game-scoped user-master operation. '
|| 'When no game identifier is resolved, do not select a prefix-specific user-master object. '
|| 'Return USER_COUNT as NULL. This does not prohibit an approved common-object query.',
embedding_input = v_input,
embedding = v_embedding,
embedding_model = 'cohere.embed-v4.0',
inspection_note = 'Canonical GAME_USER_MASTER boundary: no game target returns a NULL metric value, '
|| 'not an execution error and not a default game selection.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_REVIEW'
WHERE example_id = 5
AND reference_status = 'APPROVED'
AND reference_kind = 'NO_TARGET'
AND object_role = 'GAME_USER_MASTER';
COMMIT;
END;
/

View File

@@ -0,0 +1,19 @@
-- Select AI profile instructions contain only common SQL-generation guidance.
-- Game target routing and execution policy are supplied at runtime by
-- SG_GAME_QUERY_PLAN; they do not belong in the profile-wide prompt.
BEGIN
DBMS_CLOUD_AI.SET_ATTRIBUTE(
profile_name => 'SGMP_POC_OCI_GPT54MINI',
attribute_name => 'additional_instructions',
attribute_value => q'~Generate Oracle SQL only for the listed approved objects. Do not reference external tables. Use English aliases only. Use database comments and annotations as the source of business rules.
~'
);
END;
/
SELECT attribute_name, attribute_value
FROM user_cloud_ai_profile_attributes
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
AND attribute_name = 'additional_instructions';

View File

@@ -0,0 +1,23 @@
-- Benchmark correction: game-catalog resolution and fact-row availability are distinct.
-- Korean text is reconstructed from UTF-8 base64 so SQLcl cannot corrupt it.
UPDATE sg_ai_qa_question
SET expected_focus = utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw(
'6rKM7J6EIOuzhOy5rSDsubTtg4jroZzqt7jsl5DshJwg7ZmV7J2465CcIOqyjOyehOydgCDqs7XthrUg66ek7LacIOqwneyytOydmCBHQU1FX0lE66W8IOuzhOy5rSDsubTtg4jroZzqt7ggRVhJU1RTL+yEnOu4jOy/vOumrOuhnCDsl7DqsrDtlZzri6QuIEdBTUVfSUTrgpggcHJlZml466W8IOyngeygkSDrpqzthLDrn7TroZwg6rOg7KCV7ZWY7KeAIOyViuuKlOuLpC4g7Lm07YOI66Gc6re4IOunpOy5reqzvCDtlbTri7kg66ek7LacIOyCrOyLpCDrjbDsnbTthLAg7KG07J6sIOyXrOu2gOuKlCDrs4TqsJzsnbTrqbAsIOunpOy5reuQmOyXiOyngOunjCDtlbTri7kg7J287J6QIOunpOy2nCDtlonsnbQg7JeG7Jy866m0IOynkeqzhCBOVUxMIOuYkOuKlCAw6rG07J2AIOygleyDgSDqsrDqs7zri6QuIOyehOydmOydmCDri6Trpbgg6rKM7J6E7Jy866GcIOuMgOyytO2VmOyngCDslYrripTri6Qu'
)),
'AL32UTF8'
)
WHERE question_code = 'STD-09';
UPDATE sg_qa_vector_example
SET inspection_status = 'REVIEW',
inspection_note = 'Customer benchmark criterion updated: catalog resolution and common-fact availability are evaluated separately.'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'STD-09';
COMMIT;
SELECT question_code, expected_focus
FROM sg_ai_qa_question
WHERE question_code = 'STD-09';

View File

@@ -0,0 +1,63 @@
-- Benchmark correction: common-fact eligibility is determined by the active
-- alias source. Registry-only games are reported separately while eligible
-- targets continue through the common-fact query.
-- Korean baseline text and SQL are reconstructed from UTF-8 base64 for SQLcl safety.
UPDATE sg_ai_qa_question
SET expected_focus = utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw(
'7Jes65+sIOqyjOyehCDruYTqtZDsl5DshJwg7Lm07YOI66Gc6re466GcIO2ZleyduOuQnCDrjIDsg4HsnYAg67OE7LmtIOy5tO2TiOuhnOq3uOulvCDqtazrj5kg7KeR7ZWp7Jy866GcIO2VmOqzoCDqs7XthrUg7IKs7IukIO2FjOydtOu4lOydhCBMRUZUIEpPSU7tlZjsl6wg6rKM7J6E67OEIOynkeqzhO2VnOuLpC4g7IKs7IukIO2WieydtCDsl4bripQg64yA7IOB7J2AIDDsnLzroZwg67O07KG07ZWY6rOgLCDrp6Tsua3rkJwg64uk66W4IOuMgOyDgeydmCDqsrDqs7zrpbwg7IOd65617ZWY7KeAIOyViuuKlOuLpC4gRFVBTCBVTklPTuycvOuhnCDrjIDsg4HrqoXqs7wg6rCS7J2EIO2VqeyEse2VmOyngCDslYrripTri6Qu'
)),
'AL32UTF8'
),
baseline_answer = utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw(
'7Lm07YOI66Gc6re4IOunpOy5rSDrjIDsg4Hrs4Qg66ek7Lac7J2EIOuwmO2ZmO2VnOuLpC4g7IKs7IukIO2WieydtCDsl4bripQg64yA7IOB7J2AIDAsIOuLpOuluCDrp6Tsua0g64yA7IOB7J2AIO2VtOuLuSDsnbzsnpDsnZgg7KeR6rOE6rCS7J2EIOuwmO2ZmO2VnOuLpC4='
)),
'AL32UTF8'
),
baseline_sql = utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw(
'V0lUSCByZXF1ZXN0ZWRfZ2FtZXMgQVMgKAogIFNFTEVDVCBESVNUSU5DVCBnLiJHQU1FX0lEIiwgZy4iR0FNRV9OTSIKICBGUk9NICJTR01QX1BPQyIuIkNPTU5fR0FNRV9BTElBU19CQVMiIGcKICBXSEVSRSBnLiJVU0VfWU4iID0gJ1knCiAgICBBTkQgKAogICAgICBVUFBFUihnLiJHQU1FX05NIikgTElLRSBVUFBFUignJUJ1YmJseXolJykKICAgICAgT1IgVVBQRVIoZy4iR0FNRV9BTElBU19OTSIpIExJS0UgVVBQRVIoJyVCdWJibHl6JScpCiAgICAgIE9SIFVQUEVSKGcuIkdBTUVfTk0iKSBMSUtFIFVQUEVSKCcl7Lm07KCc64KYJScpCiAgICAgIE9SIFVQUEVSKGcuIkdBTUVfQUxJQVNfTk0iKSBMSUtFIFVQUEVSKCcl7Lm07KCc64KYJScpCiAgICApCikKU0VMRUNUIHJnLiJHQU1FX05NIiBBUyAiR0FNRV9OQU1FIiwKICAgICAgIE5WTChTVU0oQ0FTRQogICAgICAgICBXSEVOIHMuIlBBWU1UX0RUTSIgPj0gVE9fREFURSgnMjAyNjA3MTUnLCAnWVlZWU1NREQnKQogICAgICAgICAgQU5EIHMuIlBBWU1UX0RUTSIgPCBUT19EQVRFKCcyMDI2MDcxNicsICdZWVlZTU1ERCcpCiAgICAgICAgICBBTkQgcy4iRVhQVF9VU0VSX1lOIiA9ICdOJwogICAgICAgICBUSEVOIENBU1Qocy4iUEFZTVRfQU1UIiBBUyBOVU1CRVIpIEVMU0UgMCBFTkQpLCAwKSBBUyAiU0FMRVNfQU1UIgpGUk9NIHJlcXVlc3RlZF9nYW1lcyByZwpMRUZUIEpPSU4gIlNHTVBfUE9DIi4iQ09NTl9TQUxFU19UWE4iIHMKICBPTiBzLiJHQU1FX0lEIiA9IHJnLiJHQU1FX0lEIgpHUk9VUCBCWSByZy4iR0FNRV9OTSIKT1JERVIgQlkgcmcuIkdBTUVfTk0iCg=='
)),
'AL32UTF8'
)
WHERE question_code = 'STD-11';
UPDATE sg_qa_vector_example
SET inspection_status = 'REVIEW',
inspection_note = 'Customer benchmark criterion updated: multi-target common-fact comparisons preserve catalog-resolved zero-fact targets through a catalog-driven left join.'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'STD-11';
COMMIT;
-- Correct the earlier catalog-only interpretation in this same migration. A
-- registry can identify a game to the operator, but does not by itself make it
-- an approved source for a common fact query.
UPDATE sg_ai_qa_question
SET expected_focus = utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw(
'67O17IiYIOqyjOyehCDqs7XthrUg7IKs7IukIOuNsOydtO2EsCDsp4jsnZjripQg7Zmc7ISxIOqyjOyehCDrs4Tsua0g7JuQ7LKc7JeQ7IScIO2ZleyduOuQnCDrjIDsg4Hrp4wg7IKs7IukIOyhsO2ajCDrjIDsg4HsnLzroZwg7IKs7Jqp7ZWY6rOgLCDrs4Tsua0g7JuQ7LKc7J20IOyXhuuKlCDroIjsp4DsiqTtirjrpqwg7KCE7JqpIOuMgOyDgeydgCDsobDtmowg67aI6rCAIOyDge2DnOuhnCDrs4Trj4Qg7JWI64K07ZWc64ukLiDsobDtmowg6rCA64ql7ZWcIOuMgOyDgeydmCDqsrDqs7zripQg7Jyg7KeA7ZWY66mwIOyghOyytCDsmpTssq3snYQg7LCo64uo7ZWY7KeAIOyViuuKlOuLpC4gRFVBTCBVTklPTuycvOuhnCDrjIDsg4Eg7ZaJ7J2064KYIOqwkuydhCDrp4zrk6Tsp4Ag7JWK64qU64ukLg=='
)),
'AL32UTF8'
),
baseline_answer = utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw(
'7Lm07KCc64KYIOunpOy2nOydgCAyMjcsNjgx7J6F64uI64ukLiBCdWJibHl664qUIO2ZnOyEsSDqsozsnoQg67OE7LmtIOybkOyynOydtCDsl4bslrQg66ek7LacIOyhsO2ajCDrjIDsg4HsnbQg7JWE64uZ64uI64ukLg=='
)),
'AL32UTF8'
)
WHERE question_code = 'STD-11';
UPDATE sg_qa_vector_example
SET inspection_status = 'REVIEW',
inspection_note = 'Customer benchmark criterion updated: common-fact comparison queries use active-alias eligible targets; registry-only targets are separately unavailable and do not become synthetic fact rows.'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'STD-11';
COMMIT;
SELECT question_code, expected_focus, baseline_answer
FROM sg_ai_qa_question
WHERE question_code = 'STD-11';

View File

@@ -0,0 +1,22 @@
-- Object-specific, data-driven guidance for comparison queries on a common fact table.
-- No game, prefix, ID, or physical per-game object is embedded in this annotation.
DECLARE
v_result VARCHAR2(4000);
BEGIN
v_result := sgmp_set_annotation(
'SGMP_POC',
'TABLE',
'COMN_SALES_TXN',
NULL,
'Game fact scope: query this table only for plan targets marked ACTIVE_ALIAS. Derive target GAME_ID values through active COMN_GAME_ALIAS_BAS aliases rather than direct identifier or prefix literals. For a mixed request, retain the eligible target results and report other plan statuses separately; do not substitute or manufacture a target result.',
'MULTI_TARGET_COMPARISON'
);
DBMS_OUTPUT.PUT_LINE(v_result);
END;
/
SELECT annotation_name, annotation_value
FROM user_annotations_usage
WHERE object_name = 'COMN_SALES_TXN'
AND annotation_name = 'MULTI_TARGET_COMPARISON';

View File

@@ -0,0 +1,92 @@
-- Generic Few-shot structure for multi-target comparisons on a common fact object.
-- The template is intentionally logical: no current game, prefix, ID, date, or result value is embedded.
DECLARE
v_exists NUMBER;
v_input CLOB;
v_embedding VECTOR;
BEGIN
SELECT COUNT(*)
INTO v_exists
FROM sg_qa_vector_example
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'MULTI_COMMON_FACT_LEFT_JOIN';
IF v_exists = 0 THEN
v_input := TO_CLOB('Question pattern: Compare a common fact metric across multiple resolved games. Use only fact-query-eligible targets and separately report known registry-only or unavailable targets.')
|| CHR(10) || 'Logical object role: SALES_TRANSACTION'
|| CHR(10) || 'Required structure: active alias catalog distinct game set, left join fact, aggregate by catalog display identifier.';
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, object_role,
inspection_status, inspection_note, verified_at, verified_by,
source_case_id, source_type
) VALUES (
'Compare a common fact metric across multiple resolved games, including games with no fact rows.',
TO_CLOB('WITH resolved_games AS (' || CHR(10)
|| ' SELECT DISTINCT a."GAME_ID", a."GAME_NM"' || CHR(10)
|| ' FROM "SGMP_POC"."COMN_GAME_ALIAS_BAS" a' || CHR(10)
|| ' WHERE a."USE_YN" = ''Y''' || CHR(10)
|| ' AND (<ACTIVE_ALIAS_MATCHES_FOR_EACH_REQUESTED_GAME_TERM>)' || CHR(10)
|| ')' || CHR(10)
|| 'SELECT g."GAME_NM" AS "GAME_NAME",' || CHR(10)
|| ' NVL(SUM(CASE WHEN <FACT_DATE_AND_EXCLUSION_CONDITION>' || CHR(10)
|| ' THEN CAST(f."<METRIC_COLUMN>" AS NUMBER) ELSE 0 END), 0) AS "METRIC_VALUE"' || CHR(10)
|| 'FROM resolved_games g' || CHR(10)
|| 'LEFT JOIN "SGMP_POC"."<APPROVED_COMMON_FACT_OBJECT>" f' || CHR(10)
|| ' ON f."GAME_ID" = g."GAME_ID"' || CHR(10)
|| 'GROUP BY g."GAME_NM"' || CHR(10)
|| 'ORDER BY g."GAME_NM"'),
'Structural Few-shot only. Replace every angle-bracket placeholder from the current approved object metadata, the current game query plan, and the original question. Only ACTIVE_ALIAS targets enter the alias-driven LEFT JOIN and grouping; report registry-only or unavailable targets from the plan without manufacturing fact rows with DUAL/UNION.',
v_input,
v_embedding,
'cohere.embed-v4.0',
'APPROVED', 'SQL_TEMPLATE', 'MULTI', 'SALES_TRANSACTION',
'VERIFIED',
'Generic, non-customer-specific comparison structure. Verified against the game-alias and common-fact metadata contract; not an executable answer key.',
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW',
'MULTI_COMMON_FACT_LEFT_JOIN', 'POLICY_TEMPLATE'
);
END IF;
COMMIT;
END;
/
-- Keep the approved template current when the policy text evolves. The vector
-- is rebuilt from its generic retrieval text; no customer answer is embedded.
DECLARE
v_input CLOB;
v_embedding VECTOR;
BEGIN
v_input := TO_CLOB('Question pattern: Compare a common fact metric across multiple resolved games. Use only fact-query-eligible targets and separately report known registry-only or unavailable targets.')
|| CHR(10) || 'Logical object role: SALES_TRANSACTION'
|| CHR(10) || 'Required structure: active alias catalog distinct game set, left join fact, aggregate by catalog display identifier.';
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
UPDATE sg_qa_vector_example
SET question = 'Compare a common fact metric across multiple resolved games, using only fact-query-eligible targets.',
answer_text = 'Structural Few-shot only. Replace every angle-bracket placeholder from the current approved object metadata, the current game query plan, and the original question. Only ACTIVE_ALIAS targets enter the alias-driven LEFT JOIN and grouping; report registry-only or unavailable targets from the plan without manufacturing fact rows with DUAL/UNION.',
embedding_input = v_input,
embedding = v_embedding,
inspection_note = 'Generic, non-customer-specific comparison structure. Active-alias targets are fact-query eligible; registry-only targets are reported separately. Not an executable answer key.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'MULTI_COMMON_FACT_LEFT_JOIN';
COMMIT;
END;
/
SELECT example_id, reference_status, reference_kind, target_type, object_role,
source_case_id, source_type
FROM sg_qa_vector_example
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'MULTI_COMMON_FACT_LEFT_JOIN';

View File

@@ -0,0 +1,13 @@
-- Retire the overly specific no-target template. The profile and table
-- metadata carry this general scope policy without a case-shaped example.
DELETE FROM sg_qa_vector_example
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'NO_ELIGIBLE_FACT_TARGET';
COMMIT;
SELECT COUNT(*) AS remaining_template_count
FROM sg_qa_vector_example
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'NO_ELIGIBLE_FACT_TARGET';

View File

@@ -0,0 +1,70 @@
-- Generic Few-shot structure for a filtered transaction/order detail request.
-- It fixes the output grain through an approved object pattern, not a global
-- instruction or a customer-specific answer.
DECLARE
v_input CLOB;
v_embedding VECTOR;
v_exists NUMBER;
BEGIN
v_input := TO_CLOB('Question pattern: List individual payment orders that match a business date and an amount condition.')
|| CHR(10) || 'Logical object role: SALES_TRANSACTION'
|| CHR(10) || 'Required output: transaction identifiers, game, user, payment timestamp, and payment amount.';
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
v_input,
JSON(sg_qa_vector_params('search_document'))
);
SELECT COUNT(*)
INTO v_exists
FROM sg_qa_vector_example
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'FILTERED_TRANSACTION_DETAIL';
IF v_exists = 0 THEN
INSERT INTO sg_qa_vector_example (
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
reference_status, reference_kind, target_type, object_role,
inspection_status, inspection_note, verified_at, verified_by,
source_case_id, source_type
) VALUES (
'List payment orders matching a date and amount condition.',
TO_CLOB('SELECT t."PAYMT_TRANSAC_ID" AS "PAYMENT_TRANSACTION_ID",' || CHR(10)
|| ' t."PAYMT_TRANSAC_DTL_ID" AS "PAYMENT_TRANSACTION_DETAIL_ID",' || CHR(10)
|| ' t."GAME_ID" AS "GAME_ID",' || CHR(10)
|| ' t."GUID" AS "USER_ID",' || CHR(10)
|| ' t."PAYMT_DTM" AS "PAYMENT_DATETIME",' || CHR(10)
|| ' t."PAYMT_AMT" AS "PAYMENT_AMOUNT"' || CHR(10)
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" t' || CHR(10)
|| 'WHERE t."PAYMT_DTM" >= <BUSINESS_DATE_START>' || CHR(10)
|| ' AND t."PAYMT_DTM" < <BUSINESS_DATE_END>' || CHR(10)
|| ' AND CAST(t."PAYMT_AMT" AS NUMBER) <AMOUNT_CONDITION>' || CHR(10)
|| ' AND t."EXPT_USER_YN" = ''N''' || CHR(10)
|| 'ORDER BY t."PAYMT_DTM", t."PAYMT_TRANSAC_ID", t."PAYMT_TRANSAC_DTL_ID"'),
'Structural Few-shot only. Replace placeholders using the original request and approved metadata. Use the business payment timestamp for a payment-date condition. This pattern is for individual transaction detail; do not substitute an aggregate-only result for a requested order list.',
v_input,
v_embedding,
'cohere.embed-v4.0',
'APPROVED', 'SQL_TEMPLATE', 'NONE', 'SALES_TRANSACTION',
'VERIFIED',
'Generic transaction-detail output shape with no customer date, amount, game, or result value; not an executable answer key.',
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW',
'FILTERED_TRANSACTION_DETAIL', 'POLICY_TEMPLATE'
);
ELSE
UPDATE sg_qa_vector_example
SET embedding_input = v_input,
embedding = v_embedding,
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'FILTERED_TRANSACTION_DETAIL';
END IF;
COMMIT;
END;
/
SELECT example_id, reference_status, target_type, object_role, source_case_id
FROM sg_qa_vector_example
WHERE source_type = 'POLICY_TEMPLATE'
AND source_case_id = 'FILTERED_TRANSACTION_DETAIL';

View File

@@ -0,0 +1,57 @@
-- Customer question wording requests individual orders. Align the benchmark
-- with the transaction-detail output pattern rather than forcing KPI summary.
UPDATE sg_ai_qa_question
SET expected_focus = utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw(
'Q09NTl9TQUxFU19UWE7sl5DshJwgUEFZTVRfRFRNIOq4sOykgOydvCwgUEFZTVRfQU1UID4gMTAwMDAsIEVYUFRfVVNFUl9ZTj0nTicg7KGw6rG07J2YIOqwnOuzhCDso7zrrLgg7IOB7IS466W8IOyhsO2ajO2VnOuLpC4g7KO866y4IOyLneuzhOyekCwg6rKM7J6ELCDsgqzsmqnsnpAsIOqysOygnCDsnbzsi5zsmYAg6riI7JWh7J2EIOygnOqzte2VnOuLpC4='
)),
'AL32UTF8'
),
baseline_sql = TO_CLOB('SELECT s."PAYMT_TRANSAC_ID" AS "PAYMENT_TRANSACTION_ID",' || CHR(10)
|| ' s."PAYMT_TRANSAC_DTL_ID" AS "PAYMENT_TRANSACTION_DETAIL_ID",' || CHR(10)
|| ' s."GAME_ID" AS "GAME_ID",' || CHR(10)
|| ' s."GUID" AS "USER_ID",' || CHR(10)
|| ' s."PAYMT_DTM" AS "PAYMENT_DATETIME",' || CHR(10)
|| ' s."PAYMT_AMT" AS "PAYMENT_AMOUNT"' || CHR(10)
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" s' || CHR(10)
|| 'WHERE s."PAYMT_DTM" >= DATE ''2026-07-15''' || CHR(10)
|| ' AND s."PAYMT_DTM" < DATE ''2026-07-16''' || CHR(10)
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) > 10000' || CHR(10)
|| ' AND s."EXPT_USER_YN" = ''N''' || CHR(10)
|| 'ORDER BY s."PAYMT_DTM", s."PAYMT_TRANSAC_ID", s."PAYMT_TRANSAC_DTL_ID"'),
baseline_answer = utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw(
'6rKw7KCc6riI7JWhIDHrp4zsm5Ag7LSI6rO8IOyjvOusuCA26rG07J2EIOyjvOusuCDsi53rs4TsnpAsIOqyjOyehCwg7IKs7Jqp7J6QLCDqsrDsoJwg7J287IucLCDqsrDsoJzquIjslaHqs7wg7ZWo6ruYIOuwmO2ZmO2VnOuLpC4='
)),
'AL32UTF8'
)
WHERE question_code = 'STD-18';
UPDATE sg_qa_vector_example
SET answer_sql = TO_CLOB('SELECT s."PAYMT_TRANSAC_ID" AS "PAYMENT_TRANSACTION_ID",' || CHR(10)
|| ' s."PAYMT_TRANSAC_DTL_ID" AS "PAYMENT_TRANSACTION_DETAIL_ID",' || CHR(10)
|| ' s."GAME_ID" AS "GAME_ID",' || CHR(10)
|| ' s."GUID" AS "USER_ID",' || CHR(10)
|| ' s."PAYMT_DTM" AS "PAYMENT_DATETIME",' || CHR(10)
|| ' s."PAYMT_AMT" AS "PAYMENT_AMOUNT"' || CHR(10)
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" s' || CHR(10)
|| 'WHERE s."PAYMT_DTM" >= DATE ''2026-07-15''' || CHR(10)
|| ' AND s."PAYMT_DTM" < DATE ''2026-07-16''' || CHR(10)
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) > 10000' || CHR(10)
|| ' AND s."EXPT_USER_YN" = ''N''' || CHR(10)
|| 'ORDER BY s."PAYMT_DTM", s."PAYMT_TRANSAC_ID", s."PAYMT_TRANSAC_DTL_ID"'),
answer_text = 'Approved customer Few-shot: return individual qualifying payment orders with transaction identifiers, game, user, payment timestamp, and payment amount. Use PAYMT_DTM for the payment business date.',
reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
inspection_note = 'Customer benchmark aligned to detailed qualifying orders and the payment business timestamp.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'STD-18';
COMMIT;
SELECT question_code, expected_focus, baseline_answer
FROM sg_ai_qa_question
WHERE question_code = 'STD-18';

View File

@@ -0,0 +1,19 @@
-- Customer benchmark evidence is retained for evaluation only. It is not a
-- runtime Few-shot because the vector store must not become a collection of
-- case-specific benchmark overrides.
UPDATE sg_qa_vector_example
SET reference_status = 'RETIRED',
inspection_status = 'RETIRED',
inspection_note = 'Retired from runtime Few-shot retrieval; retained as customer QA evaluation evidence.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'STD-21';
COMMIT;
SELECT example_id, reference_status, inspection_status, source_case_id
FROM sg_qa_vector_example
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'STD-21';

View File

@@ -0,0 +1,17 @@
-- Promote the reviewed customer QA example for the NRU metric.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
inspection_note = 'Customer QA reviewed: NRU is measured with NRU_FLAG, with the stated date and excluded-user condition.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'STD-22';
COMMIT;
SELECT example_id, reference_status, inspection_status, source_case_id
FROM sg_qa_vector_example
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'STD-22';

View File

@@ -0,0 +1,17 @@
-- Promote the reviewed customer QA example for the standard AU metric.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
inspection_note = 'Customer QA reviewed: standard AU is measured with AU_FLAG and excluded-user filtering.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-02';
COMMIT;
SELECT example_id, reference_status, inspection_status, source_case_id
FROM sg_qa_vector_example
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-02';

View File

@@ -0,0 +1,57 @@
-- Prefer an exact approved customer QA question over semantically adjacent
-- vector neighbours. This is a general retrieval rule; it does not encode
-- a game, metric, table, or customer-case-specific SQL policy.
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
p_question IN CLOB,
p_top_k IN PLS_INTEGER DEFAULT 3,
p_target_type IN VARCHAR2 DEFAULT 'ANY'
) RETURN SYS_REFCURSOR
AUTHID DEFINER
IS
v_query_vector VECTOR;
v_results SYS_REFCURSOR;
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
BEGIN
IF p_question IS NULL THEN
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
END IF;
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
END IF;
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
RAISE_APPLICATION_ERROR(-20005, 'target_type must be NONE, SINGLE, MULTI, ALL, or ANY.');
END IF;
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
p_question,
JSON(sg_qa_vector_params('search_query'))
);
OPEN v_results FOR
SELECT example_id,
question,
answer_sql,
answer_text,
embedding_model,
reference_kind,
target_type,
object_role,
source_case_id,
source_type,
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
FROM sg_qa_vector_example
WHERE reference_status = 'APPROVED'
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
ORDER BY CASE
WHEN DBMS_LOB.COMPARE(
LOWER(TRIM(question)), LOWER(TRIM(p_question))
) = 0 THEN 0
ELSE 1
END,
vector_distance(embedding, v_query_vector, COSINE),
example_id
FETCH FIRST p_top_k ROWS ONLY;
RETURN v_results;
END;
/

View File

@@ -0,0 +1,20 @@
-- Strengthen the approved customer QA example itself. The wording belongs
-- to the benchmark Few-shot record, not to a global Select AI profile rule.
UPDATE sg_qa_vector_example
SET answer_text = 'Expected focus: CZN_COMN_USER_MST, BASE_DT=2026-07-15, AU_FLAG=1, EXPT_USER_YN=''N''. '
|| 'The phrase standard AU is the report metric label; do not add STD_USER_YN unless the question separately asks for the standard-user cohort. '
|| 'Historical answer: STD_AU_COUNT=0',
inspection_note = 'Customer QA verified: standard AU uses the AU flag and excluded-user filtering; standard-user cohort is a separate request.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-02'
AND reference_status = 'APPROVED';
COMMIT;
SELECT example_id, reference_status, inspection_status, answer_text
FROM sg_qa_vector_example
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-02';

View File

@@ -0,0 +1,23 @@
-- Approve the reviewed customer QA comparison example. Metric definitions
-- stay in the exact Few-shot example rather than becoming global profile text.
UPDATE sg_qa_vector_example
SET reference_status = 'APPROVED',
inspection_status = 'VERIFIED',
answer_text = 'Expected focus: compare two independently aggregated metrics for the same resolved game and date. '
|| 'Standard AU: CZN_COMN_USER_MST with AU_FLAG=1 and EXPT_USER_YN=''N''. '
|| 'Business AU: CZN_CUSTOM_BIZ_USER_TXN with BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''. '
|| 'The labels standard AU and business AU do not imply STD_USER_YN. '
|| 'Historical answer: STD_AU_COUNT=0, BIZ_AU_COUNT=1.',
inspection_note = 'Customer QA verified: standard and business AU are separate aggregates with their respective AU flags.',
verified_at = SYSTIMESTAMP,
verified_by = 'SGMP_POC_METADATA_REVIEW'
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-03';
COMMIT;
SELECT example_id, reference_status, inspection_status, source_case_id, answer_text
FROM sg_qa_vector_example
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = 'CZN-03';

View File

@@ -0,0 +1,58 @@
{
servers {
protocols h1 h2
}
}
http://193.122.114.213 {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
log {
output file /var/log/caddy/smilegate-console-access.log
format console
}
reverse_proxy 127.0.0.1:8622 {
transport http {
versions 1.1
}
}
}
smilegate.cloud-handson.com {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
log {
output file /var/log/caddy/smilegate-console-access.log
format console
}
reverse_proxy 127.0.0.1:8622 {
transport http {
versions 1.1
}
}
}
smilegate-backoffice.cloud-handson.com {
# Edge clients have left completed zstd-compressed HTML navigations pending.
# Use broadly supported gzip on the management UI; the console keeps zstd.
encode gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
log {
output file /var/log/caddy/smilegate-backoffice-access.log
format console
}
reverse_proxy 127.0.0.1:8082 {
transport http {
versions 1.1
}
}
}

View File

@@ -0,0 +1,20 @@
[Unit]
Description=Smilegate PoC4 Portal Authentication Gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=opc
Group=opc
WorkingDirectory=/home/opc/workspaces/vpd-permission-poc-20260628213409/poc4_active_source_20260714
EnvironmentFile=/etc/smilegate/backoffice.env
EnvironmentFile=/etc/smilegate/poc4-console.env
ExecStart=/opt/smilegate/poc4-console/venv/bin/python /home/opc/workspaces/vpd-permission-poc-20260628213409/poc4_active_source_20260714/apps/poc4/portal_auth_gateway.py
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,3 @@
[Service]
ExecStart=
ExecStart=/opt/smilegate/poc4-console/venv/bin/streamlit run /home/opc/workspaces/vpd-permission-poc-20260628213409/poc4_active_source_20260714/apps/smilegate_demo/main.py --server.address 127.0.0.1 --server.port 8622 --server.headless true --server.enableCORS false --server.enableXsrfProtection false --browser.serverAddress smilegate.cloud-handson.com --browser.serverPort 443 --browser.gatherUsageStats false --logger.level=warn

View File

@@ -0,0 +1,94 @@
# 설계서: 스마일게이트 백오피스 잔여 UI 전환 및 운영 검증
## 추적성
- Redmine: #703 `[Smilegate] 백오피스 잔여 UI 전환 및 운영 검증`
- 관련 설계: `docs/design/smilegate-demo-rebranding/README.md`, `docs/design/smilegate-identity-administration/README.md`
- 구현 대상: `src/main/resources/templates/`, `src/main/resources/static/js/app.js`, `src/main/java/com/cloudhandson/vpdbackoffice/`, `poc4_active_source_20260714/`
- 검증 대상: Maven·Streamlit 설정 테스트, 인증 후 핵심 메뉴 HTTP 응답, 화면의 잔여 고객사 문구 검사
- 상태: Implemented / deployment pending
## 프로젝트 개요
이 저장소의 Spring Boot 백오피스는 Oracle VPD, Data Redaction, FGA, ORDS 및 Select AI PoC의 운영 설정을 확인하고 관리한다. 공개 데모의 고객·업무 대상은 스마일게이트 게임 로그와 서비스 데이터 분석이다.
## 목표
백오피스에 남은 KB손해보험/보험 업무 예시를 스마일게이트 게임 데이터 기준으로 전환한다. 화면 문구만 바꾸지 않고, 실제 MCP Select AI 안내·행 접근 규칙 요약·마스킹 동기화 대상도 `SGMP_POC` 게임 데이터와 모순되지 않게 맞춘다.
## 범위
1. 웹 화면과 브라우저에서 실행되는 JavaScript에 노출된 기존 보험 업무 예시를 게임 사용자·게임 서비스·판매/환불 데이터 예시로 교체한다.
2. 권한 규칙의 표시명과 미리보기는 기존 조건 코드의 저장 형식을 보존하면서 게임 데이터 의미로 설명한다.
3. MCP 데모의 tool 식별자·설명·질의 예시를 `SGMP_POC` Select AI 프로파일 기반으로 전환한다. 행 접근 토큰을 전제로 하는 기존 KB ORDS endpoint를 게임 데이터 endpoint인 것처럼 표시하지 않는다.
4. Data Redaction 동기화는 `SGMP_POC`의 실제 게임 사용자·판매 데이터 컬럼만 관리 대상으로 삼는다.
5. 내부 호환용 `CB_*` 뷰와 과거 SQL 이력은 실행 경로에서 제외한다. Smilegate 공개 화면·MCP 설정은 이력의 고객 데이터나 endpoint를 참조하지 않는다.
6. Streamlit 외피는 Smilegate 프로필·게임 데이터 시나리오·`oracle.select_ai.smilegate_game_text2sql` MCP 하나만 노출한다. 이전 고객용 토큰 프리셋 및 감사·보안관리 탭은 기본 실행 경로에서 제외한다.
7. `/schema-metadata`의 테이블 comment·컬럼 comment·annotation 조회와 저장 DDL은 모두 `SchemaMetadataMapper`로 수행한다. 메타데이터 조회는 `SGMP_POC` owner와 허용된 테이블 목록으로 한정한다.
## 메타데이터 화면 표시 원칙
테이블 comment, 컬럼 comment, annotation의 값은 목록에서 바로 읽을 수 있어야 한다.
접기/펼치기는 수정 입력란을 여는 용도로만 사용하며, 값의 존재 여부를 판단하기 위해
사용자가 모든 컬럼을 열어 보게 하지 않는다. comment가 비어 있는 컬럼은 목록에서
`컬럼 comment 없음`으로 명시한다.
## 설계 결정
### 1. 업무 용어는 데이터 모델의 사실에 맞춘다
- 사용자 식별자: `CZN_COMN_USER_MST.GUID`/`AUID`, `COMN_SALES_USER_MST.USER_KEY_VAL`
- 게임 서비스 식별: `COMN_GAME_ALIAS_BAS``GAME_ID`, `GAME_PREFIX`, `GAME_NM`, `GAME_ALIAS_NM`
- 거래/서비스 데이터: `COMN_SALES_TXN`, `COMN_REFUND_TXN`, `CZN_CUSTOM_*`
화면 예시는 위 객체를 사용하되, 실제로 존재하지 않는 담당자·채널 컬럼을 SQL 예시로 만들지 않는다.
### 2. 조건 코드의 호환성과 표시 의미를 분리한다
`OWN_CONTRACT`, `CHANNEL_CONTRACT`, `OWN_CUSTOMER`, `CHANNEL_CUSTOMER` 같은 과거 코드값은 저장값 호환을 위해 유지한다. 화면에는 각각 `담당 게임 서비스`, `토큰 채널 게임 서비스`, `담당 게임 사용자 데이터`, `토큰 채널 게임 사용자 데이터`로 표시한다. VPD 구현이 게임 데이터에 대한 실제 관계를 갖지 않는 조건은 설명에서 일반적인 보안 범위 조건으로만 제시하고, 존재하지 않는 조인 SQL을 제안하지 않는다.
### 3. MCP/Select AI는 현재 실행 경계를 정직하게 표시한다
MCP tool은 `SGMP_POC_HAIKU45` 프로파일을 기준으로 게임 데이터의 읽기 전용 `SELECT`/`WITH` 질의를 **생성**하는 용도로 안내한다. 생성 단계는 `SHOWSQL`만 사용하며 모델이 만든 SQL을 백오피스가 자동 실행하지 않는다. 운영자는 Database Actions 또는 검증된 실행 경로에서 SQL을 검토·실행한다.
프로파일은 `SGMP_POC` 소유이므로 일반 백오피스 관리 DB 연결(ADMIN)에서 사용할 수 없다. MCP Text2SQL 서비스는 별도 `BACKOFFICE_SELECT_AI_DB_URL`, `BACKOFFICE_SELECT_AI_DB_USERNAME`, `BACKOFFICE_SELECT_AI_DB_PASSWORD` 환경 변수로 `SGMP_POC` 연결을 만들고, 설정이 없을 때는 명확한 설정 오류만 반환한다. 비밀 값은 Git·화면·로그에 저장하지 않는다.
호출 전에 백오피스의 Bearer 토큰 해시를 검증하고 활성 사용자 토큰에만 Text2SQL 요청을 허용한다. 현재 PoC의 두 데모 운영 사용자는 게임 데이터 전체 권한을 갖지만, 후속 권한 세분화 시 이 지점에 역할별 데이터 범위 검증을 추가한다.
### 4. 마스킹 대상은 관리 가능한 실제 객체로 제한한다
마스킹 동기화 대상 owner는 `SGMP_POC`다. 관리 정책은 실제 컬럼 존재 여부를 검증한 뒤 사용자 식별자와 거래 사용자 식별자에만 적용한다. 대상에 없는 규칙은 DBMS_REDACT 호출 전에 화면 설정 오류로 처리한다.
### 5. 스키마 메타데이터 접근은 MyBatis로 통일한다
`/schema-metadata`는 화면 카드 목록을 정적 허용 목록에서 만들고, 선택된 테이블의 comment·컬럼·annotation만 조회한다. 서비스 계층에는 JDBC 직접 실행을 두지 않는다. table/column comment 사전 조회는 `owner = 'SGMP_POC'` 조건을 갖는다. Oracle의 `ALL_ANNOTATIONS_USAGE`에는 객체 owner 컬럼이 없으므로 annotation 조회는 허용 목록에서 선택된 정확한 `OBJECT_NAME``OBJECT_TYPE='TABLE'`로 한정한다. DDL에 쓰이는 테이블·컬럼·annotation 이름은 호출 전에 대문자 식별자 규칙과 허용 테이블 목록으로 검증한다.
백오피스 도메인은 Caddy에서 `gzip`만 사용한다. Edge가 HTTP/2 `zstd` HTML 응답을 완료된 상태에서도 pending으로 표시한 운영 증거가 있어, Streamlit 콘솔과 분리해 관리 UI 응답의 압축 호환성을 우선한다.
공통 화면 head의 외부 UI 보조 스크립트는 `defer`로 로드한다. CDN 지연이 정적 서버 렌더링 화면의 HTML 파싱·첫 표시를 막아서는 안 된다. `schema-metadata`는 서버 렌더링만으로 테이블 선택과 comment/annotation 보기를 제공한다.
## 변경 파일과 책임
| 영역 | 파일 | 변경 |
| --- | --- | --- |
| 행 접근 화면 | `templates/permissions.html`, `static/js/app.js`, `PermissionView.java` | 보험 용어와 존재하지 않는 KB SQL 예시 제거 |
| 마스킹 화면 | `templates/masking-rules.html`, `templates/user-masking-rules.html`, `MaskingPolicySynchronizer.java` | 게임 데이터 예시 및 실제 `SGMP_POC` 관리 대상 사용 |
| VPD/운영 화면 | `templates/vpd-filter-runtime.html`, `templates/operation-status.html` | 게임 데이터 상태 표시 예시 적용 |
| MCP 화면 | `templates/mcp-sse.html`, `McpSseService.java`, `SelectAiService.java` | 업무 데이터 Select AI 도구, 토큰 검증 및 SHOWSQL 생성 |
| 보안 스크립트 화면 | `SecuritySqlScriptService.java` | UI에 노출되는 KB 설명을 게임 데이터 설명으로 교체 |
| Streamlit 외피 | `poc4_active_source_20260714/config/`, `apps/poc4/mcp_discovery_ui.py` | Smilegate 로그인/헤더/시나리오와 단일 게임 Text2SQL MCP 계약 적용 |
| 스키마 메타데이터 | `SchemaMetadataService.java`, `SchemaMetadataMapper.java`, `SchemaMetadataMapper.xml` | 직접 JDBC 제거, MyBatis 조회·DDL 통일, `SGMP_POC` owner 조건 강제 |
## 완료 기준
1. Smilegate 공개 화면·활성 MCP 설정에서 기존 고객사명·보험 원장·기존 endpoint가 검색되지 않는다. 과거 SQL 이력 및 미실행 호환 코드는 제외한다.
2. `SGMP_POC` 게임 데이터 객체만 마스킹 동기화 대상으로 선택된다.
3. `mvn test`가 통과한다.
4. 인증된 `admin`으로 주요 메뉴가 오류 배너 없이 200 응답을 반환하고, Streamlit의 MCP는 Text2SQL 생성 결과를 정상 표기한다.
5. 변경 사항은 #703을 참조하는 Git 커밋과 Redmine 작업 로그로 남긴다.
## 위험 및 완화
- 과거 KB ORDS API는 게임 데이터 정책을 보장하지 않는다. endpoint 이름만 치환해 기존 API를 재사용하지 않는다.
- 운영 VM SSH 키 인증이 거부될 수 있다. 로컬 빌드·공개 URL 확인을 먼저 수행하고, 배포 시에는 승인된 운영 접속 경로를 사용한다.

View File

@@ -0,0 +1,43 @@
# 설계서: Smilegate 전용 브랜치 분리
## 추적성
- Redmine: #704 `[Release] Smilegate 전용 브랜치 분리`
- 관련 이슈: #703 `[Smilegate] 백오피스 잔여 UI 전환 및 운영 검증`
- 작업 경로: `/Users/joungminko/claude-workspace/vpd-smilegate-rebrand`
- 원격: `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git`
- 상태: Draft
## 프로젝트 개요
`vpd-permission-poc`은 Spring Boot VPD 관리 백오피스를 포함한다. HMM과 Smilegate 데모는 현재 같은 원격 저장소를 사용하지만, 고객별 화면·데이터 모델·배포 기준은 분리돼야 한다.
## 목표
Smilegate 작업본을 원격 `smilegate` 브랜치로 분리한다. HMM은 기존 `main` 브랜치를 그대로 사용하고, Smilegate 변경은 `smilegate` 브랜치만 기준으로 커밋·푸시·배포한다.
## 범위
1. detached HEAD 상태의 Smilegate worktree에서 `smilegate` 브랜치를 생성한다.
2. `origin/smilegate`를 생성하고 현재 worktree의 upstream으로 설정한다.
3. #703의 Smilegate 전용 설계서와 UI 변경만 `smilegate` 브랜치에 기록한다.
4. HMM 작업본, `origin/main`, 다른 worktree의 파일과 HEAD를 변경하지 않는다.
## 비범위
- HMM의 로컬 수정·브랜치·배포 변경
- 기존 `main`의 이력 재작성 또는 강제 푸시
- 원격 저장소를 새로 생성하거나 삭제하는 작업
## 검증 기준
1. `git branch --show-current`은 Smilegate worktree에서 `smilegate`를 반환한다.
2. `git rev-parse --abbrev-ref @{u}``origin/smilegate`를 반환한다.
3. `origin/main`의 커밋 ID는 분리 전후 동일하다.
4. HMM 작업본의 status와 HEAD는 분리 작업으로 변경되지 않는다.
## 운영 규칙
- Smilegate 배포는 `origin/smilegate`의 검증된 커밋만 사용한다.
- HMM 변경은 `main` 또는 HMM 전용 작업 경로에서만 수행한다.
- 공통 기반을 변경해야 하면 두 고객 브랜치에 적용하기 전에 영향 범위를 별도 이슈로 검토한다.

View File

@@ -0,0 +1,77 @@
# 설계서: 스마일게이트 고객 질답 검증 이력
## 추적성
- Redmine: #706 `[Smilegate] 고객 엑셀 질답 검증 이력 및 실행 화면`
- 기준 질답서: `/Users/joungminko/claude-workspace/oci-data-flow-aidp/docs/reports/sgmp-select-ai-full-qa-term-dict-final-v2-20260721.md`
- 기준 데이터: 표준 DW 샘플 28건 + 카제나 샘플 19건 = 47건
- 대상 스키마: `SGMP_POC`
- 대상 화면: `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py`
## 프로젝트 개요
`vpd-permission-poc`은 Oracle Autonomous Database의 게임 데이터와 Select AI/MCP를 연결해 자연어 데이터 질의를 검증하는 PoC다. 이번 기능은 고객이 제공한 Excel 기반 질답서를 실행 가능한 기준 시나리오로 바꾸고, 데모 중 실제 답변 품질을 설명 가능하게 남긴다.
## 목표
1. 고객 Excel에서 정리한 47개 질문을 질문 마스터로 보관한다.
2. 기준 답변, 기준 SQL, 과거 검증 결과와 이후 실행 결과를 모두 순차 이력으로 보관한다.
3. 사용자가 후보 테이블에서 질문을 고르거나 자유 질의를 입력해 즉시 실행할 수 있게 한다.
4. 후보 질문은 SQL 의미 검증과 실행 결과로 `PASS`, `WARN`, `FAIL`을 표시한다. 정답 기준이 없는 자유 질의는 `REVIEW`로 표시한다.
## 데이터 모델
테이블은 사용자 요청에 따라 두 개만 둔다.
| 테이블 | 키 | 역할 |
| --- | --- | --- |
| `SG_AI_QA_QUESTION` | `QUESTION_ID` | 고객 Excel 질문, 출처, 기대 포인트, 원본 샘플 SQL, 기준 SQL/답변, SQL 판정 규칙을 보관한다. 자유 질의도 해시 기준으로 이 테이블에 한 번만 등록한다. |
| `SG_AI_QA_ANSWER` | `ANSWER_SEQ` | 질문별 실행 이력이다. 과거 47건도 `HISTORICAL`로 적재하고, 포털 실행은 `LIVE`로 계속 추가한다. |
`SG_AI_QA_ANSWER.QUESTION_ID`는 질문 마스터를 참조한다. 실행 결과는 JSON, 생성 SQL·답변·판정 근거는 CLOB으로 저장한다. 따라서 질문 기준은 바뀌어도 이미 실행된 이력의 원문과 당시 판정을 보존한다.
## 판정 규칙
1. 기준 시나리오는 `required_sql_terms``recommended_sql_terms`를 사용한다.
2. 필수 테이블·컬럼·집계·기간 규칙이 빠지거나 모델 오류 문구가 SQL에 섞이면 `FAIL`이다.
3. 권장 필터가 빠졌거나 지원 범위가 일부인 경우 `WARN`이다.
4. 미지원 게임 질문은 별칭 조회를 거치지 않고 임의 게임 ID나 테이블을 만들어 내면 `FAIL`이다. 안전하게 거절하거나 별칭 조회 결과가 0건이면 `PASS`이다.
5. 월간 NRU/AU, 재화 보유/사용 등 기존 질답서의 개별 보정 규칙은 같은 판정기에 반영한다.
6. 자유 질의는 기준 질문을 선택하지 않은 경우 `REVIEW`로 저장한다. 실행 성공을 정답으로 표시하지 않는다.
문장 표현의 유사도만으로 정답을 판정하지 않는다. 집계값, 생성 SQL, 실행 결과가 근거가 되므로 고객에게 왜 통과 또는 실패인지 보여줄 수 있다.
## 화면 흐름
1. `검증 시나리오` 탭에서 47개 후보를 표 형태로 표시한다. 케이스, 구분, 제목, 질문, 기대 포인트, 최근 판정, 최근 실행 시각을 보여 준다.
2. 행을 선택하면 질문 입력란이 채워지고, 우측 또는 하단에 기준 답변·기준 SQL·원본 Excel 출처를 표시한다.
3. 사용자는 선택된 기준 질문을 그대로 실행하거나 자유 텍스트를 작성한다.
4. 실행 뒤에는 현재 답변, 생성 SQL, 조회 행, 판정, 판정 근거를 표시하고 `SG_AI_QA_ANSWER`에 저장한다.
5. 같은 질문의 과거 답변은 최신 순 표로 보여 주며, 과거 기준 검증과 현재 실행을 구분한다.
## 실행 근거 표시와 가독성
선택 질문의 기준 답변과 기준 SQL은 브라우저의 다크 테마 설정과 관계없이
밝은 배경과 어두운 글자로 표시한다. 질의 실행이 끝난 뒤에는 요약 답변만
보여 주지 않고, 실제 MCP가 반환한 생성 SQL과 조회 결과 테이블을 기본으로
펼쳐서 함께 보여 준다. 결과 행이 없으면 그 사실을 명확히 표시한다.
## 적재 기준
- 기준 원본은 `sgmp-select-ai-full-qa-term-dict-final-v2-20260721.md`와 동시 생성된 JSON이다.
- JSON의 `STD-05` 실행 출력은 비정상적으로 크므로, 이력 조회 안정성을 위해 저장 시 안전한 길이로 절단하고 원본 보고서 경로를 질문에 남긴다.
- 과거 레코드는 `HISTORICAL`, 포털에서 수행하는 새 레코드는 `LIVE`로 구분한다.
## 완료 기준
- ADB에 질문 마스터 47건과 과거 답변 이력 47건이 있다.
- 답변 이력 키는 증가하는 `ANSWER_SEQ`이며 질문 외래키가 유효하다.
- 후보 선택, 자유 질의, 기준 답변/SQL, 과거 이력, PASS/WARN/FAIL/REVIEW 표기가 한 화면에서 작동한다.
- 생성 SQL의 핵심 규칙을 바꾼 실패 케이스가 `FAIL`로 판정되는 단위 테스트가 있다.
- 실제 포털 실행 한 건이 ADB 이력에 저장되는 것을 확인한다.
## 비범위
- 이 기능은 Select AI의 정답을 하드코딩해 바꾸지 않는다.
- 과거 대화 SQLite 저장소를 이번 작업에서 전면 이전하지 않는다. 고객 질답 검증 이력만 ADB의 두 테이블에 저장한다.
- 자유 질의에 임의의 정답을 부여하지 않는다.

View File

@@ -0,0 +1,112 @@
# #708 Smilegate Select AI OCI GenAI 전환
## 프로젝트 개요
Smilegate DATA & AI PoC는 Autonomous Database의 게임 데이터에서 Select AI Text2SQL을 생성하고, MCP와 백오피스를 통해 결과를 조회한다. 현재 운영 프로파일은 외부 OpenRouter 경유 Claude를 사용한다.
## 목표
`SGMP_POC_HAIKU45`가 가진 게임 데이터 object list와 메타데이터 활용 범위는 유지하면서, LLM 호출 경로만 OCI Generative AI `openai.gpt-5.4-mini`로 전환한다. ADB Resource Principal이 아닌 이 머신의 `~/.oci/config` DEFAULT API signing credential을 ADB credential으로 등록한다.
## 전환 설계
| 구분 | 기존 | 전환 후 |
| --- | --- | --- |
| 프로파일 | `SGMP_POC_HAIKU45` | `SGMP_POC_OCI_GPT54MINI` |
| 제공자 | OpenAI 호환 외부 경로 | OCI Generative AI (`provider: oci`) |
| 모델 | Claude Haiku 4.5 | `openai.gpt-5.4-mini` |
| 인증 | 외부 API credential | DEFAULT API signing key 기반 `SGMP_POC_OCI_DEFAULT_CRED` |
| OCI 리전 | 외부 서비스 | `us-chicago-1` (GPT-5.4 Mini OCI inference route) |
| 게임 데이터 범위 | 기존 object list | 기존 profile attributes에서 복제 |
| MCP 설정 | 기존 프로파일명 | `BACKOFFICE_SELECT_AI_PROFILE=SGMP_POC_OCI_GPT54MINI` |
신규 프로파일은 기존 프로파일의 metadata 관련 attributes를 복사하고, 외부 endpoint·credential·model은 OCI 값으로 새로 설정한다. 따라서 object list, comment, annotation, constraint 기반 Text2SQL 문맥은 유지된다. 기존 외부 프로파일은 삭제하지 않으며, 전환 실패 시 환경변수만 원래 값으로 되돌린다.
## 사전 조건
1. `~/.oci/config` DEFAULT의 user, tenancy, fingerprint, key_file이 유효한 OCI API signing key여야 한다.
2. DEFAULT API signing user가 Chicago 리전 root compartment에서 OCI Generative AI `openai.gpt-5.4-mini` 호출 권한을 가져야 한다. DEFAULT config의 signing region과 GPT inference route는 독립적이므로 profile attribute `region``us-chicago-1`로 명시한다.
3. 스크립트는 `SGMP_POC` 프로파일 소유자로 실행한다.
## 구현 순서
1. `sql/adb/72_sgmp_select_ai_oci_genai_profile.sql`로 신규 OCI 프로파일을 만든다.
2. 새 프로파일로 한글 Text2SQL `SHOWSQL`과 생성 SQL의 읽기 전용 실행을 검증한다.
3. 운영 서버의 `BACKOFFICE_SELECT_AI_PROFILE`만 새 프로파일로 교체하고 백오피스를 재기동한다.
4. 운영 MCP의 `oracle.select_ai.smilegate_game_text2sql` 응답 profile과 집계 결과를 검증한다.
## 롤백
새 프로파일을 삭제하지 않는다. MCP에서 오류가 나거나 SQL 품질이 허용 기준을 충족하지 않으면 `/etc/smilegate/backoffice.env``BACKOFFICE_SELECT_AI_PROFILE``SGMP_POC_HAIKU45`로 되돌린 뒤 서비스를 재기동한다.
## 검증 기준
- 새 프로파일 provider=`oci`, model=`openai.gpt-5.4-mini`, credential=`SGMP_POC_OCI_DEFAULT_CRED`, region=`us-chicago-1`
- 한글 질의는 SQLcl UTF-8 base64 복원식으로 전달한다.
- `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`의 운영 프로파일을 바꾸지
않는다.
후보 모델 비교는 `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·답변 합성 시간을
포함한 전체 체감시간과는 별도로 관리한다.

View File

@@ -0,0 +1,27 @@
# #710 POC4 URL 로그인 토큰 제거
## 프로젝트 개요
Smilegate DATA & AI PoC의 POC4 Streamlit 콘솔은 게임 데이터 MCP와 Select AI Text2SQL 데모를 제공한다. 포털 로그인은 콘솔 접근을 보호한다.
## 문제
로그인 유지용 서명 토큰이 `poc4_remember` query parameter로 URL에 포함됐다. URL은 브라우저 기록, 프록시 로그, 공유 링크, Referrer에 남을 수 있으므로 인증 정보를 전달하는 경로로 사용하면 안 된다.
## 조치 설계
1. Streamlit 코드에서 URL 토큰 생성·검증·삭제를 모두 제거한다.
2. 로그인 상태는 현재 Streamlit 브라우저 세션에서만 유지한다. 서버가 `HttpOnly`, `Secure`, `SameSite` cookie를 발급하는 전용 인증 경로가 마련되기 전에는 영구 로그인 기능을 제공하지 않는다.
3. `POC4_LOGIN_REMEMBER_SECRET`을 교체해 기존 서명 링크를 무효화한다.
4. Caddy가 기존 `poc4_remember` query 요청을 애플리케이션으로 전달하지 않고 `https://smilegate.cloud-handson.com/`으로 303 redirect한다.
## 검증 기준
- `mcp_discovery_ui.py``poc4_remember` 또는 `st.query_params` 로그인 토큰 코드가 없다.
- 기존 query URL 요청은 query가 없는 루트 URL로 303 응답한다.
- `smilegate-poc4-console.service`가 정상 기동한다.
- 토큰, password hash, signing key는 Git·Redmine·명령 출력에 기록하지 않는다.
## 후속 개선
영구 로그인 요구가 다시 생기면 POC4 자체가 아닌 서버 인증 endpoint가 `HttpOnly; Secure; SameSite=Lax` cookie를 발급하고, Streamlit은 요청 cookie의 서버 검증 결과만 읽는 구조로 구현한다.

View File

@@ -0,0 +1,78 @@
# 설계서: 환경변수 기반 공통 데이터 카탈로그
## 프로젝트 개요
이 백오피스는 Oracle Database의 권한, 메타데이터, Select AI와 정형 데이터 조회를
운영하기 위한 공통 관리 화면이다. 현재 일부 화면은 특정 스키마와 업무 테이블 목록을
코드에 고정하고 있어, 다른 프로젝트에 재사용하려면 Java와 MyBatis를 함께 수정해야 한다.
## 목표
1. DB 접속은 기존 `BACKOFFICE_*_DB_*` 환경변수 체계를 유지한다.
2. 메타데이터와 정형 데이터 조회 대상은 `BACKOFFICE_CATALOG_OWNER`
`BACKOFFICE_CATALOG_OBJECTS`에서 선언한다.
3. 테이블과 뷰를 공통 `DataCatalogObject` 인터페이스로 표현한다.
4. 서비스와 MyBatis는 검증된 카탈로그 객체에서 전달받은 owner, object name, object type만
사용한다. HTTP 요청값을 SQL 식별자로 쓰지 않는다.
5. 카탈로그 환경변수가 비어 있거나 잘못되면 기동 시 실패한다. 다른 고객의 객체를 기본값으로
참조하지 않는다.
## 설정 계약
```bash
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
export BACKOFFICE_CATALOG_OBJECTS='[
{"key":"sales","tableName":"SALES_TXN","objectType":"TABLE",
"businessName":"판매 거래","description":"판매 거래 정보"},
{"key":"daily-sales","tableName":"VW_DAILY_SALES","objectType":"VIEW",
"businessName":"일별 판매","description":"일별 판매 집계 뷰"}
]'
```
- `key`: 화면 URL과 선택값에 사용하는 영문 키. 소문자, 숫자, `-`만 허용한다.
- `tableName`: Oracle 단순 식별자. 대문자, 숫자, `_`, `$`, `#`만 허용한다.
- `objectType`: `TABLE` 또는 `VIEW`.
- `businessName`, `description`: 화면 표시용 텍스트.
잘못된 JSON, 중복 key/name, 빈 목록, 허용되지 않은 식별자는 기동 시 명확히 실패한다.
## 구조
```text
환경변수
→ CatalogProperties
→ DataCatalog
→ StructuredDataService / SchemaMetadataService
→ MyBatis Mapper
→ Oracle dictionary / 허용 객체
```
`DataCatalog`은 허용 객체를 해석하는 단일 진입점이다. 미리보기 SQL은 객체 이름을
카탈로그에서만 받아 조합하며, 목록 밖 이름은 SQL에 들어갈 수 없다.
## 보안 SQL 번들
보안 SQL 화면은 `BACKOFFICE_SECURITY_SQL_SCRIPTS` JSON 배열에 선언한 번들만 표시한다.
각 항목은 `scriptId`, `category`, `fileName`, `title`, `description`을 가진다.
`fileName`은 패키지의 `sql/adb/` 하위 상대 경로만 허용하며, 요청값으로 경로를 만들지 않는다.
기존 고객 전용 SQL은 `sql/adb/legacy/<customer>/`에 보존하고, 다른 환경에는 해당 목록을
선언하지 않는다.
## MyBatis 처리
- table/view comment와 column comment 조회는 `owner`, `objectName`을 바인드한다.
- annotation 조회는 Oracle dictionary 제약에 맞춰 `objectName`, `objectType`을 함께
바인드한다.
- 주석 DDL은 `COMMENT ON TABLE` 문법으로 테이블 또는 뷰에 적용한다.
- annotation DDL은 `TABLE`에만 허용한다. 뷰는 comment 편집만 제공한다.
## 완료 기준
- 환경변수로 테이블과 뷰를 섞은 카탈로그를 선언할 수 있다.
- metadata와 preview가 선언된 owner/object만 조회한다.
- 뷰의 comment/column comment는 조회·수정 가능하고, annotation 편집은 차단된다.
- 설정 파싱과 허용 목록 검증을 자동 테스트한다.
## 비범위
- Select AI profile 내부 object list를 자동으로 생성·변경하지 않는다.

View File

@@ -0,0 +1,74 @@
# 723. SGMP QA Vector Retrieval
## Goal
Store curated question, answer SQL, answer text, and their combined retrieval
document in `SGMP_POC`. Retrieve the top-K closest examples for a new question
and pass the returned context to the Text2SQL prompt in a later application
integration.
## Security boundary
- IAM identity: `sgmp-qa-vector-api`
- IAM group: `sgmp-vector-embed-group`
- IAM policy: only `use generative-ai-text-embedding in tenancy`
- Database credential: `SGMP_POC_QA_VECTOR_CRED`, created from that dedicated
API signing key only. It does not reuse `SGMP_POC_OCI_DEFAULT_CRED`.
- Network: HTTPS only to OCI GenAI Chicago EmbedText endpoint on port 443. The
`SGMP_POC` ACE is provisioned once by an ADB `ADMIN` connection because an
application schema cannot administer network ACLs.
- The private key is read from `VECTOR_OCI_API_KEY_FILE`; it is never committed,
displayed, or persisted outside the encrypted database credential.
## Embedding contract
- Model: `cohere.embed-v4.0`
- Dimension: `1536` FLOAT32
- The current ADB `DBMS_VECTOR` OCI adapter does not forward Cohere Embed 4's
`input_type` field; both paths therefore use the provider's compatible
default request shape. The model and 1536-dimension vector contract remain
fixed. Once the adapter exposes Embed 4 `input_type`, switch stored examples
to `search_document` and incoming questions to `search_query`.
- `p_top_k` default: `3` (accepted range `1..20`)
Oracle recommends distinct document/query input types for Cohere Embed 4 RAG
flows and its default output size is 1536. See [Cohere Embed 4](https://docs.oracle.com/en-us/iaas/Content/generative-ai/cohere-embed-4.htm).
## Database API
```sql
-- Stores question + answer SQL + optional answer and returns EXAMPLE_ID.
SELECT sg_qa_vector_store(:question, :answer_sql, :answer_text) FROM dual;
-- Returns EXAMPLE_ID, QUESTION, ANSWER_SQL, ANSWER_TEXT, MODEL and distance.
DECLARE
results SYS_REFCURSOR;
BEGIN
results := sg_qa_vector_search(:question); -- default top 3
END;
/
-- Ready-to-insert textual context for a prompt.
SELECT sg_qa_vector_context(:question, 3) FROM dual;
```
`SG_QA_VECTOR_STORE`는 SQL `SELECT` 표현식으로 호출되는 저장 함수이므로,
함수 내부의 INSERT는 자율 트랜잭션으로 수행하고 성공 시 commit, 실패 시 rollback
한다. 이 처리가 없으면 Oracle은 `ORA-14551`로 DML을 거절한다.
## Apply
```bash
export SGMP_POC_DB_PASSWORD='...'
export SGMP_POC_WALLET_DIR='/path/to/Wallet_SGMPAIPOC'
export VECTOR_OCI_USER_OCID='...'
export VECTOR_OCI_TENANCY_OCID='...'
export VECTOR_OCI_COMPARTMENT_OCID='...'
export VECTOR_OCI_API_KEY_FILE='/secure/path/sgmp_qa_vector_api_key.pem'
export VECTOR_OCI_API_KEY_FINGERPRINT='...'
./scripts/setup-sgmp-qa-vector.sh
```
For the initial small QA corpus, exact cosine search is deliberate: it makes
results immediately verifiable. Add a vector index only after the corpus size
and recall/latency target are measured.

View File

@@ -0,0 +1,52 @@
# Redmine #726 · Select AI SHOWPROMPT 진단 MCP
## 목표
기존 Text2SQL 생성·실행 tool과 분리된 읽기 전용 SHOWPROMPT 진단 tool을
MCP `tools/list`에 추가한다. 일반 질문 처리 Agent는 포털 allowlist를 통해
기존 tool만 사용하고, FAIL 개선 제안 버튼만 진단 tool을 직접 호출한다.
## 외부 설정
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME`
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL`
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION`
기본값은 제품 중립적인 `oracle.select_ai.data_showprompt`와 업무 데이터
표현을 사용한다. 스마일게이트 운영값은 외부 env에서 고객 전용 이름으로
설정한다.
## 동작
1. HTTP Bearer Token을 기존 업무 사용자 토큰으로 검증한다.
2. prompt를 기존 4,000자 제한으로 검증한다.
3. schema-owned Select AI 연결에서
`DBMS_CLOUD_AI.GENERATE(prompt, profile, 'showprompt')`를 호출한다.
4. SQL을 실행하지 않고 다음 JSON을 반환한다.
- `status=SHOWPROMPT`
- `profile`
- `selectAiPrompt`
## 변경 함수
| 파일/함수 | 변경 |
|---|---|
| `McpProperties` | SHOWPROMPT tool 이름·label·description 외부 설정 |
| `SelectAiService.generatePrompt` | 인증·prompt 검증 후 SHOWPROMPT 반환 |
| `SelectAiService.generate` | `showsql/showprompt` action을 bind하는 공통 생성 함수 |
| `McpSseService.toolsListResult` | Text2SQL과 SHOWPROMPT 두 tool 등록 |
| `McpSseService.toolsCallResult` | exact tool name에 따라 query/diagnostic 분기 |
## 안전 조건
- SHOWPROMPT tool은 생성 SQL을 실행하지 않는다.
- 임의 action 인자를 사용자에게 받지 않는다.
- 응답에 Bearer Token이나 DB 연결 정보를 포함하지 않는다.
- 기존 Text2SQL tool 이름과 계약은 유지한다.
## 테스트
- tools/list에 두 tool과 각 prompt schema가 존재한다.
- Text2SQL 호출은 기존 generate-and-execute 경로를 유지한다.
- SHOWPROMPT 호출은 generatePrompt만 실행한다.
- 누락/비활성 Bearer Token은 기존과 동일하게 거절한다.

View File

@@ -0,0 +1,65 @@
# 731. SGMP QA Vector MCP 도구
## 목표
백오피스 MCP에 저장된 QA 벡터 예제를 조회·저장하는 두 도구를 추가한다.
- 조회 도구는 현재 질문과 유사한 예제 SQL을 Select AI 호출 전에 확인하여
few-shot 컨텍스트로 사용할 수 있게 한다.
- 저장 도구는 검토된 Select AI 결과를 다음 질의 품질 개선용 예제 SQL로 저장한다.
## MCP 계약
| 도구 | 입력 | 반환 | 용도 |
|---|---|---|---|
| `oracle.select_ai.qa_vector_search` | `question`, 선택 `topK`(기본 3) | 예제 ID, 질문, 답 SQL, 답변, cosine distance | Select AI 실행 전 few-shot 후보 확인 |
| `oracle.select_ai.qa_vector_store` | `question`, `answerSql`, 선택 `answer` | 저장된 exampleId, 모델 | 검토된 Select AI 예제 SQL 축적 |
도구 이름·표시명·설명은 모두 `BACKOFFICE_MCP_QA_VECTOR_*` 환경 변수로
바꿀 수 있다. MCP의 공통 `prompt` 인자를 재사용하지 않아 검색과 저장의
입력 의미를 명확히 분리한다.
## 연결 및 보안
1. HTTP Bearer Token은 기존 업무 사용자 토큰 검증을 통과해야 한다.
2. 벡터 DB 호출은 `BACKOFFICE_SELECT_AI_DB_*`로 만든 SGMP_POC 연결만 사용한다.
3. API 서명 키, DB 비밀번호, credential 이름은 MCP 응답·로그에 포함하지 않는다.
4. 검색은 `SG_QA_VECTOR_SEARCH` DB 함수만 호출한다. 저장은
`SG_QA_VECTOR_STORE` DB 함수만 호출한다.
5. 저장 도구는 호출자가 검토한 결과만 보내는 운영 계약이다. Select AI 실행
결과를 자동으로 저장하지 않는다.
## Select AI 연계 순서
1. Agent가 사용자 질문으로 `qa_vector_search`를 호출한다.
2. 반환된 상위 2~3개 예제의 질문·답 SQL을 Select AI 프롬프트의 few-shot
컨텍스트로 사용한다.
3. 기존 Text2SQL 도구로 SQL을 생성·검토·실행한다.
4. 검토 통과한 질문·생성 SQL·필요 시 답변을 `qa_vector_store`로 저장한다.
Text2SQL은 `BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED`가 true일 때 검색 결과의
상위 `BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K`개(기본 3, 최대 3)를 내부 프롬프트에
자동 보강한다. 예제는 현재 object list·게임 별칭 해석·정책을 대체하지 않으며,
보강 실패 또는 일치 예제 없음은 기존 Text2SQL 경로를 중단시키지 않는다.
고객 질문 재평가에서 FAIL이 확인되면 기준 SQL을 검토한 뒤에만 저장하고, 같은
질문을 다시 실행해 `fewShotStatus=APPLIED` 및 판정 개선 여부를 기록한다.
## Smilegate 포털 allowlist
`poc4_active_source_20260714/config/mcp_servers.json`
`smilegate_game_data_mcp` allowlist에는 다음 세 도구만 둔다.
- `oracle.select_ai.smilegate_game_text2sql`
- `oracle.select_ai.qa_vector_search`
- `oracle.select_ai.qa_vector_store`
포털은 이 목록 밖의 백오피스 MCP 도구를 발견하거나 호출하지 않는다.
## 검증
- `tools/list`에 기존 두 도구와 새 두 도구가 함께 노출된다.
- 검색의 `topK` 기본값은 3이고 범위는 1~20이다.
- 저장 도구는 question·answerSql 없이는 호출되지 않는다.
- Bearer Token 누락 시 네 도구 모두 기존과 같은 권한 거절 응답을 반환한다.
- 서비스 단위 테스트는 DB 대신 캡처 구현으로 MCP 입력·응답 계약을 검증한다.

View File

@@ -0,0 +1,99 @@
# 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를 보강한다.
## 추출 게임명별 판정 계약
- ADB Chat이 반환한 `game_mentions`의 각 항목은 서로 독립적으로 판정한다. 벡터 검색 결과를
하나의 목록으로 합쳐 모든 후보를 지원 게임으로 취급하지 않는다.
- 벡터 검색은 후보를 찾는 단계다. ADB OCI GenAI가 추출 명칭과 후보의 카탈로그 명칭,
별칭, `GAME_ID`, `GAME_PREFIX`를 비교해 후보 중 하나를 선택하거나 전체를 거절한다.
- 애플리케이션은 모델이 반환한 `gameKey`가 실제 후보 목록에 있을 때만
`supportedGames`에 넣는다. 후보 목록에 없는 식별자는 거절한다.
- 모델이 모든 후보를 거절하면 해당 원문 명칭을 `unmatchedGames`에 남긴다. 유사도 순위가
높다는 이유만으로 다른 게임에 대입하지 않는다.
- 게임 판정에 정규식, 부분문자열 매칭, 유사도 임계값을 사용하지 않는다.
- 동일 게임이 여러 명칭으로 검색되더라도 `supportedGames``gameKey` 기준으로 중복을
제거한다. `matchedGames``unmatchedGames`에는 mention별 판정 근거를 유지한다.
- 일부만 지원되는 복수 게임 질문은 `status=PARTIAL`로 반환하고, 지원 게임의 worker 실행과
미매칭 게임 안내를 함께 수행한다.
## 검증
- view가 지원 게임과 object list 미연결 게임을 각각 반환하는지 확인한다.
- resolver MCP의 결과에 구체 게임/테이블 하드코딩이 없는지 확인한다.
- STD-06에서 미지원 게임은 worker가 호출되지 않고, 지원 게임 결과에는 few-shot 예제, 생성 SQL,
실행 결과가 포함되는지 확인한다.
- STD-06 판정은 미매칭 게임과 지원 게임을 독립적으로 평가한다. 미매칭 게임을 답변에 명시하고
지원 게임의 요청 일자와 AU 집계를 반환하면 `PARTIAL` 성공을 `PASS`로 판정하며, 미매칭
게임 때문에 지원 게임 결과를 폐기하거나 전체 결과 없음으로 만들지 않는다.
- 기존 단일 게임 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에 연결하는 작업이 선행되어야 하며, 모델 추측으로 보완하지 않는다.
## 운영 배포 계약
- `GameScopeProperties` 클래스와 `application.yml``backoffice.game-scope` 구역은 하나의
배포 단위다. Java 클래스만 반영하면 환경변수가 존재해도 기본값(`enabled=false`,
`viewName=""`)으로 바인딩되어 worker 호출이 차단된다.
- 운영 배포 후 `BACKOFFICE_GAME_SCOPE_ENABLED`,
`BACKOFFICE_GAME_SCOPE_VIEW`, `BACKOFFICE_GAME_SCOPE_MAX_SCOPES`가 실제 실행 JAR의
설정 메타데이터에 연결되는지 MCP `game_scope_resolve` 호출로 확인한다.
- 검증은 범위 판정 성공만으로 끝내지 않고, 지원 scope를 전달한 Few-shot NL2SQL이 읽기 전용
SQL 생성과 실행까지 완료하는지 확인한다.

Some files were not shown because too many files have changed in this diff Show More