diff --git a/.env.example b/.env.example
index 11a65a2..9c140a6 100644
--- a/.env.example
+++ b/.env.example
@@ -52,42 +52,14 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
export BACKOFFICE_ORDS_DB_PASSWORD=""
-# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다.
+# Smilegate Select AI는 프로파일 소유 스키마(SGMP_POC)로 별도 접속합니다.
+# 원문 비밀번호는 .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_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_DB_USERNAME="SGMP_POC"
+export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
+export BACKOFFICE_SELECT_AI_PROFILE="SGMP_POC_HAIKU45"
-# --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 ---
-# 승인 객체는 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_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_MASKING_POLICIES=''
-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
diff --git a/.gitignore b/.gitignore
index 357e835..3640865 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,11 +17,9 @@ logs/
# Java / Maven
target/
-# Python
+# Python / Streamlit
__pycache__/
*.py[cod]
-.venv/
-data/
# Locally downloaded development tools (for example SQLcl)
.tools/
diff --git a/ai-web-agent-console/app.py b/ai-web-agent-console/app.py
index 26d6e08..ad3577e 100644
--- a/ai-web-agent-console/app.py
+++ b/ai-web-agent-console/app.py
@@ -73,7 +73,7 @@ from ai_web_agent_console.query_contracts import (
LOG = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2025-11-25"
-PREFERRED_TOOL = "search_hr_data"
+PREFERRED_TOOL = "oracle.select_ai.smilegate_game_text2sql"
DEFAULT_QUESTION = ""
MAX_RESPONSE_BYTES = 1_000_000
MAX_CONVERSATION_MESSAGES = 8
@@ -91,15 +91,21 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
ENV_FILE = ROOT / ".env"
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
-DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json"
+DEMO_SCENARIOS_FILE = ROOT / "config" / "smilegate_demo_scenarios.json"
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
-DEFAULT_VPD_USER_ID = "E1001"
-VPD_OPERATIONS_URL = "https://hmm-backoffice.cloud-handson.com/"
+DEFAULT_VPD_USER_ID = "sg-teamlead"
+VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/"
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
-PORTAL_AUTH_PROXY_USER_HEADER = "X-HMM-Authenticated-User"
-PORTAL_AUTH_PROXY_EXPIRY_HEADER = "X-HMM-Auth-Expires"
+PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
+PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
+PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
+AUDIT_SCHEMA = "SGMP_POC"
+REFERENCE_EVIDENCE_ENABLED = (
+ os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower()
+ in {"1", "true", "yes"}
+)
AUDIT_DB_ENV_FILE = Path(
os.environ.get("AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE")
or os.environ.get("POC4_AUDIT_DB_ENV_FILE")
@@ -918,6 +924,8 @@ def collect_security_evidence(
) -> Mapping[str, Any]:
"""Collect predefined, token-scoped security evidence without exposing secrets."""
+ if not REFERENCE_EVIDENCE_ENABLED:
+ return {}
kind = _security_evidence_kind(question)
if not kind or token_preset is None:
return {}
@@ -1482,6 +1490,8 @@ def collect_business_evidence(
) -> Mapping[str, Any]:
"""Collect token-validated, explicitly scoped business and catalog evidence."""
+ if not REFERENCE_EVIDENCE_ENABLED:
+ return {}
kind = _business_evidence_kind(question)
if not kind or token_preset is None:
return {}
@@ -5578,13 +5588,10 @@ def main() -> None:
)
else:
selected_token_preset = None
- selected_preset_bearer = (
- selected_token_preset.token if selected_token_preset is not None else ""
- )
- if configured_mcp_bearer or selected_preset_bearer:
- # The HMM MCP gateway credential is never rendered. Each demo-user
- # preset refers to its runtime env key, allowing secure profile swaps.
- st.caption("MCP 인증: 선택 사용자 preset의 서버 관리 토큰 적용")
+ if configured_mcp_bearer:
+ # The Smilegate MCP gateway uses a server-side credential. UI-only
+ # context must never be forwarded as a second bearer token.
+ st.caption("MCP 인증: 서버 관리 토큰 적용")
manual_bearer_token = ""
elif selected_token_preset is not None:
manual_bearer_token = st.text_input(
@@ -5765,9 +5772,7 @@ def main() -> None:
'
',
unsafe_allow_html=True,
)
- architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs(
- ["아키텍처", "시나리오", "감사로그", "보안관리"]
- )
+ architecture_tab, scenario_tab = st.tabs(["아키텍처", "시나리오"])
with architecture_tab:
_render_architecture_tab()
@@ -5868,17 +5873,6 @@ def main() -> None:
height=0,
)
- with audit_tab:
- render_hmm_audit_tab(
- st,
- _load_hmm_audit_inventory,
- _load_hmm_audit_events,
- AuditLogError,
- )
-
- with operations_tab:
- _render_vpd_operations_tab()
-
if not submitted:
return
diff --git a/ai-web-agent-console/config/app_profile.json b/ai-web-agent-console/config/app_profile.json
index eea68d3..304c152 100644
--- a/ai-web-agent-console/config/app_profile.json
+++ b/ai-web-agent-console/config/app_profile.json
@@ -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"
}
}
diff --git a/ai-web-agent-console/config/mcp_servers.json b/ai-web-agent-console/config/mcp_servers.json
index 3da95f4..a5fd504 100644
--- a/ai-web-agent-console/config/mcp_servers.json
+++ b/ai-web-agent-console/config/mcp_servers.json
@@ -1,23 +1,20 @@
{
- "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_game_text2sql",
"router_model_profile": "gpt54_mini_oci",
"tool_allowlist": [
- "search_hr_data",
- "resolve_hr_term",
- "search_hr_policy",
- "search_carrier_performance"
+ "oracle.select_ai.smilegate_game_text2sql"
],
- "description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server"
+ "description": "Smilegate game-data Text2SQL MCP server"
}
]
}
diff --git a/ai-web-agent-console/config/smilegate_demo_scenarios.json b/ai-web-agent-console/config/smilegate_demo_scenarios.json
new file mode 100644
index 0000000..46ca34a
--- /dev/null
+++ b/ai-web-agent-console/config/smilegate_demo_scenarios.json
@@ -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": "최신 월 기준 게임별 신규 사용자 수를 보여줘"
+ }
+ ]
+}
diff --git a/ai-web-agent-console/config/vpd_token_presets.json b/ai-web-agent-console/config/vpd_token_presets.json
index ecd510d..dae699c 100644
--- a/ai-web-agent-console/config/vpd_token_presets.json
+++ b/ai-web-agent-console/config/vpd_token_presets.json
@@ -1,52 +1,14 @@
{
- "version": 2,
- "description": "HMM HR 데모 사용자 선택 목록입니다. 파일명은 기존 배포 호환성을 위해 유지합니다. token 원문은 저장하지 않고 mcp_token_env의 서버 환경변수만 참조합니다.",
"presets": [
{
- "enabled": true,
+ "enabled": false,
"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명의 휴가·근태 현황을 확인하는 관리자 데모"
- },
- {
- "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일 연차 신청과 휴가 근태를 확인하는 팀원 데모"
+ "token": "vpd_live_REPLACE_WITH_USER_TOKEN",
+ "user_id": "FC00789",
+ "name": "김설계",
+ "role": "설계사",
+ "channel": "설계사",
+ "scope": "본인 담당 계약 고객"
}
]
}
diff --git a/ai-web-agent-console/tests/test_scenarios.py b/ai-web-agent-console/tests/test_scenarios.py
index 7c0b3e6..8a1a034 100644
--- a/ai-web-agent-console/tests/test_scenarios.py
+++ b/ai-web-agent-console/tests/test_scenarios.py
@@ -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()
diff --git a/docs/design/703-smilegate-backoffice-finalization/README.md b/docs/design/703-smilegate-backoffice-finalization/README.md
new file mode 100644
index 0000000..d0f5be2
--- /dev/null
+++ b/docs/design/703-smilegate-backoffice-finalization/README.md
@@ -0,0 +1,76 @@
+# 설계서: 스마일게이트 백오피스 잔여 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 하나만 노출한다. 이전 고객용 토큰 프리셋 및 감사·보안관리 탭은 기본 실행 경로에서 제외한다.
+
+## 설계 결정
+
+### 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 호출 전에 화면 설정 오류로 처리한다.
+
+## 변경 파일과 책임
+
+| 영역 | 파일 | 변경 |
+| --- | --- | --- |
+| 행 접근 화면 | `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`, `SmilegateSelectAiService.java` | 게임 데이터 Select AI 도구, 토큰 검증 및 SHOWSQL 생성 |
+| 보안 스크립트 화면 | `SecuritySqlScriptService.java` | UI에 노출되는 KB 설명을 게임 데이터 설명으로 교체 |
+| Streamlit 외피 | `poc4_active_source_20260714/config/`, `apps/poc4/mcp_discovery_ui.py` | Smilegate 로그인/헤더/시나리오와 단일 게임 Text2SQL MCP 계약 적용 |
+
+## 완료 기준
+
+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 확인을 먼저 수행하고, 배포 시에는 승인된 운영 접속 경로를 사용한다.
diff --git a/docs/design/704-smilegate-branch-isolation/README.md b/docs/design/704-smilegate-branch-isolation/README.md
new file mode 100644
index 0000000..2d1a57b
--- /dev/null
+++ b/docs/design/704-smilegate-branch-isolation/README.md
@@ -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 전용 작업 경로에서만 수행한다.
+- 공통 기반을 변경해야 하면 두 고객 브랜치에 적용하기 전에 영향 범위를 별도 이슈로 검토한다.
diff --git a/vpd-backoffice/pom.xml b/vpd-backoffice/pom.xml
index d49518b..1c3329c 100644
--- a/vpd-backoffice/pom.xml
+++ b/vpd-backoffice/pom.xml
@@ -98,7 +98,8 @@
../database/adb
database/adb
- **/*.sql
+ 70_sg_tool_user.sql
+ 71_sg_identity_administration.sql
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java
index 3b31783..804fcec 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java
@@ -74,16 +74,12 @@ public record BackofficeProperties(
}
}
- /** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */
+ /** Separate ADB connection because Select AI profiles are owned by SGMP_POC. */
public record SelectAi(
String dbUrl,
String dbUsername,
String dbPassword,
- String profile,
- String runtimeDbUrl,
- String runtimeDbUsername,
- String runtimeDbPassword,
- String queryContractFile
+ String profile
) {
public boolean configured() {
@@ -92,12 +88,5 @@ public record BackofficeProperties(
&& dbPassword != null && !dbPassword.isBlank()
&& profile != null && !profile.isBlank();
}
-
- /** The generated SQL must never fall back to the privileged profile-owner connection. */
- public boolean runtimeConfigured() {
- return runtimeDbUrl != null && !runtimeDbUrl.isBlank()
- && runtimeDbUsername != null && !runtimeDbUsername.isBlank()
- && runtimeDbPassword != null && !runtimeDbPassword.isBlank();
- }
}
}
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java
index 1ea66b1..85e1faa 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java
@@ -51,6 +51,6 @@ public class DbPoolWarmup {
groupService.findGroupRoles();
permissionService.findRoles();
permissionService.findPermissionViews();
- log.info("HMM identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
+ log.info("Smilegate identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
}
}
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java
index a46e5d1..5c74db0 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java
@@ -27,10 +27,10 @@ public enum MaskingTemplate {
"A******** (예시)"),
RRN_PARTIAL(
"RRN_PARTIAL",
- "주민등록번호 부분 마스킹",
- "앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.",
+ "식별번호 부분 마스킹",
+ "앞 6자리만 표시하고 나머지를 가리는 사전 정의 식별번호 규칙입니다.",
"DBMS_REDACT.REGEXP",
- "900101-******* (예시)");
+ "123456-******* (예시)");
private final String code;
private final String label;
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java
index 2409b08..9cf213d 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java
@@ -50,16 +50,16 @@ public record PermissionView(
return "토큰으로 식별된 이해관계자 본인 행";
}
if (upper.contains(" OWN_CONTRACT")) {
- return "담당 설계사 본인 계약";
+ return "담당 게임 서비스 범위";
}
if (upper.contains(" CHANNEL_CONTRACT")) {
- return "토큰 사용자의 채널 계약";
+ return "토큰 채널 게임 서비스 범위";
}
if (upper.contains(" OWN_CUSTOMER")) {
- return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유";
+ return "담당 게임 사용자·거래 데이터 범위";
}
if (upper.contains(" CHANNEL_CUSTOMER")) {
- return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유";
+ return "토큰 채널 게임 사용자·거래 데이터 범위";
}
if (upper.contains(" STATIC_SQL ")) {
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java
index 173901d..5f88dfc 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java
@@ -258,8 +258,8 @@ public class BackofficeSchemaService {
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
- new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
- "앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
+ new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
+ "앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
);
private static final String MASKING_RULE_SEED_SQL = """
@@ -707,16 +707,21 @@ public class BackofficeSchemaService {
private String sqlclScript(String currentUser) {
String owner = currentUser == null || currentUser.isBlank() ? "ADMIN" : currentUser;
return """
- -- sqlcl에서 실행할 HMM VPD runtime 준비 순서
- -- 1. ADMIN으로 접속
- @database/adb/25_agent_ords_security_backoffice_support.sql
- @database/adb/72_hmm_leave_team_vpd.sql
+ -- sqlcl에서 실행할 VPD/ORDS runtime 준비 순서
+ -- 1. ADMIN 또는 보호 객체 owner로 접속
+ @sql/adb/17_agent_ords_security_local_vpd_setup.sql
+ @sql/adb/25_agent_ords_security_backoffice_support.sql
+ @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
+ @sql/adb/71_sg_identity_administration.sql
+ @sql/adb/21_agent_ords_security_ords_enable_schema.sql
-- 2. 비면제 업무 runtime 사용자에 필요한 최소 권한
CONNECT %s/@
- GRANT EXECUTE ON hmm_access_ctx_pkg TO ;
- GRANT SELECT ON hmm_leave_balances TO ;
- GRANT SELECT ON hmm_leave_requests TO ;
+ GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
+ GRANT SELECT ON . TO cb_ords;
+
+ -- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결
+ -- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다.
""".formatted(owner.toLowerCase());
}
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java
index 04c6b28..b40709c 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java
@@ -23,6 +23,7 @@ import org.springframework.stereotype.Service;
@Service
public class MaskingPolicySynchronizer {
+ private static final String OWNER = "SGMP_POC";
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private final JdbcTemplate jdbcTemplate;
@@ -38,8 +39,15 @@ public class MaskingPolicySynchronizer {
) {
this.jdbcTemplate = jdbcTemplate;
this.mapper = mapper;
- this.dataCatalog = dataCatalog;
- this.policyCatalog = policyCatalog;
+ }
+
+ private static Map managedPolicyMap() {
+ Map policies = new LinkedHashMap<>();
+ policies.put("CZN_COMN_USER_MST", "SG_CZN_USER_REDACT");
+ policies.put("COMN_SALES_USER_MST", "SG_SALES_USER_REDACT");
+ policies.put("COMN_SALES_TXN", "SG_SALES_TXN_REDACT");
+ policies.put("COMN_REFUND_TXN", "SG_REFUND_TXN_REDACT");
+ return Collections.unmodifiableMap(policies);
}
public Set managedObjectNames() {
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java
index b520f56..70df7d5 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java
@@ -55,7 +55,7 @@ public class MaskingRuleService {
return mapper.findColumnRules();
}
- /** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */
+ /** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */
public List findPolicyStatuses() {
List policies = maskingPolicySynchronizer.managedPolicies();
if (policies.isEmpty()) {
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
index 7c2705b..a563acd 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
@@ -1,7 +1,5 @@
package com.cloudhandson.vpdbackoffice.service;
-import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
-import com.cloudhandson.vpdbackoffice.config.McpProperties;
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -10,35 +8,29 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.springframework.stereotype.Service;
-/** Authenticated MCP boundary backed by the deployment-provided tool allow-list. */
+/** MCP boundary exposing the Smilegate game-data Select AI SHOWSQL tool. */
@Service
public class McpSseService {
- private static final String MCP_CALL_PATH = "/mcp (tools/call)";
+ private static final String SELECT_AI_VPD_QUERY_TOOL = "oracle.select_ai.smilegate_game_text2sql";
+ private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)";
+ private static final String SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45";
+ private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
+ SELECT_AI_VPD_QUERY_TOOL,
+ "SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성합니다. 생성 SQL은 자동 실행하지 않으며 테이블·컬럼 comment, annotation, constraint를 참고합니다.",
+ -1L,
+ "Smilegate 게임 데이터 Text2SQL",
+ SELECT_AI_VPD_QUERY_PATH
+ );
- private final HmmAiAgentToolRunner agentToolRunner;
- private final SelectAiService selectAiService;
- private final HmmMcpBearerAuthenticator bearerAuthenticator;
- private final McpToolCatalog toolCatalog;
- private final McpProperties mcpProperties;
- private final BackofficeProperties backofficeProperties;
+ private final SmilegateSelectAiService smilegateSelectAiService;
private final ObjectMapper objectMapper;
public McpSseService(
- HmmAiAgentToolRunner agentToolRunner,
- SelectAiService selectAiService,
- HmmMcpBearerAuthenticator bearerAuthenticator,
- McpToolCatalog toolCatalog,
- McpProperties mcpProperties,
- BackofficeProperties backofficeProperties,
+ SmilegateSelectAiService smilegateSelectAiService,
ObjectMapper objectMapper
) {
- this.agentToolRunner = agentToolRunner;
- this.selectAiService = selectAiService;
- this.bearerAuthenticator = bearerAuthenticator;
- this.toolCatalog = toolCatalog;
- this.mcpProperties = mcpProperties;
- this.backofficeProperties = backofficeProperties;
+ this.smilegateSelectAiService = smilegateSelectAiService;
this.objectMapper = objectMapper;
}
@@ -46,57 +38,47 @@ public class McpSseService {
return handle(contextPath, request, "");
}
- /** Discovery and execution use the same HMM business-user Bearer token boundary. */
- public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) {
- bearerAuthenticator.authenticate(bearerToken);
+ /**
+ * The HTTP bearer token is the business-user subject token; no separate MCP token is used.
+ */
+ public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) {
ObjectNode response = objectMapper.createObjectNode();
response.put("jsonrpc", "2.0");
if (request != null && request.has("id")) {
response.set("id", request.get("id"));
}
- String method =
- request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
- JsonNode parameters =
- request == null ? objectMapper.createObjectNode() : request.path("params");
+ String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
+ JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params");
try {
response.set("result", switch (method) {
case "initialize" -> initializeResult(contextPath);
case "notifications/initialized" -> objectMapper.createObjectNode();
case "tools/list" -> toolsListResult();
- case "tools/call" -> toolsCallResult(parameters, bearerToken);
+ case "tools/call" -> toolsCallResult(parameters, vpdBearerToken);
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
});
- } catch (McpUnauthorizedException exception) {
- throw exception;
- } catch (Exception exception) {
+ } catch (Exception e) {
response.remove("result");
ObjectNode error = objectMapper.createObjectNode();
error.put("code", -32000);
- error.put("message", safeMessage(exception));
+ error.put("message", e.getMessage());
response.set("error", error);
}
return response;
}
+ /** The only tool registered by this MCP server. */
public List registeredTools() {
- return toolCatalog.tools().stream()
- .map(tool -> new McpToolView(
- tool.name(),
- tool.description(),
- -1L,
- tool.label(),
- tool.agentTool() ? tool.targetName() : MCP_CALL_PATH
- ))
- .toList();
+ return List.of(SELECT_AI_VPD_QUERY_VIEW);
}
private ObjectNode initializeResult(String contextPath) {
ObjectNode result = objectMapper.createObjectNode();
result.put("protocolVersion", "2024-11-05");
ObjectNode serverInfo = objectMapper.createObjectNode();
- serverInfo.put("name", mcpProperties.resolvedServerName() + "-" + contextPath);
- serverInfo.put("version", "1.1.0");
+ serverInfo.put("name", "vpd-ords-backoffice-" + contextPath);
+ serverInfo.put("version", "0.1.0");
result.set("serverInfo", serverInfo);
ObjectNode capabilities = objectMapper.createObjectNode();
capabilities.set("tools", objectMapper.createObjectNode());
@@ -107,60 +89,58 @@ public class McpSseService {
private ObjectNode toolsListResult() {
ObjectNode result = objectMapper.createObjectNode();
ArrayNode tools = objectMapper.createArrayNode();
- toolCatalog.tools().forEach(tool -> tools.add(toolDefinition(tool)));
+ tools.add(selectAiVpdQueryTool());
result.set("tools", tools);
return result;
}
- private ObjectNode toolDefinition(McpToolDefinition tool) {
+ private ObjectNode selectAiVpdQueryTool() {
ObjectNode item = objectMapper.createObjectNode();
- item.put("name", tool.name());
- item.put("description", tool.description());
+ item.put("name", SELECT_AI_VPD_QUERY_TOOL);
+ item.put("description", SELECT_AI_VPD_QUERY_VIEW.description());
+
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
- ObjectNode argument = objectMapper.createObjectNode();
- argument.put("type", "string");
- argument.put("description", tool.argumentDescription());
- argument.put("maxLength", 4000);
- properties.set(tool.argumentName(), argument);
+
+ ObjectNode prompt = objectMapper.createObjectNode();
+ prompt.put("type", "string");
+ prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
+ prompt.put("maxLength", 4000);
+ properties.set("prompt", prompt);
+
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
- required.add(tool.argumentName());
+ required.add("prompt");
schema.set("required", required);
schema.put("additionalProperties", false);
item.set("inputSchema", schema);
return item;
}
- private ObjectNode toolsCallResult(JsonNode params, String bearerToken) {
- McpToolDefinition tool = toolCatalog.require(params.path("name").asText(""));
- String argument = params.path("arguments").path(tool.argumentName()).asText("").trim();
- if (argument.isBlank()) {
- throw new AppException(tool.argumentName() + " 입력값은 비워둘 수 없습니다.");
+ private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
+ String toolName = params.path("name").asText("");
+ if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
+ throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
}
- JsonNode toolResponse;
- if (tool.agentTool()) {
- ObjectNode input = objectMapper.createObjectNode();
- input.put(tool.targetParameterName(), argument);
- toolResponse = agentToolRunner.run(tool.targetName(), input, bearerToken);
- } else {
- toolResponse = selectAiService.generateAndExecute(bearerToken, argument);
+ JsonNode arguments = params.path("arguments");
+ String token = vpdBearerToken == null ? "" : vpdBearerToken.trim();
+ if (token.isBlank()) {
+ return tokenAccessDeniedResult();
+ }
+ JsonNode response;
+ try {
+ response = smilegateSelectAiService.generateShowSql(token, arguments.path("prompt").asText(""));
+ } catch (VpdTokenAccessDeniedException ignored) {
+ return tokenAccessDeniedResult();
}
ObjectNode payload = objectMapper.createObjectNode();
- payload.put("toolName", tool.name());
- payload.put("executionType", tool.executionType());
- if (tool.agentTool()) {
- payload.put("agentTool", tool.targetName());
- } else {
- BackofficeProperties.SelectAi selectAi =
- backofficeProperties == null ? null : backofficeProperties.selectAi();
- payload.put("profile", selectAi == null || selectAi.profile() == null
- ? "" : selectAi.profile());
- }
- payload.set("response", toolResponse);
+ payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
+ payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE);
+ payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
+ payload.set("response", response);
ObjectNode result = objectMapper.createObjectNode();
ArrayNode content = objectMapper.createArrayNode();
@@ -173,15 +153,26 @@ public class McpSseService {
return result;
}
- private String safeMessage(Exception exception) {
- String message = exception.getMessage();
- return message == null || message.isBlank() ? "MCP 요청 처리에 실패했습니다." : message;
+ private ObjectNode tokenAccessDeniedResult() {
+ ObjectNode payload = objectMapper.createObjectNode();
+ payload.put("status", "VPD_TOKEN_DENIED");
+ payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
+
+ ObjectNode result = objectMapper.createObjectNode();
+ ArrayNode content = objectMapper.createArrayNode();
+ ObjectNode text = objectMapper.createObjectNode();
+ text.put("type", "text");
+ text.put("text", pretty(payload));
+ content.add(text);
+ result.set("content", content);
+ result.put("isError", true);
+ return result;
}
private String pretty(Object value) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
- } catch (Exception exception) {
+ } catch (Exception e) {
return String.valueOf(value);
}
}
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java
index 25b569e..a7f4d21 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java
@@ -643,7 +643,7 @@ public class OrdsProbeService {
}
}
}
- // APEX_JSON omits a ref-cursor column whose value is NULL. The KB
+ // APEX_JSON omits a ref-cursor column whose value is NULL. The
// object handlers always select every registered protected column, so
// a sensitive column missing from every JSON row is also a NULL result
// and must be reported as masked to the MCP/UI caller.
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java
index 61dabdb..92b3f50 100644
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java
@@ -1,43 +1,41 @@
package com.cloudhandson.vpdbackoffice.service;
-import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
/**
* Read-only catalogue of security deployment SQL bundled from the Git-tracked
- * database/adb directory. Script ids are an application whitelist: request input
+ * sql/adb directory. Script ids are an application whitelist: request input
* never becomes a filesystem or classpath path.
*/
@Service
public class SecuritySqlScriptService {
- private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}");
- private static final Pattern RESOURCE_PATH = Pattern.compile(
- "(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql"
+ private static final List CURATED_SCRIPTS = List.of(
+ new ScriptDefinition(
+ "smilegate-tool-users",
+ "Smilegate 사용자",
+ "70_sg_tool_user.sql",
+ "PoC 도구 사용자 초기 데이터",
+ "Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다."
+ ),
+ new ScriptDefinition(
+ "smilegate-identity-administration",
+ "Smilegate 권한",
+ "71_sg_identity_administration.sql",
+ "사용자·그룹·역할 관리 모델",
+ "Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다."
+ )
);
- private final List scripts;
-
- public SecuritySqlScriptService(
- SecuritySqlScriptProperties properties,
- ObjectMapper objectMapper
- ) {
- scripts = parse(properties.scripts(), objectMapper);
- }
public List list() {
- return scripts.stream()
+ return CURATED_SCRIPTS.stream()
.map(definition -> new SecuritySqlScriptSummary(
definition.scriptId(),
definition.category(),
@@ -49,7 +47,7 @@ public class SecuritySqlScriptService {
}
public SecuritySqlScript find(String scriptId) {
- ScriptDefinition definition = scripts.stream()
+ ScriptDefinition definition = CURATED_SCRIPTS.stream()
.filter(candidate -> candidate.scriptId().equals(scriptId))
.findFirst()
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
@@ -64,7 +62,7 @@ public class SecuritySqlScriptService {
}
private String readSource(String fileName) {
- ClassPathResource resource = new ClassPathResource("database/adb/" + fileName);
+ ClassPathResource resource = new ClassPathResource("sql/adb/" + fileName);
try (InputStream input = resource.getInputStream()) {
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException exception) {
@@ -72,48 +70,7 @@ public class SecuritySqlScriptService {
}
}
- private List parse(String raw, ObjectMapper objectMapper) {
- if (raw == null || raw.isBlank()) {
- return List.of();
- }
- try {
- List parsed = objectMapper.readValue(raw, new TypeReference<>() {});
- if (parsed.isEmpty()) {
- throw new IllegalArgumentException("보안 SQL 목록이 비어 있습니다.");
- }
- Set scriptIds = new HashSet<>();
- Set fileNames = new HashSet<>();
- parsed.forEach(definition -> {
- validate(definition);
- if (!scriptIds.add(definition.scriptId())) {
- throw new IllegalArgumentException("중복 scriptId");
- }
- if (!fileNames.add(definition.fileName())) {
- throw new IllegalArgumentException("중복 fileName");
- }
- });
- return List.copyOf(parsed);
- } catch (Exception exception) {
- throw new IllegalStateException("BACKOFFICE_SECURITY_SQL_SCRIPTS 설정을 확인하세요.", exception);
- }
- }
-
- private void validate(ScriptDefinition definition) {
- if (definition == null
- || definition.scriptId() == null || !SCRIPT_ID.matcher(definition.scriptId()).matches()
- || definition.fileName() == null || !RESOURCE_PATH.matcher(definition.fileName()).matches()
- || blank(definition.category())
- || blank(definition.title())
- || blank(definition.description())) {
- throw new IllegalArgumentException("보안 SQL 정의가 올바르지 않습니다.");
- }
- }
-
- private boolean blank(String value) {
- return value == null || value.isBlank();
- }
-
- public record ScriptDefinition(
+ private record ScriptDefinition(
String scriptId,
String category,
String fileName,
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java
deleted file mode 100644
index bec7d62..0000000
--- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java
+++ /dev/null
@@ -1,137 +0,0 @@
-package com.cloudhandson.vpdbackoffice.service;
-
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ObjectNode;
-import java.net.URI;
-import java.util.UUID;
-import org.springframework.http.HttpEntity;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.http.HttpStatus;
-import org.springframework.stereotype.Service;
-import org.springframework.web.client.HttpStatusCodeException;
-import org.springframework.web.client.ResourceAccessException;
-import org.springframework.web.client.RestTemplate;
-import org.springframework.web.util.UriComponentsBuilder;
-
-/** Calls the ORDS boundary; the database endpoint owns bearer-to-row-access-context mapping. */
-@Service
-public class SelectAiAgentOrdsService {
-
- static final String ORDS_PATH = "/cb-ords/kb-select-ai-vpd/query";
- private static final int MAX_PROMPT_LENGTH = 4_000;
- private static final int MAX_LIMIT = 100;
-
- private final SettingService settingService;
- private final RestTemplate restTemplate;
- private final ObjectMapper objectMapper;
-
- public SelectAiAgentOrdsService(
- SettingService settingService,
- RestTemplate ordsAgentRestTemplate,
- ObjectMapper objectMapper
- ) {
- this.settingService = settingService;
- this.restTemplate = ordsAgentRestTemplate;
- this.objectMapper = objectMapper;
- }
-
- /**
- * Compatibility overload for callers of the former SQL-generation tool.
- * The row-access query endpoint is stateless and therefore ignores conversationId.
- */
- public JsonNode run(String bearerToken, String prompt, String conversationId) {
- return run(bearerToken, prompt, 50);
- }
-
- public JsonNode run(String bearerToken, String prompt, int limit) {
- String normalizedToken = required(bearerToken, "bearerToken");
- String normalizedPrompt = required(prompt, "prompt");
- if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) {
- throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
- }
- int normalizedLimit = normalizeLimit(limit);
- String baseUrl = settingService.ordsBaseUrl();
- if (baseUrl == null || baseUrl.isBlank()) {
- throw new AppException("ORDS base URL이 설정되지 않았습니다.");
- }
-
- ObjectNode requestBody = objectMapper.createObjectNode();
- requestBody.put("prompt", normalizedPrompt);
- requestBody.put("limit", normalizedLimit);
-
- HttpHeaders headers = new HttpHeaders();
- headers.setBearerAuth(normalizedToken);
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-VPD-Probe-Id", UUID.randomUUID().toString());
-
- try {
- ResponseEntity response = restTemplate.exchange(
- endpoint(baseUrl),
- HttpMethod.POST,
- new HttpEntity<>(requestBody.toString(), headers),
- String.class
- );
- JsonNode body = parse(response.getBody());
- if (body.hasNonNull("error")) {
- throw new AppException("Select AI 행 접근 ORDS 오류: " + body.path("error").asText());
- }
- return body;
- } catch (HttpStatusCodeException e) {
- if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)
- || e.getStatusCode().isSameCodeAs(HttpStatus.FORBIDDEN)) {
- throw new VpdTokenAccessDeniedException();
- }
- throw new AppException("Select AI 행 접근 ORDS HTTP " + e.getStatusCode().value()
- + ": " + responseError(e.getResponseBodyAsString()));
- } catch (ResourceAccessException e) {
- throw new AppException("Select AI 행 접근 ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
- }
- }
-
- private URI endpoint(String baseUrl) {
- return UriComponentsBuilder.fromUriString(baseUrl)
- .path(ORDS_PATH)
- .build()
- .toUri();
- }
-
- private JsonNode parse(String value) {
- try {
- if (value == null || value.isBlank()) {
- throw new AppException("Select AI 행 접근 ORDS 응답 본문이 비어 있습니다.");
- }
- return objectMapper.readTree(value);
- } catch (AppException e) {
- throw e;
- } catch (Exception e) {
- throw new AppException("Select AI 행 접근 ORDS 응답 JSON 파싱 실패: " + e.getMessage());
- }
- }
-
- private String responseError(String body) {
- try {
- JsonNode parsed = objectMapper.readTree(body);
- return parsed.path("error").asText(body == null ? "" : body);
- } catch (Exception ignored) {
- return body == null ? "" : body;
- }
- }
-
- private String required(String value, String name) {
- if (value == null || value.isBlank()) {
- throw new AppException(name + "은(는) 필수입니다.");
- }
- return value.trim();
- }
-
- private int normalizeLimit(int value) {
- if (value < 1) {
- return 50;
- }
- return Math.min(value, MAX_LIMIT);
- }
-}
diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java
new file mode 100644
index 0000000..dc367c5
--- /dev/null
+++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java
@@ -0,0 +1,125 @@
+package com.cloudhandson.vpdbackoffice.service;
+
+import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
+import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import org.springframework.stereotype.Service;
+
+/**
+ * Generates reviewed read-only SQL through the schema-owned Smilegate Select AI profile.
+ * The generated SQL is never executed by this service.
+ */
+@Service
+public class SmilegateSelectAiService {
+
+ private static final int MAX_PROMPT_LENGTH = 4_000;
+ private static final String ACTION_SHOWSQL = "showsql";
+
+ private final BackofficeProperties properties;
+ private final BearerTokenService bearerTokenService;
+ private final Clock clock;
+ private final ObjectMapper objectMapper;
+
+ public SmilegateSelectAiService(
+ BackofficeProperties properties,
+ BearerTokenService bearerTokenService,
+ Clock clock,
+ ObjectMapper objectMapper
+ ) {
+ this.properties = properties;
+ this.bearerTokenService = bearerTokenService;
+ this.clock = clock;
+ this.objectMapper = objectMapper;
+ }
+
+ public JsonNode generateShowSql(String bearerToken, String prompt) {
+ requireActiveToken(bearerToken);
+ String normalizedPrompt = requiredPrompt(prompt);
+ BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
+ if (selectAi == null || !selectAi.configured()) {
+ throw new AppException("Smilegate Select AI 연결 설정이 필요합니다. "
+ + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ + "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
+ }
+
+ String generatedSql = generate(selectAi, normalizedPrompt);
+ String normalizedSql = validateReadOnlySql(generatedSql);
+ ObjectNode response = objectMapper.createObjectNode();
+ response.put("status", "SHOWSQL");
+ response.put("profile", selectAi.profile());
+ response.put("generatedSql", normalizedSql);
+ response.put("execution", "NOT_EXECUTED");
+ response.put("nextStep", "생성 SQL을 검토한 뒤 Database Actions 또는 승인된 실행 경로에서 실행하세요.");
+ return response;
+ }
+
+ private void requireActiveToken(String bearerToken) {
+ if (bearerToken == null || bearerToken.isBlank()) {
+ throw new VpdTokenAccessDeniedException();
+ }
+ BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
+ LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
+ if (token == null || !token.active(now)) {
+ throw new VpdTokenAccessDeniedException();
+ }
+ }
+
+ private String requiredPrompt(String prompt) {
+ String normalized = prompt == null ? "" : prompt.trim();
+ if (normalized.isEmpty()) {
+ throw new AppException("prompt는 필수입니다.");
+ }
+ if (normalized.length() > MAX_PROMPT_LENGTH) {
+ throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
+ }
+ return normalized;
+ }
+
+ private String generate(BackofficeProperties.SelectAi selectAi, String prompt) {
+ String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual";
+ try (Connection connection = DriverManager.getConnection(
+ selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, prompt);
+ statement.setString(2, selectAi.profile());
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (!resultSet.next() || resultSet.getString(1) == null) {
+ throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다.");
+ }
+ return resultSet.getString(1);
+ }
+ } catch (AppException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ throw new AppException("Smilegate Select AI SHOWSQL 생성 실패: " + exception.getMessage());
+ }
+ }
+
+ private String validateReadOnlySql(String generatedSql) {
+ String normalized = generatedSql == null ? "" : generatedSql.trim();
+ if (normalized.startsWith("```")) {
+ int firstLineEnd = normalized.indexOf('\n');
+ int closingFence = normalized.lastIndexOf("```");
+ if (firstLineEnd >= 0 && closingFence > firstLineEnd) {
+ normalized = normalized.substring(firstLineEnd + 1, closingFence).trim();
+ }
+ }
+ normalized = normalized.replaceFirst(";\\s*$", "").trim();
+ if (!normalized.matches("(?is)^(select|with)\\b.*")) {
+ throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
+ }
+ if (normalized.contains(";")) {
+ throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
+ }
+ return normalized;
+ }
+}
diff --git a/vpd-backoffice/src/main/resources/application.yml b/vpd-backoffice/src/main/resources/application.yml
index 6824731..b2c1c34 100644
--- a/vpd-backoffice/src/main/resources/application.yml
+++ b/vpd-backoffice/src/main/resources/application.yml
@@ -69,33 +69,9 @@ backoffice:
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
select-ai:
- # Profile-owner connection: SHOWSQL generation only.
- db-url: ${BACKOFFICE_SELECT_AI_DB_URL:${BACKOFFICE_DB_URL:}}
- db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:${BACKOFFICE_DB_USERNAME:}}
- db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:${BACKOFFICE_DB_PASSWORD:}}
- profile: ${BACKOFFICE_SELECT_AI_PROFILE:}
- # Non-EXEMPT execution boundary: no fallback to the profile owner is allowed.
- runtime-db-url: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_URL:}
- runtime-db-username: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME:}
- runtime-db-password: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD:}
- # Customer/project query semantics are external JSON, never Java constants.
- query-contract-file: ${BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE:}
- catalog:
- owner: ${BACKOFFICE_CATALOG_OWNER:}
- objects: ${BACKOFFICE_CATALOG_OBJECTS:}
- product:
- name: ${BACKOFFICE_PRODUCT_NAME:Data & AI Backoffice}
- title: ${BACKOFFICE_PRODUCT_TITLE:Data & AI Backoffice}
- data-label: ${BACKOFFICE_PRODUCT_DATA_LABEL:업무 데이터}
- mcp:
- public-url: ${BACKOFFICE_MCP_PUBLIC_URL:${BACKOFFICE_HMM_MCP_PUBLIC_URL:/mcp}}
- server-name: ${BACKOFFICE_MCP_SERVER_NAME:data-ai-backoffice}
- tool-name: ${BACKOFFICE_MCP_TOOL_NAME:oracle.select_ai.data_text2sql}
- tool-label: ${BACKOFFICE_MCP_TOOL_LABEL:업무 데이터 Text2SQL}
- tool-description: ${BACKOFFICE_MCP_TOOL_DESCRIPTION:승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다.}
- prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.}
- tools: ${BACKOFFICE_MCP_TOOLS:}
- masking:
- policies: ${BACKOFFICE_MASKING_POLICIES:}
- security-sql-scripts:
- scripts: ${BACKOFFICE_SECURITY_SQL_SCRIPTS:}
+ # Cloud AI profiles are schema-owned. This connection must use SGMP_POC,
+ # not the ADMIN connection used by the backoffice control plane.
+ db-url: ${BACKOFFICE_SELECT_AI_DB_URL:}
+ db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
+ db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
+ profile: ${BACKOFFICE_SELECT_AI_PROFILE:SGMP_POC_HAIKU45}
diff --git a/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml b/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml
index e68c641..306c78c 100644
--- a/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml
+++ b/vpd-backoffice/src/main/resources/mapper/AuditMapper.xml
@@ -3,9 +3,10 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
- INSERT INTO hmm_access_audit (
- event_type, key_id, object_id, status, row_count, error_code, message, created_at
+ INSERT INTO sg_audit_event (
+ audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
) VALUES (
+ sg_audit_event_seq.NEXTVAL,
#{eventType,jdbcType=VARCHAR},
#{keyId,jdbcType=NUMERIC},
#{objectId,jdbcType=NUMERIC},
diff --git a/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml b/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml
index 6fd2bdc..23a563c 100644
--- a/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml
+++ b/vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml
@@ -68,20 +68,19 @@
-->