Compare commits

32 Commits

Author SHA1 Message Date
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
78 changed files with 5029 additions and 880 deletions

View File

@@ -51,6 +51,13 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
export BACKOFFICE_ORDS_DB_PASSWORD=""
# Smilegate Select AI는 프로파일 소유 스키마(SGMP_POC)로 별도 접속합니다.
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_SELECT_AI_DB_USERNAME="SGMP_POC"
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
export BACKOFFICE_SELECT_AI_PROFILE="SGMP_POC_OCI_GPT54MINI"
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
export BACKOFFICE_AI_ENABLED="false"
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci

4
.gitignore vendored
View File

@@ -17,6 +17,10 @@ logs/
# Java / Maven
target/
# Python / Streamlit
__pycache__/
*.py[cod]
# Locally downloaded development tools (for example SQLcl)
.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,48 @@
# #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`이다.

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

@@ -10,8 +10,6 @@ with the Bearer token entered on the screen.
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import html
@@ -57,12 +55,13 @@ from src.agent_console.presentation import (
render_login_brand,
)
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 QaHistoryStore, QaHistoryStoreError
LOG = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2025-11-25"
PREFERRED_TOOL = "ords.query.kb_select_ai_vpd"
PREFERRED_TOOL = "oracle.select_ai.smilegate_game_text2sql"
DEFAULT_QUESTION = ""
MAX_RESPONSE_BYTES = 1_000_000
MAX_CONVERSATION_MESSAGES = 8
@@ -77,17 +76,19 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
ENV_FILE = ROOT / ".env"
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json"
QA_BENCHMARK_FILE = ROOT / "config" / "smilegate_qa_benchmark.json"
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
DEFAULT_VPD_USER_ID = "FC00789"
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/"
DEFAULT_VPD_USER_ID = "1001"
VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/"
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
AUDIT_SCHEMA = "POC_2"
AUDIT_SCHEMA = "SGMP_POC"
REFERENCE_EVIDENCE_ENABLED = (
os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower()
in {"1", "true", "yes"}
)
AUDIT_DB_ENV_FILE = Path(
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
).expanduser()
@@ -1885,12 +1886,180 @@ def new_conversation_id() -> str:
def _question_label(question: object) -> str:
question_id = str(getattr(question, "question_id"))
category = str(getattr(question, "category"))
title = str(getattr(question, "title", getattr(question, "text")))
question_id = str(
getattr(question, "question_code", getattr(question, "question_id", ""))
)
category = str(getattr(question, "category", ""))
title = str(
getattr(question, "title", getattr(question, "question_text", ""))
)
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:
store = _qa_history_store()
questions = store.list_questions(limit=200)
if questions:
return questions, store, ""
return [], store, "질답 이력 DB에 아직 적재된 기준 질문이 없습니다."
except QaHistoryStoreError as exc:
try:
fallback = list(load_benchmark_questions(QA_BENCHMARK_FILE))
except Exception:
fallback = []
return fallback, None, str(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),
"최근 실행": 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
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 _apply_console_theme(profile: AppProfile) -> None:
apply_console_theme(st, profile)
@@ -1933,63 +2102,6 @@ def _portal_credentials_are_valid(username: str, password: str) -> bool:
return username_matches and password_matches
def _portal_remember_secret() -> str:
return _portal_auth_value("POC4_LOGIN_REMEMBER_SECRET")
def _portal_remember_token(username: str) -> str:
secret = _portal_remember_secret()
if not secret:
return ""
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()
return f"{encoded}.{signature}"
def _restore_portal_remembered_session() -> None:
if st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
return
token = st.query_params.get(PORTAL_REMEMBER_TOKEN_PARAM, "")
if not isinstance(token, str) or not token or len(token) > 2048:
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_AUTH_USER_KEY] = username
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:
with st.container(key="console_login_container"):
render_login_brand(st, profile)
@@ -2008,23 +2120,12 @@ def _render_portal_login(profile: AppProfile) -> None:
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):
@@ -2036,7 +2137,6 @@ def _render_portal_login(profile: AppProfile) -> None:
def _logout_portal() -> None:
_clear_portal_remembered_session()
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None)
@@ -2374,6 +2474,8 @@ def collect_security_evidence(
) -> Mapping[str, Any]:
"""Collect predefined, token-scoped security evidence without exposing secrets."""
if not REFERENCE_EVIDENCE_ENABLED:
return {}
kind = _security_evidence_kind(question)
if not kind or token_preset is None:
return {}
@@ -2938,6 +3040,8 @@ def collect_business_evidence(
) -> Mapping[str, Any]:
"""Collect token-validated, explicitly scoped business and catalog evidence."""
if not REFERENCE_EVIDENCE_ENABLED:
return {}
kind = _business_evidence_kind(question)
if not kind or token_preset is None:
return {}
@@ -3636,6 +3740,9 @@ def load_vpd_token_presets(
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
continue
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()
if not token or not user_id or user_id in seen:
continue
@@ -6323,6 +6430,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(
*,
question: str,
@@ -6338,6 +6528,8 @@ def _process_submitted_question(
limit: int,
selected_token_preset: VpdTokenPreset | None,
execution_mode_override: str,
benchmark_question: QaQuestion | None,
qa_history_store: QaHistoryStore | None,
) -> None:
normalized_question = question.strip()
if not normalized_question:
@@ -6880,6 +7072,32 @@ def _process_submitted_question(
],
"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, "질의 결과를 저장하고 있습니다.")
save_chat_turn(
conversation_id=conversation_id,
@@ -6930,31 +7148,16 @@ def main() -> None:
page_title=profile.page_title, page_icon=profile.page_icon, layout="wide"
)
_apply_console_theme(profile)
_restore_portal_remembered_session()
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
_render_portal_login(profile)
return
try:
questions = load_demo_scenarios(DEMO_SCENARIOS_FILE)
except ScenarioConfigError as exc:
st.error(str(exc))
return
scenario_key = "poc4_mcp_discovery_scenario"
qa_questions, qa_history_store, qa_store_error = _load_qa_questions()
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"
chat_page_key = "poc4_mcp_discovery_page"
query_progress_notice_key = "poc4_query_progress_notice"
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()
if conversation_id_key not in st.session_state:
st.session_state[conversation_id_key] = new_conversation_id()
@@ -7069,8 +7272,8 @@ def main() -> None:
else:
selected_token_preset = None
if configured_mcp_bearer:
# The HMM MCP gateway uses its own server-side credential. VPD user
# tokens remain UI context only and must never be forwarded to it.
# The Smilegate MCP gateway uses a server-side credential. UI-only
# context must never be forwarded as a second bearer token.
st.caption("MCP 인증: 서버 관리 토큰 적용")
manual_bearer_token = ""
elif selected_token_preset is not None:
@@ -7243,7 +7446,7 @@ def main() -> None:
except (OSError, UnicodeError, ValueError):
st.warning("MCP 설정 JSON을 읽지 못했습니다.")
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"selected LLM model: {selected_query_model_profile}")
st.caption(f"default model: {default_query_model_profile}")
@@ -7256,42 +7459,41 @@ def main() -> None:
'<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>',
unsafe_allow_html=True,
)
architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs(
["아키텍처", "시나리오", "감사로그", "보안관리"]
)
architecture_tab, scenario_tab = st.tabs(["아키텍처", "시나리오"])
with architecture_tab:
_render_architecture_tab()
with scenario_tab:
st.markdown(
'<div class="kb-section-title input" role="heading" aria-level="3">'
"질문 입력"
"</div>",
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"))
selected_qa_question = _render_qa_benchmark_panel(
qa_questions,
qa_history_store,
qa_store_error,
)
if question_text_key not in st.session_state:
st.session_state[question_text_key] = DEFAULT_QUESTION
if st.session_state.get(loaded_scenario_key, "") != str(
selected_scenario_id or ""
):
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 "")
selected_qa_code = (
selected_qa_question.question_code if selected_qa_question is not None else ""
)
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"):
question = st.text_area(
@@ -7300,7 +7502,7 @@ def main() -> None:
key=question_text_key,
height=120,
max_chars=1_000,
placeholder="업무 질문을 직접 입력하거나 위 질의 샘플을 선택하세요.",
placeholder="고객 검증 후보를 선택하거나 업무 질문을 직접 입력하세요.",
)
submitted = st.form_submit_button(
"질문 전송",
@@ -7359,12 +7561,6 @@ def main() -> None:
height=0,
)
with audit_tab:
_render_fga_audit_tab()
with operations_tab:
_render_vpd_operations_tab()
if not submitted:
return
@@ -7383,6 +7579,13 @@ def main() -> None:
limit=int(limit),
selected_token_preset=selected_token_preset,
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,
"product": {
"name": "AI 업무 에이전트",
"short_name": "AGENT",
"page_title": "AI 업무 에이전트",
"name": "SMILEGATE DATA & AI POC",
"short_name": "SMILEGATE",
"page_title": "SMILEGATE DATA & AI POC",
"page_icon": "🤖",
"header_title": "AI 업무 에이전트",
"header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.",
"login_kicker": "DATA & AI DEMO",
"login_title": "AI 업무 에이전트",
"login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.",
"login_footer": "인된 DEMO 사용자만 접근할 수 있습니다."
"header_title": "스마일게이트 게임 데이터 AI 에이전트",
"header_description": "게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.",
"login_kicker": "SMILEGATE DATA & AI POC",
"login_title": "스마일게이트 게임 데이터 AI 에이전트",
"login_description": "사용자 인증 후 게임 데이터 AI 질의와 보안 관리 기능을 이용할 수 있습니다.",
"login_footer": "인된 Data & AI PoC 사용자만 접근할 수 있습니다."
},
"theme": {
"primary_color": "#003b70",
"text_color": "#172b3a",
"muted_color": "#667785",
"border_color": "#dfe7ed"
"primary_color": "#113F67",
"text_color": "#15283B",
"muted_color": "#5D6C7C",
"border_color": "#D7E0E8"
}
}

View File

@@ -1,22 +1,20 @@
{
"default_server_id": "hmm_hr_mcp",
"default_server_id": "smilegate_game_data_mcp",
"servers": [
{
"id": "hmm_hr_mcp",
"id": "smilegate_game_data_mcp",
"enabled": true,
"provider": "hmm_compat_mcp",
"provider": "smilegate_select_ai_mcp",
"transport": "http",
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
"endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp",
"auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN",
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
"default_tool": "search_hr_data",
"default_tool": "oracle.select_ai.smilegate_game_text2sql",
"router_model_profile": "gpt54_mini_oci",
"tool_allowlist": [
"search_hr_data",
"resolve_hr_term",
"search_hr_policy"
"oracle.select_ai.smilegate_game_text2sql"
],
"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,
"default": true,
"token": "vpd_live_REPLACE_WITH_USER_TOKEN",
"user_id": "FC00789",
"name": "김설계",
"role": "설계사",
"channel": "설계사",
"scope": "본인 담당 계약 고객"
"user_id": "1001",
"name": "Data & AI TF 팀장",
"role": "DATA_AI_POC_ADMIN",
"channel": "DATA_AI_TF",
"scope": "SGMP_POC 게임 데이터 전체",
"token_env": "SMILEGATE_TEAMLEAD_BEARER_TOKEN"
},
{
"enabled": true,
"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
oci>=2.180,<3
oracledb>=2,<4
pandas>=2,<3
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 {{
background:#fff !important; border:1px solid var(--console-border) !important;
border-radius:4px !important; box-shadow:none !important; }}
/* Streamlit JSON uses an independently styled code surface. Keep the
MCP detail payload readable even when the browser/system prefers a
dark code theme. */
[data-testid="stJson"], [data-testid="stJson"] > div,
[data-testid="stJson"] .react-json-view, [data-testid="stJson"] pre {{
background:#f8fafc !important; color:var(--console-text) !important;
border-color:var(--console-border) !important; color-scheme:light !important; }}
[data-testid="stJson"] *, [data-testid="stJson"] pre *,
[data-testid="stJson"] code {{
background:transparent !important; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
/* 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 {{
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="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
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",
}
)
_COMPARTMENT_ID = re.compile(r"^ocid1\.compartment\.[A-Za-z0-9._-]+$")
# OCI permits the tenancy OCID when the root compartment is selected.
_COMPARTMENT_OR_ROOT_ID = re.compile(
r"^ocid1\.(?:compartment|tenancy)\.[A-Za-z0-9._-]+$"
)
class CompletionClient(Protocol):
@@ -95,7 +98,7 @@ def load_oci_settings() -> OCISettings:
raise ValueError("unsupported OCI authentication mode")
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
if not _COMPARTMENT_ID.fullmatch(compartment_id):
if not _COMPARTMENT_OR_ROOT_ID.fullmatch(compartment_id):
raise ValueError("OCI Generative AI compartment is not configured")
return OCISettings(
auth_type=auth_type,

View File

@@ -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,566 @@
"""Oracle ADB persistence for the Smilegate customer QA benchmark."""
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime, timezone
import json
import os
from pathlib import Path
from typing import Any, Iterator, Mapping
from urllib.parse import parse_qs
import oracledb
from src.poc4.qa_history import QaQuestion, load_benchmark_questions, question_fingerprint, question_from_record
class QaHistoryStoreError(RuntimeError):
"""A safe user-facing persistence error."""
QUESTION_TABLE = "SG_AI_QA_QUESTION"
ANSWER_TABLE = "SG_AI_QA_ANSWER"
HISTORICAL_RUN_KEY = "HISTORICAL:2026-07-21:term-dict-final-v2"
def _env_value(name: str, env_file: Path | None = None) -> str:
value = os.environ.get(name, "").strip()
if value or env_file is None or not env_file.is_file():
return value
try:
lines = env_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return ""
for line in lines:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[7:].lstrip()
key, raw = line.split("=", 1)
if key.strip() != name:
continue
raw = raw.strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {"'", '"'}:
raw = raw[1:-1]
return raw.strip()
return ""
def _normalize_oracle_dsn(raw_dsn: str) -> tuple[str, str]:
value = str(raw_dsn or "").strip()
if value.startswith("jdbc:oracle:thin:@"):
value = value[len("jdbc:oracle:thin:@"):]
if "?" not in value:
return value, ""
dsn, query = value.split("?", 1)
parsed = parse_qs(query, keep_blank_values=False)
wallet_dir = (parsed.get("TNS_ADMIN") or parsed.get("tns_admin") or [""])[0]
return dsn.strip(), wallet_dir.strip()
def _read_lob(value: Any) -> Any:
return value.read() if hasattr(value, "read") else value
def _record_from_cursor(cursor: Any, row: Any) -> dict[str, Any]:
names = [column[0].lower() for column in cursor.description]
return {name: _read_lob(value) for name, value in zip(names, row)}
def _to_json(value: Mapping[str, Any] | None) -> str:
payload = dict(value or {})
text = json.dumps(payload, ensure_ascii=False, default=str)
if len(text) <= 120_000:
return text
return json.dumps(
{
"truncated": True,
"preview": text[:119_800],
},
ensure_ascii=False,
)
def _answer_record(row: Mapping[str, Any]) -> dict[str, Any]:
result_json = str(row.get("result_json") or "").strip()
try:
result = json.loads(result_json) if result_json else {}
except ValueError:
result = {"raw": result_json}
return {
"answer_seq": row.get("answer_seq"),
"question_id": row.get("question_id"),
"answer_kind": str(row.get("answer_kind") or ""),
"run_key": str(row.get("run_key") or ""),
"conversation_id": str(row.get("conversation_id") or ""),
"requested_by": str(row.get("requested_by") or ""),
"requested_at": str(row.get("requested_at") or ""),
"model_profile": str(row.get("model_profile") or ""),
"generated_sql": str(row.get("generated_sql") or ""),
"answer_text": str(row.get("answer_text") or ""),
"result": result,
"execution_output": str(row.get("execution_output") or ""),
"execution_status": str(row.get("execution_status") or ""),
"judgment_status": str(row.get("judgment_status") or ""),
"judgment_reason": str(row.get("judgment_reason") or ""),
"duration_ms": row.get("duration_ms"),
"created_at": str(row.get("created_at") or ""),
}
class QaHistoryStore:
def __init__(self, *, env_file: Path | None = None) -> None:
self._env_file = env_file
self._pool: Any | None = None
def _config(self) -> dict[str, str]:
username = (
_env_value("POC4_QA_DB_USERNAME", self._env_file)
or _env_value("BACKOFFICE_SELECT_AI_DB_USERNAME", self._env_file)
)
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_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,80 @@
from __future__ import annotations
from pathlib import Path
import unittest
from src.poc4.qa_history import evaluate_sql, load_benchmark_questions
from src.poc4.qa_history_store import _normalize_oracle_dsn, schema_statements
class QaHistoryTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
benchmark = Path(__file__).parents[1] / "config" / "smilegate_qa_benchmark.json"
cls.questions = {item.question_code: item for item in load_benchmark_questions(benchmark)}
def test_customer_excel_benchmark_contains_all_47_cases(self) -> None:
self.assertEqual(47, len(self.questions))
self.assertIn("STD-01", self.questions)
self.assertIn("CZN-19", self.questions)
def test_supported_query_passes_when_required_terms_are_present(self) -> None:
judgment = evaluate_sql(
self.questions["STD-13"],
"SELECT SUM(PAYMT_AMT) FROM COMN_SALES_TXN",
execution_succeeded=True,
)
self.assertEqual("PASS", judgment.status)
def test_monthly_au_with_au_flag_fails(self) -> None:
judgment = evaluate_sql(
self.questions["STD-27"],
"""
SELECT COUNT(*)
FROM CZN_COMN_USER_MST
WHERE AU_FLAG = 1
AND BASE_DT = (SELECT MAX(BASE_DT) FROM CZN_COMN_USER_MST)
AND LAST_CONN_DT >= ADD_MONTHS(BASE_DT, -1)
AND STD_USER_YN = 'Y'
AND EXPT_USER_YN = 'N'
""",
execution_succeeded=True,
)
self.assertEqual("FAIL", judgment.status)
self.assertIn("AU_FLAG", judgment.reason)
def test_unsupported_game_requires_safe_alias_lookup(self) -> None:
safe = evaluate_sql(
self.questions["STD-02"],
"SELECT GAME_ID FROM COMN_GAME_ALIAS_BAS WHERE GAME_NM LIKE '%버블리즈%'",
execution_succeeded=True,
)
unsafe = evaluate_sql(
self.questions["STD-02"],
"SELECT COUNT(*) FROM CZN_COMN_USER_MST WHERE GAME_ID = 'STOVE_CHAOSZERO'",
execution_succeeded=True,
)
self.assertEqual("PASS", safe.status)
self.assertEqual("FAIL", unsafe.status)
def test_free_text_is_review_not_automatic_pass(self) -> None:
judgment = evaluate_sql(None, "SELECT 1 FROM DUAL", execution_succeeded=True)
self.assertEqual("REVIEW", judgment.status)
def test_jdbc_url_wallet_is_normalized_for_python_driver(self) -> None:
self.assertEqual(
("sgmpaipoc_medium", "/home/opc/wallet/sgmpaipoc"),
_normalize_oracle_dsn(
"jdbc:oracle:thin:@sgmpaipoc_medium?TNS_ADMIN=/home/opc/wallet/sgmpaipoc"
),
)
def test_schema_defines_two_history_tables_and_indexes(self) -> None:
statements = "\n".join(schema_statements())
self.assertIn("CREATE TABLE SG_AI_QA_QUESTION", statements)
self.assertIn("CREATE TABLE SG_AI_QA_ANSWER", statements)
self.assertIn("answer_seq NUMBER GENERATED ALWAYS AS IDENTITY", statements)
if __name__ == "__main__":
unittest.main()

View File

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

View File

@@ -98,11 +98,8 @@
<directory>sql/adb</directory>
<targetPath>sql/adb</targetPath>
<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>
<include>70_sg_tool_user.sql</include>
<include>71_sg_identity_administration.sql</include>
</includes>
</resource>
</resources>

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

@@ -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으로 반환하는 전체 마스킹 방식"),
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
"앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
);
private static final String MASKING_RULE_SEED_SQL = """
@@ -705,7 +705,7 @@ public class BackofficeSchemaService {
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
@sql/adb/25_agent_ords_security_backoffice_support.sql
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
@sql/adb/62_kb_aso_masking_backoffice_metadata.sql
@sql/adb/71_sg_identity_administration.sql
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
-- 2. ORDS parsing schema로 접속
@@ -717,9 +717,8 @@ public class BackofficeSchemaService {
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
-- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행
@sql/adb/64_kb_aso_masking_default_column_rules.sql
@sql/adb/63_kb_aso_masking_rule_runtime.sql
-- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결
-- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다.
""".formatted(owner.toLowerCase());
}

View File

@@ -24,7 +24,7 @@ import org.springframework.stereotype.Service;
@Service
public class MaskingPolicySynchronizer {
private static final String OWNER = "POC_2";
private static final String OWNER = "SGMP_POC";
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
@@ -38,10 +38,10 @@ public class MaskingPolicySynchronizer {
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");
policies.put("CZN_COMN_USER_MST", "SG_CZN_USER_REDACT");
policies.put("COMN_SALES_USER_MST", "SG_SALES_USER_REDACT");
policies.put("COMN_SALES_TXN", "SG_SALES_TXN_REDACT");
policies.put("COMN_REFUND_TXN", "SG_REFUND_TXN_REDACT");
return Collections.unmodifiableMap(policies);
}

View File

@@ -55,7 +55,7 @@ public class MaskingRuleService {
return mapper.findColumnRules();
}
/** Reads the actual Oracle Data Redaction state for the three managed KB objects. */
/** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */
public List<MaskingPolicyStatus> findPolicyStatuses() {
return mapper.findPolicyStatuses();
}

View File

@@ -1,5 +1,6 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -8,31 +9,26 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.springframework.stereotype.Service;
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */
/** MCP boundary exposing the Smilegate game-data Select AI generation and read-only execution tool. */
@Service
public class McpSseService {
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd";
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
private static final String SELECT_AI_VPD_QUERY_PROFILE =
"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 static final String SELECT_AI_VPD_QUERY_TOOL = "oracle.select_ai.smilegate_game_text2sql";
private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)";
private static final String DEFAULT_SELECT_AI_PROFILE = "SGMP_POC_OCI_GPT54MINI";
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
private final SmilegateSelectAiService smilegateSelectAiService;
private final ObjectMapper objectMapper;
private final BackofficeProperties properties;
public McpSseService(
SelectAiAgentOrdsService selectAiAgentOrdsService,
ObjectMapper objectMapper
SmilegateSelectAiService smilegateSelectAiService,
ObjectMapper objectMapper,
BackofficeProperties properties
) {
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
this.smilegateSelectAiService = smilegateSelectAiService;
this.objectMapper = objectMapper;
this.properties = properties;
}
public ObjectNode handle(String contextPath, JsonNode request) {
@@ -71,7 +67,7 @@ public class McpSseService {
/** The only tool registered by this MCP server. */
public List<McpToolView> registeredTools() {
return List.of(SELECT_AI_VPD_QUERY_VIEW);
return List.of(selectAiVpdQueryView());
}
private ObjectNode initializeResult(String contextPath) {
@@ -98,7 +94,7 @@ public class McpSseService {
private ObjectNode selectAiVpdQueryTool() {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
item.put("description", SELECT_AI_VPD_QUERY_VIEW.description());
item.put("description", selectAiVpdQueryView().description());
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
@@ -106,17 +102,10 @@ public class McpSseService {
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
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();
required.add("prompt");
@@ -139,18 +128,14 @@ public class McpSseService {
}
JsonNode response;
try {
response = selectAiAgentOrdsService.run(
token,
arguments.path("prompt").asText(""),
normalizeLimit(arguments.path("limit").asInt(50))
);
response = smilegateSelectAiService.generateAndExecute(token, arguments.path("prompt").asText(""));
} catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult();
}
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE);
payload.put("profile", selectAiProfile());
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
payload.set("response", response);
@@ -181,11 +166,23 @@ public class McpSseService {
return result;
}
private int normalizeLimit(int limit) {
if (limit < 1) {
return 50;
private McpToolView selectAiVpdQueryView() {
String profile = selectAiProfile();
return new McpToolView(
SELECT_AI_VPD_QUERY_TOOL,
profile + " 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.",
-1L,
"Smilegate 게임 데이터 Text2SQL",
SELECT_AI_VPD_QUERY_PATH
);
}
private String selectAiProfile() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
return DEFAULT_SELECT_AI_PROFILE;
}
return Math.min(limit, 100);
return selectAi.profile().trim();
}
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
// a sensitive column missing from every JSON row is also a NULL result
// and must be reported as masked to the MCP/UI caller.

View File

@@ -1,33 +1,41 @@
package com.cloudhandson.vpdbackoffice.service;
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.SchemaMetadataColumnRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Screen-level orchestration for schema metadata. Database reads and DDL are
* intentionally delegated only to {@link SchemaMetadataMapper}.
*/
@Service
public class SchemaMetadataService {
private static final String OWNER = "POC_2";
private static final String OWNER = "SGMP_POC";
private static final int MAX_COMMENT_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 final JdbcTemplate jdbcTemplate;
private final SchemaMetadataMapper mapper;
private final StructuredDataService structuredDataService;
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) {
this.jdbcTemplate = jdbcTemplate;
public SchemaMetadataService(
SchemaMetadataMapper mapper,
StructuredDataService structuredDataService
) {
this.mapper = mapper;
this.structuredDataService = structuredDataService;
}
@@ -41,13 +49,12 @@ public class SchemaMetadataService {
public SchemaMetadataView find(String tableKey) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = table.tableName();
String tableComment = tableComment(tableName);
String tableName = requireSimpleName(table.tableName(), "table name");
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
return new SchemaMetadataView(
table,
nullToEmpty(tableComment),
nullToEmpty(mapper.findTableComment(OWNER, tableName)),
annotations.getOrDefault(tableTargetKey(), List.of()),
columns
);
@@ -56,24 +63,24 @@ public class SchemaMetadataService {
@Transactional
public void updateTableComment(String tableKey, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = requireSimpleName(table.tableName(), "table name");
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName())
+ " IS " + quoteLiteral(normalizedComment));
mapper.updateTableComment(OWNER, tableName, quoteLiteral(normalizedComment));
}
@Transactional
public void updateColumnComment(String tableKey, String columnName, String comment) {
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");
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "."
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
mapper.updateColumnComment(OWNER, tableName, column, quoteLiteral(normalizedComment));
}
@Transactional
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue);
}
@Transactional
@@ -84,8 +91,9 @@ public class SchemaMetadataService {
String annotationValue
) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String column = requireColumn(table.tableName(), columnName);
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
String tableName = requireSimpleName(table.tableName(), "table name");
String column = requireColumn(tableName, columnName);
updateAnnotation(tableName, column, annotationName, annotationValue);
}
private void updateAnnotation(
@@ -97,75 +105,55 @@ public class SchemaMetadataService {
String key = requireSimpleName(annotationName, "annotation name");
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
if (annotationExists(tableName, columnName, key)) {
jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key)));
if (columnName == null) {
mapper.dropTableAnnotation(OWNER, tableName, key);
} else {
mapper.dropColumnAnnotation(OWNER, tableName, columnName, key);
}
}
if (!value.isBlank()) {
jdbcTemplate.execute(annotationSql(tableName, columnName,
"ADD " + quoteName(key) + " " + quoteLiteral(value)));
if (columnName == null) {
mapper.addTableAnnotation(OWNER, tableName, key, quoteLiteral(value));
} else {
mapper.addColumnAnnotation(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(
String tableName,
Map<String, List<SchemaAnnotation>> annotations
) {
return jdbcTemplate.query("""
SELECT c.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 display_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 = ?
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);
return mapper.findColumns(OWNER, tableName).stream()
.map(row -> toColumn(row, annotations))
.toList();
}
private SchemaMetadataColumn toColumn(
SchemaMetadataColumnRow row,
Map<String, List<SchemaAnnotation>> annotations
) {
String columnName = requireSimpleName(row.columnName(), "column name");
return new SchemaMetadataColumn(
columnName,
row.dataType(),
"Y".equalsIgnoreCase(row.nullable()),
nullToEmpty(row.comment()),
annotations.getOrDefault(columnTargetKey(columnName), List.of())
);
}
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
jdbcTemplate.query("""
SELECT column_name, annotation_name, annotation_value
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()
: columnTargetKey(rs.getString("column_name"));
grouped
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
.computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>())
.add(nullToEmpty(rs.getString("annotation_value")));
}, tableName);
for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(tableName)) {
String target = row.columnName() == null
? tableTargetKey()
: columnTargetKey(row.columnName());
grouped
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
.computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>())
.add(nullToEmpty(row.annotationValue()));
}
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
grouped.forEach((target, valuesByName) -> {
@@ -180,44 +168,14 @@ public class SchemaMetadataService {
}
private boolean annotationExists(String tableName, String columnName, String annotationName) {
Integer count = columnName == null
? jdbcTemplate.queryForObject("""
SELECT COUNT(*)
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 + ")";
return columnName == null
? mapper.countTableAnnotation(tableName, annotationName) > 0
: mapper.countColumnAnnotation(tableName, columnName, annotationName) > 0;
}
private String requireColumn(String tableName, String columnName) {
String column = requireSimpleName(columnName, "column name");
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_tab_columns
WHERE owner = ?
AND table_name = ?
AND column_name = ?
""", Integer.class, OWNER, tableName, column);
if (count == null || count == 0) {
if (mapper.countColumn(OWNER, tableName, column) == 0) {
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
}
return column;
@@ -242,14 +200,6 @@ public class SchemaMetadataService {
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) {
return "'" + value.replace("'", "''") + "'";
}

View File

@@ -19,39 +19,18 @@ public class SecuritySqlScriptService {
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
new ScriptDefinition(
"aso-masking-metadata",
"ASO / 마스킹",
"62_kb_aso_masking_backoffice_metadata.sql",
"컬럼 마스킹 규칙 메타데이터",
"ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
"smilegate-tool-users",
"Smilegate 사용자",
"70_sg_tool_user.sql",
"PoC 도구 사용자 초기 데이터",
"Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다."
),
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를 노출합니다."
"smilegate-identity-administration",
"Smilegate 권한",
"71_sg_identity_administration.sql",
"사용자·그룹·역할 관리 모델",
"Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다."
)
);

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,211 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.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.regex.Pattern;
import org.springframework.stereotype.Service;
/**
* Generates and executes bounded read-only SQL through the schema-owned Smilegate Select AI profile.
*/
@Service
public class SmilegateSelectAiService {
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 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;
public SmilegateSelectAiService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
Clock clock,
ObjectMapper objectMapper
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.clock = clock;
this.objectMapper = objectMapper;
}
public JsonNode generateAndExecute(String bearerToken, String prompt) {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("Smilegate Select AI 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
String generatedSql = generate(selectAi, normalizedPrompt);
String normalizedSql = validateReadOnlySql(generatedSql);
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "SHOWSQL_AND_EXECUTED");
response.put("profile", selectAi.profile());
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 void requireActiveToken(String bearerToken) {
if (bearerToken == null || bearerToken.isBlank()) {
throw new VpdTokenAccessDeniedException();
}
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
if (token == null || !token.active(now)) {
throw new VpdTokenAccessDeniedException();
}
}
private String requiredPrompt(String prompt) {
String normalized = prompt == null ? "" : prompt.trim();
if (normalized.isEmpty()) {
throw new AppException("prompt는 필수입니다.");
}
if (normalized.length() > MAX_PROMPT_LENGTH) {
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
}
return normalized;
}
private String generate(BackofficeProperties.SelectAi selectAi, String prompt) {
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual";
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, prompt);
statement.setString(2, selectAi.profile());
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next() || resultSet.getString(1) == null) {
throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다.");
}
return resultSet.getString(1);
}
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("Smilegate Select AI SHOWSQL 생성 실패: " + exception.getMessage());
}
}
private 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("Smilegate 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) {}
}

View File

@@ -11,16 +11,16 @@ import org.springframework.stereotype.Service;
@Service
public class StructuredDataService {
private static final String OWNER = "POC_2";
private static final String OWNER = "SGMP_POC";
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", "이해관계자 원장", "역할·담당자 매핑"));
new StructuredDataTable("game-users", "CZN_COMN_USER_MST", "게임 사용자", "카제나 게임 사용자 마스터"),
new StructuredDataTable("characters", "CZN_COMN_CHARACTER_MST", "캐릭터", "카제나 캐릭터 마스터"),
new StructuredDataTable("sales", "COMN_SALES_TXN", "판매 거래", "게임 상품 판매 거래"),
new StructuredDataTable("refunds", "COMN_REFUND_TXN", "환불 거래", "게임 상품 환불 거래"),
new StructuredDataTable("products", "COMN_SALES_PRODUCT_DISP_BAS", "상품", "판매 상품 전시 기준"),
new StructuredDataTable("game-servers", "COMN_GAME_SERVER_BAS", "게임 서버", "게임 서버 기준 정보"),
new StructuredDataTable("game-aliases", "COMN_GAME_ALIAS_BAS", "게임 별칭", "게임명·별칭·prefix 매핑"));
private final JdbcTemplate jdbcTemplate;
@@ -63,7 +63,7 @@ public class StructuredDataService {
previewSql(table), ROW_LIMIT);
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
} catch (DataAccessException exception) {
throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요.");
throw new AppException("게임 데이터를 조회할 수 없습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요.");
}
}
@@ -73,13 +73,13 @@ public class StructuredDataService {
*/
private String previewSql(StructuredDataTable table) {
return switch (table.key()) {
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 <= ?";
case "game-users" -> "SELECT * FROM SGMP_POC.CZN_COMN_USER_MST WHERE ROWNUM <= ?";
case "characters" -> "SELECT * FROM SGMP_POC.CZN_COMN_CHARACTER_MST WHERE ROWNUM <= ?";
case "sales" -> "SELECT * FROM SGMP_POC.COMN_SALES_TXN WHERE ROWNUM <= ?";
case "refunds" -> "SELECT * FROM SGMP_POC.COMN_REFUND_TXN WHERE ROWNUM <= ?";
case "products" -> "SELECT * FROM SGMP_POC.COMN_SALES_PRODUCT_DISP_BAS WHERE ROWNUM <= ?";
case "game-servers" -> "SELECT * FROM SGMP_POC.COMN_GAME_SERVER_BAS WHERE ROWNUM <= ?";
case "game-aliases" -> "SELECT * FROM SGMP_POC.COMN_GAME_ALIAS_BAS WHERE ROWNUM <= ?";
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
};
}

View File

@@ -56,9 +56,9 @@ public class VectorKnowledgeService {
}
public VectorKnowledgeSummary summary() {
int documents = count("SELECT COUNT(DISTINCT document_id) FROM cb_vector_document_chunk");
int chunks = count("SELECT COUNT(*) FROM cb_vector_document_chunk");
int tags = count("SELECT COUNT(*) FROM cb_vector_document_tag");
int documents = count("SELECT COUNT(DISTINCT document_id) FROM sg_vector_document_chunk");
int chunks = count("SELECT COUNT(*) FROM sg_vector_document_chunk");
int tags = count("SELECT COUNT(*) FROM sg_vector_document_tag");
boolean registered = protectedObjectService.findEnabled().stream()
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
return new VectorKnowledgeSummary(
@@ -107,13 +107,13 @@ public class VectorKnowledgeService {
String embeddingJson = json(embedding);
long chunkId = nextChunkId++;
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)
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
for (String tag : tags) {
jdbcTemplate.update("""
INSERT INTO cb_vector_document_tag (chunk_id, tech_tag)
INSERT INTO sg_vector_document_tag (chunk_id, tech_tag)
VALUES (?, ?)
""", chunkId, tag);
tagCount++;
@@ -143,18 +143,18 @@ public class VectorKnowledgeService {
private void replaceDocument(String documentId) {
jdbcTemplate.update("""
DELETE FROM cb_vector_document_tag
DELETE FROM sg_vector_document_tag
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);
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) {
try {
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) {
return sequenceValue;
}
@@ -163,7 +163,7 @@ public class VectorKnowledgeService {
// fallback keeps the demonstration usable; the setup SQL creates it.
}
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;
}

View File

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

View File

@@ -68,3 +68,10 @@ backoffice:
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
select-ai:
# Cloud AI profiles are schema-owned. This connection must use SGMP_POC,
# not the ADMIN connection used by the backoffice control plane.
db-url: ${BACKOFFICE_SELECT_AI_DB_URL:}
db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
profile: ${BACKOFFICE_SELECT_AI_PROFILE:SGMP_POC_OCI_GPT54MINI}

View File

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

View File

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

View File

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

View File

@@ -4,41 +4,41 @@
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper">
<select id="findAllRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
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
</select>
<select id="findEnabledRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule
FROM sg_masking_rule
WHERE enabled_yn = 'Y'
ORDER BY rule_name, rule_id
</select>
<select id="findRuleById" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule
FROM sg_masking_rule
WHERE rule_id = #{ruleId}
</select>
<select id="findRuleByCode" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
FROM cb_masking_rule
FROM sg_masking_rule
WHERE rule_code = #{ruleCode}
</select>
<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>
<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},
#{command.description}, 'Y')
</insert>
<update id="updateRuleActive">
UPDATE cb_masking_rule
UPDATE sg_masking_rule
SET enabled_yn = #{enabledYn}
WHERE rule_id = #{ruleId}
</update>
@@ -54,10 +54,10 @@
r.rule_name,
r.template_code,
r.enabled_yn AS rule_enabled_yn
FROM cb_column_masking_rule link
JOIN cb_protected_column c ON c.column_id = link.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
FROM sg_column_masking_rule link
JOIN sg_protected_column c ON c.column_id = link.column_id
JOIN sg_protected_object o ON o.object_id = c.object_id
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
ORDER BY o.owner, o.object_name, c.column_name
</select>
@@ -68,19 +68,19 @@
-->
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
WITH managed_policy AS (
SELECT 'KB_CUSTOMERS' AS object_name, 'KB_CUSTOMER_PII_REDACT' AS policy_name FROM dual
UNION ALL SELECT 'KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT' FROM dual
UNION ALL SELECT 'KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT' FROM dual
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT' FROM dual
SELECT 'CZN_COMN_USER_MST' AS object_name, 'SG_CZN_USER_REDACT' AS policy_name FROM dual
UNION ALL SELECT 'COMN_SALES_USER_MST', 'SG_SALES_USER_REDACT' FROM dual
UNION ALL SELECT 'COMN_SALES_TXN', 'SG_SALES_TXN_REDACT' FROM dual
UNION ALL SELECT 'COMN_REFUND_TXN', 'SG_REFUND_TXN_REDACT' FROM dual
),
configured AS (
SELECT protected_object.object_name,
COUNT(*) AS configured_column_count
FROM cb_column_masking_rule link
JOIN cb_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 cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2'
FROM sg_column_masking_rule link
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'SGMP_POC'
AND rule.enabled_yn = 'Y'
GROUP BY protected_object.object_name
),
@@ -96,21 +96,21 @@
LEFT JOIN redaction_columns policy_column
ON policy_column.object_owner = policy.object_owner
AND policy_column.object_name = policy.object_name
WHERE policy.object_owner = 'POC_2'
WHERE policy.object_owner = 'SGMP_POC'
GROUP BY policy.object_name, policy.policy_name, policy.enable
),
missing_columns AS (
SELECT protected_object.object_name,
COUNT(*) AS missing_column_count
FROM cb_column_masking_rule link
JOIN cb_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 cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
FROM sg_column_masking_rule link
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
LEFT JOIN redaction_columns policy_column
ON policy_column.object_owner = protected_object.owner
AND policy_column.object_name = protected_object.object_name
AND policy_column.column_name = protected_column.column_name
WHERE protected_object.owner = 'POC_2'
WHERE protected_object.owner = 'SGMP_POC'
AND rule.enabled_yn = 'Y'
AND policy_column.column_name IS NULL
GROUP BY protected_object.object_name
@@ -120,14 +120,14 @@
COUNT(*) AS extra_column_count
FROM redaction_columns policy_column
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
WHERE policy_column.object_owner = 'POC_2'
WHERE policy_column.object_owner = 'SGMP_POC'
AND NOT EXISTS (
SELECT 1
FROM cb_column_masking_rule link
JOIN cb_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 cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2'
FROM sg_column_masking_rule link
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'SGMP_POC'
AND protected_object.object_name = policy_column.object_name
AND protected_column.column_name = policy_column.column_name
AND rule.enabled_yn = 'Y'
@@ -138,18 +138,15 @@
SELECT policy.object_name,
COUNT(*) AS legacy_vpd_column_policy_count
FROM all_policies policy
WHERE policy.object_owner = 'POC_2'
WHERE policy.object_owner = 'SGMP_POC'
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'
'SG_CZN_USER_REDACT', 'SG_SALES_USER_REDACT',
'SG_SALES_TXN_REDACT', 'SG_REFUND_TXN_REDACT'
)
GROUP BY policy.object_name
)
SELECT 'POC_2' AS owner,
SELECT 'SGMP_POC' AS owner,
managed.object_name,
managed.policy_name,
database_policy.enable AS enabled,
@@ -180,15 +177,15 @@
r.rule_name,
r.template_code,
r.enabled_yn AS rule_enabled_yn
FROM cb_column_masking_rule link
JOIN cb_protected_column c ON c.column_id = link.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
FROM sg_column_masking_rule link
JOIN sg_protected_column c ON c.column_id = link.column_id
JOIN sg_protected_object o ON o.object_id = c.object_id
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
WHERE c.column_id = #{columnId}
</select>
<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
ON (dst.column_id = src.column_id)
WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP
@@ -197,12 +194,12 @@
</insert>
<delete id="deleteColumnRule">
DELETE FROM cb_column_masking_rule
DELETE FROM sg_column_masking_rule
WHERE column_id = #{columnId}
</delete>
<delete id="deleteUserRulesForColumn">
DELETE FROM cb_user_masking_rule
DELETE FROM sg_user_masking_rule
WHERE column_id = #{columnId}
</delete>
@@ -217,12 +214,12 @@
r.template_code,
assignment.decision,
assignment.active_yn
FROM cb_user_masking_rule assignment
JOIN cb_app_user u ON u.user_id = assignment.user_id
JOIN cb_protected_column c ON c.column_id = assignment.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id
JOIN cb_column_masking_rule link ON link.column_id = c.column_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
FROM sg_user_masking_rule assignment
JOIN sg_app_user u ON u.user_id = assignment.user_id
JOIN sg_protected_column c ON c.column_id = assignment.column_id
JOIN sg_protected_object o ON o.object_id = c.object_id
JOIN sg_column_masking_rule link ON link.column_id = c.column_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
</select>
@@ -237,18 +234,18 @@
r.template_code,
assignment.decision,
assignment.active_yn
FROM cb_user_masking_rule assignment
JOIN cb_app_user u ON u.user_id = assignment.user_id
JOIN cb_protected_column c ON c.column_id = assignment.column_id
JOIN cb_protected_object o ON o.object_id = c.object_id
JOIN cb_column_masking_rule link ON link.column_id = c.column_id
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
FROM sg_user_masking_rule assignment
JOIN sg_app_user u ON u.user_id = assignment.user_id
JOIN sg_protected_column c ON c.column_id = assignment.column_id
JOIN sg_protected_object o ON o.object_id = c.object_id
JOIN sg_column_masking_rule link ON link.column_id = c.column_id
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
WHERE assignment.user_id = #{userId}
AND assignment.column_id = #{columnId}
</select>
<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
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
@@ -257,7 +254,7 @@
</insert>
<delete id="deleteUserRule">
DELETE FROM cb_user_masking_rule
DELETE FROM sg_user_masking_rule
WHERE user_id = #{userId}
AND column_id = #{columnId}
</delete>

View File

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

View File

@@ -4,12 +4,12 @@
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.SettingMapper">
<select id="findByKey" resultType="com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting">
SELECT setting_key, setting_value
FROM cb_backoffice_setting
FROM sg_backoffice_setting
WHERE setting_key = #{settingKey,jdbcType=VARCHAR}
</select>
<update id="upsert">
MERGE INTO cb_backoffice_setting dst
MERGE INTO sg_backoffice_setting dst
USING (
SELECT #{settingKey,jdbcType=VARCHAR} setting_key,
#{settingValue,jdbcType=VARCHAR} setting_value

View File

@@ -3,31 +3,36 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper">
<select id="findTokenSubjects" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
SELECT s.user_id AS stakeholder_user_id,
s.user_nm AS username,
s.role,
s.channel,
s.access_scope,
SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
u.user_name AS username,
COALESCE((
SELECT MIN(r.role_name)
FROM sg_user_role user_role
JOIN sg_app_role r ON r.role_id = user_role.role_id
WHERE user_role.user_id = u.user_id
), 'DATA_AI_OPERATOR') AS role,
'BACKOFFICE' AS channel,
u.dept_code AS access_scope,
u.user_id AS app_user_id
FROM poc_2.kb_stakeholders s
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
FROM sg_app_user u
WHERE u.active = 'Y'
ORDER BY CASE s.role WHEN '지점장' THEN 1 WHEN '설계사' THEN 2 ELSE 3 END,
s.channel,
s.user_nm,
s.user_id
ORDER BY role, u.dept_code, u.user_name, u.user_id
</select>
<select id="findTokenSubject" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
SELECT s.user_id AS stakeholder_user_id,
s.user_nm AS username,
s.role,
s.channel,
s.access_scope,
SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
u.user_name AS username,
COALESCE((
SELECT MIN(r.role_name)
FROM sg_user_role user_role
JOIN sg_app_role r ON r.role_id = user_role.role_id
WHERE user_role.user_id = u.user_id
), 'DATA_AI_OPERATOR') AS role,
'BACKOFFICE' AS channel,
u.dept_code AS access_scope,
u.user_id AS app_user_id
FROM poc_2.kb_stakeholders s
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
WHERE s.user_id = #{stakeholderUserId,jdbcType=VARCHAR}
FROM sg_app_user u
WHERE TO_CHAR(u.user_id) = #{stakeholderUserId,jdbcType=VARCHAR}
AND u.active = 'Y'
</select>
</mapper>

View File

@@ -3,27 +3,16 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.UserMapper">
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
SELECT e.employee_id AS user_id,
e.full_name AS username,
e.employee_code AS emp_no,
t.team_code AS dept_code,
'N' AS can_read_contents,
CASE WHEN e.employment_status = 'ACTIVE' THEN 'Y' ELSE 'N' END AS active_yn
FROM hmm_hr_employees e
JOIN hmm_org_teams t ON t.team_id = e.team_id
SELECT user_id, user_name AS username, employee_no AS emp_no, dept_code,
can_read_contents, active AS active_yn
FROM sg_app_user
ORDER BY username
</select>
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
SELECT e.employee_id AS user_id,
e.full_name AS username,
e.employee_code AS emp_no,
t.team_code AS dept_code,
'N' AS can_read_contents,
CASE WHEN e.employment_status = 'ACTIVE' THEN 'Y' ELSE 'N' END AS active_yn
FROM hmm_hr_employees e
JOIN hmm_org_teams t ON t.team_id = e.team_id
WHERE e.employee_id = #{userId}
SELECT user_id, user_name AS username, employee_no AS emp_no, dept_code,
can_read_contents, active AS active_yn
FROM sg_app_user WHERE user_id = #{userId}
</select>
<select id="findUserRoles" resultType="com.cloudhandson.vpdbackoffice.domain.user.UserRoleView">
@@ -31,46 +20,32 @@
u.user_name AS username,
r.role_id,
r.role_name
FROM hmm_employee_access_roles ur
JOIN hmm_hr_employees u ON u.employee_id = ur.employee_id
JOIN hmm_access_roles r ON r.role_id = ur.role_id
ORDER BY u.full_name, r.role_name
FROM sg_user_role ur
JOIN sg_app_user u ON u.user_id = ur.user_id
JOIN sg_app_role r ON r.role_id = ur.role_id
ORDER BY u.user_name, r.role_name
</select>
<select id="nextUserId" resultType="long">
SELECT NVL(MAX(employee_id), 0) + 1 FROM hmm_hr_employees
SELECT NVL(MAX(user_id), 0) + 1 FROM sg_app_user
</select>
<insert id="insertUser">
INSERT INTO hmm_hr_employees (
employee_id, employee_code, full_name, email, job_title, team_id, hire_date, employment_status
)
SELECT #{userId},
#{command.empNo},
#{command.username},
LOWER(#{command.empNo}) || '@example.test',
'HMM Demo Employee',
t.team_id,
TRUNC(SYSDATE),
'ACTIVE'
FROM hmm_org_teams t
WHERE t.team_code = #{command.deptCode}
INSERT INTO sg_app_user (user_id, login_id, user_name, employee_no, dept_code, can_read_contents, active)
VALUES (#{userId}, LOWER(#{command.empNo}), #{command.username}, #{command.empNo},
#{command.deptCode}, 'N', 'Y')
</insert>
<update id="updateActive">
UPDATE hmm_hr_employees
SET employment_status = CASE WHEN #{activeYn} = 'Y' THEN 'ACTIVE' ELSE 'INACTIVE' END
WHERE employee_id = #{userId}
UPDATE sg_app_user SET active = #{activeYn} WHERE user_id = #{userId}
</update>
<insert id="insertUserRole">
INSERT INTO hmm_employee_access_roles (employee_id, role_id)
VALUES (#{userId}, #{roleId})
INSERT INTO sg_user_role (user_id, role_id) VALUES (#{userId}, #{roleId})
</insert>
<delete id="deleteUserRole">
DELETE FROM hmm_employee_access_roles
WHERE employee_id = #{userId}
DELETE FROM sg_user_role WHERE user_id = #{userId}
AND role_id = #{roleId}
</delete>
</mapper>

View File

@@ -728,16 +728,16 @@ function permissionRuleBusinessLabel(type, column, value) {
return `토큰 이해관계자 본인 행 (${displayColumn})`;
}
if (type === 'OWN_CONTRACT') {
return `담당 설계사 본인 계약 (${displayColumn})`;
return `담당 게임 서비스 범위 (${displayColumn})`;
}
if (type === 'CHANNEL_CONTRACT') {
return `토큰 사용자 채널 계약 (${displayColumn})`;
return `토큰 채널 게임 서비스 범위 (${displayColumn})`;
}
if (type === 'OWN_CUSTOMER') {
return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
return `담당 게임 사용자·거래 데이터 범위 (${displayColumn})`;
}
if (type === 'CHANNEL_CUSTOMER') {
return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
return `토큰 채널 게임 사용자·거래 데이터 범위 (${displayColumn})`;
}
if (type === 'STATIC_SQL') {
return `정적 SQL 조건: ${value || '조건식 미입력'}`;
@@ -813,10 +813,10 @@ function collectWizardPredicates(root) {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`;
}
if (type === 'STAKEHOLDER_SELF') {
return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_ID = TOKEN_STAKEHOLDER_ID)`;
return '조건 코드 STAKEHOLDER_SELF: DB 행 접근 함수가 토큰 컨텍스트와 등록된 업무 관계를 기준으로 조건을 생성';
}
if (type === 'STAKEHOLDER_CHANNEL') {
return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_CHANNEL = TOKEN_CHANNEL)`;
return '조건 코드 STAKEHOLDER_CHANNEL: DB 행 접근 함수가 토큰 채널과 등록된 업무 관계를 기준으로 조건을 생성';
}
if (type === 'TOKEN_SUBJECT') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`;
@@ -828,10 +828,10 @@ function collectWizardPredicates(root) {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`;
}
if (type === 'OWN_CUSTOMER') {
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'))`;
return '조건 코드 OWN_CUSTOMER: DB 행 접근 함수가 담당 사용자와 게임 데이터 관계를 기준으로 조건을 생성';
}
if (type === 'CHANNEL_CUSTOMER') {
return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL'))`;
return '조건 코드 CHANNEL_CUSTOMER: DB 행 접근 함수가 토큰 채널과 게임 데이터 관계를 기준으로 조건을 생성';
}
if (type === 'DEPT' || type === 'EMP_NO') {
return `${displayColumn} = ${sqlLiteral(value)}`;

View File

@@ -11,7 +11,7 @@
<p>업무 사용자와 데이터 접근 기준을 관리하고, DB가 적용한 결과까지 확인합니다.</p>
</div>
<div class="dashboard-hero-actions" aria-label="주요 작업">
<a class="btn rw-btn-primary" href="/probe">접근 검증</a>
<a class="btn rw-btn-primary" href="/users">사용자 관리</a>
<a class="btn rw-btn-secondary" href="/permissions">행 접근 규칙</a>
</div>
<details class="explanation-details dashboard-explanation">
@@ -152,10 +152,10 @@
<h2 id="menu-flow-title">설정은 접근 제어에서, 적용 확인은 접근 검증에서</h2>
</div>
<div class="dashboard-flow-grid">
<a class="dashboard-flow-card" href="/permissions"><strong>행 접근 규칙</strong><span>역할별 객체·행 조건</span></a>
<a class="dashboard-flow-card" href="/vpd-policies"><strong>보호·검증</strong><span>보호 연결·검증 세션·접근 확인</span></a>
<a class="dashboard-flow-card" href="/objects"><strong>연동 도구</strong><span>조회 대상·지식 검색·MCP 연동</span></a>
<a class="dashboard-flow-card" href="/operation-status"><strong>운영</strong><span>상태와 실행 결과 확인</span></a>
<a class="dashboard-flow-card" href="/users"><strong>사용자 관리</strong><span>PoC 운영 사용자를 관리</span></a>
<a class="dashboard-flow-card" href="/groups"><strong>그룹 관리</strong><span>Data &amp; AI TF 구성</span></a>
<a class="dashboard-flow-card" href="/roles"><strong>역할 관리</strong><span>운영 역할을 관리</span></a>
<a class="dashboard-flow-card" href="/permissions"><strong>행 접근 규칙</strong><span>게임 데이터 객체별 접근 규칙</span></a>
</div>
</section>

View File

@@ -8,7 +8,10 @@
<title th:text="${title == 'SMILEGATE DATA & AI POC' ? title : title + ' · SMILEGATE DATA & AI POC'}">SMILEGATE DATA & AI POC</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/app.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<!-- External UI helpers must not block initial HTML rendering. In
particular, schema-metadata is a server-rendered management page and
remains usable while a client network delays the CDN. -->
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script defer src="/js/app.js"></script>
</head>

View File

@@ -108,8 +108,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
<tbody>
<tr th:each="status : ${policyStatuses}">
<td><code th:text="${status.targetLabel()}">POC_2.KB_CUSTOMERS</code></td>
<td><code th:text="${status.policyName()}">KB_CUSTOMER_PII_REDACT</code></td>
<td><code th:text="${status.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
<td><code th:text="${status.policyName()}">SG_GAME_USER_REDACT</code></td>
<td th:text="${status.configuredColumnCount()}">0</td>
<td th:text="${status.appliedColumnCount()}">0</td>
<td><span class="badge" th:classappend="${status.legacyVpdColumnPolicyCount() == 0} ? ' text-bg-secondary' : ' text-bg-danger'" th:text="${status.legacyVpdColumnPolicyCount() == 0} ? '없음' : ${status.legacyVpdColumnPolicyCount() + '건 활성'}">없음</span></td>
@@ -142,17 +142,17 @@
<section class="content-band">
<h2>컬럼 마스킹 규칙 등록</h2>
<p class="section-subtitle">업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>KB_RRN_STANDARD</code>.</p>
<p class="section-subtitle">게임 데이터 기준의 업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>GAME_USER_ID_MASK</code>.</p>
<form method="post" action="/masking-rules" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
규칙 코드
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="KB_RRN_STANDARD">
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="GAME_USER_ID_MASK">
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
</label>
<label>
규칙명
<input class="form-control" name="ruleName" maxlength="100" required placeholder="주민번호 기본 마스킹">
<input class="form-control" name="ruleName" maxlength="100" required placeholder="게임 사용자 식별자 기본 마스킹">
</label>
<label>
마스킹 템플릿
@@ -176,8 +176,8 @@
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
<tbody>
<tr th:each="rule : ${rules}">
<td><code th:text="${rule.ruleCode()}">KB_RRN_STANDARD</code></td>
<td th:text="${rule.ruleName()}">주민번호 기본 마스킹</td>
<td><code th:text="${rule.ruleCode()}">GAME_USER_ID_MASK</code></td>
<td th:text="${rule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
<td th:text="${rule.description() ?: '-'}">설명</td>
<td><span class="badge" th:classappend="${rule.enabled()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${rule.enabledYn()}">Y</span></td>
@@ -209,11 +209,11 @@
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
th:value="|${object.objectId()}:${columnName}|"
th:text="${object.displayName() + '.' + columnName}">POC_2.KB_CONTRACTS.PREMIUM</option>
th:text="${object.displayName() + '.' + columnName}">SGMP_POC.COMN_SALES_TXN.GUID</option>
</optgroup>
</th:block>
</select>
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 객체만 표시됩니다. 예: <code>POC_2.KB_CONTRACTS.PREMIUM</code>.</span>
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 게임 데이터 객체만 표시됩니다. 예: <code>SGMP_POC.COMN_SALES_TXN.GUID</code>.</span>
</label>
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
</form>
@@ -253,9 +253,9 @@
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
<tbody>
<tr th:each="columnRule : ${columnRules}">
<td><code th:text="${columnRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
<td th:text="${columnRule.ruleName()}">주민번호 기본 마스킹</td>
<td th:text="${columnRule.template().label()}">주민등록번호 부분 마스킹</td>
<td><code th:text="${columnRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.USER_ID</code></td>
<td th:text="${columnRule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
<td th:text="${columnRule.template().label()}">식별자 부분 마스킹</td>
<td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td>
<td th:with="policyStatus=${policyStatusByObjectName[columnRule.objectName()]}">
<span class="badge"

View File

@@ -8,7 +8,7 @@
<h1>MCP 연동</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>사용자 Bearer Token을 전달해 GPT-5.4-mini Select AI 자연어 조회를 실행하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하, DB에서는 토큰 기준 행 접근 context가 적용됩니다.</p>
<p>사용자 Bearer Token을 검증한 뒤 <code>SGMP_POC_OCI_GPT54MINI</code> Select AI 프로파일로 게임 데이터 Text2SQL을 생성하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하, 생성 SQL은 자동 실행하지 않습니다.</p>
</details>
</section>
@@ -99,7 +99,7 @@
<td>
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
<small class="text-muted">
HTTP <code>Authorization</code>행 접근 컨텍스트 · <code>prompt</code>GPT-5.4-mini Select AI 자연어 질의 · <code>limit</code> → 최대 반환 행 수
HTTP <code>Authorization</code>활성 PoC 사용자 토큰 검증 · <code>prompt</code>게임 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행
</small>
</td>
</tr>
@@ -116,10 +116,9 @@
<summary>tools/call parameter 예시 보기</summary>
<h2>tools/call Arguments</h2>
<pre class="code-block">{
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
"limit": 50
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
}</pre>
<p class="form-hint">등록 tool은 <code>ords.query.kb_select_ai_vpd</code> 하나입니다. HTTP Authorization의 사용자 Bearer Token으로 컨텍스트를 설정한 뒤 GPT-5.4-mini가 comment, annotation, constraint를 참고해 생성한 검증된 읽기 전용 KB 원장 SQL만 실행합니다.</p>
<p class="form-hint">등록 tool은 <code>oracle.select_ai.smilegate_game_text2sql</code> 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 <code>SGMP_POC_OCI_GPT54MINI</code>가 comment, annotation, constraint를 참고해 읽기 전용 게임 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.</p>
</details>
</section>
@@ -153,10 +152,9 @@
"id": 3,
"method": "tools/call",
"params": {
"name": "ords.query.kb_select_ai_vpd",
"name": "oracle.select_ai.smilegate_game_text2sql",
"arguments": {
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
"limit": 50
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
}
}
}</pre>

View File

@@ -159,8 +159,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
<tbody>
<tr th:each="status : ${maskingPolicyStatuses}">
<td><code th:text="${status.targetLabel()}">POC_2.KB_CLAIMS</code></td>
<td><code th:text="${status.policyName()}">KB_CLAIM_AMOUNT_REDACT</code></td>
<td><code th:text="${status.targetLabel()}">SGMP_POC.COMN_SALES_TXN</code></td>
<td><code th:text="${status.policyName()}">SG_SALES_TXN_REDACT</code></td>
<td th:text="${status.configuredColumnCount()}">0</td>
<td th:text="${status.appliedColumnCount()}">0</td>
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>

View File

@@ -9,7 +9,7 @@
<details class="explanation-details">
<summary>도움말</summary>
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. 저장값은 최종 SQL이 아니라 <code>CB_AGENT_DOC_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.</p>
<p>컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 주민번호, 보험료, 청구금액 같은 컬럼 표시는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 관리합니다.</p>
<p>컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 게임 사용자 식별자, 결제금액 같은 민감 컬럼 표시는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 관리합니다.</p>
</details>
</div>
@@ -158,15 +158,15 @@
</select>
<select class="form-select rule-type-select" name="ruleType">
<option value="ALL">ALL</option>
<optgroup label="KB · 본인 범위">
<option value="OWN_CONTRACT">본인 담당 계약</option>
<option value="OWN_CUSTOMER">본인 담당 고객</option>
<optgroup label="게임 데이터 · 담당자 범위">
<option value="OWN_CONTRACT">담당 게임 서비스</option>
<option value="OWN_CUSTOMER">담당 게임 사용자</option>
</optgroup>
<optgroup label="KB · 채널 범위">
<option value="CHANNEL_CONTRACT">토큰 채널 계약</option>
<option value="CHANNEL_CUSTOMER">토큰 채널 고객</option>
<optgroup label="게임 데이터 · 채널 범위">
<option value="CHANNEL_CONTRACT">토큰 채널 게임 서비스</option>
<option value="CHANNEL_CUSTOMER">토큰 채널 게임 사용자</option>
</optgroup>
<optgroup label="KB · 토큰 식별">
<optgroup label="게임 데이터 · 토큰 식별">
<option value="TOKEN_SUBJECT">토큰 사용자 본인</option>
</optgroup>
<optgroup label="SQL · 정적 조건">
@@ -179,7 +179,7 @@
</div>
<details class="explanation-details">
<summary>행 규칙의 두 가지 적용 방식 보기</summary>
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context로 치환됩니다. 예를 들어 <code>본인 담당 고객 / CUST_ID</code>는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 <code>CUST_ID</code>에 적용합니다. <strong>정적 SQL 조건식</strong> <code>CONTRACT_STATUS = '정상'</code>처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 최종 WHERE 절에 직접 들어가지 않습니다.</p>
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context와 DB에 등록된 업무 관계를 바탕으로 행 접근 함수가 해석합니다. 관계가 없는 게임 데이터 객체에는 조건 코드를 억지로 적용하지 말고, <strong>정적 SQL 조건식</strong>으로 <code>GAME_ID = 'CZN'</code>처럼 실제 컬럼을 사용하세요. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다.</p>
<p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p>
</details>
<details class="explanation-details">
@@ -196,28 +196,28 @@
</tr>
<tr>
<td><code>TOKEN_SUBJECT</code></td>
<td>토큰으로 식별된 이해관계자 본인 행</td>
<td><code>USER_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
<td>토큰으로 식별된 운영 사용자 범위</td>
<td>대상 객체가 토큰 주체 식별 컬럼을 가질 때만 DB 행 접근 함수가 생성</td>
</tr>
<tr>
<td><code>OWN_CONTRACT</code></td>
<td>토큰 사용자가 담당 설계사인 계약</td>
<td><code>FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
<td>토큰 사용자가 담당하는 게임 서비스 범위</td>
<td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
</tr>
<tr>
<td><code>CHANNEL_CONTRACT</code></td>
<td>토큰 사용자의 채널에 속한 계약</td>
<td><code>FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')</code></td>
<td>토큰 채널에 속한 게임 서비스 범위</td>
<td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
</tr>
<tr>
<td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td>
<td>계약원장에서 담당자 또는 채널 기준으로 연결되는 고객·청구·담보·외부보유 행</td>
<td><code>EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS ...)</code></td>
<td>게임 서비스·사용자 기준으로 연결되는 게임 로그·판매·환불 데이터 범위</td>
<td>대상 테이블의 실제 키 관계를 DB 행 접근 함수가 검증한 뒤 생성</td>
</tr>
<tr>
<td><code>STATIC_SQL</code></td>
<td>현재 객체 컬럼으로 표현한 고정 조건</td>
<td><code>CONTRACT_STATUS = '정상'</code>처럼 검증된 현재 객체 컬럼 조건</td>
<td><code>GAME_ID = 'CZN'</code>처럼 검증된 현재 객체 컬럼 조건</td>
</tr>
</tbody>
</table>

View File

@@ -10,7 +10,7 @@
<details class="explanation-details">
<summary>도움말</summary>
<p>
<code>POC_2</code> KB 업무 테이블의 table/column comment와 Oracle annotation을 조회·수정합니다.
<code>SGMP_POC</code> 게임 데이터 테이블의 table/column comment와 Oracle annotation을 조회·수정합니다.
Select AI profile의 <code>comments=true</code>, <code>annotations=true</code> 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.
</p>
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, 백오피스가 승인한 7개 업무 테이블만 대상으로 합니다.</p>
@@ -24,7 +24,7 @@
<div class="section-heading">
<div>
<h2>테이블 선택</h2>
<p class="section-subtitle">정형 MCP/Select AI가 참조하는 KB 업무 원장 7개만 표시합니다.</p>
<p class="section-subtitle">정형 MCP/Select AI가 참조하는 게임 데이터 테이블만 표시합니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">7</span>
</div>
@@ -33,9 +33,9 @@
class="structured-table-card"
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/schema-metadata(table=${entry.key()})}">
<strong th:text="${entry.businessName()}">고객원장</strong>
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
<small th:text="${entry.description()}">고객 기본정보</small>
<strong th:text="${entry.businessName()}">게임 사용자</strong>
<code th:text="${entry.tableName()}">CZN_COMN_USER_MST</code>
<small th:text="${entry.description()}">게임 사용자 마스터</small>
</a>
</div>
</section>
@@ -44,9 +44,9 @@
<div class="section-heading">
<div>
<span class="badge text-bg-secondary">TABLE</span>
<h2 class="mt-2" th:text="${metadata.table().businessName()}">계약원장</h2>
<h2 class="mt-2" th:text="${metadata.table().businessName()}">게임 데이터</h2>
<p class="section-subtitle">
<code th:text="${'POC_2.' + metadata.table().tableName()}">POC_2.KB_CONTRACTS</code>
<code th:text="${'SGMP_POC.' + metadata.table().tableName()}">SGMP_POC.CZN_COMN_USER_MST</code>
<span th:text="${' · ' + metadata.table().description()}"> · 설명</span>
</p>
</div>

View File

@@ -29,9 +29,9 @@
<tbody>
<tr th:each="item : ${scripts}" th:classappend="${selectedScript != null and item.scriptId() == selectedScript.scriptId()} ? ' table-primary'">
<td><span class="badge text-bg-light" th:text="${item.category()}">ASO / 마스킹</span></td>
<td><code th:text="${item.fileName()}">62_kb_aso_masking_backoffice_metadata.sql</code></td>
<td><code th:text="${item.fileName()}">71_sg_identity_administration.sql</code></td>
<td>
<strong th:text="${item.title()}">컬럼 마스킹 규칙 메타데이터</strong>
<strong th:text="${item.title()}">사용자·그룹·역할 관리 모델</strong>
<div class="form-hint" th:text="${item.description()}">설명</div>
</td>
<td><a class="btn btn-sm rw-btn-secondary" th:href="@{/security-sql-scripts(script=${item.scriptId()})}">원문 보기</a></td>
@@ -45,13 +45,13 @@
<section class="content-band" th:if="${selectedScript}">
<div class="section-heading">
<div>
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">ASO / 마스킹</span>
<h2 class="mt-2" th:text="${selectedScript.title()}">컬럼 마스킹 규칙 메타데이터</h2>
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">Smilegate 권한</span>
<h2 class="mt-2" th:text="${selectedScript.title()}">사용자·그룹·역할 관리 모델</h2>
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
</div>
<code th:text="${selectedScript.fileName()}">62_kb_aso_masking_backoffice_metadata.sql</code>
<code th:text="${selectedScript.fileName()}">71_sg_identity_administration.sql</code>
</div>
<p class="form-hint">Git source: <code th:text="${'sql/adb/' + selectedScript.fileName()}">sql/adb/62_kb_aso_masking_backoffice_metadata.sql</code>. 실제 DB 배포본은 <a href="/vpd-filter-runtime">행 접근 필터 구조</a> 및 DB 배포 이력과 함께 확인하세요.</p>
<p class="form-hint">Git source: <code th:text="${'sql/adb/' + selectedScript.fileName()}">sql/adb/71_sg_identity_administration.sql</code>. 실제 DB 배포본은 <a href="/vpd-filter-runtime">행 접근 필터 구조</a> 및 DB 배포 이력과 함께 확인하세요.</p>
<form hx-post="/security-sql-scripts/explanation" hx-target="#security-sql-explanation" hx-swap="innerHTML" class="mb-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}">

View File

@@ -8,19 +8,19 @@
<h1>정형 데이터 조회</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>보험 원장 7개만 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
<p>스마일게이트 게임 데이터 테이블을 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
</details>
</div>
<div class="alert alert-warning">
이 화면은 관리자용 원장 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
이 화면은 관리자용 게임 데이터 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
</div>
<section class="content-band">
<div class="section-heading">
<div>
<h2>조회할 원장 선택</h2>
<p class="section-subtitle">KBAIPOC의 <code>POC_2</code> 스키마에서 승인된 정형 테이블만 표시합니다.</p>
<h2>조회할 게임 데이터 선택</h2>
<p class="section-subtitle"><code>SGMP_POC</code> 스키마에서 PoC 대상으로 등록한 게임 데이터 테이블만 표시합니다.</p>
</div>
</div>
<div class="structured-table-grid">
@@ -28,9 +28,9 @@
class="structured-table-card"
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/structured-data(table=${entry.key()})}">
<strong th:text="${entry.businessName()}">고객원장</strong>
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
<small th:text="${entry.description()}">고객 기본정보</small>
<strong th:text="${entry.businessName()}">게임 사용자</strong>
<code th:text="${entry.tableName()}">CZN_COMN_USER_MST</code>
<small th:text="${entry.description()}">게임 사용자 마스터</small>
</a>
</div>
</section>
@@ -40,9 +40,9 @@
<section class="content-band" th:if="${preview}">
<div class="section-heading">
<div>
<h2 th:text="${preview.table().businessName()}">고객원장</h2>
<h2 th:text="${preview.table().businessName()}">게임 사용자</h2>
<p class="section-subtitle">
<code th:text="${'POC_2.' + preview.table().tableName()}">POC_2.KB_CUSTOMERS</code>
<code th:text="${'SGMP_POC.' + preview.table().tableName()}">SGMP_POC.CZN_COMN_USER_MST</code>
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
</p>
</div>

View File

@@ -1,14 +1,14 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('HMM 액세스 토큰')}"></head>
<head th:replace="~{fragments/layout :: head('Smilegate 액세스 토큰')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>HMM 액세스 토큰</h1>
<h1>Smilegate 액세스 토큰</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>HMM HR 직원에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
<p>Data &amp; AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
</details>
</div>
@@ -27,14 +27,14 @@
<section class="content-band">
<div class="section-heading">
<div>
<h2>직원 토큰 발급</h2>
<p class="section-subtitle">활성 HMM 직원에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p>
<h2>도구 사용자 토큰 발급</h2>
<p class="section-subtitle">활성 Data &amp; AI PoC 사용자에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p>
</div>
</div>
<form method="post" action="/tokens" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
HMM 직원
도구 사용자
<select class="form-select" name="userId" required>
<option value="" selected disabled>토큰을 발급할 직원 선택</option>
<option th:each="user : ${users}"
@@ -48,7 +48,7 @@
</label>
<label>
용도 메모
<input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인">
<input class="form-control" name="description" maxlength="200" placeholder="예: 게임 데이터 AI 질의 검증">
</label>
<button class="btn rw-btn-primary" type="submit">검증 세션 발급</button>
</form>

View File

@@ -26,7 +26,7 @@
<select class="form-select" name="userId" required>
<option value="">선택하세요</option>
<option th:each="user : ${users}" th:value="${user.userId()}" th:attr="data-user-label=${user.username()}"
th:text="${user.username() + ' (' + user.empNo() + ')'}">KB_VPD_ADMIN</option>
th:text="${user.username() + ' (' + user.empNo() + ')'}">sg-teamlead (SG-001)</option>
</select>
</label>
<label>
@@ -39,7 +39,7 @@
data-result=${columnRule.template().previewResult()},
data-aso-function=${columnRule.template().asoFunction()},
data-context=${'MR_' + columnRule.columnId()}"
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">KB_CUSTOMERS.RRN_MASKED</option>
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</option>
</select>
</label>
<div>
@@ -85,9 +85,9 @@
<thead><tr><th>사용자</th><th>대상 컬럼</th><th>기본 규칙</th><th>원문 표시 상태</th><th>상태</th><th></th></tr></thead>
<tbody>
<tr th:each="userRule : ${userRules}">
<td th:text="${userRule.username()}">KB_VPD_ADMIN</td>
<td><code th:text="${userRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
<td th:text="${userRule.ruleLabel()}">주민번호 기본 마스킹 · 주민등록번호 부분 마스킹</td>
<td th:text="${userRule.username()}">sg-teamlead</td>
<td><code th:text="${userRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</code></td>
<td th:text="${userRule.ruleLabel()}">게임 사용자 식별자 기본 마스킹 · 식별번호 부분 마스킹</td>
<td><span class="badge" th:classappend="${userRule.unmasked()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${userRule.decisionLabel()}">원문 표시 예외</span></td>
<td th:text="${userRule.activeYn()}">Y</td>
<td>

View File

@@ -82,8 +82,8 @@
</thead>
<tbody>
<tr th:each="policy : ${policies}">
<td><code th:text="${policy.objectDisplayName()}">POC_2.KB_CONTRACTS</code></td>
<td><code th:text="${policy.policyName()}">KB_KB_CONTRACTS_ROW_POLICY</code></td>
<td><code th:text="${policy.objectDisplayName()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
<td><code th:text="${policy.policyName()}">SG_CZN_USER_ROW_POLICY</code></td>
<td th:text="${policy.statementTypes()}">SELECT</td>
<td><span class="badge" th:classappend="${policy.enabled() == 'YES'} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${policy.enabled() == 'YES'} ? '적용됨' : '중지됨'">적용됨</span></td>
<td><code th:text="${policy.functionDisplayName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</code></td>
@@ -105,7 +105,7 @@
<tr><td>토큰 신뢰 경계</td><td>Bearer Token은 ORDS에서 검증하고 요청 종료 시 컨텍스트를 정리합니다.</td><td>ORDS Handler / <code>CB_AGENT_CTX_PKG</code></td></tr>
<tr><td>권한 결합</td><td>권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.</td><td><a href="/permissions">행 접근 규칙</a></td></tr>
<tr><td>오류·미권한</td><td>유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.</td><td><a href="/probe">접근 검증</a></td></tr>
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 보험료·주민번호 등의 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 게임 사용자 식별자·거래 식별자의 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,22 @@
package com.cloudhandson.vpdbackoffice.mapper;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class SchemaMetadataMapperXmlTest {
@Test
void annotationUsageQueriesUseOnlyColumnsProvidedByTheAllView() throws IOException {
try (var input = getClass().getResourceAsStream("/mapper/SchemaMetadataMapper.xml")) {
String mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
assertThat(mapperXml)
.contains("FROM all_annotations_usage")
.contains("object_name = #{tableName,jdbcType=VARCHAR}")
.doesNotContain("object_owner");
}
}
}

View File

@@ -23,8 +23,8 @@ class MaskingPolicySynchronizerTest {
}
@Test
void kbContractsPremiumPolicyIsManagedByBackoffice() {
assertThat(MaskingPolicySynchronizer.managedPolicyName("KB_CONTRACTS"))
.isEqualTo("KB_CONTRACT_PREMIUM_REDACT");
void smilegateSalesTransactionPolicyIsManagedByBackoffice() {
assertThat(MaskingPolicySynchronizer.managedPolicyName("COMN_SALES_TXN"))
.isEqualTo("SG_SALES_TXN_REDACT");
}
}

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -9,10 +10,17 @@ import org.junit.jupiter.api.Test;
class McpSseServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService();
private final SmilegateSelectAiService selectAiService = new CapturingSmilegateSelectAiService();
private final McpSseService service = new McpSseService(
selectAiAgentOrdsService,
objectMapper
selectAiService,
objectMapper,
new BackofficeProperties(
null,
null,
null,
null,
new BackofficeProperties.SelectAi("", "", "", "SGMP_POC_OCI_GPT54MINI")
)
);
@Test
@@ -22,12 +30,13 @@ class McpSseServiceTest {
var tools = response.path("result").path("tools");
assertThat(tools).hasSize(1);
var selectAi = tools.get(0);
assertThat(selectAi.path("name").asText()).isEqualTo("ords.query.kb_select_ai_vpd");
assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.smilegate_game_text2sql");
assertThat(selectAi.path("description").asText()).contains("SGMP_POC_OCI_GPT54MINI");
assertThat(selectAi.path("inputSchema").path("required"))
.extracting(node -> node.asText())
.contains("prompt");
assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse();
assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isTrue();
assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isFalse();
assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse();
}
@@ -35,31 +44,33 @@ class McpSseServiceTest {
void callsVpdSelectAiThroughOrdsService() {
ObjectNode request = request(2, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "ords.query.kb_select_ai_vpd");
params.put("name", "oracle.select_ai.smilegate_game_text2sql");
ObjectNode arguments = params.putObject("arguments");
arguments.put("prompt", "고객 수를 조회해 줘");
arguments.put("limit", 25);
arguments.put("prompt", "카제나 AU를 조회해 줘");
ObjectNode response = service.handle("default", request, "user-bearer");
CapturingSelectAiAgentOrdsService agentService =
(CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService;
CapturingSmilegateSelectAiService agentService =
(CapturingSmilegateSelectAiService) selectAiService;
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
assertThat(agentService.prompt).isEqualTo("고객 수를 조회해 줘");
assertThat(agentService.limit).isEqualTo(25);
assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘");
assertThat(response.path("error").isMissingNode()).isTrue();
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
assertThat(response.path("result").path("content").get(0).path("text").asText())
.contains("KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
.contains("SELECT COUNT(*) FROM KB_CUSTOMERS");
String payload = response.path("result").path("content").get(0).path("text").asText();
assertThat(payload)
.contains("SGMP_POC_OCI_GPT54MINI")
.contains("SELECT 1 FROM DUAL")
.contains("SHOWSQL_AND_EXECUTED")
.contains("READ_ONLY_EXECUTED")
.contains("RESULT");
}
@Test
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
ObjectNode request = request(3, "tools/call");
ObjectNode params = (ObjectNode) request.putObject("params");
params.put("name", "ords.query.kb_select_ai_vpd");
params.putObject("arguments").put("prompt", "고객 수를 조회해 줘");
params.put("name", "oracle.select_ai.smilegate_game_text2sql");
params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘");
ObjectNode response = service.handle("default", request, "");
@@ -78,24 +89,27 @@ class McpSseServiceTest {
return request;
}
private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService {
private static final class CapturingSmilegateSelectAiService extends SmilegateSelectAiService {
private String bearerToken;
private String prompt;
private int limit;
private CapturingSelectAiAgentOrdsService() {
super(null, null, new ObjectMapper());
private CapturingSmilegateSelectAiService() {
super(null, null, null, new ObjectMapper());
}
@Override
public JsonNode run(String bearerToken, String prompt, int limit) {
public JsonNode generateAndExecute(String bearerToken, String prompt) {
this.bearerToken = bearerToken;
this.prompt = prompt;
this.limit = limit;
return new ObjectMapper().createObjectNode()
.put("profile", "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
.put("generatedSql", "SELECT COUNT(*) FROM KB_CUSTOMERS");
ObjectNode response = new ObjectMapper().createObjectNode()
.put("profile", "SGMP_POC_OCI_GPT54MINI")
.put("generatedSql", "SELECT 1 FROM DUAL")
.put("status", "SHOWSQL_AND_EXECUTED")
.put("execution", "READ_ONLY_EXECUTED")
.put("rowCount", 1);
response.putArray("items").addObject().put("RESULT", 1);
return response;
}
}
}

View File

@@ -0,0 +1,101 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class SchemaMetadataServiceTest {
private static final StructuredDataTable GAME_USERS = new StructuredDataTable(
"game-users", "CZN_COMN_USER_MST", "게임 사용자", "카제나 게임 사용자 마스터");
private SchemaMetadataMapper mapper;
private SchemaMetadataService service;
@BeforeEach
void setUp() {
mapper = mock(SchemaMetadataMapper.class);
StructuredDataService structuredDataService = mock(StructuredDataService.class);
when(structuredDataService.requireTable("game-users")).thenReturn(GAME_USERS);
when(structuredDataService.tables()).thenReturn(List.of(GAME_USERS));
when(structuredDataService.defaultKey()).thenReturn("game-users");
service = new SchemaMetadataService(mapper, structuredDataService);
}
@Test
void readsOnlyTheSelectedSmilegateTableThroughMapper() {
when(mapper.findTableComment("SGMP_POC", "CZN_COMN_USER_MST"))
.thenReturn("게임별 사용자 기준 정보");
when(mapper.findAnnotations("CZN_COMN_USER_MST")).thenReturn(List.of(
new SchemaMetadataAnnotationRow(null, "BUSINESS_TERM", "게임 사용자"),
new SchemaMetadataAnnotationRow("GUID", "BUSINESS_TERM", "사용자 고유 식별자"),
new SchemaMetadataAnnotationRow("GUID", "BUSINESS_TERM", "고객 계정 식별자")
));
when(mapper.findColumns("SGMP_POC", "CZN_COMN_USER_MST")).thenReturn(List.of(
new SchemaMetadataColumnRow("GUID", "VARCHAR2(64)", "N", "사용자 GUID")
));
SchemaMetadataView view = service.find("game-users");
assertThat(view.tableComment()).isEqualTo("게임별 사용자 기준 정보");
assertThat(view.tableAnnotations()).singleElement()
.extracting(annotation -> annotation.name(), annotation -> annotation.value())
.containsExactly("BUSINESS_TERM", "게임 사용자");
assertThat(view.columns()).singleElement()
.satisfies(column -> {
assertThat(column.columnName()).isEqualTo("GUID");
assertThat(column.nullable()).isFalse();
assertThat(column.annotations()).singleElement()
.extracting(annotation -> annotation.value())
.isEqualTo("사용자 고유 식별자\n--- duplicate annotation value ---\n고객 계정 식별자");
});
verify(mapper).findAnnotations("CZN_COMN_USER_MST");
verify(mapper).findColumns("SGMP_POC", "CZN_COMN_USER_MST");
}
@Test
void replacesAnExistingColumnAnnotationUsingMapperDdl() {
when(mapper.countColumn("SGMP_POC", "CZN_COMN_USER_MST", "GUID")).thenReturn(1);
when(mapper.countColumnAnnotation("CZN_COMN_USER_MST", "GUID", "BUSINESS_TERM"))
.thenReturn(1);
service.updateColumnAnnotation("game-users", "guid", "business_term", "사용자 고유 식별자");
verify(mapper).dropColumnAnnotation("SGMP_POC", "CZN_COMN_USER_MST", "GUID", "BUSINESS_TERM");
verify(mapper).addColumnAnnotation(
"SGMP_POC", "CZN_COMN_USER_MST", "GUID", "BUSINESS_TERM", "'사용자 고유 식별자'");
}
@Test
void clearsAnExistingTableAnnotationWithoutAddingAnEmptyValue() {
when(mapper.countTableAnnotation("CZN_COMN_USER_MST", "BUSINESS_TERM"))
.thenReturn(1);
service.updateTableAnnotation("game-users", "business_term", " ");
verify(mapper).dropTableAnnotation("SGMP_POC", "CZN_COMN_USER_MST", "BUSINESS_TERM");
verify(mapper, never()).addTableAnnotation(anyString(), anyString(), anyString(), anyString());
}
@Test
void rejectsAColumnThatIsNotPresentInTheSelectedTable() {
when(mapper.countColumn("SGMP_POC", "CZN_COMN_USER_MST", "UNKNOWN_COL")).thenReturn(0);
assertThatThrownBy(() -> service.updateColumnComment("game-users", "unknown_col", "설명"))
.isInstanceOf(AppException.class)
.hasMessage("선택한 테이블에 존재하지 않는 컬럼입니다.");
}
}

View File

@@ -14,16 +14,13 @@ class SecuritySqlScriptServiceTest {
assertThat(service.list())
.extracting(item -> item.fileName())
.containsExactly(
"62_kb_aso_masking_backoffice_metadata.sql",
"63_kb_aso_masking_rule_runtime.sql",
"64_kb_aso_masking_default_column_rules.sql",
"65_kb_select_ai_vpd_query_api.sql",
"66_kb_select_ai_vpd_query_ords.sql"
"70_sg_tool_user.sql",
"71_sg_identity_administration.sql"
);
assertThat(service.find("select-ai-vpd-ords").source())
.contains("POST /ords/cb-ords/kb-select-ai-vpd/query")
.contains("Authorization: Bearer <VPD token>");
assertThat(service.find("smilegate-identity-administration").source())
.contains("create table sg_app_user")
.contains("DATA_AI_TF");
}
@Test

View File

@@ -12,22 +12,22 @@ class StructuredDataServiceTest {
private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class));
@Test
void exposesOnlyTheSevenApprovedStructuredTables() {
void exposesOnlyTheSevenApprovedSmilegateStructuredTables() {
assertThat(service.tables())
.extracting(table -> table.tableName())
.containsExactly(
"KB_CUSTOMERS",
"KB_PRODUCTS",
"KB_CONTRACTS",
"KB_COVERAGES",
"KB_CLAIMS",
"KB_EXTERNAL_HOLDINGS",
"KB_STAKEHOLDERS");
"CZN_COMN_USER_MST",
"CZN_COMN_CHARACTER_MST",
"COMN_SALES_TXN",
"COMN_REFUND_TXN",
"COMN_SALES_PRODUCT_DISP_BAS",
"COMN_GAME_SERVER_BAS",
"COMN_GAME_ALIAS_BAS");
}
@Test
void rejectsAnyTableOutsideTheServerSideAllowlist() {
assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG"))
assertThatThrownBy(() -> service.requireTable("security-audit-log"))
.isInstanceOf(AppException.class)
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
}

View File

@@ -35,7 +35,7 @@ class GuidedFlowTemplateTest {
.doesNotContain("dashboard-workflow")
.doesNotContain(">01<", ">02<");
assertThat(layout)
.contains("데이터 접근 제어")
.contains("운영 사용자 관리")
.contains("접근 제어")
.contains("보호·검증")
.contains("연동 도구")
@@ -122,7 +122,7 @@ class GuidedFlowTemplateTest {
.contains("data-wizard-target=\"2\">대상")
.doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number");
assertThat(tokens)
.contains("직원 토큰 발급", "검증 세션 발급")
.contains("도구 사용자 토큰 발급", "검증 세션 발급")
.doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급");
assertThat(probe)
.contains("접근 검증 실행", "<strong>검증 결과</strong>")
@@ -198,7 +198,7 @@ class GuidedFlowTemplateTest {
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
assertThat(html)
.contains("value=\"OWN_CUSTOMER\">본인 담당 고객")
.contains("value=\"OWN_CUSTOMER\">담당 게임 사용자")
.contains("value=\"STATIC_SQL\">정적 SQL 조건식")
.contains("행 규칙의 두 가지 적용 방식 보기")
.contains("한 권한 안의 규칙은 모두 AND")
@@ -221,8 +221,8 @@ class GuidedFlowTemplateTest {
String filters = template("vpd-filter-policies.html");
assertThat(roles)
.contains("표시 보호 등급 상한")
.contains("행 접근 권한은 행 접근 규칙과 Oracle VPD 정책에서 결정합니다");
.contains("스마일게이트 Data & AI PoC 운영 역할")
.contains("데이터 취급 등급");
assertThat(tokens)
.contains("회수·만료 포함")
.contains("현재 사용 가능한 토큰만");

View File

@@ -31,26 +31,26 @@ class MaskingRuleTemplateRenderTest {
var engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
var object = new ProtectedObject(7L, "POC_2", "KB_CUSTOMERS", "kb/customers", "Y");
var contractObject = new ProtectedObject(8L, "POC_2", "KB_CONTRACTS", "kb/contracts", "Y");
var column = new ProtectedColumn(11L, 7L, "RRN_MASKED", "Y", null, "RESTRICTED", "NULLIFY");
var rule = new MaskingRule(1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "테스트", "Y");
var columnRule = new ColumnMaskingRule(11L, 7L, "POC_2", "KB_CUSTOMERS", "RRN_MASKED",
1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "Y");
var object = new ProtectedObject(7L, "SGMP_POC", "CZN_COMN_USER_MST", "sgmp-poc/czn-users", "Y");
var transactionObject = new ProtectedObject(8L, "SGMP_POC", "COMN_SALES_TXN", "sgmp-poc/sales", "Y");
var column = new ProtectedColumn(11L, 7L, "GUID", "Y", null, "RESTRICTED", "NULLIFY");
var rule = new MaskingRule(1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "테스트", "Y");
var columnRule = new ColumnMaskingRule(11L, 7L, "SGMP_POC", "CZN_COMN_USER_MST", "GUID",
1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "Y");
var context = new Context(Locale.KOREAN);
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
context.setVariable("templates", List.of(MaskingTemplate.values()));
context.setVariable("rules", List.of(rule));
context.setVariable("columnRules", List.of(columnRule));
var status = new MaskingPolicyStatus(
"POC_2", "KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT", "YES", 1, 1, 0, 0
"SGMP_POC", "CZN_COMN_USER_MST", "SG_CZN_USER_REDACT", "YES", 1, 1, 0, 0
);
context.setVariable("policyStatuses", List.of(status));
context.setVariable("policyStatusByObjectName", Map.of("KB_CUSTOMERS", status));
context.setVariable("objects", List.of(object, contractObject));
context.setVariable("maskingTargetObjects", List.of(object, contractObject));
context.setVariable("policyStatusByObjectName", Map.of("CZN_COMN_USER_MST", status));
context.setVariable("objects", List.of(object, transactionObject));
context.setVariable("maskingTargetObjects", List.of(object, transactionObject));
context.setVariable("sensitiveColumnsByObject", Map.of(7L, List.of(column), 8L, List.of()));
context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("CUST_NM"), 8L, List.of("PREMIUM")));
context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("GUID"), 8L, List.of("GUID")));
String rulesPage = engine.process("masking-rules", context);
@@ -64,13 +64,13 @@ class MaskingRuleTemplateRenderTest {
.contains("DB Redaction 컬럼")
.contains("레거시 VPD 컬럼 제어")
.contains("마스킹 대상 컬럼 추가")
.contains("POC_2.KB_CONTRACTS.PREMIUM")
.contains("KB_CUSTOMER_PII_REDACT")
.contains("SGMP_POC.COMN_SALES_TXN.GUID")
.contains("SG_CZN_USER_REDACT")
.contains("적용됨");
context.setVariable("users", List.of(new AppUser(2L, "KB_VPD_ADMIN", "ADMIN", "SEC", "N", "Y")));
context.setVariable("userRules", List.of(new UserMaskingRule(2L, "KB_VPD_ADMIN", 11L,
"POC_2", "KB_CUSTOMERS", "RRN_MASKED", "주민번호 기본 마스킹", "RRN_PARTIAL", "UNMASK", "Y")));
context.setVariable("users", List.of(new AppUser(2L, "sg-teamlead", "SG-001", "DATA_AI", "N", "Y")));
context.setVariable("userRules", List.of(new UserMaskingRule(2L, "sg-teamlead", 11L,
"SGMP_POC", "CZN_COMN_USER_MST", "GUID", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "UNMASK", "Y")));
String userPage = engine.process("user-masking-rules", context);
assertThat(userPage)