Compare commits

77 Commits

Author SHA1 Message Date
devmrko
8b04fb4e89 refs #708: benchmark Llama game scope profile 2026-07-29 10:07:20 +09:00
devmrko
271f764f45 refs #739: formalize game query plan contract 2026-07-29 10:07:20 +09:00
devmrko
9d2c75459f refs #740: add Few-shot management tab 2026-07-28 15:46:53 +09:00
devmrko
041ab4f287 refs #739: separate catalog and fact availability 2026-07-28 13:39:33 +09:00
devmrko
98c8ccde85 refs #739: generalize game target profile guidance 2026-07-28 13:25:00 +09:00
devmrko
987ea44f32 refs #739: guide no-target few-shot SQL generation 2026-07-28 12:49:46 +09:00
devmrko
5409a29297 refs #739: seed verified Smilegate QA few-shot candidates 2026-07-28 12:34:40 +09:00
devmrko
154572c885 refs #739: govern Smilegate few-shot references 2026-07-28 12:17:21 +09:00
devmrko
6d98931ce1 refs #739: normalize MCP query plan envelopes 2026-07-28 10:31:20 +09:00
devmrko
d13d6d48ca refs #739: unify Smilegate game target planning 2026-07-28 10:09:12 +09:00
devmrko
57f6677afb refs #737: align STD-06 partial success baseline 2026-07-27 16:51:05 +09:00
devmrko
00a939f9a1 refs #737: move game query planning into ADB MCP tool 2026-07-27 16:39:58 +09:00
devmrko
8dffa2f515 refs #737: preserve unmatched game mentions in query plan 2026-07-27 16:25:33 +09:00
devmrko
3cc033e4e6 refs #737: align MCP tests with game planning tools 2026-07-27 16:19:03 +09:00
devmrko
f31292ecb3 refs #731: document game scope deployment contract 2026-07-27 16:13:50 +09:00
devmrko
040d4f3d4b remove unconfigured scope bypass 2026-07-27 14:54:52 +09:00
devmrko
fbb04a55a2 allow plan context when optional scope view unavailable 2026-07-27 14:54:10 +09:00
devmrko
956693bb23 return structured next action for game query plan 2026-07-27 14:49:30 +09:00
devmrko
ec5d731bf5 pass previous tool context into few-shot query 2026-07-27 14:47:37 +09:00
devmrko
3a7e2ced00 mark approved game plan candidates as supported 2026-07-27 14:34:51 +09:00
devmrko
55faaa0794 remove hardcoded game scope policy from prompt 2026-07-27 14:33:30 +09:00
devmrko
10581dc605 add game query plan MCP tool 2026-07-27 14:23:17 +09:00
devmrko
beaef2d171 chain ADB chat game extraction before vector resolution 2026-07-27 14:14:20 +09:00
devmrko
ed3139f9f7 add OCI GenAI game mention extraction function 2026-07-27 14:12:37 +09:00
devmrko
bd651eb66f inject game catalog context into few-shot prompt 2026-07-27 13:58:37 +09:00
devmrko
2e26e3261f implement game catalog vector MCP resolver 2026-07-27 13:54:49 +09:00
devmrko
97cd5c9f25 add game catalog resolver MCP tool 2026-07-27 13:51:56 +09:00
devmrko
7ddb8ef0fc add metadata-driven game catalog vector schema 2026-07-27 13:47:30 +09:00
devmrko
455e2aa222 refs #731: expose database game scope MCP 2026-07-27 13:43:59 +09:00
devmrko
a17f18cffe refs #731: add DB game scope metadata 2026-07-27 13:43:37 +09:00
devmrko
943c38f181 derive unresolved scope response from resolver status 2026-07-27 13:30:51 +09:00
devmrko
443d6677a3 add metadata-backed game scope resolver 2026-07-27 13:04:43 +09:00
devmrko
9c48d86696 generalize game scope guidance through metadata few-shot 2026-07-27 13:03:33 +09:00
devmrko
c58cdb5797 refs #736: route Smilegate portal to few-shot MCP 2026-07-27 10:01:44 +09:00
devmrko
e753027536 refs #735: add few-shot NL2SQL MCP tool 2026-07-24 16:21:01 +09:00
devmrko
da992ff524 refs #731: guard missing game identifiers in few-shot prompt 2026-07-24 16:14:18 +09:00
devmrko
00b62bfb41 refs #731: preserve Select AI configuration binding 2026-07-24 16:09:02 +09:00
devmrko
d552e5e25b refs #734: add validated annotation PL/SQL API 2026-07-24 16:03:50 +09:00
devmrko
3fcd699d92 refs #731: enrich Text2SQL prompts with QA examples 2026-07-24 15:59:41 +09:00
devmrko
c8fa6af679 refs #731: fix autonomous vector example storage 2026-07-24 15:53:47 +09:00
devmrko
8e4d2f656f refs #731: allow QA vector MCP tools in portal 2026-07-24 14:45:08 +09:00
devmrko
df19912378 refs #731: add QA vector MCP tools 2026-07-24 14:41:00 +09:00
devmrko
744dcad634 feat: add dedicated Cohere Embed 4 QA vector retrieval 2026-07-24 14:23:33 +09:00
devmrko
23bc4424e3 refs #726: add Select AI SHOWPROMPT diagnostic tool 2026-07-23 20:27:14 +09:00
devmrko
53342e7bc3 refs #722: externalize backoffice customer configuration 2026-07-23 19:14:37 +09:00
devmrko
7ba173240c refs #708: report configured Smilegate Select AI profile 2026-07-23 14:06:16 +09:00
devmrko
ebc8aeee41 refs #710: remove POC4 URL remember tokens 2026-07-23 14:03:20 +09:00
devmrko
75d0a9a998 refs #708: move Smilegate Select AI to OCI GPT 5.4 Mini 2026-07-23 13:45:55 +09:00
devmrko
32fccced20 refs #703: avoid blocking Smilegate backoffice rendering 2026-07-23 13:04:28 +09:00
devmrko
a46ebe6bfd refs #703: force Smilegate PoC HTTP 1.1 2026-07-23 12:57:59 +09:00
devmrko
1771dccd96 refs #703: use gzip for Smilegate backoffice 2026-07-23 12:50:45 +09:00
devmrko
cbee6c31c7 refs #703: fix Smilegate annotation metadata query 2026-07-23 10:46:11 +09:00
devmrko
f730c73ceb refs #703: log schema metadata lookup failures 2026-07-23 10:38:02 +09:00
devmrko
b106e40631 refs #703: route schema metadata through MyBatis 2026-07-23 10:29:22 +09:00
devmrko
99798aa6bb refs #706: add Smilegate QA history benchmark 2026-07-23 10:12:36 +09:00
devmrko
1fe8404e4b refs #703: cover Streamlit expander dark theme internals 2026-07-23 09:38:10 +09:00
devmrko
1fcc231eff refs #703: fix dark mode expander contrast 2026-07-22 20:53:02 +09:00
devmrko
63f294a857 refs #703: enforce chat answer contrast in dark mode 2026-07-22 20:49:28 +09:00
devmrko
20836e458d refs #703: fix dark mode secondary button contrast 2026-07-22 20:36:55 +09:00
devmrko
9eb1f4d1fd refs #703: preserve read-only transaction for Text2SQL 2026-07-22 20:19:47 +09:00
devmrko
914dc087ed refs #703: execute validated Smilegate Text2SQL 2026-07-22 20:15:10 +09:00
devmrko
27757ebff3 refs #703: configure OCI GenAI root compartment 2026-07-22 20:07:47 +09:00
devmrko
4211f11cae refs #703: include OCI SDK for console runtime 2026-07-22 16:29:37 +09:00
devmrko
a91ad3c364 refs #703: fix MCP detail contrast 2026-07-22 16:26:44 +09:00
devmrko
90d76f1033 refs #703: configure smilegate mcp token presets 2026-07-22 16:16:57 +09:00
devmrko
211dd2b2c8 refs #703 #704: finalize smilegate game data poc 2026-07-22 15:35:02 +09:00
devmrko
20c6a82338 fix(backoffice): persist login until logout 2026-07-22 14:29:42 +09:00
devmrko
b3f2422b63 fix(backoffice): remove KB copy from game data screens 2026-07-22 14:28:41 +09:00
devmrko
1538aadb14 feat(backoffice): migrate remaining menus to Smilegate tables 2026-07-22 14:18:54 +09:00
devmrko
404244534a fix(backoffice): restore Smilegate group and role mappers 2026-07-22 14:13:23 +09:00
devmrko
ea4fc1de8b fix(backoffice): restore Smilegate user mapper 2026-07-22 14:11:23 +09:00
devmrko
0eb68664f1 fix(backoffice): restore operational navigation 2026-07-22 14:08:24 +09:00
devmrko
4eec33b2ed feat(backoffice): migrate masking rules to Smilegate tables 2026-07-22 14:05:33 +09:00
devmrko
b4ed8649f1 feat(backoffice): replace KB structured data with Smilegate games 2026-07-22 14:01:39 +09:00
devmrko
f7295277f1 fix(backoffice): remove legacy KB navigation 2026-07-22 13:56:14 +09:00
devmrko
075eb46ef8 fix(backoffice): support VPD policy notes on Smilegate schema 2026-07-22 13:53:59 +09:00
devmrko
2b66e27bfb feat(backoffice): grant full game data access to PoC users 2026-07-22 13:52:16 +09:00
126 changed files with 10437 additions and 987 deletions

View File

@@ -51,6 +51,48 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS" export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
export BACKOFFICE_ORDS_DB_PASSWORD="" export BACKOFFICE_ORDS_DB_PASSWORD=""
# Select AI는 프로파일 소유 스키마로 별도 접속합니다.
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_SELECT_AI_DB_USERNAME=""
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
export BACKOFFICE_SELECT_AI_PROFILE=""
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"
# 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다.
# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
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="업무 데이터"
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_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=''
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) --- # --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
export BACKOFFICE_AI_ENABLED="false" export BACKOFFICE_AI_ENABLED="false"
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci export BACKOFFICE_AI_PROVIDER="openai" # openai | oci

4
.gitignore vendored
View File

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

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,87 @@
# 설계서: 스마일게이트 백오피스 잔여 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와 허용된 테이블 목록으로 한정한다.
## 설계 결정
### 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`, `SmilegateSelectAiService.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,70 @@
# 설계서: 스마일게이트 고객 질답 검증 이력
## 추적성
- 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. 같은 질문의 과거 답변은 최신 순 표로 보여 주며, 과거 기준 검증과 현재 실행을 구분한다.
## 적재 기준
- 기준 원본은 `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,69 @@
# #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`의 운영 프로파일을 바꾸지
않는다.

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 생성과 실행까지 완료하는지 확인한다.

View File

@@ -0,0 +1,42 @@
# SGMP annotation 관리 API (#734)
> 상태: Approved
> 구현: `sql/adb/76_sgmp_annotation_api.sql`
> 목적: 백오피스와 운영 스크립트가 동일한 검증 규칙으로 테이블/뷰 annotation을 추가·수정하도록 한다.
## 입력 계약
`SGMP_SET_ANNOTATION`은 스키마, 대상 종류(`TABLE` 또는 `COLUMN`), 테이블/뷰 이름, 선택적 컬럼 이름, annotation 값, 선택적 annotation 이름을 받는다. annotation 이름 기본값은 `AI_GUIDANCE`이며, 이름을 명시하면 그 이름을 사용한다.
컬럼 이름은 `COLUMN` 대상에서만 필수다. 값은 4,000자 이내이며 빈 값은 허용하지 않는다. 객체와 컬럼은 `ALL_OBJECTS`/`ALL_TAB_COLUMNS`에서 확인하고 식별자는 `DBMS_ASSERT`로 제한한다.
## 동작
1. 대상 객체가 TABLE 또는 VIEW인지 확인한다.
2. VIEW의 COLUMN 대상은 Oracle에서 변경할 수 없으므로 명확한 오류로 거부한다.
3. `ALL_ANNOTATIONS_USAGE`에서 동일 annotation의 존재 여부를 확인한다.
4. 기존 값이 있으면 DROP 후 ADD, 없으면 ADD만 수행한다.
5. 수행 결과(`ADDED` 또는 `REPLACED`)와 정규화된 대상을 반환한다.
DDL은 Oracle의 implicit commit 특성이 있으므로 호출자는 별도 트랜잭션으로 간주하지 않는다. 이 함수는 게임명·prefix·특정 테이블 정책을 하드코딩하지 않으며, 입력 객체의 존재와 Oracle 문법만 검증한다.
## 호출 예
```sql
SELECT SGMP_SET_ANNOTATION(
'SGMP_POC', 'TABLE', 'CZN_COMN_USER_MST', NULL,
'AU는 최신 BASE_DT에서 AU_FLAG=1이고 EXPT_USER_YN=N인 활성 사용자 수다.',
'AI_GUIDANCE'
) FROM dual;
SELECT SGMP_SET_ANNOTATION(
'SGMP_POC', 'COLUMN', 'CZN_COMN_USER_MST', 'AU_FLAG',
'AU 집계용 활성 사용자 플래그(1=활성).', 'BUSINESS_DEFINITION'
) FROM dual;
```
## 테스트 기준
- TABLE 신규 annotation은 `ADDED`를 반환한다.
- 같은 이름을 다시 호출하면 기존 값을 교체하고 `REPLACED`를 반환한다.
- 존재하지 않는 객체/컬럼, 잘못된 대상 종류, VIEW의 COLUMN 대상은 오류를 반환한다.

View File

@@ -0,0 +1,37 @@
# SGMP Few-shot NL2SQL MCP (#735)
> 상태: Approved
> 구현: `McpSseService`, `SelectAiService`, `McpProperties`
## 목적
기존 Select AI Text2SQL 경로와 분리된 검증용 MCP tool을 제공한다. 질문과 유사한 검토 완료 예제를 벡터 검색하고, 그 SQL 패턴을 prompt에 참고자료로 넣은 뒤 `SHOWSQL`로 생성한 SQL을 읽기 전용으로 실행한다.
## MCP 계약
- **tool name**: `oracle.select_ai.smilegate_fewshot_nl2sql` (환경변수로 재정의 가능)
- **입력**: `prompt` (최대 4,000자)
- **출력**: 벡터 Few-shot 예제(질문, SQL, 모델, cosine distance), 생성 SQL, 실행 상태, 행 수, 최대 100건 결과
Few-shot SQL은 실행하지 않는다. 현재 메타데이터·alias 정책을 우선하고, Select AI가 새로 생성한 SQL만 read-only 검증 후 실행한다.
## 안전 규칙
1. 벡터 검색 실패는 `UNAVAILABLE` 상태로 남기고 기존 정책 prompt로 폴백한다.
2. 생성 결과는 단일 `SELECT` 또는 `WITH`만 허용한다.
3. DDL, DML, PL/SQL, 시스템 객체, 잠금 구문, 다중 문장은 차단한다.
4. JDBC read-only 트랜잭션과 30초 query timeout, 최대 100행 제한을 적용한다.
5. 기존 `data_text2sql`, `data_showprompt`, `qa_vector_search`, `qa_vector_store` tool은 변경하지 않는다.
## 설정
```text
BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_NAME
BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_LABEL
BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION
```
## 추적성
- Redmine: #735
- 테스트: `McpSseServiceTest`

View File

@@ -0,0 +1,14 @@
# Smilegate main MCP route: Few-shot NL2SQL (#736)
## 변경
Smilegate 포털의 `smilegate_game_data_mcp` 기본 tool과 선호 route를
`oracle.select_ai.smilegate_fewshot_nl2sql`로 전환한다. 기존 Text2SQL과 QA vector tool은
allowlist에 유지해 호환성과 진단 경로를 보존한다.
## 검증
- tools/list에서 새 tool이 발견되는지 확인
- 포털의 기본 단일 호출 route가 새 tool인지 확인
- 새 tool 응답에 `fewShotExamples`, `generatedSql`, `items`가 존재하는지 확인
- 백오피스에 새 tool이 배포되지 않은 경우 포털에서 명확한 MCP 오류를 표시

View File

@@ -0,0 +1,242 @@
# Smilegate 게임 대상 계약 통합 설계
## 1. 배경
현재 `game_query_plan`은 게임을 찾지 못하면 `STOP_NO_MATCH`, 여러 게임이면
`FAN_OUT`을 반환한다. Portal의 ReAct 모델이 이 지시를 종료 조건으로 해석하거나
일부 게임만 다음 도구로 전달하면서 다음 문제가 발생했다.
- 게임명이 없는 전체 매출 질의가 SQL 생성 전에 중단된다.
- 여러 게임 중 일부만 매칭되면 매칭된 게임의 조회도 생략될 수 있다.
- 계획 결과를 전달해도 NL2SQL worker가 질문 전체를 다시 단일 게임 벡터검색하여
범위를 축소할 수 있다.
- 게임별 사용자 마스터 물리 객체를 모델이 추측해야 한다.
## 2. 목표
질문의 게임 대상을 `NONE`, `SINGLE`, `MULTI`, `ALL` 네 가지로 통일하고,
모든 유형에서 원 질문과 전체 계획 결과를 `smilegate_fewshot_nl2sql`에 한 번
전달한다. 계획 단계는 조회 중단이나 SQL 조립을 결정하지 않는다.
## 3. 계약
`game_query_plan`은 ReAct가 판단에 사용할 고정 JSON 계약을 반환한다. 자연어
설명, 후보 검색 원문, 기존 호환 필드는 진단 영역으로 분리하며 실행 판단은 아래
권위 필드만 사용한다.
```json
{
"contractVersion": "1.0",
"scopeType": "MULTI",
"targets": [
{
"mention": "사용자가 언급한 게임명",
"matchStatus": "MATCHED",
"dataStatus": "AVAILABLE",
"gameKey": "카탈로그 키 또는 null",
"gameId": "카탈로그 식별자 또는 null",
"userMasterObjectName": "승인 객체 또는 null"
}
],
"dataEligibleTargets": [],
"unresolvedTargets": []
}
```
고정 enum은 다음과 같다.
- `scopeType`: `NONE`, `SINGLE`, `MULTI`, `ALL`
- `matchStatus`: `NOT_APPLICABLE`, `MATCHED`, `UNMATCHED`
- `dataStatus`: `NOT_APPLICABLE`, `AVAILABLE`, `UNAVAILABLE`
`scopeType`은 질문에 언급된 범위만 나타낸다. 실행 가능성이나 SQL 조립 방식을
암시하지 않는다. `targets`는 언급 순서를 유지하고, `dataEligibleTargets`는 현재
질의에 사용할 수 있는 카탈로그 대상만, `unresolvedTargets`는 미매칭 또는 데이터
객체가 없는 대상을 보존한다. ReAct는 이 계약 전체를 관찰한 뒤 공통 팩트 단일
호출, 대상별 호출, 또는 빈 결과 응답을 선택한다.
기존 API 호환 기간에는 아래 필드도 반환할 수 있지만 ReAct의 실행 판단에 사용하지
않는다: `targetType`, `status`, `matchedGames`, `supportedGames`,
`mentionResults`, `dataIneligibleTargets`, `unmatchedGames`, `nextAction`,
`executionMode`.
기존 필드의 의미는 다음과 같다.
| 필드 | 내용 |
|---|---|
| `targetType` | 호환용 `scopeType` 별칭 |
| `status` | 호환용 요약 상태. ReAct 실행 분기에 사용하지 않음 |
| `targets` | 언급 순서를 보존한 게임 대상 배열 |
| `nextAction` | 호환용 안내. 실행을 강제하지 않음 |
| `executionMode` | 호환용 안내. 실행을 강제하지 않음 |
`targets` 항목은 다음 값을 가진다.
| 필드 | 내용 |
|---|---|
| `mention` | 질문에서 추출한 게임명. `NONE``null` |
| `status` | 호환용 대상 상태 |
| `matchStatus` | `NOT_APPLICABLE`, `MATCHED`, `UNMATCHED` |
| `dataStatus` | `NOT_APPLICABLE`, `AVAILABLE`, `UNAVAILABLE` |
| `gameKey`, `gameId` | DB 카탈로그가 반환한 게임 식별자 |
| `gamePrefix` | DB 카탈로그가 반환한 prefix |
| `gameName`, `aliases` | 표시명과 별칭 |
| `userMasterObjectName` | 현재 Select AI 승인 객체 중 해당 게임의 사용자 마스터 물리 객체. 없으면 `null` |
| `cosineDistance` | 후보 검색 거리. 적용되지 않으면 `null` |
| `reasonCode`, `reason` | 매칭 또는 미매칭 근거 |
유형별 처리 방식은 다음과 같다.
- `NONE`: 게임 대상 항목 하나를 모든 게임 필드가 `null`인 상태로 전달한다.
공통 거래·기준 객체 질의는 계속하되 prefix 전용 객체를 임의 선택하지 않는다.
- `SINGLE`: 질문에서 언급된 대상 한 건을 전달한다. 미매칭이면 게임 필드가
`null`인 대상도 그대로 다음 도구에 전달한다.
- `MULTI`: 언급된 대상 모두를 전달한다. 매칭·미매칭을 함께 보존한다.
- `ALL`: 게임 카탈로그의 활성 게임을 모두 반환한다. 사용자 마스터 물리 객체가
없는 게임도 `null`로 보존한다.
## 4. DB 설계
`SG_GAME_CATALOG``USER_MASTER_OBJECT_NAME`을 추가한다.
- 값은 `COMN_GAME_ALIAS_BAS`/운영 게임 registry의 `GAME_PREFIX`와 현재
`USER_CLOUD_AI_PROFILE_ATTRIBUTES.object_list`, `USER_OBJECTS`를 조합해 계산한다.
- 공통 정책이나 애플리케이션 코드에는 특정 게임명, prefix, 테이블명을 넣지 않는다.
- 운영 registry에만 존재하는 게임도 카탈로그에 동기화하고 임베딩을 생성한다.
- 게임별 물리 객체가 없으면 컬럼은 `null`이다.
`SG_GAME_CATALOG_SEARCH`는 물리 객체명을 후보 결과에 포함한다.
`SG_GAME_QUERY_PLAN`은 추출된 mention마다 후보 선택 결과를 `targets`로 만들며,
`ALL`은 벡터 선택 없이 전체 카탈로그를 사용한다.
## 5. MCP와 NL2SQL 설계
`game_query_plan` 도구 설명은 “항상 먼저 호출하고 결과를 원 질문과 함께 다음
도구에 전달”하도록 바꾼다. `smilegate_fewshot_nl2sql`의 입력은 다음과 같다.
- `prompt`: 사용자가 입력한 원 질문
- `queryPlan`: 바로 앞 `game_query_plan`의 전체 JSON
- `scopeGameKey`: 하위 호환용 선택 값. 새 흐름에서는 사용하지 않는다.
NL2SQL worker는 전달된 `queryPlan`을 권위 있는 게임 범위로 사용한다.
계획이 있으면 질문 전체를 다시 게임 카탈로그 벡터검색하지 않는다.
- 공통 객체에 게임 식별 컬럼이 있으면 질문 의미에 따라 `IN``GROUP BY`
사용할 수 있다.
- 대상별 물리 객체가 다르면 제공된 객체만 사용해 `UNION ALL`할 수 있다.
- 물리 객체가 `null`인 대상을 임의 객체로 대체하지 않는다.
- `NONE`에서 생성 SQL이 prefix 전용 객체를 참조하면 실행 전에 차단한다.
- 최종 SQL은 기존과 같이 단일 `SELECT`/`WITH`, read-only transaction,
최대 행수와 timeout 제한을 적용한다.
## 6. Portal ReAct 설계
Portal은 도구 호출 순서를 Java/Python 조건문으로 강제하지 않는다. 외부 환경변수의
system prompt와 MCP 도구 설명으로 다음 절차를 안내한다.
1. `game_query_plan`을 호출한다.
2. `targetType`과 상관없이 원 질문, 전체 `queryPlan`으로
`smilegate_fewshot_nl2sql`을 한 번 호출한다.
3. SQL, 실행 결과, 매칭·미매칭 대상을 함께 설명한다.
## 7. 검증
운영 MCP에서 다음 네 질문군을 실제 호출한다.
| 유형 | 검증 내용 |
|---|---|
| `NONE` | 게임명이 없는 전체 매출 질의가 공통 테이블로 실행되고 게임 필터가 임의 추가되지 않는다. |
| `SINGLE` | 한 게임의 AU 질의가 해당 게임 식별자/물리 객체로 실행된다. |
| `MULTI` | 지원·미지원 게임을 함께 질문해 지원 결과와 미지원 상태가 한 답변에 보존된다. |
| `ALL` | 전체 게임 질의가 전체 카탈로그 대상을 받아 공통 객체 그룹 또는 게임별 객체 결합 SQL로 실행된다. |
추가 검증:
- Maven 테스트
- Portal Python 테스트
- MCP `tools/list` schema/description 확인
- 생성 SQL에서 `NONE`의 prefix 전용 객체 차단 확인
- 운영 서비스 health와 Portal ReAct 실제 응답 확인
## 8. Few-shot 참조 거버넌스
Few-shot 원문 SQL은 실행 이력 그대로 검색하지 않는다. 특히 특정 게임의 물리 객체명이
포함된 예제는 다른 게임 또는 게임 미지정 질의의 벡터 후보가 될 수 있으므로, 예제는
검증·정규화·승인 단계를 거쳐야 한다.
`SG_QA_VECTOR_EXAMPLE`에는 다음 참조 메타데이터를 둔다.
| 필드 | 내용 |
|---|---|
| `REFERENCE_STATUS` | `DRAFT`, `APPROVED`, `RETIRED`. 검색은 `APPROVED`만 사용한다. |
| `REFERENCE_KIND` | `SQL_TEMPLATE`, `NO_TARGET`, `OBJECT_UNAVAILABLE`, `METADATA_POLICY` |
| `TARGET_TYPE` | `NONE`, `SINGLE`, `MULTI`, `ALL`, `ANY`. 현재 game query plan과 일치하는 예제만 벡터 순위에 포함한다. |
| `OBJECT_ROLE` | 물리 테이블명이 아닌 `GAME_USER_MASTER` 등 논리 객체 역할 |
| `INSPECTION_STATUS`, `INSPECTION_NOTE` | 전수 실행·정책 검토 결과와 사유 |
| `VERIFIED_AT`, `VERIFIED_BY` | 승인 감사 이력 |
물리 테이블명은 Few-shot의 의미 규칙으로 저장하지 않는다. `SQL_TEMPLATE`
`<RESOLVED_GAME_USER_MASTER>` 같은 논리 placeholder를 사용하며, 실제 객체는 오직
현재 `queryPlan.targets[*].userMasterObjectName`에서만 해석한다. `NO_TARGET`
`OBJECT_UNAVAILABLE` 예제는 범위 경계 안내와 검증된 빈 결과 SQL 템플릿을 함께
프롬프트에 포함한다. 이 템플릿은 애플리케이션에서 직접 실행하거나 답변으로
반환하지 않으며, Select AI가 현재 질문의 범위를 해석해 SQL을 생성할 때만 Few-shot
근거로 사용한다.
`NONE`은 게임이 선택되지 않았다는 범위 정보이며 질의 불가 상태가 아니다. 각 경계
예제는 `OBJECT_ROLE`로 적용 대상을 밝힌다. 예를 들어 게임별 사용자 마스터 경계는
공통 판매·환불 같은 공통 객체 질의에 적용하지 않는다. 공통 객체로 답할 수 있는
질문은 게임 중립 SQL로 계속 실행하고, 게임별 객체가 본질적으로 필요한 질문만 빈
결과 또는 게임 식별 요청으로 처리한다.
`UNMATCHED``OBJECT_STATUS=UNAVAILABLE``NONE`과 별도 계약이다. 요청한
게임 전용 논리 객체를 현재 카탈로그에서 확인할 수 없다는 뜻이므로 다른 게임의
객체로 대체하지 않는다. 해당 객체가 반드시 필요한 연산이면 실행 가능한 읽기 전용
0행 결과로 종료한다. 이때 `NULL`을 담은 합성 1행은 빈 결과로 취급하지 않는다.
이 규칙은 특정 게임·prefix·물리 객체에 의존하지 않으며, Java 분기가 아니라 Select
AI 프로파일의 `additional_instructions`로 관리한다. 애플리케이션은 계획 전달과
read-only·객체 범위 검증만 담당한다.
공통 거래처럼 게임 식별 컬럼이 있는 객체는 게임 식별자를 SQL 리터럴로 직접 넣지
않는다. 활성 게임 별칭 카탈로그를 통해 허용 식별자를 구하는 서브쿼리 또는 `EXISTS`
조건을 사용하며, 카탈로그에는 있으나 해당 공통 객체의 데이터가 없는 경우는 다른
게임으로 대체하지 않고 빈 결과 또는 집계 `NULL`을 그대로 보존한다.
기존 예제는 삭제하지 않는다. 실행 가능성, 게임 범위 일치, 물리 객체 의존성을 전수
검사해 `RETIRED`로 분리하고, 검증된 정규화 예제만 `APPROVED`로 전환한다.
### 고객 질답 47건 후보화·승인 규칙
`SG_AI_QA_QUESTION`의 고객 Excel 47건은 `SOURCE_TYPE=CUSTOMER_QA_BENCHMARK`,
`SOURCE_CASE_ID`로 벡터 예제에 한 번씩 적재한다. 초기 상태는 모두 `DRAFT`이고,
질문·기대 포인트·기준 SQL을 embedding 문서로 사용한다. 이 상태의 원문은 운영 검색
결과에 절대 포함되지 않는다.
`SG_QA_VECTOR_APPROVE_VERIFIED_BENCHMARK`는 최신 Portal ReAct 실행이 다음을 모두
만족한 후보만 자동으로 `APPROVED`로 전환한다.
1. 고객 지원 범위가 `SUPPORTED`일 것
2. 최신 실행이 `COMPLETED`이며 고객 기준 판정이 `PASS`일 것
3. 실행 가능한 읽기 전용 SQL이 있을 것
승인 시 최신 실행 SQL과 응답으로 embedding을 재생성하고, 실행 이력의 query plan에서
확인된 `TARGET_TYPE`을 함께 보관한다. `WARN`·`FAIL`·미지원·SQL 미생성 후보는
`DRAFT / REVIEW`로 남긴다. 따라서 개선 전 SQL이 Few-shot 근거로 노출되지 않으며,
각 검색 결과는 `SOURCE_CASE_ID`로 고객 기준 항목까지 추적할 수 있다.
## 9. 변경 이력
- Redmine: `#739`
- Backoffice branch: `smilegate`
- Portal branch: `main`
- 2026-07-28: `STD-03``NO_TARGET` 예제에 검증된 빈 결과 SQL 템플릿을 Few-shot
근거로 노출했다. 운영 Portal ReAct에서 `game_query_plan → fewshot_nl2sql` 경로로
생성·실행한 SQL은 `SELECT CAST(NULL AS NUMBER) ... FROM DUAL WHERE 1 = 0`이었고,
답변 이력 `422``PASS`로 기록됐다. 직접 정답 실행 분기는 두지 않는다.
- 2026-07-28: `NONE`을 질의 차단으로 해석하지 않도록 경계 예제에 논리 객체 역할을
부여하고, 공통 객체 질의에는 경계 SQL을 일반화하지 않는 프롬프트 규칙을 추가했다.
- 2026-07-29: ReAct가 자유 설명문이나 중복 요약 필드를 해석하지 않도록, 게임 범위
계획의 권위 JSON 계약을 `contractVersion`, `scopeType`, `targets[*].matchStatus`,
`targets[*].dataStatus`, `dataEligibleTargets`, `unresolvedTargets`로 정리했다.
이 계약은 게임명·prefix·물리 객체를 애플리케이션에 고정하지 않으며, 다음 도구 호출
횟수와 방식은 ReAct가 현재 질문과 계획 결과로 판단한다.

View File

@@ -0,0 +1,30 @@
# Smilegate 질문별 Few-shot 관리 탭 (#740)
## 프로젝트 개요
Smilegate Portal은 고객 질답 벤치마크를 실행하고, Oracle Select AI MCP가 승인된 few-shot 예제를 검색해 읽기 전용 SQL을 생성·실행하는 서비스다. 고객 질문, 실행 이력, few-shot 예제는 Oracle ADB의 `SG_AI_QA_QUESTION`, `SG_AI_QA_ANSWER`, `SG_QA_VECTOR_EXAMPLE`에 보관한다.
## 목표
Portal에 **Few-shot 관리** 탭을 추가해 운영자가 질문별 few-shot 내용을 확인하고 수정할 수 있게 한다.
## 설계
- 질문 목록은 기존 고객 QA 기준 파일을 사용하고, 예제는 `source_type=CUSTOMER_QA_BENCHMARK``source_case_id=질문 코드`로 연결한다.
- 탭은 질문, 예제 SQL, 설명, 참조 종류, 적용 게임 범위, 논리 객체 역할, 검토 메모, 검색 상태를 표시한다.
- 저장은 예제 본문과 메타데이터를 갱신하고 embedding 입력과 vector embedding을 함께 재생성한다. 따라서 수정 후 벡터 검색이 수정 전 내용과 어긋나지 않는다.
- 저장 시 상태를 자동 승인하지 않는다. `APPROVED`는 기존 승인 정책에 맞는 운영자 선택으로만 유지·변경하며, `RETIRED` 예제는 검색에서 제외된다.
- 물리 게임 테이블명은 예제의 정책 값으로 넣지 않고 논리 `object_role`만 보관한다. 게임 범위는 현재 query plan이 권위 있는 원천이다.
## 완료 기준
1. 인증된 Portal에서 기존 아키텍처·시나리오와 함께 Few-shot 관리 탭을 볼 수 있다.
2. 질문을 고르면 해당 few-shot 예제의 전체 내용을 확인할 수 있다.
3. 유효한 값으로 수정 후 저장하면 다시 열었을 때 저장 내용이 보인다.
4. Python 단위 테스트가 저장 입력 검증과 DB 갱신 SQL을 확인한다.
## 검증 계획
- `python -m pytest`로 Portal 테스트를 실행한다.
- Python compile 검사로 Streamlit 모듈 구문을 확인한다.
- DB 접속 설정이 있는 환경에서는 질문별 예제 조회와 수정 후 재조회로 검증한다.

View File

@@ -11,7 +11,6 @@ with the Bearer token entered on the screen.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import binascii
import hashlib import hashlib
import hmac import hmac
import html import html
@@ -57,12 +56,20 @@ from src.agent_console.presentation import (
render_login_brand, render_login_brand,
) )
from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios from src.poc4.qa_history import QaJudgment, QaQuestion, evaluate_sql, load_benchmark_questions
from src.poc4.qa_history_store import (
INSPECTION_STATUSES,
REFERENCE_KINDS,
REFERENCE_STATUSES,
TARGET_TYPES,
QaHistoryStore,
QaHistoryStoreError,
)
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2025-11-25" MCP_PROTOCOL_VERSION = "2025-11-25"
PREFERRED_TOOL = "ords.query.kb_select_ai_vpd" PREFERRED_TOOL = "oracle.select_ai.smilegate_fewshot_nl2sql"
DEFAULT_QUESTION = "" DEFAULT_QUESTION = ""
MAX_RESPONSE_BYTES = 1_000_000 MAX_RESPONSE_BYTES = 1_000_000
MAX_CONVERSATION_MESSAGES = 8 MAX_CONVERSATION_MESSAGES = 8
@@ -77,17 +84,20 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
ENV_FILE = ROOT / ".env" ENV_FILE = ROOT / ".env"
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json" MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json" VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json" QA_BENCHMARK_FILE = ROOT / "config" / "smilegate_qa_benchmark.json"
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json" APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3" CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
DEFAULT_VPD_USER_ID = "FC00789" DEFAULT_VPD_USER_ID = "1001"
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/" VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/"
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated" PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember" PORTAL_AUTH_COOKIE_NAME = "poc4_portal_auth"
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 AUDIT_SCHEMA = "SGMP_POC"
AUDIT_SCHEMA = "POC_2" REFERENCE_EVIDENCE_ENABLED = (
os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower()
in {"1", "true", "yes"}
)
AUDIT_DB_ENV_FILE = Path( AUDIT_DB_ENV_FILE = Path(
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env") os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
).expanduser() ).expanduser()
@@ -1885,12 +1895,329 @@ def new_conversation_id() -> str:
def _question_label(question: object) -> str: def _question_label(question: object) -> str:
question_id = str(getattr(question, "question_id")) question_id = str(
category = str(getattr(question, "category")) getattr(question, "question_code", getattr(question, "question_id", ""))
title = str(getattr(question, "title", getattr(question, "text"))) )
category = str(getattr(question, "category", ""))
title = str(
getattr(question, "title", getattr(question, "question_text", ""))
)
return f"{question_id} · {category} · {title}" return f"{question_id} · {category} · {title}"
@st.cache_resource(show_spinner=False)
def _qa_history_store() -> QaHistoryStore:
return QaHistoryStore()
def _load_qa_questions() -> tuple[list[QaQuestion], QaHistoryStore | None, str]:
try:
return list(load_benchmark_questions(QA_BENCHMARK_FILE)), _qa_history_store(), ""
except Exception as exc:
return [], None, f"질답 기준 파일을 읽지 못했습니다: {exc}"
def _judgment_label(status: str) -> str:
labels = {
"PASS": "통과",
"WARN": "검토",
"FAIL": "실패",
"REVIEW": "수동 검토",
}
return labels.get(str(status or "").upper(), "미실행")
def _qa_answer_preview(value: object, limit: int = 180) -> str:
text = str(value or "").strip().replace("\n", " ")
return text if len(text) <= limit else text[:limit] + "..."
def _render_qa_benchmark_panel(
questions: list[QaQuestion],
store: QaHistoryStore | None,
store_error: str,
) -> QaQuestion | None:
st.markdown(
'<div class="kb-section-title input" role="heading" aria-level="3">'
"고객 질답 검증 시나리오"
"</div>",
unsafe_allow_html=True,
)
st.caption(
"고객 제공 Excel에서 정리한 47개 후보입니다. 후보를 선택하면 기준 답변과 과거 검증 이력을 확인하고 바로 실행할 수 있습니다."
)
if store_error:
st.warning(
"현재는 파일 기준 후보만 표시합니다. ADB 이력 저장은 연결 복구 후 사용할 수 있습니다. "
+ store_error
)
if not questions:
st.error("표시할 고객 질답 기준이 없습니다.")
return None
category_options = tuple(sorted({question.category for question in questions}))
category = st.selectbox(
"시나리오 구분",
options=("ALL", *category_options),
format_func=lambda item: "전체 47개" if item == "ALL" else item,
key="poc4_qa_category_filter",
)
visible_questions = [
question for question in questions if category == "ALL" or question.category == category
]
st.dataframe(
[
{
"케이스": question.question_code,
"구분": question.category,
"제목": question.title,
"질문": question.question_text,
"기대 기준": _qa_answer_preview(question.expected_focus, 150),
"기준 상태": _judgment_label(question.last_judgment_status)
if question.last_judgment_status
else "과거 기준",
"최근 실행": question.last_evaluated_at or "후보 선택 시 조회",
}
for question in visible_questions
],
hide_index=True,
width="stretch",
height=min(520, 110 + 36 * len(visible_questions)),
column_config={
"질문": st.column_config.TextColumn(width="large"),
"기대 기준": st.column_config.TextColumn(width="large"),
"최근 실행": st.column_config.TextColumn(width="medium"),
},
)
by_code = {question.question_code: question for question in visible_questions}
selected_code = st.selectbox(
"실행할 검증 후보 선택",
options=("", *by_code),
format_func=lambda code: "선택 안 함 · 자유 질의" if not code else _question_label(by_code[code]),
key="poc4_qa_question_code",
)
selected = by_code.get(selected_code)
if selected is None:
st.info("자유 텍스트 질문도 실행하고 이력으로 남길 수 있습니다. 자유 질의는 정답 기준이 없어 수동 검토로 표시됩니다.")
return None
if store is not None:
try:
stored_question = store.get_question_by_code(selected.question_code)
if stored_question is not None:
selected = stored_question
else:
st.warning("선택한 후보가 ADB 이력 테이블에 아직 적재되지 않았습니다.")
except QaHistoryStoreError as exc:
st.warning("선택한 후보의 ADB 이력을 불러오지 못했습니다. " + str(exc))
with st.expander("선택한 질문의 정답 기준·원본·과거 답변", expanded=True):
left, right = st.columns(2)
with left:
st.markdown("**정답 기준**")
st.write(selected.expected_focus or "정답 기준 설명이 없습니다.")
st.markdown("**기준 답변**")
st.code(selected.baseline_answer or "기준 실행 결과가 없습니다.", language=None)
with right:
st.markdown("**고객 원본 출처**")
st.write(
f"{selected.source_document} / {selected.source_sheet} / 행 {selected.source_row or '-'}"
)
if selected.source_scenario:
st.caption(selected.source_scenario)
st.markdown("**기준 SQL**")
st.code(selected.baseline_sql or selected.sample_sql or "기준 SQL이 없습니다.", language="sql")
if store is not None and selected.question_id is not None:
try:
answers = store.list_answers(selected.question_id, limit=30)
except QaHistoryStoreError as exc:
st.warning(str(exc))
answers = []
if answers:
st.markdown("**과거 답변 이력**")
st.dataframe(
[
{
"순번": item["answer_seq"],
"구분": "과거 기준" if item["answer_kind"] == "HISTORICAL" else "이번 실행",
"판정": _judgment_label(item["judgment_status"]),
"실행 상태": item["execution_status"],
"실행 시각": item["requested_at"],
"답변": _qa_answer_preview(item["answer_text"]),
}
for item in answers
],
hide_index=True,
width="stretch",
)
answer_by_seq = {str(item["answer_seq"]): item for item in answers}
detail_seq = st.selectbox(
"답변 이력 상세",
options=tuple(answer_by_seq),
format_func=lambda value: (
f"#{value} · {_judgment_label(answer_by_seq[value]['judgment_status'])} · "
f"{answer_by_seq[value]['requested_at']}"
),
key=f"poc4_qa_answer_detail_{selected.question_id}",
)
detail = answer_by_seq[detail_seq]
st.markdown("**판정 근거**")
st.write(detail["judgment_reason"] or "판정 근거가 없습니다.")
st.markdown("**생성 SQL**")
st.code(detail["generated_sql"] or "생성 SQL이 없습니다.", language="sql")
st.markdown("**실행 답변**")
st.write(detail["answer_text"] or "답변이 없습니다.")
else:
st.info("아직 저장된 답변 이력이 없습니다.")
return selected
def _render_few_shot_management_tab(
questions: list[QaQuestion],
store: QaHistoryStore | None,
store_error: str,
) -> None:
st.subheader("질문별 Few-shot 관리")
st.caption(
"고객 질답 질문에 연결된 few-shot 예제를 검토하고 수정합니다. 저장하면 검색용 embedding도 함께 다시 생성됩니다."
)
if store_error or store is None:
st.warning(
"Few-shot 관리 DB에 연결하지 못했습니다. "
+ (store_error or "질답 이력 DB 접속 설정을 확인해 주세요.")
)
return
if not questions:
st.info("표시할 고객 질답 질문이 없습니다.")
return
by_code = {question.question_code: question for question in questions}
selected_code = st.selectbox(
"질문 선택",
options=tuple(by_code),
format_func=lambda code: _question_label(by_code[code]),
key="poc4_few_shot_question_code",
)
selected_question = by_code[selected_code]
st.markdown("**선택한 질문**")
st.write(selected_question.question_text)
try:
examples = store.list_vector_examples_by_case(selected_question.question_code)
except QaHistoryStoreError as exc:
st.error(str(exc))
return
if not examples:
st.info(
"이 질문에는 아직 연결된 few-shot 후보가 없습니다. "
"후보 적재 작업 후 이 화면에서 검토할 수 있습니다."
)
return
example_by_id = {str(item["example_id"]): item for item in examples}
selected_example_id = st.selectbox(
"Few-shot 예제",
options=tuple(example_by_id),
format_func=lambda example_id: (
f"예제 #{example_id} · "
f"{example_by_id[example_id]['reference_status']} · "
f"{example_by_id[example_id]['inspection_status']}"
),
key=f"poc4_few_shot_example_{selected_question.question_code}",
)
example = example_by_id[selected_example_id]
st.caption(
"검색 대상은 APPROVED 예제뿐입니다. 게임별 물리 테이블은 여기에 넣지 않고, "
"현재 질문의 query plan이 해석하는 논리 객체 역할만 관리합니다."
)
if example["verified_at"]:
st.caption(
f"마지막 승인: {example['verified_at']} · {example['verified_by'] or '확인자 미기록'}"
)
form_key = f"poc4_few_shot_form_{example['example_id']}"
with st.form(form_key):
question = st.text_area(
"Few-shot 질문",
value=example["question"],
height=100,
max_chars=4_000,
)
answer_sql = st.text_area(
"Few-shot SQL 템플릿",
value=example["answer_sql"],
height=220,
max_chars=12_000,
help="읽기 전용 SQL 템플릿을 사용하세요. 게임별 물리 객체는 논리 placeholder로 표현합니다.",
)
answer_text = st.text_area(
"설명 / 답변 보조 문구",
value=example["answer_text"],
height=120,
max_chars=8_000,
)
left, right = st.columns(2)
with left:
reference_status = st.selectbox(
"검색 상태",
options=REFERENCE_STATUSES,
index=REFERENCE_STATUSES.index(example["reference_status"]),
help="APPROVED만 실제 few-shot 검색에 사용됩니다.",
)
reference_kind = st.selectbox(
"참조 종류",
options=REFERENCE_KINDS,
index=REFERENCE_KINDS.index(example["reference_kind"]),
)
with right:
target_type = st.selectbox(
"적용 게임 범위",
options=TARGET_TYPES,
index=TARGET_TYPES.index(example["target_type"]),
)
inspection_status = st.selectbox(
"검토 상태",
options=INSPECTION_STATUSES,
index=INSPECTION_STATUSES.index(example["inspection_status"]),
)
object_role = st.text_input(
"논리 객체 역할",
value=example["object_role"],
max_chars=64,
placeholder="예: GAME_USER_MASTER",
)
inspection_note = st.text_area(
"검토 메모",
value=example["inspection_note"],
height=100,
max_chars=4_000,
)
submitted = st.form_submit_button("Few-shot 변경 저장", type="primary")
if not submitted:
return
try:
store.update_vector_example(
example_id=example["example_id"],
source_case_id=selected_question.question_code,
question=question,
answer_sql=answer_sql,
answer_text=answer_text,
reference_status=reference_status,
reference_kind=reference_kind,
target_type=target_type,
object_role=object_role,
inspection_status=inspection_status,
inspection_note=inspection_note,
verified_by=str(st.session_state.get(PORTAL_AUTH_USER_KEY) or "PORTAL_OPERATOR"),
)
except QaHistoryStoreError as exc:
st.error(str(exc))
return
st.success("Few-shot 예제와 검색용 embedding을 저장했습니다.")
st.rerun()
def _apply_console_theme(profile: AppProfile) -> None: def _apply_console_theme(profile: AppProfile) -> None:
apply_console_theme(st, profile) apply_console_theme(st, profile)
@@ -1933,102 +2260,78 @@ def _portal_credentials_are_valid(username: str, password: str) -> bool:
return username_matches and password_matches return username_matches and password_matches
def _portal_remember_secret() -> str: def _portal_cookie_user() -> str:
return _portal_auth_value("POC4_LOGIN_REMEMBER_SECRET") """Return the verified portal user stored in the HttpOnly auth cookie."""
try:
token = str(st.context.cookies.get(PORTAL_AUTH_COOKIE_NAME, "")).strip()
def _portal_remember_token(username: str) -> str: encoded_claims, supplied_signature = token.rsplit(".", 1)
secret = _portal_remember_secret() secret = _portal_auth_value("POC4_LOGIN_REMEMBER_SECRET")
if not secret: expected_signature = hmac.new(
return "" secret.encode("utf-8"), encoded_claims.encode("ascii"), hashlib.sha256
payload = {
"v": 1,
"u": username.strip(),
"e": int(datetime.now(timezone.utc).timestamp()) + PORTAL_REMEMBER_MAX_AGE_SECONDS,
}
encoded = base64.urlsafe_b64encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
).decode("ascii").rstrip("=")
signature = hmac.new(
secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256
).hexdigest() ).hexdigest()
return f"{encoded}.{signature}" if not secret or not hmac.compare_digest(supplied_signature, expected_signature):
return ""
padding = "=" * (-len(encoded_claims) % 4)
claims = json.loads(
base64.urlsafe_b64decode((encoded_claims + padding).encode("ascii")).decode("utf-8")
)
username = str(claims.get("u", "")).strip()
expires_at = int(claims.get("e", 0))
expected_username = _portal_auth_value("POC4_LOGIN_USER")
if claims.get("v") != 1 or expires_at <= int(datetime.now(timezone.utc).timestamp()):
return ""
if not username or not hmac.compare_digest(username, expected_username):
return ""
return username
except (AttributeError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError):
return ""
def _restore_portal_remembered_session() -> None: def _restore_portal_session_from_cookie() -> None:
if st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): if st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
return return
token = st.query_params.get(PORTAL_REMEMBER_TOKEN_PARAM, "") username = _portal_cookie_user()
if not isinstance(token, str) or not token or len(token) > 2048: if username:
return
secret = _portal_remember_secret()
expected_username = _portal_auth_value("POC4_LOGIN_USER")
try:
encoded, supplied_signature = token.split(".", 1)
expected_signature = hmac.new(
secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256
).hexdigest()
padded = encoded + "=" * (-len(encoded) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
expires_at = int(payload["e"])
username = str(payload["u"])
except (binascii.Error, KeyError, TypeError, ValueError, UnicodeDecodeError):
return
if not secret or not hmac.compare_digest(supplied_signature, expected_signature):
return
if expires_at < int(datetime.now(timezone.utc).timestamp()):
return
if not hmac.compare_digest(username, expected_username):
return
st.session_state[PORTAL_AUTHENTICATED_KEY] = True st.session_state[PORTAL_AUTHENTICATED_KEY] = True
st.session_state[PORTAL_AUTH_USER_KEY] = username st.session_state[PORTAL_AUTH_USER_KEY] = username
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False
def _clear_portal_remembered_session() -> None:
if PORTAL_REMEMBER_TOKEN_PARAM in st.query_params:
del st.query_params[PORTAL_REMEMBER_TOKEN_PARAM]
def _render_portal_login(profile: AppProfile) -> None: def _render_portal_login(profile: AppProfile) -> None:
with st.container(key="console_login_container"): with st.container(key="console_login_container"):
render_login_brand(st, profile) render_login_brand(st, profile)
if not _portal_auth_configured(): if not _portal_auth_configured():
st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.") st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.")
return return
with st.form("poc4_portal_login_form", clear_on_submit=True): if st.query_params.get("login") == "failed":
username = st.text_input(
"사용자 ID",
max_chars=80,
placeholder="사용자 ID를 입력하세요.",
)
password = st.text_input(
"비밀번호",
type="password",
max_chars=200,
placeholder="비밀번호를 입력하세요.",
)
remember_login = st.checkbox(
"로그인 유지 (7일)",
disabled=not bool(_portal_remember_secret()),
help="이 브라우저에서 7일 동안 로그인 상태를 유지합니다.",
)
submitted = st.form_submit_button("로그인", use_container_width=True)
if submitted:
if _portal_credentials_are_valid(username, password):
st.session_state[PORTAL_AUTHENTICATED_KEY] = True
st.session_state[PORTAL_AUTH_USER_KEY] = username.strip()
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False
if remember_login:
token = _portal_remember_token(username)
if token:
st.query_params[PORTAL_REMEMBER_TOKEN_PARAM] = token
else:
_clear_portal_remembered_session()
st.rerun()
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = True
if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False):
st.error("사용자 ID 또는 비밀번호를 확인해 주세요.") st.error("사용자 ID 또는 비밀번호를 확인해 주세요.")
components.html(
"""
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: sans-serif; color: #15293a; }
form { display: grid; gap: 10px; }
label { font-size: 13px; font-weight: 700; color: #365064; }
input { width: 100%; border: 1px solid #afc4d3; border-radius: 7px;
padding: 11px 12px; font-size: 15px; color: #15293a; }
button { margin-top: 6px; width: 100%; border: 0; border-radius: 7px;
padding: 12px; background: #005c97; color: white; cursor: pointer;
font-size: 15px; font-weight: 700; }
button:hover { background: #004b7c; }
</style>
<form action="/_poc4/auth/login" method="post" target="_parent">
<label>사용자 ID
<input name="username" autocomplete="username" maxlength="80" required>
</label>
<label>비밀번호
<input name="password" type="password" autocomplete="current-password"
maxlength="200" required>
</label>
<button type="submit">로그인</button>
</form>
""",
height=220,
)
st.markdown( st.markdown(
f'<p class="console-muted">{html.escape(profile.login_footer)}</p>', f'<p class="console-muted">{html.escape(profile.login_footer)}</p>',
unsafe_allow_html=True, unsafe_allow_html=True,
@@ -2036,11 +2339,13 @@ def _render_portal_login(profile: AppProfile) -> None:
def _logout_portal() -> None: def _logout_portal() -> None:
_clear_portal_remembered_session()
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None) st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
st.session_state.pop(PORTAL_AUTH_USER_KEY, None) st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None) st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None)
st.rerun() components.html(
'<script>window.parent.location.assign("/_poc4/auth/logout");</script>',
height=0,
)
def _render_app_header(profile: AppProfile) -> None: def _render_app_header(profile: AppProfile) -> None:
@@ -2374,6 +2679,8 @@ def collect_security_evidence(
) -> Mapping[str, Any]: ) -> Mapping[str, Any]:
"""Collect predefined, token-scoped security evidence without exposing secrets.""" """Collect predefined, token-scoped security evidence without exposing secrets."""
if not REFERENCE_EVIDENCE_ENABLED:
return {}
kind = _security_evidence_kind(question) kind = _security_evidence_kind(question)
if not kind or token_preset is None: if not kind or token_preset is None:
return {} return {}
@@ -2938,6 +3245,8 @@ def collect_business_evidence(
) -> Mapping[str, Any]: ) -> Mapping[str, Any]:
"""Collect token-validated, explicitly scoped business and catalog evidence.""" """Collect token-validated, explicitly scoped business and catalog evidence."""
if not REFERENCE_EVIDENCE_ENABLED:
return {}
kind = _business_evidence_kind(question) kind = _business_evidence_kind(question)
if not kind or token_preset is None: if not kind or token_preset is None:
return {} return {}
@@ -3636,6 +3945,9 @@ def load_vpd_token_presets(
if not isinstance(item, Mapping) or item.get("enabled", True) is not True: if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
continue continue
token = _normalized_bearer(item.get("token")) token = _normalized_bearer(item.get("token"))
if not token:
token_env = str(item.get("token_env") or "").strip()
token = _normalized_bearer(_runtime_env_value(token_env)) if token_env else ""
user_id = str(item.get("user_id") or "").strip() user_id = str(item.get("user_id") or "").strip()
if not token or not user_id or user_id in seen: if not token or not user_id or user_id in seen:
continue continue
@@ -5138,15 +5450,17 @@ def _render_mcp_result_sections(
st.json(step.get("result_summary", {})) st.json(step.get("result_summary", {}))
if generated_sql: if generated_sql:
with st.expander("생성 SQL"): with st.expander("실행 SQL", expanded=True):
st.code(generated_sql, language="sql") st.code(generated_sql, language="sql")
if items: if items:
with st.expander(f"조회 결과 테이블 · {len(items)}"): with st.expander(f"조회 결과 · {len(items)}", expanded=True):
display_items = items[:100] display_items = items[:100]
st.dataframe(display_items, use_container_width=True) st.dataframe(display_items, use_container_width=True)
if len(items) > len(display_items): if len(items) > len(display_items):
st.caption(f"화면에는 최초 {len(display_items)}건만 표시합니다.") st.caption(f"화면에는 최초 {len(display_items)}건만 표시합니다.")
elif generated_sql:
st.info("실행 SQL은 생성되었지만 반환된 조회 결과 행이 없습니다.")
with st.expander("MCP 호출 상세"): with st.expander("MCP 호출 상세"):
st.write(f"route: {details.get('server_id')} / {details.get('tool_name')}") st.write(f"route: {details.get('server_id')} / {details.get('tool_name')}")
@@ -6323,6 +6637,89 @@ def _render_vpd_operations_tab() -> None:
) )
def _select_ai_execution_summary(payload: Any) -> dict[str, Any]:
if not isinstance(payload, Mapping):
return {
"generated_sql": "",
"execution_status": "UNKNOWN",
"execution_succeeded": False,
"result": {},
}
generated_sql = str(
payload.get("generatedSql") or payload.get("generated_sql") or ""
).strip()
status = str(payload.get("status") or "").strip().upper()
execution = str(payload.get("execution") or "").strip().upper()
execution_succeeded = (
status == "SHOWSQL_AND_EXECUTED" and execution == "READ_ONLY_EXECUTED"
)
result = {
key: payload.get(key)
for key in (
"status",
"execution",
"rowCount",
"truncated",
"columns",
"items",
"generatedSql",
)
if key in payload
}
return {
"generated_sql": generated_sql,
"execution_status": status or execution or "UNKNOWN",
"execution_succeeded": execution_succeeded,
"result": result,
}
def _record_qa_history(
*,
store: QaHistoryStore | None,
benchmark_question: QaQuestion | None,
question_text: str,
conversation_id: str,
requested_by: str,
model_profile: str,
answer_text: str,
mcp_result: Any,
duration_ms: int,
) -> tuple[QaQuestion | None, QaJudgment | None]:
if store is None:
return benchmark_question, None
try:
history_question = benchmark_question or store.find_or_create_free_text_question(
question_text
)
execution = _select_ai_execution_summary(mcp_result)
judgment = evaluate_sql(
benchmark_question,
execution["generated_sql"],
execution_succeeded=bool(execution["execution_succeeded"]),
error_text=_bounded_json(mcp_result, max_chars=8000),
)
store.record_answer(
question_id=int(history_question.question_id or 0),
answer_kind="LIVE",
conversation_id=conversation_id,
requested_by=requested_by,
model_profile=model_profile,
generated_sql=execution["generated_sql"],
answer_text=answer_text,
result=execution["result"],
execution_output=_bounded_json(mcp_result, max_chars=24_000),
execution_status=str(execution["execution_status"]),
judgment_status=judgment.status,
judgment_reason=judgment.reason,
duration_ms=duration_ms,
)
return history_question, judgment
except QaHistoryStoreError as exc:
st.warning(f"질답 이력 저장에 실패했습니다: {exc}")
return benchmark_question, None
def _process_submitted_question( def _process_submitted_question(
*, *,
question: str, question: str,
@@ -6338,6 +6735,8 @@ def _process_submitted_question(
limit: int, limit: int,
selected_token_preset: VpdTokenPreset | None, selected_token_preset: VpdTokenPreset | None,
execution_mode_override: str, execution_mode_override: str,
benchmark_question: QaQuestion | None,
qa_history_store: QaHistoryStore | None,
) -> None: ) -> None:
normalized_question = question.strip() normalized_question = question.strip()
if not normalized_question: if not normalized_question:
@@ -6880,6 +7279,32 @@ def _process_submitted_question(
], ],
"discovery_failures": discovery_failures, "discovery_failures": discovery_failures,
} }
history_question, history_judgment = _record_qa_history(
store=qa_history_store,
benchmark_question=benchmark_question,
question_text=normalized_question,
conversation_id=conversation_id,
requested_by=(
selected_token_preset.user_id
if selected_token_preset is not None
else "portal-user"
),
model_profile=answer_model_profile,
answer_text=str(assistant_message["content"]),
mcp_result=answer_source,
duration_ms=round((perf_counter() - process_started) * 1000),
)
if history_judgment is not None:
assistant_message["details"]["qa_history"] = {
"question_id": history_question.question_id if history_question else None,
"question_code": history_question.question_code if history_question else "",
"judgment_status": history_judgment.status,
"judgment_reason": history_judgment.reason,
}
st.info(
"질답 검증 판정: "
f"{_judgment_label(history_judgment.status)} · {history_judgment.reason}"
)
refresh_progress(98, "질의 결과를 저장하고 있습니다.") refresh_progress(98, "질의 결과를 저장하고 있습니다.")
save_chat_turn( save_chat_turn(
conversation_id=conversation_id, conversation_id=conversation_id,
@@ -6930,31 +7355,17 @@ def main() -> None:
page_title=profile.page_title, page_icon=profile.page_icon, layout="wide" page_title=profile.page_title, page_icon=profile.page_icon, layout="wide"
) )
_apply_console_theme(profile) _apply_console_theme(profile)
_restore_portal_remembered_session() _restore_portal_session_from_cookie()
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
_render_portal_login(profile) _render_portal_login(profile)
return return
try: qa_questions, qa_history_store, qa_store_error = _load_qa_questions()
questions = load_demo_scenarios(DEMO_SCENARIOS_FILE)
except ScenarioConfigError as exc:
st.error(str(exc))
return
scenario_key = "poc4_mcp_discovery_scenario"
question_text_key = "poc4_mcp_discovery_question_text" question_text_key = "poc4_mcp_discovery_question_text"
loaded_scenario_key = "poc4_mcp_discovery_loaded_scenario_id" loaded_qa_question_key = "poc4_mcp_discovery_loaded_qa_question_code"
conversation_id_key = "poc4_mcp_discovery_conversation_id" conversation_id_key = "poc4_mcp_discovery_conversation_id"
chat_page_key = "poc4_mcp_discovery_page" chat_page_key = "poc4_mcp_discovery_page"
query_progress_notice_key = "poc4_query_progress_notice" query_progress_notice_key = "poc4_query_progress_notice"
mcp_cache_generation_key = "poc4_mcp_tools_cache_generation" mcp_cache_generation_key = "poc4_mcp_tools_cache_generation"
selected_scenario_state = st.session_state.get(scenario_key)
scenario_ids = {question.question_id for question in questions}
if str(getattr(selected_scenario_state, "question_id", "")).strip().upper() not in (
"",
*scenario_ids,
):
st.session_state.pop(scenario_key, None)
st.session_state.pop(loaded_scenario_key, None)
st.session_state[question_text_key] = DEFAULT_QUESTION
init_chat_store() init_chat_store()
if conversation_id_key not in st.session_state: if conversation_id_key not in st.session_state:
st.session_state[conversation_id_key] = new_conversation_id() st.session_state[conversation_id_key] = new_conversation_id()
@@ -7069,8 +7480,8 @@ def main() -> None:
else: else:
selected_token_preset = None selected_token_preset = None
if configured_mcp_bearer: if configured_mcp_bearer:
# The HMM MCP gateway uses its own server-side credential. VPD user # The Smilegate MCP gateway uses a server-side credential. UI-only
# tokens remain UI context only and must never be forwarded to it. # context must never be forwarded as a second bearer token.
st.caption("MCP 인증: 서버 관리 토큰 적용") st.caption("MCP 인증: 서버 관리 토큰 적용")
manual_bearer_token = "" manual_bearer_token = ""
elif selected_token_preset is not None: elif selected_token_preset is not None:
@@ -7243,7 +7654,7 @@ def main() -> None:
except (OSError, UnicodeError, ValueError): except (OSError, UnicodeError, ValueError):
st.warning("MCP 설정 JSON을 읽지 못했습니다.") st.warning("MCP 설정 JSON을 읽지 못했습니다.")
st.caption(f"config: {MCP_SERVERS_FILE}") st.caption(f"config: {MCP_SERVERS_FILE}")
st.caption(f"scenario config: {DEMO_SCENARIOS_FILE}") st.caption(f"QA benchmark: {QA_BENCHMARK_FILE}")
st.caption(f"token presets: {VPD_TOKEN_PRESETS_FILE}") st.caption(f"token presets: {VPD_TOKEN_PRESETS_FILE}")
st.caption(f"selected LLM model: {selected_query_model_profile}") st.caption(f"selected LLM model: {selected_query_model_profile}")
st.caption(f"default model: {default_query_model_profile}") st.caption(f"default model: {default_query_model_profile}")
@@ -7256,42 +7667,43 @@ def main() -> None:
'<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>', '<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>',
unsafe_allow_html=True, unsafe_allow_html=True,
) )
architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs( architecture_tab, scenario_tab, few_shot_tab = st.tabs(
["아키텍처", "시나리오", "감사로그", "보안관리"] ["아키텍처", "시나리오", "Few-shot 관리"]
) )
with architecture_tab: with architecture_tab:
_render_architecture_tab() _render_architecture_tab()
with scenario_tab: with scenario_tab:
st.markdown( selected_qa_question = _render_qa_benchmark_panel(
'<div class="kb-section-title input" role="heading" aria-level="3">' qa_questions,
"질문 입력" qa_history_store,
"</div>", qa_store_error,
unsafe_allow_html=True,
)
selected_scenario = st.selectbox(
"업무 데모 질의 샘플",
options=(None, *questions),
format_func=lambda item: (
"선택 안 함 · 직접 질문" if item is None else _question_label(item)
),
key=scenario_key,
)
selected_scenario_id = (
None
if selected_scenario is None
else str(getattr(selected_scenario, "question_id"))
) )
if question_text_key not in st.session_state: if question_text_key not in st.session_state:
st.session_state[question_text_key] = DEFAULT_QUESTION st.session_state[question_text_key] = DEFAULT_QUESTION
if st.session_state.get(loaded_scenario_key, "") != str( selected_qa_code = (
selected_scenario_id or "" selected_qa_question.question_code if selected_qa_question is not None else ""
):
if selected_scenario is not None:
st.session_state[question_text_key] = str(
getattr(selected_scenario, "text")
) )
st.session_state[loaded_scenario_key] = str(selected_scenario_id or "") if st.session_state.get(loaded_qa_question_key, "") != selected_qa_code:
st.session_state[question_text_key] = (
selected_qa_question.question_text
if selected_qa_question is not None
else DEFAULT_QUESTION
)
st.session_state[loaded_qa_question_key] = selected_qa_code
st.markdown(
'<div class="kb-section-title input" role="heading" aria-level="3">'
"질문 실행"
"</div>",
unsafe_allow_html=True,
)
if selected_qa_question is not None:
st.caption(
"선택한 후보 문장을 수정하지 않고 실행하면 자동 판정합니다. 문장을 수정하면 자유 질의로 저장되어 수동 검토 대상이 됩니다."
)
else:
st.caption("자유 질의는 실행 이력으로 남기며, 고객 기준 정답과 자동 비교하지 않습니다.")
with st.form("poc4_mcp_question_form"): with st.form("poc4_mcp_question_form"):
question = st.text_area( question = st.text_area(
@@ -7300,7 +7712,7 @@ def main() -> None:
key=question_text_key, key=question_text_key,
height=120, height=120,
max_chars=1_000, max_chars=1_000,
placeholder="업무 질문을 직접 입력하거나 위 질의 샘플을 선택하세요.", placeholder="고객 검증 후보를 선택하거나 업무 질문을 직접 입력하세요.",
) )
submitted = st.form_submit_button( submitted = st.form_submit_button(
"질문 전송", "질문 전송",
@@ -7359,11 +7771,12 @@ def main() -> None:
height=0, height=0,
) )
with audit_tab: with few_shot_tab:
_render_fga_audit_tab() _render_few_shot_management_tab(
qa_questions,
with operations_tab: qa_history_store,
_render_vpd_operations_tab() qa_store_error,
)
if not submitted: if not submitted:
return return
@@ -7383,6 +7796,13 @@ def main() -> None:
limit=int(limit), limit=int(limit),
selected_token_preset=selected_token_preset, selected_token_preset=selected_token_preset,
execution_mode_override=execution_mode_override, execution_mode_override=execution_mode_override,
benchmark_question=(
selected_qa_question
if selected_qa_question is not None
and question.strip() == selected_qa_question.question_text.strip()
else None
),
qa_history_store=qa_history_store,
) )

View File

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

View File

@@ -1,22 +1,23 @@
{ {
"default_server_id": "hmm_hr_mcp", "default_server_id": "smilegate_game_data_mcp",
"servers": [ "servers": [
{ {
"id": "hmm_hr_mcp", "id": "smilegate_game_data_mcp",
"enabled": true, "enabled": true,
"provider": "hmm_compat_mcp", "provider": "smilegate_select_ai_mcp",
"transport": "http", "transport": "http",
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp", "endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp",
"auth_token_env": "HMM_MCP_BEARER_TOKEN", "auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN",
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS", "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
"default_tool": "search_hr_data", "default_tool": "oracle.select_ai.smilegate_fewshot_nl2sql",
"router_model_profile": "gpt54_mini_oci", "router_model_profile": "gpt54_mini_oci",
"tool_allowlist": [ "tool_allowlist": [
"search_hr_data", "oracle.select_ai.smilegate_fewshot_nl2sql",
"resolve_hr_term", "oracle.select_ai.smilegate_game_text2sql",
"search_hr_policy" "oracle.select_ai.qa_vector_search",
"oracle.select_ai.qa_vector_store"
], ],
"description": "HMM HR knowledge and data MCP server" "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

@@ -3,12 +3,22 @@
{ {
"enabled": true, "enabled": true,
"default": true, "default": true,
"token": "vpd_live_REPLACE_WITH_USER_TOKEN", "user_id": "1001",
"user_id": "FC00789", "name": "Data & AI TF 팀장",
"name": "김설계", "role": "DATA_AI_POC_ADMIN",
"role": "설계사", "channel": "DATA_AI_TF",
"channel": "설계사", "scope": "SGMP_POC 게임 데이터 전체",
"scope": "본인 담당 계약 고객" "token_env": "SMILEGATE_TEAMLEAD_BEARER_TOKEN"
},
{
"enabled": true,
"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

@@ -1,5 +1,5 @@
openpyxl>=3.1,<4 openpyxl>=3.1,<4
oci>=2.180,<3
oracledb>=2,<4 oracledb>=2,<4
pandas>=2,<3 pandas>=2,<3
streamlit>=1.35,<2 streamlit>=1.35,<2

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

@@ -33,8 +33,54 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{ input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
background:#fff !important; border:1px solid var(--console-border) !important; background:#fff !important; border:1px solid var(--console-border) !important;
border-radius:4px !important; box-shadow:none !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; }}
/* 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 {{ div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
border-radius:4px !important; box-shadow:none !important; }} 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; 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="stButton"] > button[kind="primary"],
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{ div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }} background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}

View File

@@ -26,7 +26,10 @@ ALLOWED_OCI_SETTINGS = frozenset(
"OCI_PROFILE", "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): class CompletionClient(Protocol):
@@ -95,7 +98,7 @@ def load_oci_settings() -> OCISettings:
raise ValueError("unsupported OCI authentication mode") raise ValueError("unsupported OCI authentication mode")
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip() 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") raise ValueError("OCI Generative AI compartment is not configured")
return OCISettings( return OCISettings(
auth_type=auth_type, auth_type=auth_type,

View File

@@ -0,0 +1,289 @@
"""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 = "",
) -> 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":
uses_alias_lookup = "COMN_GAME_ALIAS_BAS" in upper_sql
substitutes_sample_game = "STOVE_CHAOSZERO" in upper_sql
if not sql and any(marker in error_text.lower() for marker in failure_markers):
return QaJudgment("PASS", "미지원 게임 질문이 실행 가능한 SQL로 변환되지 않았습니다. 기대한 안전 차단입니다.")
if uses_alias_lookup and not substitutes_sample_game and execution_succeeded:
return QaJudgment("PASS", "미지원 게임을 별칭 테이블로만 확인했고 샘플 게임 ID를 임의 대입하지 않았습니다.")
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 또는 실행 검증에 실패했습니다.")
_apply_case_specific_rules(question.question_code, sql, upper_sql, issues)
if any(issue.startswith("필수") or issue.startswith("월간") or issue.startswith("일별") or issue.startswith("주간") or issue.startswith("CZN-") 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", "필수 테이블·컬럼·집계 조건과 실행 결과를 확인했습니다.")
def _apply_case_specific_rules(code: str, sql: str, upper_sql: str, issues: list[str]) -> None:
if code == "STD-26":
_append_issue(issues, "TRUNC(DATE" in upper_sql, "월간 NRU는 DATE 리터럴이 아니라 BASE_DT 기준 월 범위를 사용해야 합니다.")
_append_issue(issues, "MAX" not in upper_sql or "BASE_DT" not in upper_sql, "월간 NRU는 최신 MAX(BASE_DT) 스냅샷을 기준으로 해야 합니다.")
_append_issue(issues, "RAW_NRU_DT" in upper_sql and "TRUNC(" not in upper_sql, "월간 NRU의 RAW_NRU_DT 기간은 BASE_DT 기준으로 계산해야 합니다.")
elif code == "STD-27":
_append_issue(issues, "AU_FLAG" in upper_sql, "월간 AU는 AU_FLAG가 아니라 LAST_CONN_DT 전월 조건으로 계산해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("MAX", "BASE_DT", "ADD_MONTHS")), "월간 AU는 최신 BASE_DT의 전월을 기준으로 해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("STD_USER_YN", "EXPT_USER_YN", "LAST_CONN_DT")), "월간 AU에는 STD_USER_YN, EXPT_USER_YN, LAST_CONN_DT 조건이 필요합니다.")
elif code == "STD-22":
_append_issue(issues, "NRU_FLAG" not in upper_sql, "일별 NRU는 NRU_FLAG=1을 사용해야 합니다.")
elif code == "STD-25":
_append_issue(issues, "LAST_CONN_DT" not in upper_sql, "최근 7일 AU는 LAST_CONN_DT 기간 조건을 사용해야 합니다.")
_append_issue(issues, "AU_FLAG" in upper_sql, "최근 7일 AU는 AU_FLAG로 제한하면 안 됩니다.")
_append_issue(issues, any(term not in upper_sql for term in ("STD_USER_YN", "EXPT_USER_YN")), "최근 7일 AU에는 STD_USER_YN과 EXPT_USER_YN 조건이 필요합니다.")
_append_issue(issues, "GROUP BY" in upper_sql, "최근 7일 AU는 일자별 목록이 아니라 단일 집계여야 합니다.")
elif code == "STD-28":
_append_issue(issues, any(term not in upper_sql for term in ("STD_USER_YN", "EXPT_USER_YN")), "전체 유저 수에는 STD_USER_YN과 EXPT_USER_YN 조건이 필요합니다.")
_append_issue(issues, "RU_FLAG" in upper_sql, "이 기준의 전체 유저 수는 RU_FLAG가 아니라 STD_USER_YN으로 계산해야 합니다.")
elif code in {"CZN-07", "CZN-08", "CZN-13", "CZN-17"}:
_apply_goods_rules(code, sql, upper_sql, issues)
def _apply_goods_rules(code: str, sql: str, upper_sql: str, issues: list[str]) -> None:
dimension_name_terms = ("DIM_KR_NM", "DIM_EN_NM", "DIM_CTG", "CD_DESC", "CD_DTL_DESC")
has_dimension_name = any(term in upper_sql for term in dimension_name_terms)
if code in {"CZN-07", "CZN-08", "CZN-17"}:
_append_issue(issues, "CZN_COMN_SVC_DIM_BAS" not in upper_sql or not has_dimension_name, f"{code}은 CZN_COMN_SVC_DIM_BAS의 이름/분류 컬럼으로 재화명을 해석해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("DIM_TYPE_DIV_CD", "DIM_CTG")) or "크리스탈" not in sql, f"{code}은 GOODS_AGG와 크리스탈 분류 조건을 사용해야 합니다.")
_append_issue(issues, "HAVE_CNT" in upper_sql and "<>" not in upper_sql and "!=" not in upper_sql, f"{code}은 HAVE_CNT <> 0으로 0 보유량을 제외해야 합니다.")
if code == "CZN-07":
_append_issue(issues, any(term not in upper_sql for term in ("CZN_COMN_USER_MST", "EXPT_USER_YN")), "CZN-07은 유저 마스터를 조인하고 제외 유저를 필터링해야 합니다.")
_append_issue(issues, "GROUP BY" not in upper_sql or "BASE_DT" not in upper_sql, "CZN-07은 기간별 일자 집계를 위해 BASE_DT GROUP BY가 필요합니다.")
elif code == "CZN-08":
_append_issue(issues, any(term not in upper_sql for term in ("AU_FLAG", "EXPT_USER_YN")), "CZN-08은 표준 AU와 제외 유저 조건이 필요합니다.")
_append_issue(issues, "STD_USER_YN" in upper_sql, "CZN-08 기준에는 STD_USER_YN을 추가하면 모집단이 과도하게 좁아집니다.")
_append_issue(issues, "AVG(" in upper_sql, "CZN-08 1인당 평균은 AVG(HAVE_CNT)가 아니라 합계/고유 유저 수여야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("SUM", "COUNT", "DISTINCT", "/")), "CZN-08 평균은 SUM(HAVE_CNT)/COUNT(DISTINCT GUID)로 계산해야 합니다.")
elif code == "CZN-13":
_append_issue(issues, "CZN_CUSTOM_GOODS_HAVE_TXN" in upper_sql, "CZN-13은 보유 스냅샷이 아니라 재화 변동 테이블을 사용해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("CZN_CUSTOM_GOODS_CHANGE_TXN", "GOODS_CHANGE_CNT")), "CZN-13은 GOODS_CHANGE_CNT를 사용해야 합니다.")
_append_issue(issues, "CZN_COMN_SVC_DIM_BAS" not in upper_sql or not has_dimension_name, "CZN-13은 차원 테이블로 에테르를 해석해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("DIM_TYPE_DIV_CD", "DIM_CTG", "CHANGE_TYPE_CD", "'USE'")) or "에테르" not in sql, "CZN-13은 에테르 사용 분류와 CHANGE_TYPE_CD='USE' 조건이 필요합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("CZN_COMN_USER_MST", "EXPT_USER_YN", "COUNT", "DISTINCT", "GUID", "SUM")), "CZN-13은 대상 유저 조인, 고유 사용 유저 수, 사용량 합계가 필요합니다.")
elif code == "CZN-17":
_append_issue(issues, any(term not in upper_sql for term in ("CZN_CUSTOM_GOODS_HAVE_TXN", "HAVE_CNT")), "CZN-17은 재화 보유 스냅샷을 사용해야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("CZN_COMN_CHARACTER_MST", "AU_FLAG", "EXPT_USER_YN")), "CZN-17은 캐릭터 AU와 제외 유저 조건이 필요합니다.")
_append_issue(issues, "AVG(" in upper_sql, "CZN-17 평균은 AVG(HAVE_CNT)가 아니라 합계/고유 캐릭터 수여야 합니다.")
_append_issue(issues, any(term not in upper_sql for term in ("SUM", "COUNT", "DISTINCT", "/", "CUID")), "CZN-17 평균은 SUM(HAVE_CNT)/COUNT(DISTINCT CUID)로 계산해야 합니다.")
_append_issue(issues, "GROUP BY" in upper_sql, "CZN-17은 기준일 단일 집계여야 하므로 상세 GROUP BY를 사용하면 안 됩니다.")
_append_issue(issues, any(term in upper_sql for term in ("LEVEL_COL", "ACM_CONN_DCNT", "TDAY_PLAY_TIME")), "CZN-17에는 레벨·접속일·플레이타임 지표가 포함되면 안 됩니다.")

View File

@@ -0,0 +1,697 @@
"""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"
VECTOR_EXAMPLE_TABLE = "SG_QA_VECTOR_EXAMPLE"
HISTORICAL_RUN_KEY = "HISTORICAL:2026-07-21:term-dict-final-v2"
REFERENCE_STATUSES = ("DRAFT", "APPROVED", "RETIRED")
REFERENCE_KINDS = ("SQL_TEMPLATE", "NO_TARGET", "OBJECT_UNAVAILABLE", "METADATA_POLICY")
TARGET_TYPES = ("NONE", "SINGLE", "MULTI", "ALL", "ANY")
INSPECTION_STATUSES = ("PENDING", "REVIEW", "VERIFIED", "RETIRED")
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 ""),
}
def _vector_example_record(row: Mapping[str, Any]) -> dict[str, Any]:
return {
"example_id": int(row["example_id"]),
"question": str(row.get("question") or ""),
"answer_sql": str(row.get("answer_sql") or ""),
"answer_text": str(row.get("answer_text") or ""),
"reference_status": str(row.get("reference_status") or "DRAFT"),
"reference_kind": str(row.get("reference_kind") or "SQL_TEMPLATE"),
"target_type": str(row.get("target_type") or "ANY"),
"object_role": str(row.get("object_role") or ""),
"inspection_status": str(row.get("inspection_status") or "PENDING"),
"inspection_note": str(row.get("inspection_note") or ""),
"verified_at": str(row.get("verified_at") or ""),
"verified_by": str(row.get("verified_by") or ""),
"source_case_id": str(row.get("source_case_id") or ""),
"source_type": str(row.get("source_type") or ""),
}
def _choice(value: object, choices: tuple[str, ...], field: str) -> str:
normalized = str(value or "").strip().upper()
if normalized not in choices:
raise QaHistoryStoreError(f"{field} 값을 확인해 주세요.")
return normalized
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)
)
password = (
_env_value("POC4_QA_DB_PASSWORD", self._env_file)
or _env_value("BACKOFFICE_SELECT_AI_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)
)
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)
kwargs["wallet_location"] = 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 list_vector_examples_by_case(self, question_code: str) -> list[dict[str, Any]]:
"""Return governed few-shot examples linked to one customer QA case."""
case_id = str(question_code or "").strip().upper()
if not case_id:
raise QaHistoryStoreError("질문 코드를 확인해 주세요.")
sql = f"""
SELECT example_id, question, answer_sql, answer_text,
reference_status, reference_kind, target_type, object_role,
inspection_status, inspection_note,
TO_CHAR(verified_at AT TIME ZONE 'Asia/Seoul',
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS verified_at,
verified_by, source_case_id, source_type
FROM {VECTOR_EXAMPLE_TABLE}
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = :source_case_id
ORDER BY example_id
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, {"source_case_id": case_id})
rows = [_record_from_cursor(cursor, row) for row in cursor]
return [_vector_example_record(row) for row in rows]
def update_vector_example(
self,
*,
example_id: int,
source_case_id: str,
question: str,
answer_sql: str,
answer_text: str,
reference_status: str,
reference_kind: str,
target_type: str,
object_role: str,
inspection_status: str,
inspection_note: str,
verified_by: str,
) -> None:
"""Update one customer example and rebuild its vector document atomically."""
case_id = str(source_case_id or "").strip().upper()
normalized_question = str(question or "").strip()
normalized_sql = str(answer_sql or "").strip()
if not case_id or not normalized_question or not normalized_sql:
raise QaHistoryStoreError("질문과 예제 SQL은 비워둘 수 없습니다.")
status = _choice(reference_status, REFERENCE_STATUSES, "검색 상태")
kind = _choice(reference_kind, REFERENCE_KINDS, "참조 종류")
target = _choice(target_type, TARGET_TYPES, "적용 범위")
inspection = _choice(inspection_status, INSPECTION_STATUSES, "검토 상태")
sql = f"""
UPDATE {VECTOR_EXAMPLE_TABLE}
SET question = :question,
answer_sql = :answer_sql,
answer_text = :answer_text,
embedding_input = TO_CLOB('Customer QA question: ') || :question
|| TO_CLOB(CHR(10) || 'Few-shot SQL template: ') || :answer_sql
|| CASE WHEN :answer_text IS NULL THEN NULL
ELSE TO_CLOB(CHR(10) || 'Few-shot answer: ') || :answer_text END,
embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
TO_CLOB('Customer QA question: ') || :question
|| TO_CLOB(CHR(10) || 'Few-shot SQL template: ') || :answer_sql
|| CASE WHEN :answer_text IS NULL THEN NULL
ELSE TO_CLOB(CHR(10) || 'Few-shot answer: ') || :answer_text END,
JSON(sg_qa_vector_params('search_document'))
),
reference_status = :reference_status,
reference_kind = :reference_kind,
target_type = :target_type,
object_role = :object_role,
inspection_status = :inspection_status,
inspection_note = :inspection_note,
verified_at = CASE WHEN :reference_status = 'APPROVED'
THEN SYSTIMESTAMP ELSE NULL END,
verified_by = CASE WHEN :reference_status = 'APPROVED'
THEN :verified_by ELSE NULL END
WHERE example_id = :example_id
AND source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = :source_case_id
"""
binds = {
"example_id": int(example_id),
"source_case_id": case_id,
"question": normalized_question,
"answer_sql": normalized_sql,
"answer_text": str(answer_text or "").strip() or None,
"reference_status": status,
"reference_kind": kind,
"target_type": target,
"object_role": str(object_role or "").strip()[:64] or None,
"inspection_status": inspection,
"inspection_note": str(inspection_note or "").strip() or None,
"verified_by": str(verified_by or "").strip()[:128] or "PORTAL_OPERATOR",
}
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, binds)
if cursor.rowcount != 1:
raise QaHistoryStoreError("수정할 질문별 few-shot 예제를 찾지 못했습니다.")
connection.commit()
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,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,91 @@
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 (
REFERENCE_STATUSES,
QaHistoryStoreError,
_choice,
_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)
def test_few_shot_status_is_normalized_and_unknown_status_is_rejected(self) -> None:
self.assertEqual("APPROVED", _choice(" approved ", REFERENCE_STATUSES, "검색 상태"))
with self.assertRaises(QaHistoryStoreError):
_choice("LIVE", REFERENCE_STATUSES, "검색 상태")
if __name__ == "__main__":
unittest.main()

View File

@@ -16,29 +16,29 @@ class DemoScenarioConfigTest(unittest.TestCase):
with patch.dict( with patch.dict(
"os.environ", "os.environ",
{ {
"AGENT_CONSOLE_SHORT_NAME": "HMM", "AGENT_CONSOLE_SHORT_NAME": "SMILEGATE",
"AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트", "AGENT_CONSOLE_PAGE_TITLE": "SMILEGATE DATA & AI POC",
"AGENT_CONSOLE_PRIMARY_COLOR": "#003b70", "AGENT_CONSOLE_PRIMARY_COLOR": "#113F67",
}, },
clear=False, clear=False,
): ):
profile = load_app_profile(path) profile = load_app_profile(path)
self.assertEqual(profile.short_name, "HMM") self.assertEqual(profile.short_name, "SMILEGATE")
self.assertEqual(profile.page_title, "HMM AI 업무 에이전트") self.assertEqual(profile.page_title, "SMILEGATE DATA & AI POC")
self.assertEqual(profile.primary_color, "#003b70") self.assertEqual(profile.primary_color, "#113F67")
def test_profile_reads_dotenv_values(self) -> None: def test_profile_reads_dotenv_values(self) -> None:
path = Path(__file__).parents[1] / "config" / "app_profile.json" path = Path(__file__).parents[1] / "config" / "app_profile.json"
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
env_file = Path(temp_dir) / ".env" 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) profile = load_app_profile(path, env_file)
self.assertEqual(profile.short_name, "HMM") self.assertEqual(profile.short_name, "SMILEGATE")
def test_hmm_scenarios_are_enabled_and_unique(self) -> None: def test_smilegate_scenarios_are_enabled_and_unique(self) -> None:
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json" path = Path(__file__).parents[1] / "config" / "smilegate_demo_scenarios.json"
scenarios = load_demo_scenarios(path) scenarios = load_demo_scenarios(path)
self.assertGreaterEqual(len(scenarios), 3) self.assertGreaterEqual(len(scenarios), 3)

12
pom.xml
View File

@@ -92,18 +92,12 @@
<resource> <resource>
<directory>src/main/resources</directory> <directory>src/main/resources</directory>
</resource> </resource>
<!-- Keep the reviewed SQL in sql/adb as the sole Git source, and bundle <!-- Keep reviewed SQL in sql/adb. Runtime exposure remains restricted by
only the curated read-only viewer set into the deployed JAR. --> BACKOFFICE_SECURITY_SQL_SCRIPTS, never by a request path. -->
<resource> <resource>
<directory>sql/adb</directory> <directory>sql/adb</directory>
<targetPath>sql/adb</targetPath> <targetPath>sql/adb</targetPath>
<includes> <includes><include>**/*.sql</include></includes>
<include>62_kb_aso_masking_backoffice_metadata.sql</include>
<include>63_kb_aso_masking_rule_runtime.sql</include>
<include>64_kb_aso_masking_default_column_rules.sql</include>
<include>65_kb_select_ai_vpd_query_api.sql</include>
<include>66_kb_select_ai_vpd_query_ords.sql</include>
</includes>
</resource> </resource>
</resources> </resources>
<plugins> <plugins>

61
scripts/setup-sgmp-qa-vector.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# Registers only the dedicated OCI API signing key for the QA vector store,
# then applies sql/adb/75_*.sql. The narrow outbound HTTPS ACL is an ADMIN
# operation and must be applied before this script runs.
# Secrets stay in environment variables and are never written to this repo.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
: "${SGMP_POC_DB_PASSWORD:?SGMP_POC_DB_PASSWORD is required}"
SQLCL_BIN="${SQLCL_BIN:-sql}"
SGMP_POC_DB_USER="${SGMP_POC_DB_USER:-SGMP_POC}"
SGMP_POC_DB_SERVICE="${SGMP_POC_DB_SERVICE:-sgmpaipoc_low}"
SGMP_POC_WALLET_DIR="${SGMP_POC_WALLET_DIR:?SGMP_POC_WALLET_DIR is required}"
VECTOR_OCI_USER_OCID="${VECTOR_OCI_USER_OCID:?VECTOR_OCI_USER_OCID is required}"
VECTOR_OCI_TENANCY_OCID="${VECTOR_OCI_TENANCY_OCID:?VECTOR_OCI_TENANCY_OCID is required}"
VECTOR_OCI_COMPARTMENT_OCID="${VECTOR_OCI_COMPARTMENT_OCID:?VECTOR_OCI_COMPARTMENT_OCID is required}"
VECTOR_OCI_API_KEY_FILE="${VECTOR_OCI_API_KEY_FILE:?VECTOR_OCI_API_KEY_FILE is required}"
VECTOR_OCI_API_KEY_FINGERPRINT="${VECTOR_OCI_API_KEY_FINGERPRINT:?VECTOR_OCI_API_KEY_FINGERPRINT is required}"
command -v "$SQLCL_BIN" >/dev/null
command -v jq >/dev/null
[[ -f "$SGMP_POC_WALLET_DIR/tnsnames.ora" ]]
[[ -f "$VECTOR_OCI_API_KEY_FILE" ]]
private_key_json="$(jq -Rs . "$VECTOR_OCI_API_KEY_FILE")"
credential_params="$(jq -cn \
--arg user_ocid "$VECTOR_OCI_USER_OCID" \
--arg tenancy_ocid "$VECTOR_OCI_TENANCY_OCID" \
--arg compartment_ocid "$VECTOR_OCI_COMPARTMENT_OCID" \
--argjson private_key "$private_key_json" \
--arg fingerprint "$VECTOR_OCI_API_KEY_FINGERPRINT" \
'{user_ocid:$user_ocid, tenancy_ocid:$tenancy_ocid, compartment_ocid:$compartment_ocid, private_key:$private_key, fingerprint:$fingerprint}')"
credential_params_b64="$(printf '%s' "$credential_params" | base64 | tr -d '\n')"
"$SQLCL_BIN" -thin -L -S -tnsadmin "$SGMP_POC_WALLET_DIR" /nolog <<SQL
whenever oserror exit failure
connect ${SGMP_POC_DB_USER}/"${SGMP_POC_DB_PASSWORD}"@${SGMP_POC_DB_SERVICE}
whenever sqlerror exit sql.sqlcode
DECLARE
v_params CLOB := utl_i18n.raw_to_char(
utl_encode.base64_decode(utl_raw.cast_to_raw('${credential_params_b64}')),
'AL32UTF8'
);
BEGIN
BEGIN
DBMS_VECTOR.DROP_CREDENTIAL('SGMP_POC_QA_VECTOR_CRED');
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -20004 THEN RAISE; END IF;
END;
DBMS_VECTOR.CREATE_CREDENTIAL(
credential_name => 'SGMP_POC_QA_VECTOR_CRED',
params => JSON(v_params)
);
END;
/
@${ROOT}/sql/adb/75_sgmp_qa_vector_retrieval.sql
SELECT config_key, config_value FROM sg_qa_vector_config ORDER BY config_key;
exit success
SQL

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,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,20 @@
-- OCI GenAI chat extraction. This function returns JSON only; it never chooses tables.
CREATE OR REPLACE FUNCTION sg_game_extract_mentions(p_question IN CLOB)
RETURN CLOB AUTHID DEFINER
IS
v_prompt CLOB;
v_result CLOB;
BEGIN
v_prompt := 'Extract only game-name mentions from the user question. '
|| 'Return exactly one JSON object with keys game_mentions (array of strings) '
|| 'and scope_hint (GLOBAL, SINGLE_GAME, MULTI_GAME, ALL_GAMES, UNKNOWN). '
|| 'Do not resolve names to IDs and do not generate SQL. Question: '
|| DBMS_LOB.SUBSTR(p_question, 4000, 1);
v_result := DBMS_CLOUD_AI.GENERATE(
prompt => v_prompt,
profile_name => 'SGMP_POC_OCI_GPT54MINI',
action => 'chat'
);
RETURN v_result;
END;
/

View File

@@ -0,0 +1,364 @@
-- OCI GenAI candidate selection. Vector distance only orders candidates; the
-- model may select one supplied game_key or explicitly reject all candidates.
CREATE OR REPLACE FUNCTION sg_game_match_candidate(
p_mention IN CLOB,
p_candidates_json IN CLOB
) RETURN CLOB AUTHID DEFINER
IS
v_prompt CLOB;
v_result CLOB;
BEGIN
v_prompt := 'Decide whether the extracted game-name mention refers to exactly '
|| 'one game in the supplied candidate array. Candidate vector distance is '
|| 'retrieval evidence only and must not establish identity. Compare the '
|| 'mention with candidate gameName, aliases, gameId, and gamePrefix. '
|| 'If one candidate clearly identifies the same game, return exactly '
|| '{"status":"MATCHED","game_key":"candidate key","reason":"short reason"}. '
|| 'If none clearly identifies the same game, return exactly '
|| '{"status":"UNMATCHED","game_key":null,"reason":"short reason"}. '
|| 'Never invent a game_key and never return a key absent from the supplied '
|| 'candidate array. Mention: '
|| DBMS_LOB.SUBSTR(p_mention, 1000, 1)
|| ' Candidates: '
|| DBMS_LOB.SUBSTR(p_candidates_json, 12000, 1);
v_result := DBMS_CLOUD_AI.GENERATE(
prompt => v_prompt,
profile_name => 'SGMP_POC_OCI_GPT54MINI',
action => 'chat'
);
RETURN v_result;
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_seen_game_keys t_seen_game_keys;
v_result JSON_OBJECT_T := JSON_OBJECT_T();
v_matched_count PLS_INTEGER := 0;
v_data_eligible_count PLS_INTEGER := 0;
v_unmatched_count PLS_INTEGER := 0;
v_mention VARCHAR2(1000);
v_candidates JSON_ARRAY_T;
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_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', 'LLM_CANDIDATE_MATCH');
v_mention_result.put('matchedGameKey', v_selected_key);
v_target := JSON_OBJECT_T.parse(v_matched_candidate.to_clob);
v_target.put('mention', v_mention);
v_target.put('status', 'MATCHED');
v_target.put('matchStatus', 'MATCHED');
v_target.put('reasonCode', 'LLM_CANDIDATE_MATCH');
v_target.put('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');
IF v_decision_status = 'MATCHED' THEN
v_mention_result.put(
'reasonCode',
'LLM_SELECTED_UNKNOWN_CANDIDATE'
);
ELSE
v_mention_result.put('reasonCode', 'LLM_NO_CANDIDATE_MATCH');
END IF;
v_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;
v_result.put('contractVersion', '1.0');
v_result.put('targetType', v_target_type);
v_result.put('scopeType', v_target_type);
v_result.put('extractScopeHint', v_scope_type);
IF v_target_type = 'NONE' THEN
v_result.put('status', 'NO_TARGET');
ELSIF v_matched_count = 0 THEN
v_result.put('status', 'UNMATCHED');
ELSIF v_data_eligible_count = 0 THEN
v_result.put('status', 'UNAVAILABLE');
ELSIF v_unmatched_count > 0 THEN
v_result.put('status', 'PARTIAL');
ELSE
v_result.put('status', 'SUPPORTED');
END IF;
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);
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,18 @@
-- General target-contract guidance belongs to the Select AI profile, not application branches.
-- It contains no current game, prefix, ID, or physical object name.
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.
When an authoritative game query plan is supplied in the user request, use its targets and statuses as the only game-scope source; do not independently rematch a game, infer a default game, or substitute one target's object for another. A plan with no selected game does not by itself prohibit a query: use an approved common object when it can answer the operation. If an operation inherently requires a game-scoped logical object and the relevant plan target is unresolved or its physical object is unavailable, return an appropriate zero-row result. A zero-row result is not a synthetic row containing a NULL value and is not an execution failure. For multiple or all resolved targets, use the supplied target identifiers to group a common object when applicable, or combine only the supplied available objects. When filtering an approved common object by game, derive the allowed game identifiers through the active approved game-alias catalog lookup; do not put a game identifier or prefix directly as a SQL literal predicate. Preserve an alias lookup with no matching data as an empty or aggregate-null result, never as a substituted game. Never invent identifiers, prefixes, or physical object names.~'
);
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

@@ -130,6 +130,69 @@ begin
)'); )');
create_if_missing('create sequence sg_permission_seq start with 1 increment by 1 nocache'); create_if_missing('create sequence sg_permission_seq start with 1 increment by 1 nocache');
create_if_missing('create sequence sg_permission_rule_seq start with 1 increment by 1 nocache'); create_if_missing('create sequence sg_permission_rule_seq start with 1 increment by 1 nocache');
create_if_missing('create table sg_vpd_policy_note (
object_owner varchar2(128) not null,
object_name varchar2(128) not null,
policy_name varchar2(128) not null,
description varchar2(2000),
updated_at timestamp default systimestamp not null,
constraint sg_vpd_policy_note_pk primary key (object_owner, object_name, policy_name)
)');
create_if_missing('create table sg_vpd_filter_note (
function_owner varchar2(128) not null,
function_name varchar2(128) not null,
description varchar2(2000),
updated_at timestamp default systimestamp not null,
constraint sg_vpd_filter_note_pk primary key (function_owner, function_name)
)');
create_if_missing('create table sg_masking_rule (
rule_id number primary key,
rule_code varchar2(100) not null unique,
rule_name varchar2(200) not null,
template_code varchar2(100) not null,
description varchar2(2000),
enabled_yn char(1) default ''Y'' not null,
created_at timestamp default systimestamp not null,
updated_at timestamp default systimestamp not null,
constraint sg_masking_rule_enabled_ck check (enabled_yn in (''Y'', ''N''))
)');
create_if_missing('create table sg_access_bearer_token (
key_id number primary key, user_id number not null, key_prefix varchar2(100) not null,
key_hash varchar2(256) not null unique, expires_at timestamp not null, revoked_at timestamp,
description varchar2(500), created_at timestamp default systimestamp not null,
constraint sg_access_bearer_token_user_fk foreign key (user_id) references sg_app_user(user_id)
)');
create_if_missing('create table sg_backoffice_setting (
setting_key varchar2(200) primary key, setting_value varchar2(4000),
updated_at timestamp default systimestamp not null
)');
create_if_missing('create table sg_vector_document_chunk (
chunk_id number primary key, document_id varchar2(200) not null, chunk_no number not null,
content clob not null, embedding vector(1536, float32), created_at timestamp default systimestamp not null
)');
create_if_missing('create table sg_vector_document_tag (
chunk_id number not null, tech_tag varchar2(200) not null,
constraint sg_vector_document_tag_pk primary key (chunk_id, tech_tag),
constraint sg_vector_document_tag_chunk_fk foreign key (chunk_id) references sg_vector_document_chunk(chunk_id)
)');
create_if_missing('create sequence sg_vector_chunk_seq start with 1 increment by 1 nocache');
create_if_missing('create table sg_column_masking_rule (
column_id number primary key,
rule_id number not null,
updated_at timestamp default systimestamp not null,
constraint sg_column_masking_rule_column_fk foreign key (column_id) references sg_protected_column(column_id),
constraint sg_column_masking_rule_rule_fk foreign key (rule_id) references sg_masking_rule(rule_id)
)');
create_if_missing('create table sg_user_masking_rule (
user_id number not null,
column_id number not null,
decision varchar2(30) not null,
active_yn char(1) default ''Y'' not null,
updated_at timestamp default systimestamp not null,
constraint sg_user_masking_rule_pk primary key (user_id, column_id),
constraint sg_user_masking_rule_user_fk foreign key (user_id) references sg_app_user(user_id),
constraint sg_user_masking_rule_column_fk foreign key (column_id) references sg_protected_column(column_id)
)');
end; end;
/ /
@@ -168,3 +231,99 @@ merge into sg_group_role t using (select 2001 group_id, 3002 role_id from dual)
on (t.group_id=s.group_id and t.role_id=s.role_id) when not matched then insert (group_id, role_id) values (s.group_id, s.role_id); on (t.group_id=s.group_id and t.role_id=s.role_id) when not matched then insert (group_id, role_id) values (s.group_id, s.role_id);
commit; commit;
-- Compatibility layer for remaining backoffice modules. The data is stored
-- only in SG_* tables; these views prevent older controller paths from
-- querying non-existent CB_* physical tables during the Smilegate transition.
create or replace view cb_app_user as
select user_id, user_name, employee_no, dept_code, can_read_contents, active from sg_app_user;
create or replace view cb_app_group as
select group_id, group_code, group_name, description, active_yn from sg_app_group;
create or replace view cb_app_role as
select role_id, role_name, max_sensitivity_level from sg_app_role;
create or replace view cb_user_role as select user_id, role_id from sg_user_role;
create or replace view cb_user_group as select group_id, user_id from sg_user_group;
create or replace view cb_group_role as select group_id, role_id from sg_group_role;
create or replace view cb_protected_object as
select object_id, owner, object_name, ords_path, enabled_yn, description from sg_protected_object;
create or replace view cb_protected_column as
select column_id, object_id, column_name, sensitive_yn, visible_role_id, sensitivity_level, redaction_method from sg_protected_column;
create or replace view cb_permission as
select perm_id, role_id, target_name, action_name, permission_effect from sg_permission;
create or replace view cb_permission_rule as
select rule_id, perm_id, rule_column, rule_type, rule_value from sg_permission_rule;
create or replace view cb_permission_column as
select permission_id, column_name from sg_permission_column;
create or replace view cb_vpd_policy_note as
select object_owner, object_name, policy_name, description, updated_at from sg_vpd_policy_note;
create or replace view cb_vpd_filter_note as
select function_owner, function_name, description, updated_at from sg_vpd_filter_note;
create or replace view cb_masking_rule as
select rule_id, rule_code, rule_name, template_code, description, enabled_yn from sg_masking_rule;
create or replace view cb_column_masking_rule as
select column_id, rule_id, updated_at from sg_column_masking_rule;
create or replace view cb_user_masking_rule as
select user_id, column_id, decision, active_yn, updated_at from sg_user_masking_rule;
create or replace view cb_ords_probe_audit as
select audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at from sg_audit_event;
create or replace view cb_backoffice_setting as
select setting_key, setting_value, updated_at from sg_backoffice_setting;
-- PoC administrator access: both demo operators can manage and query every
-- Smilegate game-data object registered in SGMP_POC.
merge into sg_app_role t
using (select 3099 role_id, 'DATA_AI_POC_ADMIN' role_name,
'Full access to all Smilegate PoC game-data objects' description,
'RESTRICTED' max_sensitivity_level from dual) s
on (t.role_id = s.role_id)
when matched then update set t.role_name=s.role_name, t.description=s.description,
t.max_sensitivity_level=s.max_sensitivity_level, t.updated_at=systimestamp
when not matched then insert (role_id, role_name, description, max_sensitivity_level)
values (s.role_id, s.role_name, s.description, s.max_sensitivity_level);
merge into sg_user_role t
using (select 1001 user_id, 3099 role_id from dual union all select 1002, 3099 from dual) s
on (t.user_id=s.user_id and t.role_id=s.role_id)
when not matched then insert (user_id, role_id) values (s.user_id, s.role_id);
declare
l_object_id number;
l_permission_id number;
begin
for source_object in (
select table_name
from all_tables
where owner = 'SGMP_POC'
and table_name not in ('SEMANTIC_METADATA_CHANGE_LOG', 'SGMP_TERM_CONTEXT_CACHE',
'SGMP_TERM_DICTIONARY', 'SGMP_TERM_SEARCH_LOG', 'SGMP_TERM_SYNONYM')
order by table_name
) loop
begin
select object_id into l_object_id
from sg_protected_object
where owner = 'SGMP_POC' and object_name = source_object.table_name;
exception
when no_data_found then
select nvl(max(object_id), 0) + 1 into l_object_id from sg_protected_object;
insert into sg_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description)
values (l_object_id, 'SGMP_POC', source_object.table_name,
'/sgmp-poc/' || lower(source_object.table_name), 'Y',
'Smilegate PoC game-data object');
end;
begin
select perm_id into l_permission_id
from sg_permission
where role_id = 3099 and target_name = source_object.table_name;
exception
when no_data_found then
l_permission_id := sg_permission_seq.nextval;
insert into sg_permission (perm_id, role_id, target_name, action_name, permission_effect)
values (l_permission_id, 3099, source_object.table_name, 'SELECT', 'ALLOW');
insert into sg_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
values (sg_permission_rule_seq.nextval, l_permission_id, null, 'ALL', null);
end;
end loop;
commit;
end;
/

View File

@@ -6,7 +6,15 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@Configuration @Configuration
@EnableConfigurationProperties(BackofficeProperties.class) @EnableConfigurationProperties({
BackofficeProperties.class,
CatalogProperties.class,
GameScopeProperties.class,
MaskingProperties.class,
McpProperties.class,
ProductProperties.class,
SecuritySqlScriptProperties.class
})
public class AppConfig { public class AppConfig {
@Bean @Bean

View File

@@ -8,7 +8,8 @@ public record BackofficeProperties(
Security security, Security security,
Token token, Token token,
Ords ords, Ords ords,
Ai ai Ai ai,
SelectAi selectAi
) { ) {
public record Security( public record Security(
@@ -72,4 +73,22 @@ public record BackofficeProperties(
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", ""); this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
} }
} }
/** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */
public record SelectAi(
String dbUrl,
String dbUsername,
String dbPassword,
String profile,
Boolean fewShotEnabled,
Integer fewShotTopK
) {
public boolean configured() {
return dbUrl != null && !dbUrl.isBlank()
&& dbUsername != null && !dbUsername.isBlank()
&& dbPassword != null && !dbPassword.isBlank()
&& profile != null && !profile.isBlank();
}
}
} }

View File

@@ -0,0 +1,7 @@
package com.cloudhandson.vpdbackoffice.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "backoffice.catalog")
public record CatalogProperties(String owner, String objects) {
}

View File

@@ -51,6 +51,6 @@ public class DbPoolWarmup {
groupService.findGroupRoles(); groupService.findGroupRoles();
permissionService.findRoles(); permissionService.findRoles();
permissionService.findPermissionViews(); permissionService.findPermissionViews();
log.info("HMM identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000); log.info("Identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
} }
} }

View File

@@ -0,0 +1,26 @@
package com.cloudhandson.vpdbackoffice.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Names the customer-owned DB view used to resolve game query scopes.
*
* <p>The view is a stable integration contract, not a Smilegate-specific table.
* Each environment can expose its own game master, alias source, and approved
* object inventory behind this view without changing application code.</p>
*/
@ConfigurationProperties(prefix = "backoffice.game-scope")
public record GameScopeProperties(
boolean enabled,
String viewName,
int maxScopes
) {
public boolean configured() {
return enabled && viewName != null && !viewName.isBlank();
}
public int resolvedMaxScopes() {
return maxScopes >= 1 && maxScopes <= 20 ? maxScopes : 8;
}
}

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** JSON configuration of database redaction policies this backoffice is allowed to manage. */
@ConfigurationProperties(prefix = "backoffice.masking")
public record MaskingProperties(String policies) {
}

View File

@@ -0,0 +1,148 @@
package com.cloudhandson.vpdbackoffice.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Product-neutral labels and endpoint details for the MCP Select AI tool. */
@ConfigurationProperties(prefix = "backoffice.mcp")
public record McpProperties(
String toolName,
String toolLabel,
String toolDescription,
String promptDescription,
String showpromptToolName,
String showpromptToolLabel,
String showpromptToolDescription,
String qaVectorSearchToolName,
String qaVectorSearchToolLabel,
String qaVectorSearchToolDescription,
String qaVectorStoreToolName,
String qaVectorStoreToolLabel,
String qaVectorStoreToolDescription,
String fewShotNl2SqlToolName,
String fewShotNl2SqlToolLabel,
String fewShotNl2SqlToolDescription,
String gameScopeToolName,
String gameScopeToolLabel,
String gameScopeToolDescription
) {
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL";
private static final String DEFAULT_TOOL_DESCRIPTION =
"승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. "
+ "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.";
private static final String DEFAULT_PROMPT_DESCRIPTION =
"업무 데이터에서 조회할 내용을 자연어로 입력합니다.";
private static final String DEFAULT_SHOWPROMPT_TOOL_NAME =
"oracle.select_ai.data_showprompt";
private static final String DEFAULT_SHOWPROMPT_TOOL_LABEL =
"업무 데이터 SHOWPROMPT";
private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION =
"Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. "
+ "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME =
"oracle.select_ai.qa_vector_search";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL = "Select AI 예제 SQL 조회";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION =
"현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. "
+ "반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다.";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_NAME =
"oracle.select_ai.qa_vector_store";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_LABEL = "Select AI 예제 SQL 저장";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION =
"검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. "
+ "질문과 읽기 전용 답 SQL이 필요합니다.";
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME =
"oracle.select_ai.smilegate_fewshot_nl2sql";
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL =
"Few-shot NL2SQL 실행";
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION =
"벡터 Few-shot 예제를 찾아 prompt에 반영하고, SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. "
+ "Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다.";
private static final String DEFAULT_GAME_SCOPE_TOOL_NAME =
"oracle.select_ai.game_scope_resolve";
private static final String DEFAULT_GAME_SCOPE_TOOL_LABEL = "게임 조회 범위 확인";
private static final String DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION =
"질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다. 데이터 SQL은 실행하지 않으며, "
+ "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.";
public String resolvedToolName() {
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
}
public String resolvedToolLabel() {
return requiredOrDefault(toolLabel, DEFAULT_TOOL_LABEL);
}
public String resolvedToolDescription() {
return requiredOrDefault(toolDescription, DEFAULT_TOOL_DESCRIPTION);
}
public String resolvedPromptDescription() {
return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION);
}
public String resolvedShowpromptToolName() {
return requiredOrDefault(showpromptToolName, DEFAULT_SHOWPROMPT_TOOL_NAME);
}
public String resolvedShowpromptToolLabel() {
return requiredOrDefault(showpromptToolLabel, DEFAULT_SHOWPROMPT_TOOL_LABEL);
}
public String resolvedShowpromptToolDescription() {
return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION);
}
public String resolvedQaVectorSearchToolName() {
return requiredOrDefault(qaVectorSearchToolName, DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME);
}
public String resolvedQaVectorSearchToolLabel() {
return requiredOrDefault(qaVectorSearchToolLabel, DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL);
}
public String resolvedQaVectorSearchToolDescription() {
return requiredOrDefault(qaVectorSearchToolDescription, DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION);
}
public String resolvedQaVectorStoreToolName() {
return requiredOrDefault(qaVectorStoreToolName, DEFAULT_QA_VECTOR_STORE_TOOL_NAME);
}
public String resolvedQaVectorStoreToolLabel() {
return requiredOrDefault(qaVectorStoreToolLabel, DEFAULT_QA_VECTOR_STORE_TOOL_LABEL);
}
public String resolvedQaVectorStoreToolDescription() {
return requiredOrDefault(qaVectorStoreToolDescription, DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION);
}
public String resolvedFewShotNl2SqlToolName() {
return requiredOrDefault(fewShotNl2SqlToolName, DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME);
}
public String resolvedFewShotNl2SqlToolLabel() {
return requiredOrDefault(fewShotNl2SqlToolLabel, DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL);
}
public String resolvedFewShotNl2SqlToolDescription() {
return requiredOrDefault(fewShotNl2SqlToolDescription, DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION);
}
public String resolvedGameScopeToolName() {
return requiredOrDefault(gameScopeToolName, DEFAULT_GAME_SCOPE_TOOL_NAME);
}
public String resolvedGameScopeToolLabel() {
return requiredOrDefault(gameScopeToolLabel, DEFAULT_GAME_SCOPE_TOOL_LABEL);
}
public String resolvedGameScopeToolDescription() {
return requiredOrDefault(gameScopeToolDescription, DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION);
}
private String requiredOrDefault(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.vpdbackoffice.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "backoffice.product")
public record ProductProperties(String name, String title, String dataLabel) {
public String displayName() { return name == null || name.isBlank() ? "Data & AI Backoffice" : name; }
public String pageTitle() { return title == null || title.isBlank() ? displayName() : title; }
public String dataName() { return dataLabel == null || dataLabel.isBlank() ? "업무 데이터" : dataLabel; }
}

View File

@@ -37,7 +37,7 @@ public class SecurityConfig {
.rememberMeCookieName("VPD_REMEMBER_ME") .rememberMeCookieName("VPD_REMEMBER_ME")
.tokenValiditySeconds(security.rememberMeValiditySeconds()) .tokenValiditySeconds(security.rememberMeValiditySeconds())
.useSecureCookie(true) .useSecureCookie(true)
.alwaysRemember(false)); .alwaysRemember(true));
} }
return http return http

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Deployment-provided allow-list for bundled security SQL shown by the backoffice. */
@ConfigurationProperties(prefix = "backoffice.security-sql-scripts")
public record SecuritySqlScriptProperties(String scripts) {
}

View File

@@ -27,10 +27,10 @@ public enum MaskingTemplate {
"A******** (예시)"), "A******** (예시)"),
RRN_PARTIAL( RRN_PARTIAL(
"RRN_PARTIAL", "RRN_PARTIAL",
"주민등록번호 부분 마스킹", "식별번호 부분 마스킹",
"앞 6자리만 표시하고 나머지 가리는 사전 정의 식별번호 규칙입니다.", "앞 6자리만 표시하고 나머지 가리는 사전 정의 식별번호 규칙입니다.",
"DBMS_REDACT.REGEXP", "DBMS_REDACT.REGEXP",
"900101-******* (예시)"); "123456-******* (예시)");
private final String code; private final String code;
private final String label; private final String label;

View File

@@ -50,16 +50,16 @@ public record PermissionView(
return "토큰으로 식별된 이해관계자 본인 행"; return "토큰으로 식별된 이해관계자 본인 행";
} }
if (upper.contains(" OWN_CONTRACT")) { if (upper.contains(" OWN_CONTRACT")) {
return "담당 설계사 본인 계약"; return "담당 게임 서비스 범위";
} }
if (upper.contains(" CHANNEL_CONTRACT")) { if (upper.contains(" CHANNEL_CONTRACT")) {
return "토큰 사용자의 채널 계약"; return "토큰 채널 게임 서비스 범위";
} }
if (upper.contains(" OWN_CUSTOMER")) { if (upper.contains(" OWN_CUSTOMER")) {
return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유"; return "담당 게임 사용자·거래 데이터 범위";
} }
if (upper.contains(" CHANNEL_CUSTOMER")) { if (upper.contains(" CHANNEL_CUSTOMER")) {
return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유"; return "토큰 채널 게임 사용자·거래 데이터 범위";
} }
if (upper.contains(" STATIC_SQL ")) { if (upper.contains(" STATIC_SQL ")) {
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", ""); return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
/**
* Database row from ALL_ANNOTATIONS_USAGE for one table or column annotation.
*/
public record SchemaMetadataAnnotationRow(
String columnName,
String annotationName,
String annotationValue
) {
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
/**
* Database row used only while assembling the schema metadata screen.
*/
public record SchemaMetadataColumnRow(
String columnName,
String dataType,
String nullable,
String comment
) {
}

View File

@@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.domain.structured;
public record StructuredDataTable( public record StructuredDataTable(
String key, String key,
String tableName, String tableName,
String objectType,
String businessName, String businessName,
String description String description
) { ) {
public StructuredDataTable(String key, String tableName, String businessName, String description) {
this(key, tableName, "TABLE", businessName, description);
}
} }

View File

@@ -5,6 +5,7 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@@ -28,7 +29,10 @@ public interface MaskingRuleMapper {
List<ColumnMaskingRule> findColumnRules(); List<ColumnMaskingRule> findColumnRules();
List<MaskingPolicyStatus> findPolicyStatuses(); List<MaskingPolicyStatus> findPolicyStatuses(
@Param("owner") String owner,
@Param("targets") List<MaskingPolicyTarget> targets
);
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId); ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);

View File

@@ -0,0 +1,82 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* All database access for the schema metadata screen. Identifier parameters
* are validated against the screen's closed table list before mapper calls.
*/
@Mapper
public interface SchemaMetadataMapper {
String findTableComment(@Param("owner") String owner, @Param("tableName") String tableName);
List<SchemaMetadataColumnRow> findColumns(
@Param("owner") String owner,
@Param("tableName") String tableName
);
List<SchemaMetadataAnnotationRow> findAnnotations(@Param("tableName") String tableName);
int countColumn(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("columnName") String columnName
);
int countTableAnnotation(
@Param("tableName") String tableName,
@Param("annotationName") String annotationName
);
int countColumnAnnotation(
@Param("tableName") String tableName,
@Param("columnName") String columnName,
@Param("annotationName") String annotationName
);
void updateTableComment(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("commentLiteral") String commentLiteral
);
void updateColumnComment(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("columnName") String columnName,
@Param("commentLiteral") String commentLiteral
);
void dropTableAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("annotationName") String annotationName
);
void addTableAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("annotationName") String annotationName,
@Param("annotationValueLiteral") String annotationValueLiteral
);
void dropColumnAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("columnName") String columnName,
@Param("annotationName") String annotationName
);
void addColumnAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("columnName") String columnName,
@Param("annotationName") String annotationName,
@Param("annotationValueLiteral") String annotationValueLiteral
);
}

View File

@@ -258,8 +258,8 @@ public class BackofficeSchemaService {
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"), "문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL", new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"), "첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL", new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식") "앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
); );
private static final String MASKING_RULE_SEED_SQL = """ private static final String MASKING_RULE_SEED_SQL = """
@@ -272,10 +272,16 @@ public class BackofficeSchemaService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final BackofficeProperties properties; private final BackofficeProperties properties;
private final DataCatalog dataCatalog;
public BackofficeSchemaService(JdbcTemplate jdbcTemplate, BackofficeProperties properties) { public BackofficeSchemaService(
JdbcTemplate jdbcTemplate,
BackofficeProperties properties,
DataCatalog dataCatalog
) {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.properties = properties; this.properties = properties;
this.dataCatalog = dataCatalog;
} }
public SchemaPreflightView preflight() { public SchemaPreflightView preflight() {
@@ -705,7 +711,7 @@ public class BackofficeSchemaService {
@sql/adb/17_agent_ords_security_local_vpd_setup.sql @sql/adb/17_agent_ords_security_local_vpd_setup.sql
@sql/adb/25_agent_ords_security_backoffice_support.sql @sql/adb/25_agent_ords_security_backoffice_support.sql
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
@sql/adb/62_kb_aso_masking_backoffice_metadata.sql -- 4. 배포 환경에서 선택한 사용자·권한 초기화 SQL을 별도로 실행
@sql/adb/21_agent_ords_security_ords_enable_schema.sql @sql/adb/21_agent_ords_security_ords_enable_schema.sql
-- 2. ORDS parsing schema로 접속 -- 2. ORDS parsing schema로 접속
@@ -717,10 +723,9 @@ public class BackofficeSchemaService {
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords; GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords; GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
-- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행 -- 5. 마스킹 규칙을 UI에서 등록된 업무 데이터 컬럼에 연결
@sql/adb/64_kb_aso_masking_default_column_rules.sql -- DBMS_REDACT 정책은 백오피스가 %s 대상에 자동 동기화합니다.
@sql/adb/63_kb_aso_masking_rule_runtime.sql """.formatted(owner.toLowerCase(), dataCatalog.owner());
""".formatted(owner.toLowerCase());
} }
private void appendSql(StringBuilder builder, String sql) { private void appendSql(StringBuilder builder, String sql) {

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import java.util.List;
public interface DataCatalog {
String owner();
List<StructuredDataTable> objects();
StructuredDataTable require(String key);
}

View File

@@ -0,0 +1,54 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
@Service
public class EnvironmentDataCatalog implements DataCatalog {
private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private static final Pattern KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}");
private final String owner;
private final List<StructuredDataTable> objects;
public EnvironmentDataCatalog(CatalogProperties properties, ObjectMapper mapper) {
owner = requireName(properties.owner());
objects = parse(properties.objects(), mapper);
}
@Override public String owner() { return owner; }
@Override public List<StructuredDataTable> objects() { return objects; }
@Override public StructuredDataTable require(String key) {
return objects.stream().filter(item -> item.key().equals(key)).findFirst()
.orElseThrow(() -> new AppException("선택할 수 없는 카탈로그 객체입니다."));
}
private List<StructuredDataTable> parse(String raw, ObjectMapper mapper) {
if (raw == null || raw.isBlank()) {
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.");
}
try {
List<StructuredDataTable> values = mapper.readValue(raw, new TypeReference<>() {});
if (values.isEmpty() || values.stream().map(StructuredDataTable::key).distinct().count() != values.size()) throw new IllegalArgumentException();
values.forEach(this::validate);
return List.copyOf(values);
} catch (Exception exception) {
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.", exception);
}
}
private void validate(StructuredDataTable value) {
if (value == null || value.key() == null || !KEY.matcher(value.key()).matches()
|| !NAME.matcher(value.tableName().toUpperCase(Locale.ROOT)).matches()
|| !("TABLE".equalsIgnoreCase(value.objectType()) || "VIEW".equalsIgnoreCase(value.objectType()))) throw new IllegalArgumentException();
}
private String requireName(String value) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (!NAME.matcher(normalized).matches()) throw new IllegalStateException("BACKOFFICE_CATALOG_OWNER 설정을 확인하세요.");
return normalized;
}
}

View File

@@ -0,0 +1,52 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.MaskingProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/** Loads the managed redaction policy allow-list from BACKOFFICE_MASKING_POLICIES. */
@Service
public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog {
private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private final List<MaskingPolicyTarget> targets;
public EnvironmentMaskingPolicyCatalog(MaskingProperties properties, ObjectMapper objectMapper) {
targets = parse(properties.policies(), objectMapper);
}
@Override
public List<MaskingPolicyTarget> targets() {
return targets;
}
private List<MaskingPolicyTarget> parse(String raw, ObjectMapper objectMapper) {
if (raw == null || raw.isBlank()) {
return List.of();
}
try {
List<MaskingPolicyTarget> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
if (parsed.isEmpty()
|| parsed.stream().map(MaskingPolicyTarget::objectName).distinct().count() != parsed.size()) {
throw new IllegalArgumentException();
}
List<MaskingPolicyTarget> normalized = parsed.stream()
.map(item -> new MaskingPolicyTarget(normalize(item.objectName()), normalize(item.policyName())))
.toList();
return List.copyOf(normalized);
} catch (Exception exception) {
throw new IllegalStateException("BACKOFFICE_MASKING_POLICIES 설정을 확인하세요.", exception);
}
}
private String normalize(String value) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (!NAME.matcher(normalized).matches()) {
throw new IllegalArgumentException();
}
return normalized;
}
}

View File

@@ -0,0 +1,144 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.sql.CallableStatement;
import java.sql.Clob;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
/**
* MCP database gateway for the ADB-owned game query planning functions.
*
* <p>Game mention extraction, vector candidate retrieval, and OCI GenAI
* candidate selection run inside {@code SG_GAME_QUERY_PLAN}. Java validates
* authentication and transports the structured JSON result without applying
* game-name matching rules.</p>
*/
@Service
public class GameCatalogVectorService {
private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService;
private final ObjectMapper objectMapper;
public GameCatalogVectorService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
ObjectMapper objectMapper
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.objectMapper = objectMapper;
}
public ObjectNode queryPlan(
String token, String question, int topK
) {
requireActiveToken(token);
BackofficeProperties.SelectAi database = requiredDatabase();
try (Connection connection = DriverManager.getConnection(
database.dbUrl(), database.dbUsername(), database.dbPassword());
CallableStatement statement = connection.prepareCall(
"{ ? = call sg_game_query_plan(?, ?) }")) {
statement.registerOutParameter(1, Types.CLOB);
statement.setString(2, question);
statement.setInt(3, boundedTopK(topK));
statement.execute();
Clob value = (Clob) statement.getObject(1);
String raw = value == null ? "" :
value.getSubString(1, (int) value.length());
JsonNode parsed = objectMapper.readTree(raw);
if (!(parsed instanceof ObjectNode plan)
|| !plan.hasNonNull("status")
|| !plan.hasNonNull("targetType")
|| !plan.path("targets").isArray()
|| !plan.path("supportedGames").isArray()
|| !plan.path("unmatchedGames").isArray()) {
throw new AppException("ADB 게임 질의 계획 응답 형식이 올바르지 않습니다.");
}
return plan;
} catch (SQLException exception) {
throw new AppException(
"ADB 게임 질의 계획 실패: " + exception.getMessage());
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException(
"ADB 게임 질의 계획 JSON 파싱 실패: " + exception.getMessage());
}
}
public List<GameCandidate> search(
String token, String question, int topK
) {
requireActiveToken(token);
BackofficeProperties.SelectAi database = requiredDatabase();
List<GameCandidate> result = new ArrayList<>();
try (Connection connection = DriverManager.getConnection(
database.dbUrl(), database.dbUsername(), database.dbPassword());
CallableStatement statement = connection.prepareCall(
"{ ? = call sg_game_catalog_search(?, ?) }")) {
statement.registerOutParameter(1, Types.REF_CURSOR);
statement.setString(2, question);
statement.setInt(3, boundedTopK(topK));
statement.execute();
try (ResultSet rows = (ResultSet) statement.getObject(1)) {
while (rows.next()) {
result.add(new GameCandidate(
rows.getString("GAME_KEY"),
rows.getString("GAME_ID"),
rows.getString("GAME_PREFIX"),
rows.getString("GAME_NM"),
rows.getString("GAME_ALIAS_NM"),
rows.getString("USER_MASTER_OBJECT_NAME"),
rows.getDouble("COSINE_DISTANCE")
));
}
}
return result;
} catch (SQLException exception) {
throw new AppException(
"게임 카탈로그 벡터 검색 실패: " + exception.getMessage());
}
}
static int boundedTopK(int topK) {
return Math.min(Math.max(topK, 1), 20);
}
private BackofficeProperties.SelectAi requiredDatabase() {
BackofficeProperties.SelectAi database =
properties == null ? null : properties.selectAi();
if (database == null || !database.configured()) {
throw new AppException("게임 카탈로그 DB 설정이 필요합니다.");
}
return database;
}
private void requireActiveToken(String token) {
if (token == null || token.isBlank()
|| bearerTokenService == null
|| bearerTokenService.findByPlainToken(token.trim()) == null) {
throw new VpdTokenAccessDeniedException();
}
}
public record GameCandidate(
String gameKey,
String gameId,
String gamePrefix,
String gameName,
String aliases,
String userMasterObjectName,
double similarity
) {}
}

View File

@@ -0,0 +1,212 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.config.GameScopeProperties;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
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 java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/**
* Resolves only DB-declared game scopes before an agent invokes a SQL worker.
*
* <p>The service never infers a game, table, prefix, or support status. It reads
* the configured customer view and returns only aliases physically present in the
* question. This makes unavailable games observable when they are retained in
* the customer's game master, while keeping product-specific data out of code.</p>
*/
@Service
public class GameScopeService {
private static final int MAX_QUESTION_LENGTH = 4_000;
private static final Pattern VIEW_NAME = Pattern.compile(
"^[A-Za-z][A-Za-z0-9_$#]*(?:\\.[A-Za-z][A-Za-z0-9_$#]*)?$");
private final BackofficeProperties properties;
private final GameScopeProperties gameScopeProperties;
private final BearerTokenService bearerTokenService;
private final Clock clock;
public GameScopeService(
BackofficeProperties properties,
GameScopeProperties gameScopeProperties,
BearerTokenService bearerTokenService,
Clock clock
) {
this.properties = properties;
this.gameScopeProperties = gameScopeProperties;
this.bearerTokenService = bearerTokenService;
this.clock = clock;
}
public GameScopeResult resolve(String bearerToken, String question) {
requireActiveToken(bearerToken);
String normalizedQuestion = requiredQuestion(question);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
String viewName = requiredViewName();
String comparableQuestion = comparable(normalizedQuestion);
List<GameScope> matches = new ArrayList<>();
String sql = "SELECT game_key, display_name, game_alias, query_allowed_yn, reason_code, "
+ "alias_priority, scope_version FROM " + viewName
+ " WHERE profile_name = ?"
+ " AND INSTR(?, REGEXP_REPLACE(UPPER(game_alias), '[[:space:][:punct:]]', '')) > 0"
+ " ORDER BY alias_priority DESC, LENGTH(game_alias) DESC, game_key";
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, selectAi.profile());
statement.setString(2, comparableQuestion);
try (ResultSet rows = statement.executeQuery()) {
while (rows.next()) {
matches.add(new GameScope(
rows.getString("GAME_KEY"),
rows.getString("DISPLAY_NAME"),
rows.getString("GAME_ALIAS"),
"Y".equalsIgnoreCase(rows.getString("QUERY_ALLOWED_YN")) ? "SUPPORTED" : "UNSUPPORTED",
rows.getString("REASON_CODE"),
rows.getInt("ALIAS_PRIORITY"),
rows.getString("SCOPE_VERSION")
));
}
}
} catch (Exception exception) {
throw new AppException("게임 범위 DB 조회 실패: " + exception.getMessage());
}
Map<String, GameScope> uniqueGames = new LinkedHashMap<>();
for (GameScope match : matches) {
uniqueGames.putIfAbsent(match.gameKey(), match);
}
List<GameScope> scopes = uniqueGames.values().stream()
.sorted(Comparator.comparing(GameScope::aliasPriority).reversed()
.thenComparing(GameScope::gameKey))
.limit(gameScopeProperties.resolvedMaxScopes())
.toList();
String status = scopes.isEmpty() ? "NO_MATCH" : "RESOLVED";
return new GameScopeResult(normalizedQuestion, status, scopes);
}
public boolean configured() {
return gameScopeProperties != null && gameScopeProperties.configured();
}
/**
* Returns true when a generated SQL references a DB-declared game-prefix
* object. Prefixes are read from the configured scope view; no game or table
* name is embedded in application code.
*/
public boolean referencesGameScopedObject(String bearerToken, String sql) {
return referencesGameScopedObjectOutsidePrefixes(bearerToken, sql, Set.of());
}
/**
* Returns true when SQL references a DB-declared game-prefix object whose
* prefix is not present in the authoritative query plan.
*/
public boolean referencesGameScopedObjectOutsidePrefixes(
String bearerToken, String sql, Set<String> allowedPrefixes
) {
requireActiveToken(bearerToken);
if (!gameScopeProperties.configured() || sql == null || sql.isBlank()) {
return false;
}
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
String viewName = requiredViewName();
String prefixSql = "SELECT DISTINCT game_prefix FROM " + viewName
+ " WHERE profile_name = ? AND active_yn = 'Y'"
+ " AND query_allowed_yn = 'Y' AND game_prefix IS NOT NULL";
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(prefixSql)) {
statement.setString(1, selectAi.profile());
try (ResultSet rows = statement.executeQuery()) {
String normalizedSql = sql.toUpperCase(Locale.ROOT);
Set<String> normalizedAllowed = allowedPrefixes == null ? Set.of()
: allowedPrefixes.stream()
.filter(value -> value != null && !value.isBlank())
.map(value -> value.trim().toUpperCase(Locale.ROOT))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
while (rows.next()) {
String prefix = rows.getString("GAME_PREFIX");
if (prefix != null && Pattern.compile(
"(?<![A-Z0-9_$#])" + Pattern.quote(prefix.toUpperCase(Locale.ROOT))
+ "_[A-Z0-9_$#]+(?![A-Z0-9_$#])").matcher(normalizedSql).find()
&& !normalizedAllowed.contains(prefix.toUpperCase(Locale.ROOT))) {
return true;
}
}
}
} catch (Exception exception) {
throw new AppException("게임 범위 prefix 검증 실패: " + exception.getMessage());
}
return false;
}
private String requiredViewName() {
String value = gameScopeProperties == null ? "" : gameScopeProperties.viewName();
if (gameScopeProperties == null || !gameScopeProperties.configured() || !VIEW_NAME.matcher(value.trim()).matches()) {
throw new AppException("게임 범위 view 설정이 필요합니다. BACKOFFICE_GAME_SCOPE_VIEW를 확인하세요.");
}
return value.trim();
}
private BackofficeProperties.SelectAi requiredSelectAi() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("게임 범위 DB 연결 설정이 필요합니다.");
}
return selectAi;
}
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 requiredQuestion(String question) {
String value = question == null ? "" : question.trim();
if (value.isEmpty()) {
throw new AppException("question은 필수입니다.");
}
if (value.length() > MAX_QUESTION_LENGTH) {
throw new AppException("question은 " + MAX_QUESTION_LENGTH + "자 이하여야 합니다.");
}
return value;
}
private String comparable(String value) {
return value.toUpperCase(Locale.ROOT).replaceAll("[\\s\\p{Punct}]", "");
}
public record GameScope(
String gameKey,
String displayName,
String matchedAlias,
String status,
String reasonCode,
int aliasPriority,
String scopeVersion
) {}
public record GameScopeResult(String question, String status, List<GameScope> scopes) {}
}

View File

@@ -0,0 +1,16 @@
package com.cloudhandson.vpdbackoffice.service;
import java.util.List;
import java.util.Set;
public interface MaskingPolicyCatalog {
List<MaskingPolicyTarget> targets();
default Set<String> objectNames() {
return targets().stream().map(MaskingPolicyTarget::objectName).collect(java.util.stream.Collectors.toUnmodifiableSet());
}
default boolean containsObject(String objectName) {
return objectName != null && objectNames().contains(objectName.trim().toUpperCase(java.util.Locale.ROOT));
}
}

View File

@@ -4,7 +4,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -24,37 +23,31 @@ import org.springframework.stereotype.Service;
@Service @Service
public class MaskingPolicySynchronizer { public class MaskingPolicySynchronizer {
private static final String OWNER = "POC_2";
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final MaskingRuleMapper mapper; private final MaskingRuleMapper mapper;
private final DataCatalog dataCatalog;
private final MaskingPolicyCatalog policyCatalog;
public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) { public MaskingPolicySynchronizer(
JdbcTemplate jdbcTemplate,
MaskingRuleMapper mapper,
DataCatalog dataCatalog,
MaskingPolicyCatalog policyCatalog
) {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.mapper = mapper; this.mapper = mapper;
} this.dataCatalog = dataCatalog;
this.policyCatalog = policyCatalog;
private static Map<String, String> managedPolicyMap() {
Map<String, String> policies = new LinkedHashMap<>();
policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT");
policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT");
policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT");
policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT");
return Collections.unmodifiableMap(policies);
} }
public Set<String> managedObjectNames() { public Set<String> managedObjectNames() {
return MANAGED_POLICIES.keySet(); return policyCatalog.objectNames();
} }
public boolean isManagedObject(String objectName) { public boolean isManagedObject(String objectName) {
return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); return policyCatalog.containsObject(objectName);
}
static String managedPolicyName(String objectName) {
return MANAGED_POLICIES.get(objectName);
} }
/** /**
@@ -67,9 +60,9 @@ public class MaskingPolicySynchronizer {
public MaskingPolicySyncResult synchronize() { public MaskingPolicySyncResult synchronize() {
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>(); Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
for (ColumnMaskingRule rule : mapper.findColumnRules()) { for (ColumnMaskingRule rule : mapper.findColumnRules()) {
if (OWNER.equalsIgnoreCase(rule.owner()) if (dataCatalog.owner().equalsIgnoreCase(rule.owner())
&& rule.ruleEnabled() && rule.ruleEnabled()
&& MANAGED_POLICIES.containsKey(rule.objectName())) { && policyCatalog.containsObject(rule.objectName())) {
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule); desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
} }
} }
@@ -79,9 +72,9 @@ public class MaskingPolicySynchronizer {
int addedColumns = 0; int addedColumns = 0;
int modifiedColumns = 0; int modifiedColumns = 0;
int droppedColumns = 0; int droppedColumns = 0;
for (Map.Entry<String, String> policy : MANAGED_POLICIES.entrySet()) { for (MaskingPolicyTarget policy : policyCatalog.targets()) {
String objectName = policy.getKey(); String objectName = policy.objectName();
String policyName = policy.getValue(); String policyName = policy.policyName();
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of()); List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
String enableStatus = policyEnableStatus(objectName, policyName); String enableStatus = policyEnableStatus(objectName, policyName);
if (desired.isEmpty()) { if (desired.isEmpty()) {
@@ -141,7 +134,7 @@ public class MaskingPolicySynchronizer {
SELECT enable SELECT enable
FROM redaction_policies FROM redaction_policies
WHERE object_owner = ? AND object_name = ? AND policy_name = ? WHERE object_owner = ? AND object_name = ? AND policy_name = ?
""", String.class, OWNER, objectName, policyName); """, String.class, dataCatalog.owner(), objectName, policyName);
return statuses.isEmpty() ? null : statuses.getFirst(); return statuses.isEmpty() ? null : statuses.getFirst();
} }
@@ -150,7 +143,7 @@ public class MaskingPolicySynchronizer {
SELECT column_name SELECT column_name
FROM redaction_columns FROM redaction_columns
WHERE object_owner = ? AND object_name = ? WHERE object_owner = ? AND object_name = ?
""", String.class, OWNER, objectName).stream() """, String.class, dataCatalog.owner(), objectName).stream()
.map(this::requiredColumnName) .map(this::requiredColumnName)
.toList(); .toList();
} }
@@ -160,7 +153,7 @@ public class MaskingPolicySynchronizer {
BEGIN BEGIN
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
END; END;
""", OWNER, objectName, policyName); """, dataCatalog.owner(), objectName, policyName);
} }
private void enablePolicy(String objectName, String policyName) { private void enablePolicy(String objectName, String policyName) {
@@ -168,7 +161,7 @@ public class MaskingPolicySynchronizer {
BEGIN BEGIN
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
END; END;
""", OWNER, objectName, policyName); """, dataCatalog.owner(), objectName, policyName);
} }
private void dropColumn(String objectName, String policyName, String columnName) { private void dropColumn(String objectName, String policyName, String columnName) {
@@ -179,7 +172,7 @@ public class MaskingPolicySynchronizer {
action => DBMS_REDACT.DROP_COLUMN, column_name => ? action => DBMS_REDACT.DROP_COLUMN, column_name => ?
); );
END; END;
""", OWNER, objectName, policyName, columnName); """, dataCatalog.owner(), objectName, policyName, columnName);
} }
private void addPolicy( private void addPolicy(
@@ -251,9 +244,9 @@ public class MaskingPolicySynchronizer {
END; END;
""".formatted(functionConstant); """.formatted(functionConstant);
if (regexPattern == null) { if (regexPattern == null) {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
} else { } else {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
} }
return; return;
} }
@@ -276,9 +269,9 @@ public class MaskingPolicySynchronizer {
END; END;
""".formatted(actionConstant, functionConstant); """.formatted(actionConstant, functionConstant);
if (regexPattern == null) { if (regexPattern == null) {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
} else { } else {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
} }
} }
@@ -314,7 +307,7 @@ public class MaskingPolicySynchronizer {
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ? object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
); );
END; END;
""", OWNER, objectName, columnName, expressionName); """, dataCatalog.owner(), objectName, columnName, expressionName);
} }
} }

View File

@@ -0,0 +1,5 @@
package com.cloudhandson.vpdbackoffice.service;
/** A validated object-to-redaction-policy mapping supplied by deployment configuration. */
public record MaskingPolicyTarget(String objectName, String policyName) {
}

View File

@@ -28,19 +28,25 @@ public class MaskingRuleService {
private final ProtectedObjectService protectedObjectService; private final ProtectedObjectService protectedObjectService;
private final AuditService auditService; private final AuditService auditService;
private final MaskingPolicySynchronizer maskingPolicySynchronizer; private final MaskingPolicySynchronizer maskingPolicySynchronizer;
private final DataCatalog dataCatalog;
private final MaskingPolicyCatalog maskingPolicyCatalog;
public MaskingRuleService( public MaskingRuleService(
MaskingRuleMapper mapper, MaskingRuleMapper mapper,
UserMapper userMapper, UserMapper userMapper,
ProtectedObjectService protectedObjectService, ProtectedObjectService protectedObjectService,
AuditService auditService, AuditService auditService,
MaskingPolicySynchronizer maskingPolicySynchronizer MaskingPolicySynchronizer maskingPolicySynchronizer,
DataCatalog dataCatalog,
MaskingPolicyCatalog maskingPolicyCatalog
) { ) {
this.mapper = mapper; this.mapper = mapper;
this.userMapper = userMapper; this.userMapper = userMapper;
this.protectedObjectService = protectedObjectService; this.protectedObjectService = protectedObjectService;
this.auditService = auditService; this.auditService = auditService;
this.maskingPolicySynchronizer = maskingPolicySynchronizer; this.maskingPolicySynchronizer = maskingPolicySynchronizer;
this.dataCatalog = dataCatalog;
this.maskingPolicyCatalog = maskingPolicyCatalog;
} }
public List<MaskingRule> findAllRules() { public List<MaskingRule> findAllRules() {
@@ -55,9 +61,12 @@ public class MaskingRuleService {
return mapper.findColumnRules(); return mapper.findColumnRules();
} }
/** Reads the actual Oracle Data Redaction state for the three managed KB objects. */ /** Reads the actual Oracle Data Redaction state for configured managed objects. */
public List<MaskingPolicyStatus> findPolicyStatuses() { public List<MaskingPolicyStatus> findPolicyStatuses() {
return mapper.findPolicyStatuses(); if (maskingPolicyCatalog.targets().isEmpty()) {
return List.of();
}
return mapper.findPolicyStatuses(dataCatalog.owner(), maskingPolicyCatalog.targets());
} }
public Set<String> managedObjectNames() { public Set<String> managedObjectNames() {

View File

@@ -1,5 +1,7 @@
package com.cloudhandson.vpdbackoffice.service; 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.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -8,31 +10,38 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */ /** MCP boundary exposing Select AI query and read-only prompt diagnostic tools. */
@Service @Service
public class McpSseService { public class McpSseService {
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd"; private static final String SELECT_AI_TOOL_PATH = "/mcp (tools/call)";
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query"; private static final String GAME_CATALOG_TOOL = "oracle.select_ai.game_catalog_resolve";
private static final String SELECT_AI_VPD_QUERY_PROFILE = private static final String GAME_QUERY_PLAN_TOOL = "oracle.select_ai.game_query_plan";
"KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
SELECT_AI_VPD_QUERY_TOOL,
"GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.",
-1L,
"KB Select AI 행 접근 자연어 조회",
SELECT_AI_VPD_QUERY_PATH
);
private final SelectAiAgentOrdsService selectAiAgentOrdsService; private final SelectAiService selectAiService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final BackofficeProperties properties;
private final McpProperties mcpProperties;
private final QaVectorService qaVectorService;
private final GameScopeService gameScopeService;
private final GameCatalogVectorService gameCatalogVectorService;
public McpSseService( public McpSseService(
SelectAiAgentOrdsService selectAiAgentOrdsService, SelectAiService selectAiService,
ObjectMapper objectMapper ObjectMapper objectMapper,
BackofficeProperties properties,
McpProperties mcpProperties,
QaVectorService qaVectorService,
GameScopeService gameScopeService,
GameCatalogVectorService gameCatalogVectorService
) { ) {
this.selectAiAgentOrdsService = selectAiAgentOrdsService; this.selectAiService = selectAiService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.properties = properties;
this.mcpProperties = mcpProperties;
this.qaVectorService = qaVectorService;
this.gameScopeService = gameScopeService;
this.gameCatalogVectorService = gameCatalogVectorService;
} }
public ObjectNode handle(String contextPath, JsonNode request) { public ObjectNode handle(String contextPath, JsonNode request) {
@@ -69,9 +78,11 @@ public class McpSseService {
return response; return response;
} }
/** The only tool registered by this MCP server. */ /** Tools registered by this MCP server. */
public List<McpToolView> registeredTools() { public List<McpToolView> registeredTools() {
return List.of(SELECT_AI_VPD_QUERY_VIEW); return List.of(
selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(),
qaVectorStoreView(), fewShotNl2SqlView(), gameScopeView(), gameCatalogView(), gameQueryPlanView());
} }
private ObjectNode initializeResult(String contextPath) { private ObjectNode initializeResult(String contextPath) {
@@ -90,46 +101,85 @@ public class McpSseService {
private ObjectNode toolsListResult() { private ObjectNode toolsListResult() {
ObjectNode result = objectMapper.createObjectNode(); ObjectNode result = objectMapper.createObjectNode();
ArrayNode tools = objectMapper.createArrayNode(); ArrayNode tools = objectMapper.createArrayNode();
tools.add(selectAiVpdQueryTool()); tools.add(toolDefinition(selectAiQueryView()));
tools.add(toolDefinition(selectAiShowpromptView()));
tools.add(toolDefinition(qaVectorSearchView()));
tools.add(toolDefinition(qaVectorStoreView()));
tools.add(toolDefinition(fewShotNl2SqlView()));
tools.add(toolDefinition(gameScopeView()));
tools.add(toolDefinition(gameCatalogView()));
tools.add(toolDefinition(gameQueryPlanView()));
result.set("tools", tools); result.set("tools", tools);
return result; return result;
} }
private ObjectNode selectAiVpdQueryTool() { private ObjectNode toolDefinition(McpToolView toolView) {
ObjectNode item = objectMapper.createObjectNode(); ObjectNode item = objectMapper.createObjectNode();
item.put("name", SELECT_AI_VPD_QUERY_TOOL); item.put("name", toolView.name());
item.put("description", SELECT_AI_VPD_QUERY_VIEW.description()); item.put("description", toolView.description());
ObjectNode schema = objectMapper.createObjectNode(); ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object"); schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode(); ObjectNode properties = objectMapper.createObjectNode();
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("maxLength", 4000);
properties.set("prompt", prompt);
ObjectNode limit = objectMapper.createObjectNode();
limit.put("type", "integer");
limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다.");
limit.put("minimum", 1);
limit.put("maximum", 100);
properties.set("limit", limit);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode(); ArrayNode required = objectMapper.createArrayNode();
if (qaVectorSearchToolName().equals(toolView.name()) || gameScopeToolName().equals(toolView.name())
|| GAME_CATALOG_TOOL.equals(toolView.name()) || GAME_QUERY_PLAN_TOOL.equals(toolView.name())) {
addStringProperty(properties, "question", "few-shot 예제 SQL을 찾을 현재 질문입니다.", 4000);
ObjectNode topK = properties.putObject("topK");
topK.put("type", "integer");
topK.put("description", "반환할 유사 예제 수입니다. 기본값은 3입니다.");
topK.put("minimum", 1);
topK.put("maximum", 20);
topK.put("default", 3);
required.add("question");
} else if (qaVectorStoreToolName().equals(toolView.name())) {
addStringProperty(properties, "question", "검토된 Select AI 예제가 답한 업무 질문입니다.", 4000);
addStringProperty(properties, "answerSql", "검토된 단일 읽기 전용 SELECT/WITH SQL입니다.", 20000);
addStringProperty(properties, "answer", "선택 사항인 답변 또는 검토 메모입니다.", 20000);
required.add("question");
required.add("answerSql");
} else {
addStringProperty(properties, "prompt", promptDescription(), 4000);
if (fewShotNl2SqlToolName().equals(toolView.name())) {
addStringProperty(properties, "scopeGameKey",
"하위 호환용 선택 값입니다. 새 흐름에서는 queryPlan 전체를 전달하고 비워 둡니다.", 128);
ObjectNode plan = properties.putObject("queryPlan");
plan.put("type", "object");
plan.put("description",
"바로 앞 game_query_plan의 전체 결과입니다. targetType이 NONE, SINGLE, MULTI, ALL 중 "
+ "어느 값이어도 원 질문과 함께 그대로 전달합니다.");
plan.put("additionalProperties", true);
}
required.add("prompt"); required.add("prompt");
}
schema.set("properties", properties);
schema.set("required", required); schema.set("required", required);
schema.put("additionalProperties", false); schema.put("additionalProperties", false);
item.set("inputSchema", schema); item.set("inputSchema", schema);
return item; return item;
} }
private void addStringProperty(ObjectNode properties, String name, String description, int maxLength) {
ObjectNode property = properties.putObject(name);
property.put("type", "string");
property.put("description", description);
property.put("maxLength", maxLength);
}
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) { private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
String toolName = params.path("name").asText(""); String calledToolName = params.path("name").asText("");
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) { boolean queryTool = toolName().equals(calledToolName);
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName); boolean showpromptTool = showpromptToolName().equals(calledToolName);
boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName);
boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName);
boolean fewShotNl2SqlTool = fewShotNl2SqlToolName().equals(calledToolName);
boolean gameScopeTool = gameScopeToolName().equals(calledToolName);
boolean gameCatalogTool = GAME_CATALOG_TOOL.equals(calledToolName);
boolean gameQueryPlanTool = GAME_QUERY_PLAN_TOOL.equals(calledToolName);
if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool && !fewShotNl2SqlTool
&& !gameScopeTool && !gameCatalogTool && !gameQueryPlanTool) {
throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName);
} }
JsonNode arguments = params.path("arguments"); JsonNode arguments = params.path("arguments");
@@ -139,19 +189,43 @@ public class McpSseService {
} }
JsonNode response; JsonNode response;
try { try {
response = selectAiAgentOrdsService.run( if (qaVectorSearchTool) {
response = qaVectorSearchResponse(
qaVectorService.search(token, arguments.path("question").asText(""), arguments.path("topK").asInt(3)));
} else if (qaVectorStoreTool) {
response = qaVectorStoreResponse(qaVectorService.store(
token, token,
arguments.path("prompt").asText(""), arguments.path("question").asText(""),
normalizeLimit(arguments.path("limit").asInt(50)) arguments.path("answerSql").asText(""),
arguments.path("answer").isMissingNode() ? null : arguments.path("answer").asText(null)
));
} else if (gameScopeTool) {
response = gameScopeResponse(gameScopeService.resolve(
token, arguments.path("question").asText("")));
} else if (gameCatalogTool || gameQueryPlanTool) {
response = gameCatalogVectorService.queryPlan(
token,
arguments.path("question").asText(""),
arguments.path("topK").asInt(5)
); );
} else {
String prompt = arguments.path("prompt").asText("");
response = queryTool
? selectAiService.generateAndExecute(token, prompt)
: fewShotNl2SqlTool
? selectAiService.generateAndExecute(token, prompt,
arguments.path("scopeGameKey").asText(""),
arguments.path("queryPlan"))
: selectAiService.generatePrompt(token, prompt);
}
} catch (VpdTokenAccessDeniedException ignored) { } catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult(); return tokenAccessDeniedResult();
} }
ObjectNode payload = objectMapper.createObjectNode(); ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL); payload.put("toolName", calledToolName);
payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE); payload.put("profile", selectAiProfile());
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH); payload.put("ordsPath", SELECT_AI_TOOL_PATH);
payload.set("response", response); payload.set("response", response);
ObjectNode result = objectMapper.createObjectNode(); ObjectNode result = objectMapper.createObjectNode();
@@ -165,6 +239,72 @@ public class McpSseService {
return result; return result;
} }
private ObjectNode qaVectorSearchResponse(QaVectorService.VectorSearchResult result) {
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "QA_VECTOR_SEARCH");
response.put("instruction", "Select AI SQL 생성 전에 few-shot 예제 SQL 후보를 확인합니다.");
response.put("question", result.question());
response.put("topK", result.topK());
ArrayNode examples = response.putArray("examples");
for (QaVectorService.VectorExample example : result.examples()) {
ObjectNode item = examples.addObject();
item.put("exampleId", example.exampleId());
item.put("question", example.question());
item.put("answerSql", example.answerSql());
if (example.answer() != null) {
item.put("answer", example.answer());
}
item.put("embeddingModel", example.embeddingModel());
item.put("referenceKind", example.referenceKind());
item.put("targetType", example.targetType());
if (example.objectRole() != null) {
item.put("objectRole", example.objectRole());
}
if (example.sourceCaseId() != null) {
item.put("sourceCaseId", example.sourceCaseId());
}
if (example.sourceType() != null) {
item.put("sourceType", example.sourceType());
}
item.put("cosineDistance", example.cosineDistance());
}
return response;
}
private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) {
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "QA_VECTOR_STORED");
response.put("instruction", "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장했습니다.");
response.put("exampleId", stored.exampleId());
response.put("question", stored.question());
response.put("embeddingModel", stored.embeddingModel());
response.put("referenceStatus", stored.referenceStatus());
response.put("nextStep", "DRAFT 예제입니다. 실행·정책 검토 후 APPROVED로 전환해야 Few-shot 검색에 사용됩니다.");
return response;
}
/** Converts DB-derived scope facts to a tool response; this tool never executes query SQL. */
private ObjectNode gameScopeResponse(GameScopeService.GameScopeResult result) {
ObjectNode response = objectMapper.createObjectNode();
response.put("status", result.status());
response.put("instruction", "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.");
response.put("question", result.question());
ArrayNode scopes = response.putArray("scopes");
for (GameScopeService.GameScope scope : result.scopes()) {
ObjectNode item = scopes.addObject();
item.put("gameKey", scope.gameKey());
item.put("displayName", scope.displayName());
item.put("matchedAlias", scope.matchedAlias());
item.put("status", scope.status());
item.put("reasonCode", scope.reasonCode());
item.put("aliasPriority", scope.aliasPriority());
item.put("scopeVersion", scope.scopeVersion());
item.put("nextAction", "SUPPORTED".equals(scope.status())
? "CALL_FEW_SHOT_NL2SQL" : "REPORT_UNSUPPORTED");
}
return response;
}
private ObjectNode tokenAccessDeniedResult() { private ObjectNode tokenAccessDeniedResult() {
ObjectNode payload = objectMapper.createObjectNode(); ObjectNode payload = objectMapper.createObjectNode();
payload.put("status", "VPD_TOKEN_DENIED"); payload.put("status", "VPD_TOKEN_DENIED");
@@ -181,11 +321,178 @@ public class McpSseService {
return result; return result;
} }
private int normalizeLimit(int limit) { private McpToolView selectAiQueryView() {
if (limit < 1) { String profile = selectAiProfile();
return 50; return new McpToolView(
toolName(),
profile + " 프로파일로 " + toolDescription(),
-1L,
toolLabel(),
SELECT_AI_TOOL_PATH
);
} }
return Math.min(limit, 100);
private McpToolView selectAiShowpromptView() {
String profile = selectAiProfile();
return new McpToolView(
showpromptToolName(),
profile + " 프로파일로 " + showpromptToolDescription(),
-1L,
showpromptToolLabel(),
SELECT_AI_TOOL_PATH
);
}
private McpToolView qaVectorSearchView() {
return new McpToolView(
qaVectorSearchToolName(), qaVectorSearchToolDescription(), -1L,
qaVectorSearchToolLabel(), SELECT_AI_TOOL_PATH);
}
private McpToolView qaVectorStoreView() {
return new McpToolView(
qaVectorStoreToolName(), qaVectorStoreToolDescription(), -1L,
qaVectorStoreToolLabel(), SELECT_AI_TOOL_PATH);
}
private McpToolView fewShotNl2SqlView() {
return new McpToolView(
fewShotNl2SqlToolName(),
fewShotNl2SqlToolDescription()
+ " game_query_plan의 targetType이 NONE, SINGLE, MULTI, ALL 중 어느 값이어도 "
+ "원 질문과 전체 queryPlan을 한 번 받아 단일 읽기 전용 SQL을 생성·실행합니다. "
+ "queryPlan에 없는 게임, prefix, 물리 객체를 추측하지 않습니다.",
-1L,
fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH);
}
private McpToolView gameScopeView() {
return new McpToolView(
gameScopeToolName(), gameScopeToolDescription(), -1L,
gameScopeToolLabel(), SELECT_AI_TOOL_PATH);
}
private McpToolView gameCatalogView() {
return new McpToolView(
GAME_CATALOG_TOOL,
"게임 카탈로그에서 질문의 게임 범위를 확인합니다. SQL은 실행하지 않습니다.",
-1L, "게임 카탈로그 범위 확인", SELECT_AI_TOOL_PATH);
}
private McpToolView gameQueryPlanView() {
return new McpToolView(GAME_QUERY_PLAN_TOOL,
"항상 먼저 호출해 질문의 게임 대상을 NONE, SINGLE, MULTI, ALL로 판정합니다. "
+ "targets에는 DB 카탈로그의 게임 식별자와 승인된 사용자 마스터 물리 객체명이 포함됩니다. "
+ "어떤 targetType도 종료 조건이 아닙니다. 원 질문과 이 도구의 전체 결과를 "
+ "smilegate_fewshot_nl2sql의 prompt와 queryPlan에 한 번 전달하세요. SQL은 실행하지 않습니다.",
-1L, "게임 질의 계획", SELECT_AI_TOOL_PATH);
}
private String selectAiProfile() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
return "";
}
return selectAi.profile().trim();
}
private String toolName() {
return mcpProperties == null ? "oracle.select_ai.data_text2sql" : mcpProperties.resolvedToolName();
}
private String toolLabel() {
return mcpProperties == null ? "업무 데이터 Text2SQL" : mcpProperties.resolvedToolLabel();
}
private String toolDescription() {
return mcpProperties == null
? "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다."
: mcpProperties.resolvedToolDescription();
}
private String promptDescription() {
return mcpProperties == null ? "업무 데이터에서 조회할 내용을 자연어로 입력합니다." : mcpProperties.resolvedPromptDescription();
}
private String showpromptToolName() {
return mcpProperties == null
? "oracle.select_ai.data_showprompt"
: mcpProperties.resolvedShowpromptToolName();
}
private String showpromptToolLabel() {
return mcpProperties == null
? "업무 데이터 SHOWPROMPT"
: mcpProperties.resolvedShowpromptToolLabel();
}
private String showpromptToolDescription() {
return mcpProperties == null
? "Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다."
: mcpProperties.resolvedShowpromptToolDescription();
}
private String qaVectorSearchToolName() {
return mcpProperties == null ? "oracle.select_ai.qa_vector_search"
: mcpProperties.resolvedQaVectorSearchToolName();
}
private String qaVectorSearchToolLabel() {
return mcpProperties == null ? "Select AI 예제 SQL 조회"
: mcpProperties.resolvedQaVectorSearchToolLabel();
}
private String qaVectorSearchToolDescription() {
return mcpProperties == null
? "현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다."
: mcpProperties.resolvedQaVectorSearchToolDescription();
}
private String qaVectorStoreToolName() {
return mcpProperties == null ? "oracle.select_ai.qa_vector_store"
: mcpProperties.resolvedQaVectorStoreToolName();
}
private String qaVectorStoreToolLabel() {
return mcpProperties == null ? "Select AI 예제 SQL 저장"
: mcpProperties.resolvedQaVectorStoreToolLabel();
}
private String qaVectorStoreToolDescription() {
return mcpProperties == null
? "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다."
: mcpProperties.resolvedQaVectorStoreToolDescription();
}
private String fewShotNl2SqlToolName() {
return mcpProperties == null ? "oracle.select_ai.smilegate_fewshot_nl2sql"
: mcpProperties.resolvedFewShotNl2SqlToolName();
}
private String fewShotNl2SqlToolLabel() {
return mcpProperties == null ? "Few-shot NL2SQL 실행"
: mcpProperties.resolvedFewShotNl2SqlToolLabel();
}
private String fewShotNl2SqlToolDescription() {
return mcpProperties == null
? "벡터 Few-shot 예제를 찾아 prompt에 반영하고 SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다."
: mcpProperties.resolvedFewShotNl2SqlToolDescription();
}
private String gameScopeToolName() {
return mcpProperties == null ? "oracle.select_ai.game_scope_resolve"
: mcpProperties.resolvedGameScopeToolName();
}
private String gameScopeToolLabel() {
return mcpProperties == null ? "게임 조회 범위 확인" : mcpProperties.resolvedGameScopeToolLabel();
}
private String gameScopeToolDescription() {
return mcpProperties == null
? "질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다."
: mcpProperties.resolvedGameScopeToolDescription();
} }
private String pretty(Object value) { private String pretty(Object value) {

View File

@@ -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 // object handlers always select every registered protected column, so
// a sensitive column missing from every JSON row is also a NULL result // a sensitive column missing from every JSON row is also a NULL result
// and must be reported as masked to the MCP/UI caller. // and must be reported as masked to the MCP/UI caller.

View File

@@ -0,0 +1,201 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Types;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/** Calls the SGMP_POC QA-vector database API through the schema-owned Select AI connection. */
@Service
public class QaVectorService {
private static final int MAX_QUESTION_LENGTH = 4_000;
private static final int MAX_ANSWER_SQL_LENGTH = 20_000;
private static final int MAX_ANSWER_LENGTH = 20_000;
private static final Pattern UNSAFE_SQL = Pattern.compile(
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|dbms_[a-z0-9_]*|"
+ "utl_[a-z0-9_]*|sys\\s*\\.)\\b"
);
private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService;
private final Clock clock;
public QaVectorService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
Clock clock
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.clock = clock;
}
public VectorSearchResult search(String bearerToken, String question, int topK) {
return search(bearerToken, question, topK, "ANY");
}
/** Searches only approved examples compatible with the current game target contract. */
public VectorSearchResult search(
String bearerToken, String question, int topK, String targetType) {
requireActiveToken(bearerToken);
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
if (topK < 1 || topK > 20) {
throw new AppException("topK는 1에서 20 사이여야 합니다.");
}
String normalizedTargetType = requiredTargetType(targetType);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
List<VectorExample> examples = new ArrayList<>();
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
CallableStatement statement = connection.prepareCall("{ ? = call sg_qa_vector_search(?, ?, ?) }")) {
statement.registerOutParameter(1, Types.REF_CURSOR);
statement.setString(2, normalizedQuestion);
statement.setInt(3, topK);
statement.setString(4, normalizedTargetType);
statement.execute();
try (ResultSet resultSet = (ResultSet) statement.getObject(1)) {
while (resultSet.next()) {
examples.add(new VectorExample(
resultSet.getLong("EXAMPLE_ID"),
resultSet.getString("QUESTION"),
resultSet.getString("ANSWER_SQL"),
resultSet.getString("ANSWER_TEXT"),
resultSet.getString("EMBEDDING_MODEL"),
resultSet.getString("REFERENCE_KIND"),
resultSet.getString("TARGET_TYPE"),
resultSet.getString("OBJECT_ROLE"),
resultSet.getString("SOURCE_CASE_ID"),
resultSet.getString("SOURCE_TYPE"),
resultSet.getDouble("COSINE_DISTANCE")
));
}
}
} catch (Exception exception) {
throw new AppException("QA 벡터 예제 SQL 조회 실패: " + exception.getMessage());
}
return new VectorSearchResult(normalizedQuestion, topK, examples);
}
public VectorStoreResult store(String bearerToken, String question, String answerSql, String answer) {
requireActiveToken(bearerToken);
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
String normalizedAnswerSql = requiredReadOnlySql(answerSql);
String normalizedAnswer = optionalText(answer, "answer", MAX_ANSWER_LENGTH);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(
"SELECT sg_qa_vector_store(?, ?, ?) AS example_id FROM dual")) {
statement.setString(1, normalizedQuestion);
statement.setString(2, normalizedAnswerSql);
statement.setString(3, normalizedAnswer);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
throw new AppException("QA 벡터 예제 SQL 저장 결과가 없습니다.");
}
return new VectorStoreResult(
resultSet.getLong("EXAMPLE_ID"), normalizedQuestion, "cohere.embed-v4.0", "DRAFT");
}
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("QA 벡터 예제 SQL 저장 실패: " + exception.getMessage());
}
}
private BackofficeProperties.SelectAi requiredSelectAi() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("QA 벡터 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
return selectAi;
}
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 requiredReadOnlySql(String answerSql) {
String normalized = requiredText(answerSql, "answerSql", MAX_ANSWER_SQL_LENGTH)
.replaceFirst(";\\s*$", "").trim();
if (!normalized.matches("(?is)^(select|with)\\b.*") || normalized.contains(";")
|| normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|| UNSAFE_SQL.matcher(normalized).find()) {
throw new AppException("answerSql은 단일 읽기 전용 SELECT/WITH SQL이어야 합니다.");
}
return normalized;
}
private String requiredText(String value, String fieldName, int maximumLength) {
String normalized = optionalText(value, fieldName, maximumLength);
if (normalized == null) {
throw new AppException(fieldName + "는 필수입니다.");
}
return normalized;
}
private String requiredTargetType(String value) {
String normalized = value == null ? "ANY" : value.trim().toUpperCase();
if (!List.of("NONE", "SINGLE", "MULTI", "ALL", "ANY").contains(normalized)) {
throw new AppException("targetType은 NONE, SINGLE, MULTI, ALL, ANY 중 하나여야 합니다.");
}
return normalized;
}
private String optionalText(String value, String fieldName, int maximumLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
return null;
}
if (normalized.length() > maximumLength) {
throw new AppException(fieldName + "" + maximumLength + "자 이하여야 합니다.");
}
return normalized;
}
public record VectorExample(
long exampleId,
String question,
String answerSql,
String answer,
String embeddingModel,
String referenceKind,
String targetType,
String objectRole,
String sourceCaseId,
String sourceType,
double cosineDistance
) {
}
public record VectorSearchResult(String question, int topK, List<VectorExample> examples) {
}
public record VectorStoreResult(
long exampleId, String question, String embeddingModel, String referenceStatus) {
}
}

View File

@@ -1,34 +1,43 @@
package com.cloudhandson.vpdbackoffice.service; package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
/**
* Screen-level orchestration for schema metadata. Database reads and DDL are
* intentionally delegated only to {@link SchemaMetadataMapper}.
*/
@Service @Service
public class SchemaMetadataService { public class SchemaMetadataService {
private static final String OWNER = "POC_2";
private static final int MAX_COMMENT_LENGTH = 4000; private static final int MAX_COMMENT_LENGTH = 4000;
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000; private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private final JdbcTemplate jdbcTemplate; private final SchemaMetadataMapper mapper;
private final StructuredDataService structuredDataService; private final StructuredDataService structuredDataService;
private final DataCatalog catalog;
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) { public SchemaMetadataService(
this.jdbcTemplate = jdbcTemplate; SchemaMetadataMapper mapper,
StructuredDataService structuredDataService, DataCatalog catalog
) {
this.mapper = mapper;
this.structuredDataService = structuredDataService; this.structuredDataService = structuredDataService;
this.catalog = catalog;
} }
public List<StructuredDataTable> tables() { public List<StructuredDataTable> tables() {
@@ -41,13 +50,12 @@ public class SchemaMetadataService {
public SchemaMetadataView find(String tableKey) { public SchemaMetadataView find(String tableKey) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = table.tableName(); String tableName = requireSimpleName(table.tableName(), "table name");
String tableComment = tableComment(tableName);
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName); Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
List<SchemaMetadataColumn> columns = columns(tableName, annotations); List<SchemaMetadataColumn> columns = columns(tableName, annotations);
return new SchemaMetadataView( return new SchemaMetadataView(
table, table,
nullToEmpty(tableComment), nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)),
annotations.getOrDefault(tableTargetKey(), List.of()), annotations.getOrDefault(tableTargetKey(), List.of()),
columns columns
); );
@@ -56,24 +64,24 @@ public class SchemaMetadataService {
@Transactional @Transactional
public void updateTableComment(String tableKey, String comment) { public void updateTableComment(String tableKey, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = requireSimpleName(table.tableName(), "table name");
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment"); String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName()) mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment));
+ " IS " + quoteLiteral(normalizedComment));
} }
@Transactional @Transactional
public void updateColumnComment(String tableKey, String columnName, String comment) { public void updateColumnComment(String tableKey, String columnName, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
String column = requireColumn(table.tableName(), columnName); String tableName = requireSimpleName(table.tableName(), "table name");
String column = requireColumn(tableName, columnName);
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment"); String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "." mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment));
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
} }
@Transactional @Transactional
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) { public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
updateAnnotation(table.tableName(), null, annotationName, annotationValue); updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue);
} }
@Transactional @Transactional
@@ -84,8 +92,9 @@ public class SchemaMetadataService {
String annotationValue String annotationValue
) { ) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
String column = requireColumn(table.tableName(), columnName); String tableName = requireSimpleName(table.tableName(), "table name");
updateAnnotation(table.tableName(), column, annotationName, annotationValue); String column = requireColumn(tableName, columnName);
updateAnnotation(tableName, column, annotationName, annotationValue);
} }
private void updateAnnotation( private void updateAnnotation(
@@ -97,75 +106,55 @@ public class SchemaMetadataService {
String key = requireSimpleName(annotationName, "annotation name"); String key = requireSimpleName(annotationName, "annotation name");
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value"); String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
if (annotationExists(tableName, columnName, key)) { if (annotationExists(tableName, columnName, key)) {
jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key))); if (columnName == null) {
mapper.dropTableAnnotation(catalog.owner(), tableName, key);
} else {
mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key);
}
} }
if (!value.isBlank()) { if (!value.isBlank()) {
jdbcTemplate.execute(annotationSql(tableName, columnName, if (columnName == null) {
"ADD " + quoteName(key) + " " + quoteLiteral(value))); mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value));
} else {
mapper.addColumnAnnotation(catalog.owner(), tableName, columnName, key, quoteLiteral(value));
} }
} }
private String tableComment(String tableName) {
List<String> values = jdbcTemplate.query("""
SELECT comments
FROM all_tab_comments
WHERE owner = ?
AND table_name = ?
""", (rs, rowNum) -> rs.getString(1), OWNER, tableName);
return values.isEmpty() ? "" : values.getFirst();
} }
private List<SchemaMetadataColumn> columns( private List<SchemaMetadataColumn> columns(
String tableName, String tableName,
Map<String, List<SchemaAnnotation>> annotations Map<String, List<SchemaAnnotation>> annotations
) { ) {
return jdbcTemplate.query(""" return mapper.findColumns(catalog.owner(), tableName).stream()
SELECT c.column_name, .map(row -> toColumn(row, annotations))
CASE .toList();
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR') }
THEN c.data_type || '(' || c.char_length || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL private SchemaMetadataColumn toColumn(
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')' SchemaMetadataColumnRow row,
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL Map<String, List<SchemaAnnotation>> annotations
THEN c.data_type || '(' || c.data_precision || ')' ) {
ELSE c.data_type String columnName = requireSimpleName(row.columnName(), "column name");
END AS display_type, return new SchemaMetadataColumn(
c.nullable, columnName,
cc.comments row.dataType(),
FROM all_tab_columns c "Y".equalsIgnoreCase(row.nullable()),
LEFT JOIN all_col_comments cc nullToEmpty(row.comment()),
ON cc.owner = c.owner annotations.getOrDefault(columnTargetKey(columnName), List.of())
AND cc.table_name = c.table_name );
AND cc.column_name = c.column_name
WHERE c.owner = ?
AND c.table_name = ?
ORDER BY c.column_id
""", (rs, rowNum) -> new SchemaMetadataColumn(
rs.getString("column_name"),
rs.getString("display_type"),
"Y".equalsIgnoreCase(rs.getString("nullable")),
nullToEmpty(rs.getString("comments")),
annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of())
), OWNER, tableName);
} }
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) { private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>(); Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
jdbcTemplate.query(""" for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(tableName)) {
SELECT column_name, annotation_name, annotation_value String target = row.columnName() == null
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
""", rs -> {
String target = rs.getString("column_name") == null
? tableTargetKey() ? tableTargetKey()
: columnTargetKey(rs.getString("column_name")); : columnTargetKey(row.columnName());
grouped grouped
.computeIfAbsent(target, ignored -> new LinkedHashMap<>()) .computeIfAbsent(target, ignored -> new LinkedHashMap<>())
.computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>()) .computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>())
.add(nullToEmpty(rs.getString("annotation_value"))); .add(nullToEmpty(row.annotationValue()));
}, tableName); }
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>(); Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
grouped.forEach((target, valuesByName) -> { grouped.forEach((target, valuesByName) -> {
@@ -180,44 +169,14 @@ public class SchemaMetadataService {
} }
private boolean annotationExists(String tableName, String columnName, String annotationName) { private boolean annotationExists(String tableName, String columnName, String annotationName) {
Integer count = columnName == null return columnName == null
? jdbcTemplate.queryForObject(""" ? mapper.countTableAnnotation(tableName, annotationName) > 0
SELECT COUNT(*) : mapper.countColumnAnnotation(tableName, columnName, annotationName) > 0;
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
AND annotation_name = ?
AND column_name IS NULL
""", Integer.class, tableName, annotationName)
: jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
AND annotation_name = ?
AND column_name = ?
""", Integer.class, tableName, annotationName, columnName);
return count != null && count > 0;
}
private String annotationSql(String tableName, String columnName, String operation) {
if (columnName == null) {
return "ALTER TABLE " + qualifiedTable(tableName) + " ANNOTATIONS (" + operation + ")";
}
return "ALTER TABLE " + qualifiedTable(tableName) + " MODIFY " + quoteName(columnName)
+ " ANNOTATIONS (" + operation + ")";
} }
private String requireColumn(String tableName, String columnName) { private String requireColumn(String tableName, String columnName) {
String column = requireSimpleName(columnName, "column name"); String column = requireSimpleName(columnName, "column name");
Integer count = jdbcTemplate.queryForObject(""" if (mapper.countColumn(catalog.owner(), tableName, column) == 0) {
SELECT COUNT(*)
FROM all_tab_columns
WHERE owner = ?
AND table_name = ?
AND column_name = ?
""", Integer.class, OWNER, tableName, column);
if (count == null || count == 0) {
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다."); throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
} }
return column; return column;
@@ -242,14 +201,6 @@ public class SchemaMetadataService {
return normalized; return normalized;
} }
private String qualifiedTable(String tableName) {
return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name"));
}
private String quoteName(String value) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
private String quoteLiteral(String value) { private String quoteLiteral(String value) {
return "'" + value.replace("'", "''") + "'"; return "'" + value.replace("'", "''") + "'";
} }

View File

@@ -1,11 +1,15 @@
package com.cloudhandson.vpdbackoffice.service; package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript; import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary; 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.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -17,46 +21,18 @@ import org.springframework.stereotype.Service;
@Service @Service
public class SecuritySqlScriptService { public class SecuritySqlScriptService {
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of( private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}");
new ScriptDefinition( private static final Pattern RESOURCE_PATH = Pattern.compile(
"aso-masking-metadata", "(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql"
"ASO / 마스킹",
"62_kb_aso_masking_backoffice_metadata.sql",
"컬럼 마스킹 규칙 메타데이터",
"ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
),
new ScriptDefinition(
"aso-masking-runtime",
"ASO / 마스킹",
"63_kb_aso_masking_rule_runtime.sql",
"ASO 마스킹 런타임 적용",
"백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다."
),
new ScriptDefinition(
"aso-masking-default-columns",
"ASO / 마스킹",
"64_kb_aso_masking_default_column_rules.sql",
"ASO 기본 대상 컬럼",
"주민번호·청구/지급금·타사보유 컬럼의 마스킹 블랙리스트 초기값을 연결합니다."
),
new ScriptDefinition(
"select-ai-vpd-api",
"Select AI / 행 접근",
"65_kb_select_ai_vpd_query_api.sql",
"행 접근 적용 Select AI 조회 API",
"생성 SQL을 KB 업무 테이블의 단일 읽기 전용 SELECT/WITH로 검증해 행 접근 컨텍스트에서 실행합니다."
),
new ScriptDefinition(
"select-ai-vpd-ords",
"ORDS / Select AI",
"66_kb_select_ai_vpd_query_ords.sql",
"Select AI 행 접근 ORDS Endpoint",
"Bearer 토큰을 검증해 행 접근 컨텍스트를 설정한 뒤 Select AI 조회 API를 노출합니다."
)
); );
private final List<ScriptDefinition> scripts;
public SecuritySqlScriptService(SecuritySqlScriptProperties properties, ObjectMapper objectMapper) {
scripts = parse(properties.scripts(), objectMapper);
}
public List<SecuritySqlScriptSummary> list() { public List<SecuritySqlScriptSummary> list() {
return CURATED_SCRIPTS.stream() return scripts.stream()
.map(definition -> new SecuritySqlScriptSummary( .map(definition -> new SecuritySqlScriptSummary(
definition.scriptId(), definition.scriptId(),
definition.category(), definition.category(),
@@ -68,7 +44,7 @@ public class SecuritySqlScriptService {
} }
public SecuritySqlScript find(String scriptId) { public SecuritySqlScript find(String scriptId) {
ScriptDefinition definition = CURATED_SCRIPTS.stream() ScriptDefinition definition = scripts.stream()
.filter(candidate -> candidate.scriptId().equals(scriptId)) .filter(candidate -> candidate.scriptId().equals(scriptId))
.findFirst() .findFirst()
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다.")); .orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
@@ -91,7 +67,36 @@ public class SecuritySqlScriptService {
} }
} }
private record ScriptDefinition( private List<ScriptDefinition> parse(String raw, ObjectMapper objectMapper) {
if (raw == null || raw.isBlank()) {
return List.of();
}
try {
List<ScriptDefinition> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
if (parsed.isEmpty() || parsed.stream().map(ScriptDefinition::scriptId).distinct().count() != parsed.size()) {
throw new IllegalArgumentException();
}
parsed.forEach(this::validate);
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();
}
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
public record ScriptDefinition(
String scriptId, String scriptId,
String category, String category,
String fileName, String fileName,

View File

@@ -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<String> 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);
}
}

View File

@@ -0,0 +1,649 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Statement;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/**
* Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile.
*/
@Service
public class SelectAiService {
private static final int MAX_PROMPT_LENGTH = 4_000;
private static final int MAX_RESULT_ROWS = 100;
private static final int QUERY_TIMEOUT_SECONDS = 30;
private static final int MAX_FEW_SHOT_EXAMPLES = 3;
private static final int MAX_FEW_SHOT_SQL_CHARS = 4_000;
private static final int MAX_ENRICHED_PROMPT_LENGTH = 16_000;
private static final String POLICY_PREFIX =
"Answer the original user question using the current approved object list and profile policy.\n\n";
private static final Pattern UNSAFE_SQL = Pattern.compile(
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
+ "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b"
);
private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService;
private final Clock clock;
private final ObjectMapper objectMapper;
private final QaVectorService qaVectorService;
private final GameScopeService gameScopeService;
private final GameCatalogVectorService gameCatalogVectorService;
public SelectAiService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
Clock clock,
ObjectMapper objectMapper,
QaVectorService qaVectorService,
GameScopeService gameScopeService,
GameCatalogVectorService gameCatalogVectorService
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.clock = clock;
this.objectMapper = objectMapper;
this.qaVectorService = qaVectorService;
this.gameScopeService = gameScopeService;
this.gameCatalogVectorService = gameCatalogVectorService;
}
public JsonNode generateAndExecute(String bearerToken, String prompt) {
return generateAndExecute(bearerToken, prompt, null);
}
/**
* Executes one DB-resolved game scope. The optional key is revalidated for
* the original question; it is never a caller-provided table or prefix.
*/
public JsonNode generateAndExecute(String bearerToken, String prompt, String scopeGameKey) {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
ResolvedExecutionScope scope = resolveExecutionScope(bearerToken, normalizedPrompt, scopeGameKey);
GameContext gameContext = resolveGameContext(bearerToken, normalizedPrompt);
return generateAndExecutePrepared(
bearerToken,
normalizedPrompt,
selectAi,
gameContext.prompt(scope.prompt()),
gameContext.scopeType(),
gameContext.status(),
gameContext.candidates().size(),
scope,
null,
null
);
}
public JsonNode generateAndExecute(
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) {
if (priorToolContext == null || priorToolContext.isMissingNode()
|| priorToolContext.isNull()) {
return generateAndExecute(bearerToken, prompt, scopeGameKey);
}
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
QueryPlanContext queryPlan = requiredQueryPlan(priorToolContext);
ResolvedExecutionScope scope = queryPlan.scope(scopeGameKey);
return generateAndExecutePrepared(
bearerToken,
normalizedPrompt,
selectAi,
queryPlan.prompt(normalizedPrompt),
queryPlan.targetType(),
queryPlan.status(),
queryPlan.targetCount(),
scope,
queryPlan.allowedPrefixes(),
queryPlan
);
}
private JsonNode generateAndExecutePrepared(
String bearerToken,
String originalPrompt,
BackofficeProperties.SelectAi selectAi,
String executionPrompt,
String gameScopeType,
String gameScopeStatus,
int gameCandidateCount,
ResolvedExecutionScope scope,
Set<String> allowedPrefixes,
QueryPlanContext queryPlan
) {
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
bearerToken, selectAi, executionPrompt, queryPlan == null ? "ANY" : queryPlan.targetType());
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
String normalizedSql = validateReadOnlySql(generatedSql);
if (allowedPrefixes != null && gameScopeService != null
&& gameScopeService.configured()
&& gameScopeService.referencesGameScopedObjectOutsidePrefixes(
bearerToken, normalizedSql, allowedPrefixes)) {
// This is a model-output validation failure, not an answer fallback.
// Give Select AI its own violated-plan feedback once and validate the
// regenerated SQL under exactly the same read-only and scope rules.
generatedSql = generate(selectAi, scopeCorrectionPrompt(enrichedPrompt.prompt()), "showsql");
normalizedSql = validateReadOnlySql(generatedSql);
if (gameScopeService.referencesGameScopedObjectOutsidePrefixes(
bearerToken, normalizedSql, allowedPrefixes)) {
throw new AppException(
"Select AI 생성 SQL이 게임 질의 계획에 없는 prefix 전용 객체를 참조했습니다.");
}
}
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "SHOWSQL_AND_EXECUTED");
response.put("profile", selectAi.profile());
response.put("originalPrompt", originalPrompt);
response.put("gameScopeType", gameScopeType);
response.put("gameScopeStatus", gameScopeStatus);
response.put("gameCandidateCount", gameCandidateCount);
if (queryPlan != null) {
response.put("queryPlanTargetType", queryPlan.targetType());
response.put("queryPlanStatus", queryPlan.status());
response.put("queryPlanTargetCount", queryPlan.targetCount());
}
if (scope.gameKey() != null) {
response.put("scopeGameKey", scope.gameKey());
response.put("scopeDisplayName", scope.displayName());
response.put("scopeStatus", queryPlan == null
? "DB_REVALIDATED" : "QUERY_PLAN_VALIDATED");
}
response.put("fewShotStatus", enrichedPrompt.status());
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
addFewShotExamples(response, enrichedPrompt.examples());
response.put("generatedSql", normalizedSql);
response.put("execution", "READ_ONLY_EXECUTED");
response.put("rowCount", execution.items().size());
response.put("truncated", execution.truncated());
response.set("items", execution.items());
response.put("nextStep", execution.truncated()
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다."
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
return response;
}
private QueryPlanContext requiredQueryPlan(JsonNode plan) {
JsonNode normalizedPlan = unwrapMcpToolResult(plan);
String targetType = normalizedPlan.path("targetType")
.asText("").trim().toUpperCase(Locale.ROOT);
if (!Set.of("NONE", "SINGLE", "MULTI", "ALL").contains(targetType)
|| !normalizedPlan.path("targets").isArray()) {
throw new AppException(
"queryPlan은 targetType(NONE/SINGLE/MULTI/ALL)과 targets 배열이 필요합니다.");
}
Set<String> allowedPrefixes = new LinkedHashSet<>();
for (JsonNode target : normalizedPlan.path("targets")) {
String prefix = target.path("gamePrefix").asText("").trim();
if (!prefix.isEmpty()) {
allowedPrefixes.add(prefix.toUpperCase(Locale.ROOT));
}
}
return new QueryPlanContext(
targetType,
normalizedPlan.path("status").asText(""),
normalizedPlan.path("targets").size(),
Set.copyOf(allowedPrefixes),
normalizedPlan.deepCopy()
);
}
/**
* Accept the direct game-query-plan contract as well as the standard MCP
* tools/call envelope the ReAct model can copy from a preceding observation.
* This is transport normalization only; the target contract itself remains
* the authoritative data-driven policy.
*/
JsonNode unwrapMcpToolResult(JsonNode candidate) {
JsonNode current = candidate;
for (int depth = 0; depth < 6 && current != null; depth++) {
if (current.isTextual()) {
try {
current = objectMapper.readTree(current.asText());
continue;
} catch (JsonProcessingException ignored) {
return current;
}
}
// LangChain can forward an MCP TextContent item verbatim as
// {"type":"text","text":"{...tools/call response...}"}. Unwrap the
// transport envelope before looking for the response payload.
if (current.isObject() && "text".equals(current.path("type").asText())
&& current.path("text").isTextual()) {
current = current.path("text");
continue;
}
JsonNode response = current.path("response");
if (response.isObject() || response.isTextual()) {
current = response;
continue;
}
break;
}
return current == null ? objectMapper.createObjectNode() : current;
}
private GameContext resolveGameContext(String bearerToken, String question) {
try {
List<GameCatalogVectorService.GameCandidate> candidates =
gameCatalogVectorService.search(bearerToken, question, 5);
String status = candidates.isEmpty() ? "NO_MATCH" : "RESOLVED";
String scopeType = candidates.isEmpty() ? "UNKNOWN" : "SINGLE_GAME";
return new GameContext(scopeType, status, candidates);
} catch (Exception ignored) {
return new GameContext("UNKNOWN", "UNAVAILABLE", List.of());
}
}
private ResolvedExecutionScope resolveExecutionScope(
String bearerToken, String originalPrompt, String scopeGameKey
) {
String requestedKey = scopeGameKey == null ? "" : scopeGameKey.trim();
if (requestedKey.isEmpty()) {
return new ResolvedExecutionScope(originalPrompt, null, null);
}
if (gameScopeService == null) {
throw new AppException("게임 범위 검증 서비스를 사용할 수 없습니다.");
}
GameScopeService.GameScope scope = gameScopeService.resolve(bearerToken, originalPrompt).scopes().stream()
.filter(item -> requestedKey.equals(item.gameKey()))
.findFirst()
.orElseThrow(() -> new AppException("요청한 게임 범위가 DB 조회 결과에 없습니다."));
if (!"SUPPORTED".equals(scope.status())) {
throw new AppException("DB 게임 범위가 조회 실행을 허용하지 않습니다: " + scope.reasonCode());
}
String scopedPrompt = "Use only the DB-resolved game scope below. Do not generate SQL for any other "
+ "game mentioned in the original question. The scope was validated through current game alias "
+ "metadata and approved object availability.\n"
+ "Resolved game key: " + scope.gameKey() + "\n"
+ "Resolved display name: " + scope.displayName() + "\n"
+ "Matched alias: " + scope.matchedAlias() + "\n\n"
+ "Original user question:\n" + originalPrompt;
return new ResolvedExecutionScope(scopedPrompt, scope.gameKey(), scope.displayName());
}
private void addFewShotExamples(ObjectNode response, List<QaVectorService.VectorExample> examples) {
ArrayNode items = response.putArray("fewShotExamples");
for (QaVectorService.VectorExample example : examples) {
ObjectNode item = items.addObject();
item.put("exampleId", example.exampleId());
item.put("question", example.question());
item.put("answerSql", example.answerSql());
if (example.answer() != null) {
item.put("answer", example.answer());
}
item.put("embeddingModel", example.embeddingModel());
item.put("referenceKind", example.referenceKind());
item.put("targetType", example.targetType());
if (example.objectRole() != null) {
item.put("objectRole", example.objectRole());
}
if (example.sourceCaseId() != null) {
item.put("sourceCaseId", example.sourceCaseId());
}
if (example.sourceType() != null) {
item.put("sourceType", example.sourceType());
}
item.put("cosineDistance", example.cosineDistance());
}
}
/** Returns the prompt Select AI assembled for SQL generation without executing generated SQL. */
public JsonNode generatePrompt(String bearerToken, String prompt) {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt, "ANY");
String selectAiPrompt = generate(selectAi, enrichedPrompt.prompt(), "showprompt");
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "SHOWPROMPT");
response.put("profile", selectAi.profile());
response.put("originalPrompt", normalizedPrompt);
response.put("fewShotStatus", enrichedPrompt.status());
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
response.put("selectAiPrompt", selectAiPrompt);
return response;
}
private BackofficeProperties.SelectAi requiredSelectAi() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("Select AI 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
return selectAi;
}
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 action
) {
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, ?) 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());
statement.setString(3, action);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next() || resultSet.getString(1) == null) {
throw new AppException("Select AI가 " + action.toUpperCase() + " 결과를 반환하지 않았습니다.");
}
return resultSet.getString(1);
}
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("Select AI " + action.toUpperCase() + " 생성 실패: "
+ exception.getMessage());
}
}
private EnrichedPrompt enrichWithFewShot(
String bearerToken,
BackofficeProperties.SelectAi selectAi,
String prompt,
String targetType
) {
if (!fewShotEnabled(selectAi) || qaVectorService == null) {
return new EnrichedPrompt(prompt, "DISABLED", 0, List.of());
}
try {
List<QaVectorService.VectorExample> examples = qaVectorService
.search(bearerToken, prompt, fewShotTopK(selectAi), targetType)
.examples();
if (examples.isEmpty()) {
return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of());
}
return new EnrichedPrompt(
composeFewShotPrompt(prompt, examples), "APPLIED", Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES),
examples.subList(0, Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES)));
} catch (Exception ignored) {
// Vector retrieval is an optional prompt aid; preserve the normal Text2SQL path on failure.
return new EnrichedPrompt(composePolicyPrompt(prompt), "UNAVAILABLE", 0, List.of());
}
}
static String composePolicyPrompt(String prompt) {
return POLICY_PREFIX
+ "Resolve business terms and game names from the approved game-alias metadata before selecting a "
+ "game-scoped object. A generic term such as common user means no particular game. If no game alias "
+ "is resolved, do not substitute an arbitrary game-scoped object. Keep common-object questions "
+ "game-neutral and preserve the resolver status for the answer layer.\n"
+ "Original user question:\n" + prompt;
}
static String scopeCorrectionPrompt(String originalPrompt) {
return originalPrompt
+ "\n\n[SQL VALIDATION FEEDBACK]\n"
+ "The previous SQL selected a game-scoped object outside the authoritative query plan. "
+ "Regenerate one read-only SQL statement using the same plan. Do not substitute any "
+ "game-scoped object. Apply the active profile instructions and do not report a SQL failure.\n";
}
static String composeFewShotPrompt(String prompt, List<QaVectorService.VectorExample> examples) {
StringBuilder enriched = new StringBuilder(POLICY_PREFIX
+ "The verified examples below are guidance only: use only relevant SQL patterns, do not invent "
+ "identifiers, and do not override current metadata or game-alias resolution policy. "
+ "When examples use a game-specific object, reuse that pattern only after the current question "
+ "resolves the same game alias; otherwise keep the query unscoped or request clarification.\n\n"
+ "Verified few-shot examples:\n");
int included = 0;
for (QaVectorService.VectorExample example : examples) {
if (included >= MAX_FEW_SHOT_EXAMPLES) {
break;
}
String candidate = referenceCandidate(included + 1, example);
if (candidate.isBlank()) {
continue;
}
if (enriched.length() + candidate.length() + prompt.length() > MAX_ENRICHED_PROMPT_LENGTH) {
break;
}
enriched.append(candidate);
included++;
}
if (included == 0) {
return composePolicyPrompt(prompt);
}
return enriched.append("Original user question:\n").append(prompt).toString();
}
private static String referenceCandidate(int index, QaVectorService.VectorExample example) {
String prefix = "Example " + index + " question:\n" + example.question()
+ "\nExample " + index + " target type: " + example.targetType()
+ "\nExample " + index + " reference kind: " + example.referenceKind()
+ "\nExample " + index + " logical object role: "
+ (example.objectRole() == null || example.objectRole().isBlank()
? "UNSPECIFIED" : example.objectRole()) + "\n";
if ("NO_TARGET".equals(example.referenceKind())
|| "OBJECT_UNAVAILABLE".equals(example.referenceKind())) {
String boundary = truncate(example.answer(), MAX_FEW_SHOT_SQL_CHARS);
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
if (boundary.isBlank() || answerSql.isBlank()) {
return "";
}
return prefix + "Boundary rule (when the current plan is NONE and this logical object role "
+ "matches the requested operation, follow this boundary SQL template instead of "
+ "substituting a game-scoped object; do not apply it to an approved common-object operation):\n"
+ boundary
+ "\nVerified boundary SQL template:\n" + answerSql + "\n\n";
}
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
return answerSql.isBlank() ? "" : prefix + "Verified SQL template:\n" + answerSql + "\n\n";
}
private static String truncate(String value, int maxLength) {
String normalized = value == null ? "" : value.trim();
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
}
private boolean fewShotEnabled(BackofficeProperties.SelectAi selectAi) {
return selectAi.fewShotEnabled() == null || selectAi.fewShotEnabled();
}
private int fewShotTopK(BackofficeProperties.SelectAi selectAi) {
Integer configured = selectAi.fewShotTopK();
if (configured == null) {
return MAX_FEW_SHOT_EXAMPLES;
}
return Math.max(1, Math.min(configured, MAX_FEW_SHOT_EXAMPLES));
}
private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) {
ArrayNode items = objectMapper.createArrayNode();
boolean truncated = false;
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
Statement transaction = connection.createStatement()) {
connection.setAutoCommit(false);
connection.setReadOnly(true);
transaction.execute("SET TRANSACTION READ ONLY");
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
statement.setFetchSize(MAX_RESULT_ROWS + 1);
statement.setMaxRows(MAX_RESULT_ROWS + 1);
try (ResultSet resultSet = statement.executeQuery()) {
ResultSetMetaData metadata = resultSet.getMetaData();
while (resultSet.next()) {
if (items.size() >= MAX_RESULT_ROWS) {
truncated = true;
break;
}
ObjectNode row = items.addObject();
for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) {
String column = metadata.getColumnLabel(columnIndex);
if (column == null || column.isBlank()) {
column = metadata.getColumnName(columnIndex);
}
putResultValue(row, column, resultSet.getObject(columnIndex));
}
}
}
} finally {
connection.rollback();
}
} catch (Exception exception) {
throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage());
}
return new QueryExecution(items, truncated);
}
private void putResultValue(ObjectNode row, String column, Object value) {
if (value == null) {
row.putNull(column);
} else if (value instanceof BigDecimal number) {
row.put(column, number);
} else if (value instanceof BigInteger number) {
row.put(column, number);
} else if (value instanceof Integer number) {
row.put(column, number);
} else if (value instanceof Long number) {
row.put(column, number);
} else if (value instanceof Short number) {
row.put(column, number);
} else if (value instanceof Float number) {
row.put(column, number);
} else if (value instanceof Double number) {
row.put(column, number);
} else if (value instanceof Boolean bool) {
row.put(column, bool);
} else {
row.put(column, String.valueOf(value));
}
}
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 문장이 포함되어 있어 반환하지 않습니다.");
}
if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|| UNSAFE_SQL.matcher(normalized).find()) {
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
}
return normalized;
}
private record QueryExecution(ArrayNode items, boolean truncated) {}
private record EnrichedPrompt(
String prompt, String status, int exampleCount, List<QaVectorService.VectorExample> examples) {}
private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {}
private record QueryPlanContext(
String targetType,
String status,
int targetCount,
Set<String> allowedPrefixes,
JsonNode plan
) {
String prompt(String originalQuestion) {
return "[AUTHORITATIVE GAME QUERY PLAN]\n" + plan
+ "\n[ORIGINAL USER QUESTION]\n" + originalQuestion;
}
ResolvedExecutionScope scope(String requestedGameKey) {
String requested = requestedGameKey == null ? "" : requestedGameKey.trim();
if (requested.isEmpty()) {
return new ResolvedExecutionScope("", null, null);
}
for (JsonNode target : plan.path("targets")) {
if (requested.equals(target.path("gameKey").asText(""))) {
return new ResolvedExecutionScope(
"", requested, target.path("gameName").asText(requested));
}
}
throw new AppException("scopeGameKey가 queryPlan targets에 없습니다.");
}
}
private record GameContext(
String scopeType, String status, List<GameCatalogVectorService.GameCandidate> candidates) {
String prompt(String original) {
StringBuilder context = new StringBuilder();
context.append("[GAME CATALOG MATCH]\n")
.append("scope_type: ").append(scopeType).append('\n')
.append("status: ").append(status).append('\n');
for (GameCatalogVectorService.GameCandidate candidate : candidates) {
context.append("game_key: ").append(candidate.gameKey()).append('\n')
.append("game_id: ").append(candidate.gameId()).append('\n')
.append("game_prefix: ").append(candidate.gamePrefix()).append('\n')
.append("game_name: ").append(candidate.gameName()).append('\n')
.append("matched_aliases: ").append(candidate.aliases()).append('\n')
.append("user_master_object_name: ")
.append(candidate.userMasterObjectName()).append('\n')
.append("cosine_distance: ").append(candidate.similarity()).append('\n');
}
context.append("[GAME SCOPE METADATA]\n")
.append("The following resolver facts are authoritative metadata. ")
.append("Apply the table and column annotations associated with these facts; ")
.append("do not invent identifiers or scope rules.\n\n")
.append(original);
return context.toString();
}
}
}

View File

@@ -11,36 +11,25 @@ import org.springframework.stereotype.Service;
@Service @Service
public class StructuredDataService { public class StructuredDataService {
private static final String OWNER = "POC_2";
private static final int ROW_LIMIT = 50; private static final int ROW_LIMIT = 50;
private static final List<StructuredDataTable> TABLES = List.of(
new StructuredDataTable("customers", "KB_CUSTOMERS", "고객원장", "고객 기본정보"),
new StructuredDataTable("products", "KB_PRODUCTS", "상품원장", "보험상품 마스터"),
new StructuredDataTable("contracts", "KB_CONTRACTS", "계약원장", "보험계약 정보"),
new StructuredDataTable("coverages", "KB_COVERAGES", "담보원장", "보장·특약 정보"),
new StructuredDataTable("claims", "KB_CLAIMS", "청구원장", "보험금 청구·지급 정보"),
new StructuredDataTable("external-holdings", "KB_EXTERNAL_HOLDINGS", "외부보유정보 원장", "타사·외부 가입·보유 정보"),
new StructuredDataTable("stakeholders", "KB_STAKEHOLDERS", "이해관계자 원장", "역할·담당자 매핑"));
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final DataCatalog catalog;
public StructuredDataService(JdbcTemplate jdbcTemplate) { public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.catalog = catalog;
} }
public List<StructuredDataTable> tables() { public List<StructuredDataTable> tables() {
return TABLES; return catalog.objects();
} }
public String defaultKey() { public String defaultKey() {
return TABLES.getFirst().key(); return catalog.objects().getFirst().key();
} }
public StructuredDataTable requireTable(String key) { public StructuredDataTable requireTable(String key) {
return TABLES.stream() return catalog.require(key);
.filter(table -> table.key().equals(key))
.findFirst()
.orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다."));
} }
public StructuredDataPreview preview(String key) { public StructuredDataPreview preview(String key) {
@@ -54,7 +43,7 @@ public class StructuredDataService {
AND table_name = ? AND table_name = ?
ORDER BY column_id ORDER BY column_id
""", """,
(resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName()); (resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName());
if (columns.isEmpty()) { if (columns.isEmpty()) {
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
} }
@@ -63,24 +52,11 @@ public class StructuredDataService {
previewSql(table), ROW_LIMIT); previewSql(table), ROW_LIMIT);
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
} catch (DataAccessException exception) { } catch (DataAccessException exception) {
throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요."); throw new AppException("카탈로그 데이터를 조회할 수 없습니다. DB 권한과 대상 객체 상태를 확인하세요.");
} }
} }
/**
* The table is selected from a closed application whitelist, so the query
* text remains fixed and no request value can become a SQL identifier.
*/
private String previewSql(StructuredDataTable table) { private String previewSql(StructuredDataTable table) {
return switch (table.key()) { return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?";
case "customers" -> "SELECT * FROM POC_2.KB_CUSTOMERS WHERE ROWNUM <= ?";
case "products" -> "SELECT * FROM POC_2.KB_PRODUCTS WHERE ROWNUM <= ?";
case "contracts" -> "SELECT * FROM POC_2.KB_CONTRACTS WHERE ROWNUM <= ?";
case "coverages" -> "SELECT * FROM POC_2.KB_COVERAGES WHERE ROWNUM <= ?";
case "claims" -> "SELECT * FROM POC_2.KB_CLAIMS WHERE ROWNUM <= ?";
case "external-holdings" -> "SELECT * FROM POC_2.KB_EXTERNAL_HOLDINGS WHERE ROWNUM <= ?";
case "stakeholders" -> "SELECT * FROM POC_2.KB_STAKEHOLDERS WHERE ROWNUM <= ?";
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
};
} }
} }

View File

@@ -56,9 +56,9 @@ public class VectorKnowledgeService {
} }
public VectorKnowledgeSummary summary() { public VectorKnowledgeSummary summary() {
int documents = count("SELECT COUNT(DISTINCT document_id) FROM cb_vector_document_chunk"); int documents = count("SELECT COUNT(DISTINCT document_id) FROM sg_vector_document_chunk");
int chunks = count("SELECT COUNT(*) FROM cb_vector_document_chunk"); int chunks = count("SELECT COUNT(*) FROM sg_vector_document_chunk");
int tags = count("SELECT COUNT(*) FROM cb_vector_document_tag"); int tags = count("SELECT COUNT(*) FROM sg_vector_document_tag");
boolean registered = protectedObjectService.findEnabled().stream() boolean registered = protectedObjectService.findEnabled().stream()
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName())); .anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
return new VectorKnowledgeSummary( return new VectorKnowledgeSummary(
@@ -107,13 +107,13 @@ public class VectorKnowledgeService {
String embeddingJson = json(embedding); String embeddingJson = json(embedding);
long chunkId = nextChunkId++; long chunkId = nextChunkId++;
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO cb_vector_document_chunk INSERT INTO sg_vector_document_chunk
(chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding) (chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?)) VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson); """, chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
for (String tag : tags) { for (String tag : tags) {
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO cb_vector_document_tag (chunk_id, tech_tag) INSERT INTO sg_vector_document_tag (chunk_id, tech_tag)
VALUES (?, ?) VALUES (?, ?)
""", chunkId, tag); """, chunkId, tag);
tagCount++; tagCount++;
@@ -143,18 +143,18 @@ public class VectorKnowledgeService {
private void replaceDocument(String documentId) { private void replaceDocument(String documentId) {
jdbcTemplate.update(""" jdbcTemplate.update("""
DELETE FROM cb_vector_document_tag DELETE FROM sg_vector_document_tag
WHERE chunk_id IN ( WHERE chunk_id IN (
SELECT chunk_id FROM cb_vector_document_chunk WHERE document_id = ? SELECT chunk_id FROM sg_vector_document_chunk WHERE document_id = ?
) )
""", documentId); """, documentId);
jdbcTemplate.update("DELETE FROM cb_vector_document_chunk WHERE document_id = ?", documentId); jdbcTemplate.update("DELETE FROM sg_vector_document_chunk WHERE document_id = ?", documentId);
} }
private long nextChunkId(int chunkCount) { private long nextChunkId(int chunkCount) {
try { try {
Long sequenceValue = jdbcTemplate.queryForObject( Long sequenceValue = jdbcTemplate.queryForObject(
"SELECT cb_vector_chunk_seq.NEXTVAL FROM dual", Long.class); "SELECT sg_vector_chunk_seq.NEXTVAL FROM dual", Long.class);
if (sequenceValue != null) { if (sequenceValue != null) {
return sequenceValue; return sequenceValue;
} }
@@ -163,7 +163,7 @@ public class VectorKnowledgeService {
// fallback keeps the demonstration usable; the setup SQL creates it. // fallback keeps the demonstration usable; the setup SQL creates it.
} }
Long max = jdbcTemplate.queryForObject( Long max = jdbcTemplate.queryForObject(
"SELECT NVL(MAX(chunk_id), 28000) FROM cb_vector_document_chunk", Long.class); "SELECT NVL(MAX(chunk_id), 28000) FROM sg_vector_document_chunk", Long.class);
return (max == null ? 28000 : max) + 1; return (max == null ? 28000 : max) + 1;
} }

View File

@@ -27,7 +27,7 @@ public class DashboardController {
@GetMapping("/") @GetMapping("/")
public String dashboard(Model model) { public String dashboard(Model model) {
// The Smilegate PoC home is an identity-administration landing page. // The backoffice home is an identity-administration landing page.
// It intentionally does not query legacy CB_* VPD catalog objects. // It intentionally does not query legacy CB_* VPD catalog objects.
model.addAttribute("users", userService.findAll()); model.addAttribute("users", userService.findAll());
model.addAttribute("groups", groupService.findAll()); model.addAttribute("groups", groupService.findAll());

View File

@@ -0,0 +1,25 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.config.ProductProperties;
import com.cloudhandson.vpdbackoffice.config.McpProperties;
import com.cloudhandson.vpdbackoffice.service.DataCatalog;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
@ControllerAdvice
public class ProductModelAdvice {
private final ProductProperties product;
private final DataCatalog catalog;
private final McpProperties mcp;
public ProductModelAdvice(ProductProperties product, DataCatalog catalog, McpProperties mcp) {
this.product = product;
this.catalog = catalog;
this.mcp = mcp;
}
@ModelAttribute("product")
ProductProperties product() { return product; }
@ModelAttribute("catalogOwner")
String catalogOwner() { return catalog.owner(); }
@ModelAttribute("mcp")
McpProperties mcp() { return mcp; }
}

View File

@@ -3,6 +3,8 @@ package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.AppException; import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.SchemaMetadataService; import com.cloudhandson.vpdbackoffice.service.SchemaMetadataService;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -13,6 +15,8 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller @Controller
public class SchemaMetadataController { public class SchemaMetadataController {
private static final Logger log = LoggerFactory.getLogger(SchemaMetadataController.class);
private final SchemaMetadataService schemaMetadataService; private final SchemaMetadataService schemaMetadataService;
public SchemaMetadataController(SchemaMetadataService schemaMetadataService) { public SchemaMetadataController(SchemaMetadataService schemaMetadataService) {
@@ -27,7 +31,8 @@ public class SchemaMetadataController {
try { try {
model.addAttribute("metadata", schemaMetadataService.find(selectedKey)); model.addAttribute("metadata", schemaMetadataService.find(selectedKey));
} catch (AppException | DataAccessException exception) { } catch (AppException | DataAccessException exception) {
model.addAttribute("errorMessage", safeMessage(exception)); log.warn("Schema metadata lookup failed for table key={}", selectedKey, exception);
model.addAttribute("errorMessage", readMessage(exception));
} }
return "schema-metadata"; return "schema-metadata";
} }
@@ -105,4 +110,10 @@ public class SchemaMetadataController {
? exception.getMessage() ? exception.getMessage()
: "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요."; : "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요.";
} }
private String readMessage(Exception exception) {
return exception instanceof AppException
? exception.getMessage()
: "DB 메타데이터를 조회하지 못했습니다. 카탈로그 소유자 조회 권한과 대상 객체 상태를 확인하세요.";
}
} }

View File

@@ -68,3 +68,49 @@ backoffice:
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}} oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}} oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}} oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
select-ai:
# Cloud AI profiles are schema-owned. This connection must use the profile owner's account,
# not the control-plane account used by the backoffice.
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:}
few-shot-enabled: ${BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED:true}
few-shot-top-k: ${BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K:3}
game-scope:
# Customer DB view contract: aliases + profile object list + Oracle objects.
# No customer game or table identifier is embedded in application code.
enabled: ${BACKOFFICE_GAME_SCOPE_ENABLED:false}
view-name: ${BACKOFFICE_GAME_SCOPE_VIEW:}
max-scopes: ${BACKOFFICE_GAME_SCOPE_MAX_SCOPES:8}
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:
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을 생성하고 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.}
prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.}
showprompt-tool-name: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME:oracle.select_ai.data_showprompt}
showprompt-tool-label: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL:업무 데이터 SHOWPROMPT}
showprompt-tool-description: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION:Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. 생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.}
qa-vector-search-tool-name: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_NAME:oracle.select_ai.qa_vector_search}
qa-vector-search-tool-label: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_LABEL:Select AI 예제 SQL 조회}
qa-vector-search-tool-description: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION:현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. 반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다.}
qa-vector-store-tool-name: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME:oracle.select_ai.qa_vector_store}
qa-vector-store-tool-label: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL:Select AI 예제 SQL 저장}
qa-vector-store-tool-description: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION:검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. 질문과 읽기 전용 답 SQL이 필요합니다.}
few-shot-nl2sql-tool-name: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_NAME:oracle.select_ai.smilegate_fewshot_nl2sql}
few-shot-nl2sql-tool-label: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_LABEL:Few-shot NL2SQL 실행}
few-shot-nl2sql-tool-description: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION:벡터 Few-shot 예제를 찾아 prompt에 반영하고 SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다.}
game-scope-tool-name: ${BACKOFFICE_MCP_GAME_SCOPE_TOOL_NAME:oracle.select_ai.game_scope_resolve}
game-scope-tool-label: ${BACKOFFICE_MCP_GAME_SCOPE_TOOL_LABEL:게임 조회 범위 확인}
game-scope-tool-description: ${BACKOFFICE_MCP_GAME_SCOPE_TOOL_DESCRIPTION:질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다. 데이터 SQL은 실행하지 않으며, 반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.}
masking:
policies: ${BACKOFFICE_MASKING_POLICIES:}
security-sql-scripts:
scripts: ${BACKOFFICE_SECURITY_SQL_SCRIPTS:}

View File

@@ -3,9 +3,10 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper"> <mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent"> <insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
INSERT INTO hmm_access_audit ( INSERT INTO sg_audit_event (
event_type, key_id, object_id, status, row_count, error_code, message, created_at audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
) VALUES ( ) VALUES (
sg_audit_event_seq.NEXTVAL,
#{eventType,jdbcType=VARCHAR}, #{eventType,jdbcType=VARCHAR},
#{keyId,jdbcType=NUMERIC}, #{keyId,jdbcType=NUMERIC},
#{objectId,jdbcType=NUMERIC}, #{objectId,jdbcType=NUMERIC},

View File

@@ -3,47 +3,44 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper"> <mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper">
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord"> <select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username, SELECT k.key_id, k.user_id, u.user_name AS username,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id, CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel, CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
k.key_prefix, k.key_hash, k.key_prefix, k.key_hash,
k.expires_at, k.revoked_at, k.description k.expires_at, k.revoked_at, k.description
FROM hmm_access_bearer_tokens k FROM sg_access_bearer_token k JOIN sg_app_user u ON u.user_id = k.user_id
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
WHERE #{includeInactive} = 1 WHERE #{includeInactive} = 1
OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP) OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP)
ORDER BY k.key_id DESC ORDER BY k.key_id DESC
</select> </select>
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord"> <select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username, SELECT k.key_id, k.user_id, u.user_name AS username,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id, CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel, CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
k.key_prefix, k.key_hash, k.key_prefix, k.key_hash,
k.expires_at, k.revoked_at, k.description k.expires_at, k.revoked_at, k.description
FROM hmm_access_bearer_tokens k FROM sg_access_bearer_token k JOIN sg_app_user u ON u.user_id = k.user_id
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
WHERE k.key_id = #{keyId} WHERE k.key_id = #{keyId}
</select> </select>
<select id="findByHash" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord"> <select id="findByHash" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username, SELECT k.key_id, k.user_id, u.user_name AS username,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id, CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel, CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
k.key_prefix, k.key_hash, k.key_prefix, k.key_hash,
k.expires_at, k.revoked_at, k.description k.expires_at, k.revoked_at, k.description
FROM hmm_access_bearer_tokens k FROM sg_access_bearer_token k JOIN sg_app_user u ON u.user_id = k.user_id
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
WHERE k.key_hash = #{keyHash,jdbcType=VARCHAR} WHERE k.key_hash = #{keyHash,jdbcType=VARCHAR}
</select> </select>
<select id="nextKeyId" resultType="long"> <select id="nextKeyId" resultType="long">
SELECT NVL(MAX(key_id), 0) + 1 FROM hmm_access_bearer_tokens SELECT NVL(MAX(key_id), 0) + 1 FROM sg_access_bearer_token
</select> </select>
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord"> <insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
INSERT INTO hmm_access_bearer_tokens ( INSERT INTO sg_access_bearer_token (
key_id, employee_id, key_prefix, key_hash, expires_at, revoked_at, description key_id, user_id, key_prefix, key_hash, expires_at, revoked_at, description
) VALUES ( ) VALUES (
#{keyId,jdbcType=NUMERIC}, #{keyId,jdbcType=NUMERIC},
#{userId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC},
@@ -56,7 +53,7 @@
</insert> </insert>
<update id="revokeToken"> <update id="revokeToken">
UPDATE hmm_access_bearer_tokens UPDATE sg_access_bearer_token
SET revoked_at = #{revokedAt,jdbcType=TIMESTAMP}, SET revoked_at = #{revokedAt,jdbcType=TIMESTAMP},
description = CASE description = CASE
WHEN #{reason,jdbcType=VARCHAR} IS NULL THEN description WHEN #{reason,jdbcType=VARCHAR} IS NULL THEN description

View File

@@ -8,7 +8,7 @@
group_name, group_name,
description, description,
active_yn active_yn
FROM hmm_access_groups FROM sg_app_group
ORDER BY group_code ORDER BY group_code
</select> </select>
@@ -16,12 +16,12 @@
SELECT g.group_id, SELECT g.group_id,
g.group_code, g.group_code,
g.group_name, g.group_name,
u.employee_id AS user_id, u.user_id,
u.full_name AS username u.user_name AS username
FROM hmm_access_group_members ug FROM sg_user_group ug
JOIN hmm_access_groups g ON g.group_id = ug.group_id JOIN sg_app_group g ON g.group_id = ug.group_id
JOIN hmm_hr_employees u ON u.employee_id = ug.employee_id JOIN sg_app_user u ON u.user_id = ug.user_id
ORDER BY g.group_code, u.full_name ORDER BY g.group_code, u.user_name
</select> </select>
<select id="findGroupRoles" resultType="com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView"> <select id="findGroupRoles" resultType="com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView">
@@ -30,18 +30,18 @@
g.group_name, g.group_name,
r.role_id, r.role_id,
r.role_name r.role_name
FROM hmm_group_access_roles gr FROM sg_group_role gr
JOIN hmm_access_groups g ON g.group_id = gr.group_id JOIN sg_app_group g ON g.group_id = gr.group_id
JOIN hmm_access_roles r ON r.role_id = gr.role_id JOIN sg_app_role r ON r.role_id = gr.role_id
ORDER BY g.group_code, r.role_name ORDER BY g.group_code, r.role_name
</select> </select>
<select id="nextGroupId" resultType="long"> <select id="nextGroupId" resultType="long">
SELECT NVL(MAX(group_id), 0) + 1 FROM hmm_access_groups SELECT NVL(MAX(group_id), 0) + 1 FROM sg_app_group
</select> </select>
<insert id="insertGroup"> <insert id="insertGroup">
INSERT INTO hmm_access_groups ( INSERT INTO sg_app_group (
group_id, group_code, group_name, description, active_yn group_id, group_code, group_name, description, active_yn
) VALUES ( ) VALUES (
#{groupId,jdbcType=NUMERIC}, #{groupId,jdbcType=NUMERIC},
@@ -53,29 +53,29 @@
</insert> </insert>
<update id="updateActive"> <update id="updateActive">
UPDATE hmm_access_groups UPDATE sg_app_group
SET active_yn = #{activeYn,jdbcType=VARCHAR} SET active_yn = #{activeYn,jdbcType=VARCHAR}
WHERE group_id = #{groupId,jdbcType=NUMERIC} WHERE group_id = #{groupId,jdbcType=NUMERIC}
</update> </update>
<insert id="insertGroupUser"> <insert id="insertGroupUser">
INSERT INTO hmm_access_group_members (group_id, employee_id) INSERT INTO sg_user_group (group_id, user_id)
VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC}) VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC})
</insert> </insert>
<delete id="deleteGroupUser"> <delete id="deleteGroupUser">
DELETE FROM hmm_access_group_members DELETE FROM sg_user_group
WHERE group_id = #{groupId,jdbcType=NUMERIC} WHERE group_id = #{groupId,jdbcType=NUMERIC}
AND employee_id = #{userId,jdbcType=NUMERIC} AND user_id = #{userId,jdbcType=NUMERIC}
</delete> </delete>
<insert id="insertGroupRole"> <insert id="insertGroupRole">
INSERT INTO hmm_group_access_roles (group_id, role_id) INSERT INTO sg_group_role (group_id, role_id)
VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC}) VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC})
</insert> </insert>
<delete id="deleteGroupRole"> <delete id="deleteGroupRole">
DELETE FROM hmm_group_access_roles DELETE FROM sg_group_role
WHERE group_id = #{groupId,jdbcType=NUMERIC} WHERE group_id = #{groupId,jdbcType=NUMERIC}
AND role_id = #{roleId,jdbcType=NUMERIC} AND role_id = #{roleId,jdbcType=NUMERIC}
</delete> </delete>

View File

@@ -4,41 +4,41 @@
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper"> <mapper namespace="com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper">
<select id="findAllRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule"> <select id="findAllRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule FROM sg_masking_rule
ORDER BY enabled_yn DESC, rule_name, rule_id ORDER BY enabled_yn DESC, rule_name, rule_id
</select> </select>
<select id="findEnabledRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule"> <select id="findEnabledRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule FROM sg_masking_rule
WHERE enabled_yn = 'Y' WHERE enabled_yn = 'Y'
ORDER BY rule_name, rule_id ORDER BY rule_name, rule_id
</select> </select>
<select id="findRuleById" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule"> <select id="findRuleById" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule FROM sg_masking_rule
WHERE rule_id = #{ruleId} WHERE rule_id = #{ruleId}
</select> </select>
<select id="findRuleByCode" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule"> <select id="findRuleByCode" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule FROM sg_masking_rule
WHERE rule_code = #{ruleCode} WHERE rule_code = #{ruleCode}
</select> </select>
<select id="nextRuleId" resultType="long"> <select id="nextRuleId" resultType="long">
SELECT NVL(MAX(rule_id), 0) + 1 FROM cb_masking_rule SELECT NVL(MAX(rule_id), 0) + 1 FROM sg_masking_rule
</select> </select>
<insert id="insertRule"> <insert id="insertRule">
INSERT INTO cb_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn) INSERT INTO sg_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn)
VALUES (#{ruleId}, #{command.ruleCode}, #{command.ruleName}, #{command.templateCode}, VALUES (#{ruleId}, #{command.ruleCode}, #{command.ruleName}, #{command.templateCode},
#{command.description}, 'Y') #{command.description}, 'Y')
</insert> </insert>
<update id="updateRuleActive"> <update id="updateRuleActive">
UPDATE cb_masking_rule UPDATE sg_masking_rule
SET enabled_yn = #{enabledYn} SET enabled_yn = #{enabledYn}
WHERE rule_id = #{ruleId} WHERE rule_id = #{ruleId}
</update> </update>
@@ -54,10 +54,10 @@
r.rule_name, r.rule_name,
r.template_code, r.template_code,
r.enabled_yn AS rule_enabled_yn r.enabled_yn AS rule_enabled_yn
FROM cb_column_masking_rule link FROM sg_column_masking_rule link
JOIN cb_protected_column c ON c.column_id = link.column_id JOIN sg_protected_column c ON c.column_id = link.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id JOIN sg_protected_object o ON o.object_id = c.object_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id JOIN sg_masking_rule r ON r.rule_id = link.rule_id
ORDER BY o.owner, o.object_name, c.column_name ORDER BY o.owner, o.object_name, c.column_name
</select> </select>
@@ -68,19 +68,18 @@
--> -->
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus"> <select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
WITH managed_policy AS ( WITH managed_policy AS (
SELECT 'KB_CUSTOMERS' AS object_name, 'KB_CUSTOMER_PII_REDACT' AS policy_name FROM dual <foreach collection="targets" item="target" separator=" UNION ALL ">
UNION ALL SELECT 'KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT' FROM dual SELECT #{target.objectName} AS object_name, #{target.policyName} AS policy_name FROM dual
UNION ALL SELECT 'KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT' FROM dual </foreach>
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT' FROM dual
), ),
configured AS ( configured AS (
SELECT protected_object.object_name, SELECT protected_object.object_name,
COUNT(*) AS configured_column_count COUNT(*) AS configured_column_count
FROM cb_column_masking_rule link FROM sg_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2' WHERE protected_object.owner = #{owner}
AND rule.enabled_yn = 'Y' AND rule.enabled_yn = 'Y'
GROUP BY protected_object.object_name GROUP BY protected_object.object_name
), ),
@@ -96,21 +95,21 @@
LEFT JOIN redaction_columns policy_column LEFT JOIN redaction_columns policy_column
ON policy_column.object_owner = policy.object_owner ON policy_column.object_owner = policy.object_owner
AND policy_column.object_name = policy.object_name AND policy_column.object_name = policy.object_name
WHERE policy.object_owner = 'POC_2' WHERE policy.object_owner = #{owner}
GROUP BY policy.object_name, policy.policy_name, policy.enable GROUP BY policy.object_name, policy.policy_name, policy.enable
), ),
missing_columns AS ( missing_columns AS (
SELECT protected_object.object_name, SELECT protected_object.object_name,
COUNT(*) AS missing_column_count COUNT(*) AS missing_column_count
FROM cb_column_masking_rule link FROM sg_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
LEFT JOIN redaction_columns policy_column LEFT JOIN redaction_columns policy_column
ON policy_column.object_owner = protected_object.owner ON policy_column.object_owner = protected_object.owner
AND policy_column.object_name = protected_object.object_name AND policy_column.object_name = protected_object.object_name
AND policy_column.column_name = protected_column.column_name AND policy_column.column_name = protected_column.column_name
WHERE protected_object.owner = 'POC_2' WHERE protected_object.owner = #{owner}
AND rule.enabled_yn = 'Y' AND rule.enabled_yn = 'Y'
AND policy_column.column_name IS NULL AND policy_column.column_name IS NULL
GROUP BY protected_object.object_name GROUP BY protected_object.object_name
@@ -120,14 +119,14 @@
COUNT(*) AS extra_column_count COUNT(*) AS extra_column_count
FROM redaction_columns policy_column FROM redaction_columns policy_column
JOIN managed_policy managed ON managed.object_name = policy_column.object_name JOIN managed_policy managed ON managed.object_name = policy_column.object_name
WHERE policy_column.object_owner = 'POC_2' WHERE policy_column.object_owner = #{owner}
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 SELECT 1
FROM cb_column_masking_rule link FROM sg_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2' WHERE protected_object.owner = #{owner}
AND protected_object.object_name = policy_column.object_name AND protected_object.object_name = policy_column.object_name
AND protected_column.column_name = policy_column.column_name AND protected_column.column_name = policy_column.column_name
AND rule.enabled_yn = 'Y' AND rule.enabled_yn = 'Y'
@@ -138,18 +137,14 @@
SELECT policy.object_name, SELECT policy.object_name,
COUNT(*) AS legacy_vpd_column_policy_count COUNT(*) AS legacy_vpd_column_policy_count
FROM all_policies policy FROM all_policies policy
WHERE policy.object_owner = 'POC_2' JOIN managed_policy managed
ON managed.object_name = policy.object_name
AND managed.policy_name = policy.policy_name
WHERE policy.object_owner = #{owner}
AND policy.enable = 'YES' AND policy.enable = 'YES'
AND policy.policy_name IN (
'KB_CUST_NM_CLS_POLICY', 'KB_RRN_CLS_POLICY',
'KB_PREMIUM_CLS_POLICY',
'KB_CLAIM_AMT_CLS_POLICY', 'KB_PAID_AMT_CLS_POLICY',
'KB_EXT_INSURER_CLS_POLICY', 'KB_EXT_PRODUCT_GRP_CLS_POLICY',
'KB_EXT_PRODUCT_TYPE_CLS_POLICY'
)
GROUP BY policy.object_name GROUP BY policy.object_name
) )
SELECT 'POC_2' AS owner, SELECT #{owner} AS owner,
managed.object_name, managed.object_name,
managed.policy_name, managed.policy_name,
database_policy.enable AS enabled, database_policy.enable AS enabled,
@@ -180,15 +175,15 @@
r.rule_name, r.rule_name,
r.template_code, r.template_code,
r.enabled_yn AS rule_enabled_yn r.enabled_yn AS rule_enabled_yn
FROM cb_column_masking_rule link FROM sg_column_masking_rule link
JOIN cb_protected_column c ON c.column_id = link.column_id JOIN sg_protected_column c ON c.column_id = link.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id JOIN sg_protected_object o ON o.object_id = c.object_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id JOIN sg_masking_rule r ON r.rule_id = link.rule_id
WHERE c.column_id = #{columnId} WHERE c.column_id = #{columnId}
</select> </select>
<insert id="upsertColumnRule"> <insert id="upsertColumnRule">
MERGE INTO cb_column_masking_rule dst MERGE INTO sg_column_masking_rule dst
USING (SELECT #{columnId} column_id, #{ruleId} rule_id FROM dual) src USING (SELECT #{columnId} column_id, #{ruleId} rule_id FROM dual) src
ON (dst.column_id = src.column_id) ON (dst.column_id = src.column_id)
WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP
@@ -197,12 +192,12 @@
</insert> </insert>
<delete id="deleteColumnRule"> <delete id="deleteColumnRule">
DELETE FROM cb_column_masking_rule DELETE FROM sg_column_masking_rule
WHERE column_id = #{columnId} WHERE column_id = #{columnId}
</delete> </delete>
<delete id="deleteUserRulesForColumn"> <delete id="deleteUserRulesForColumn">
DELETE FROM cb_user_masking_rule DELETE FROM sg_user_masking_rule
WHERE column_id = #{columnId} WHERE column_id = #{columnId}
</delete> </delete>
@@ -217,12 +212,12 @@
r.template_code, r.template_code,
assignment.decision, assignment.decision,
assignment.active_yn assignment.active_yn
FROM cb_user_masking_rule assignment FROM sg_user_masking_rule assignment
JOIN cb_app_user u ON u.user_id = assignment.user_id JOIN sg_app_user u ON u.user_id = assignment.user_id
JOIN cb_protected_column c ON c.column_id = assignment.column_id JOIN sg_protected_column c ON c.column_id = assignment.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id JOIN sg_protected_object o ON o.object_id = c.object_id
JOIN cb_column_masking_rule link ON link.column_id = c.column_id JOIN sg_column_masking_rule link ON link.column_id = c.column_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id JOIN sg_masking_rule r ON r.rule_id = link.rule_id
ORDER BY u.user_name, o.owner, o.object_name, c.column_name ORDER BY u.user_name, o.owner, o.object_name, c.column_name
</select> </select>
@@ -237,18 +232,18 @@
r.template_code, r.template_code,
assignment.decision, assignment.decision,
assignment.active_yn assignment.active_yn
FROM cb_user_masking_rule assignment FROM sg_user_masking_rule assignment
JOIN cb_app_user u ON u.user_id = assignment.user_id JOIN sg_app_user u ON u.user_id = assignment.user_id
JOIN cb_protected_column c ON c.column_id = assignment.column_id JOIN sg_protected_column c ON c.column_id = assignment.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id JOIN sg_protected_object o ON o.object_id = c.object_id
JOIN cb_column_masking_rule link ON link.column_id = c.column_id JOIN sg_column_masking_rule link ON link.column_id = c.column_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id JOIN sg_masking_rule r ON r.rule_id = link.rule_id
WHERE assignment.user_id = #{userId} WHERE assignment.user_id = #{userId}
AND assignment.column_id = #{columnId} AND assignment.column_id = #{columnId}
</select> </select>
<insert id="upsertUserRule"> <insert id="upsertUserRule">
MERGE INTO cb_user_masking_rule dst MERGE INTO sg_user_masking_rule dst
USING (SELECT #{userId} user_id, #{columnId} column_id, #{decision} decision FROM dual) src USING (SELECT #{userId} user_id, #{columnId} column_id, #{decision} decision FROM dual) src
ON (dst.user_id = src.user_id AND dst.column_id = src.column_id) ON (dst.user_id = src.user_id AND dst.column_id = src.column_id)
WHEN MATCHED THEN UPDATE SET dst.decision = src.decision, dst.active_yn = 'Y', dst.updated_at = SYSTIMESTAMP WHEN MATCHED THEN UPDATE SET dst.decision = src.decision, dst.active_yn = 'Y', dst.updated_at = SYSTIMESTAMP
@@ -257,7 +252,7 @@
</insert> </insert>
<delete id="deleteUserRule"> <delete id="deleteUserRule">
DELETE FROM cb_user_masking_rule DELETE FROM sg_user_masking_rule
WHERE user_id = #{userId} WHERE user_id = #{userId}
AND column_id = #{columnId} AND column_id = #{columnId}
</delete> </delete>

View File

@@ -7,8 +7,7 @@
role_name, role_name,
description, description,
max_sensitivity_level max_sensitivity_level
FROM hmm_access_roles FROM sg_app_role
WHERE active_yn = 'Y'
ORDER BY role_name ORDER BY role_name
</select> </select>
@@ -17,45 +16,44 @@
role_name, role_name,
description, description,
max_sensitivity_level max_sensitivity_level
FROM hmm_access_roles FROM sg_app_role
WHERE role_id = #{roleId} WHERE role_id = #{roleId}
</select> </select>
<select id="nextRoleId" resultType="long"> <select id="nextRoleId" resultType="long">
SELECT NVL(MAX(role_id), 0) + 1 FROM hmm_access_roles SELECT NVL(MAX(role_id), 0) + 1 FROM sg_app_role
</select> </select>
<insert id="insertRole"> <insert id="insertRole">
INSERT INTO hmm_access_roles (role_id, role_name, description, max_sensitivity_level, active_yn) INSERT INTO sg_app_role (role_id, role_name, description, max_sensitivity_level)
VALUES ( VALUES (
#{roleId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC},
UPPER(#{roleName,jdbcType=VARCHAR}), UPPER(#{roleName,jdbcType=VARCHAR}),
#{description,jdbcType=VARCHAR}, #{description,jdbcType=VARCHAR},
#{maxSensitivityLevel,jdbcType=VARCHAR}, #{maxSensitivityLevel,jdbcType=VARCHAR}
'Y'
) )
</insert> </insert>
<update id="updateRoleMaxSensitivity"> <update id="updateRoleMaxSensitivity">
UPDATE hmm_access_roles UPDATE sg_app_role
SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR} SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR}
WHERE role_id = #{roleId,jdbcType=NUMERIC} WHERE role_id = #{roleId,jdbcType=NUMERIC}
</update> </update>
<delete id="deleteRole"> <delete id="deleteRole">
DELETE FROM hmm_access_roles DELETE FROM sg_app_role
WHERE role_id = #{roleId,jdbcType=NUMERIC} WHERE role_id = #{roleId,jdbcType=NUMERIC}
</delete> </delete>
<select id="countUserRolesByRoleId" resultType="int"> <select id="countUserRolesByRoleId" resultType="int">
SELECT COUNT(*) SELECT COUNT(*)
FROM hmm_employee_access_roles FROM sg_user_role
WHERE role_id = #{roleId,jdbcType=NUMERIC} WHERE role_id = #{roleId,jdbcType=NUMERIC}
</select> </select>
<select id="countGroupRolesByRoleId" resultType="int"> <select id="countGroupRolesByRoleId" resultType="int">
SELECT COUNT(*) SELECT COUNT(*)
FROM hmm_group_access_roles FROM sg_group_role
WHERE role_id = #{roleId,jdbcType=NUMERIC} WHERE role_id = #{roleId,jdbcType=NUMERIC}
</select> </select>

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper">
<select id="findTableComment" resultType="string">
SELECT comments
FROM all_tab_comments
WHERE owner = #{owner,jdbcType=VARCHAR}
AND table_name = #{tableName,jdbcType=VARCHAR}
</select>
<select id="findColumns"
resultType="com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow">
SELECT c.column_name AS column_name,
CASE
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
THEN c.data_type || '(' || c.char_length || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
THEN c.data_type || '(' || c.data_precision || ')'
ELSE c.data_type
END AS data_type,
c.nullable,
cc.comments
FROM all_tab_columns c
LEFT JOIN all_col_comments cc
ON cc.owner = c.owner
AND cc.table_name = c.table_name
AND cc.column_name = c.column_name
WHERE c.owner = #{owner,jdbcType=VARCHAR}
AND c.table_name = #{tableName,jdbcType=VARCHAR}
ORDER BY c.column_id
</select>
<select id="findAnnotations"
resultType="com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow">
SELECT column_name,
annotation_name,
annotation_value
FROM all_annotations_usage
WHERE object_name = #{tableName,jdbcType=VARCHAR}
AND object_type = 'TABLE'
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
</select>
<select id="countColumn" resultType="int">
SELECT COUNT(*)
FROM all_tab_columns
WHERE owner = #{owner,jdbcType=VARCHAR}
AND table_name = #{tableName,jdbcType=VARCHAR}
AND column_name = #{columnName,jdbcType=VARCHAR}
</select>
<select id="countTableAnnotation" resultType="int">
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_name = #{tableName,jdbcType=VARCHAR}
AND object_type = 'TABLE'
AND annotation_name = #{annotationName,jdbcType=VARCHAR}
AND column_name IS NULL
</select>
<select id="countColumnAnnotation" resultType="int">
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_name = #{tableName,jdbcType=VARCHAR}
AND object_type = 'TABLE'
AND annotation_name = #{annotationName,jdbcType=VARCHAR}
AND column_name = #{columnName,jdbcType=VARCHAR}
</select>
<!-- Identifiers are server-side validated names. Oracle DDL comments require a literal. -->
<update id="updateTableComment">
COMMENT ON TABLE "${owner}"."${tableName}" IS ${commentLiteral}
</update>
<update id="updateColumnComment">
COMMENT ON COLUMN "${owner}"."${tableName}"."${columnName}" IS ${commentLiteral}
</update>
<update id="dropTableAnnotation">
ALTER TABLE "${owner}"."${tableName}" ANNOTATIONS (DROP "${annotationName}")
</update>
<update id="addTableAnnotation">
ALTER TABLE "${owner}"."${tableName}"
ANNOTATIONS (ADD "${annotationName}" ${annotationValueLiteral})
</update>
<update id="dropColumnAnnotation">
ALTER TABLE "${owner}"."${tableName}" MODIFY "${columnName}"
ANNOTATIONS (DROP "${annotationName}")
</update>
<update id="addColumnAnnotation">
ALTER TABLE "${owner}"."${tableName}" MODIFY "${columnName}"
ANNOTATIONS (ADD "${annotationName}" ${annotationValueLiteral})
</update>
</mapper>

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