Compare commits
49 Commits
hmm-backof
...
455e2aa222
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
455e2aa222 | ||
|
|
a17f18cffe | ||
|
|
943c38f181 | ||
|
|
443d6677a3 | ||
|
|
9c48d86696 | ||
|
|
c58cdb5797 | ||
|
|
e753027536 | ||
|
|
da992ff524 | ||
|
|
00b62bfb41 | ||
|
|
d552e5e25b | ||
|
|
3fcd699d92 | ||
|
|
c8fa6af679 | ||
|
|
8e4d2f656f | ||
|
|
df19912378 | ||
|
|
744dcad634 | ||
|
|
23bc4424e3 | ||
|
|
53342e7bc3 | ||
|
|
7ba173240c | ||
|
|
ebc8aeee41 | ||
|
|
75d0a9a998 | ||
|
|
32fccced20 | ||
|
|
a46ebe6bfd | ||
|
|
1771dccd96 | ||
|
|
cbee6c31c7 | ||
|
|
f730c73ceb | ||
|
|
b106e40631 | ||
|
|
99798aa6bb | ||
|
|
1fe8404e4b | ||
|
|
1fcc231eff | ||
|
|
63f294a857 | ||
|
|
20836e458d | ||
|
|
9eb1f4d1fd | ||
|
|
914dc087ed | ||
|
|
27757ebff3 | ||
|
|
4211f11cae | ||
|
|
a91ad3c364 | ||
|
|
90d76f1033 | ||
|
|
211dd2b2c8 | ||
|
|
20c6a82338 | ||
|
|
b3f2422b63 | ||
|
|
1538aadb14 | ||
|
|
404244534a | ||
|
|
ea4fc1de8b | ||
|
|
0eb68664f1 | ||
|
|
4eec33b2ed | ||
|
|
b4ed8649f1 | ||
|
|
f7295277f1 | ||
|
|
075eb46ef8 | ||
|
|
2b66e27bfb |
42
.env.example
42
.env.example
@@ -51,6 +51,48 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
|
|||||||
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
||||||
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
||||||
|
|
||||||
|
# Select AI는 프로파일 소유 스키마로 별도 접속합니다.
|
||||||
|
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
|
||||||
|
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
|
||||||
|
export BACKOFFICE_SELECT_AI_DB_USERNAME=""
|
||||||
|
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
|
||||||
|
export BACKOFFICE_SELECT_AI_PROFILE=""
|
||||||
|
export BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED="true"
|
||||||
|
export BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K="3"
|
||||||
|
# Customer-owned DB view: game aliases, approved profile objects, and valid DB objects.
|
||||||
|
export BACKOFFICE_GAME_SCOPE_ENABLED="false"
|
||||||
|
export BACKOFFICE_GAME_SCOPE_VIEW=""
|
||||||
|
export BACKOFFICE_GAME_SCOPE_MAX_SCOPES="8"
|
||||||
|
|
||||||
|
# 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다.
|
||||||
|
# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.
|
||||||
|
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
|
||||||
|
export BACKOFFICE_CATALOG_OBJECTS='[{"key":"customers","tableName":"CUSTOMER","objectType":"TABLE","businessName":"고객","description":"고객 기본 정보"}]'
|
||||||
|
export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
|
||||||
|
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
|
||||||
|
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
|
||||||
|
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
|
||||||
|
export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL"
|
||||||
|
export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다."
|
||||||
|
export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다."
|
||||||
|
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME="oracle.select_ai.data_showprompt"
|
||||||
|
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL="업무 데이터 SHOWPROMPT"
|
||||||
|
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION="Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다."
|
||||||
|
# Select AI few-shot 예제 SQL 조회·저장 MCP. 운영 환경은 고객별 도구명과 안내문만 변경합니다.
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_NAME="oracle.select_ai.qa_vector_search"
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_LABEL="Select AI 예제 SQL 조회"
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION="현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다."
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME="oracle.select_ai.qa_vector_store"
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL="Select AI 예제 SQL 저장"
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION="검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다."
|
||||||
|
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_NAME="oracle.select_ai.game_scope_resolve"
|
||||||
|
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_LABEL="게임 조회 범위 확인"
|
||||||
|
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_DESCRIPTION="질문의 게임 별칭을 DB 범위 view로 확인하고, SUPPORTED 결과에만 Few-shot NL2SQL을 호출하도록 안내합니다."
|
||||||
|
# 마스킹 관리 대상. objectName/policyName JSON 배열이며, 비우면 어떤 DB 정책도 관리하지 않습니다.
|
||||||
|
export BACKOFFICE_MASKING_POLICIES=''
|
||||||
|
# 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다.
|
||||||
|
export BACKOFFICE_SECURITY_SQL_SCRIPTS=''
|
||||||
|
|
||||||
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||||
export BACKOFFICE_AI_ENABLED="false"
|
export BACKOFFICE_AI_ENABLED="false"
|
||||||
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,6 +17,10 @@ logs/
|
|||||||
# Java / Maven
|
# Java / Maven
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# Python / Streamlit
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
# Locally downloaded development tools (for example SQLcl)
|
# Locally downloaded development tools (for example SQLcl)
|
||||||
.tools/
|
.tools/
|
||||||
|
|
||||||
|
|||||||
58
deploy/caddy/smilegate/Caddyfile
Normal file
58
deploy/caddy/smilegate/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
docs/design/703-smilegate-backoffice-finalization/README.md
Normal file
87
docs/design/703-smilegate-backoffice-finalization/README.md
Normal 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 확인을 먼저 수행하고, 배포 시에는 승인된 운영 접속 경로를 사용한다.
|
||||||
43
docs/design/704-smilegate-branch-isolation/README.md
Normal file
43
docs/design/704-smilegate-branch-isolation/README.md
Normal 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 전용 작업 경로에서만 수행한다.
|
||||||
|
- 공통 기반을 변경해야 하면 두 고객 브랜치에 적용하기 전에 영향 범위를 별도 이슈로 검토한다.
|
||||||
70
docs/design/706-smilegate-qa-history/README.md
Normal file
70
docs/design/706-smilegate-qa-history/README.md
Normal 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의 두 테이블에 저장한다.
|
||||||
|
- 자유 질의에 임의의 정답을 부여하지 않는다.
|
||||||
48
docs/design/708-smilegate-select-ai-oci-genai/README.md
Normal file
48
docs/design/708-smilegate-select-ai-oci-genai/README.md
Normal 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`이다.
|
||||||
27
docs/design/710-smilegate-poc4-url-token-security/README.md
Normal file
27
docs/design/710-smilegate-poc4-url-token-security/README.md
Normal 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의 서버 검증 결과만 읽는 구조로 구현한다.
|
||||||
78
docs/design/722-configurable-data-catalog/README.md
Normal file
78
docs/design/722-configurable-data-catalog/README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# 설계서: 환경변수 기반 공통 데이터 카탈로그
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
이 백오피스는 Oracle Database의 권한, 메타데이터, Select AI와 정형 데이터 조회를
|
||||||
|
운영하기 위한 공통 관리 화면이다. 현재 일부 화면은 특정 스키마와 업무 테이블 목록을
|
||||||
|
코드에 고정하고 있어, 다른 프로젝트에 재사용하려면 Java와 MyBatis를 함께 수정해야 한다.
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
1. DB 접속은 기존 `BACKOFFICE_*_DB_*` 환경변수 체계를 유지한다.
|
||||||
|
2. 메타데이터와 정형 데이터 조회 대상은 `BACKOFFICE_CATALOG_OWNER`와
|
||||||
|
`BACKOFFICE_CATALOG_OBJECTS`에서 선언한다.
|
||||||
|
3. 테이블과 뷰를 공통 `DataCatalogObject` 인터페이스로 표현한다.
|
||||||
|
4. 서비스와 MyBatis는 검증된 카탈로그 객체에서 전달받은 owner, object name, object type만
|
||||||
|
사용한다. HTTP 요청값을 SQL 식별자로 쓰지 않는다.
|
||||||
|
5. 카탈로그 환경변수가 비어 있거나 잘못되면 기동 시 실패한다. 다른 고객의 객체를 기본값으로
|
||||||
|
참조하지 않는다.
|
||||||
|
|
||||||
|
## 설정 계약
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
|
||||||
|
export BACKOFFICE_CATALOG_OBJECTS='[
|
||||||
|
{"key":"sales","tableName":"SALES_TXN","objectType":"TABLE",
|
||||||
|
"businessName":"판매 거래","description":"판매 거래 정보"},
|
||||||
|
{"key":"daily-sales","tableName":"VW_DAILY_SALES","objectType":"VIEW",
|
||||||
|
"businessName":"일별 판매","description":"일별 판매 집계 뷰"}
|
||||||
|
]'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `key`: 화면 URL과 선택값에 사용하는 영문 키. 소문자, 숫자, `-`만 허용한다.
|
||||||
|
- `tableName`: Oracle 단순 식별자. 대문자, 숫자, `_`, `$`, `#`만 허용한다.
|
||||||
|
- `objectType`: `TABLE` 또는 `VIEW`.
|
||||||
|
- `businessName`, `description`: 화면 표시용 텍스트.
|
||||||
|
|
||||||
|
잘못된 JSON, 중복 key/name, 빈 목록, 허용되지 않은 식별자는 기동 시 명확히 실패한다.
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|
```text
|
||||||
|
환경변수
|
||||||
|
→ CatalogProperties
|
||||||
|
→ DataCatalog
|
||||||
|
→ StructuredDataService / SchemaMetadataService
|
||||||
|
→ MyBatis Mapper
|
||||||
|
→ Oracle dictionary / 허용 객체
|
||||||
|
```
|
||||||
|
|
||||||
|
`DataCatalog`은 허용 객체를 해석하는 단일 진입점이다. 미리보기 SQL은 객체 이름을
|
||||||
|
카탈로그에서만 받아 조합하며, 목록 밖 이름은 SQL에 들어갈 수 없다.
|
||||||
|
|
||||||
|
## 보안 SQL 번들
|
||||||
|
|
||||||
|
보안 SQL 화면은 `BACKOFFICE_SECURITY_SQL_SCRIPTS` JSON 배열에 선언한 번들만 표시한다.
|
||||||
|
각 항목은 `scriptId`, `category`, `fileName`, `title`, `description`을 가진다.
|
||||||
|
`fileName`은 패키지의 `sql/adb/` 하위 상대 경로만 허용하며, 요청값으로 경로를 만들지 않는다.
|
||||||
|
기존 고객 전용 SQL은 `sql/adb/legacy/<customer>/`에 보존하고, 다른 환경에는 해당 목록을
|
||||||
|
선언하지 않는다.
|
||||||
|
|
||||||
|
## MyBatis 처리
|
||||||
|
|
||||||
|
- table/view comment와 column comment 조회는 `owner`, `objectName`을 바인드한다.
|
||||||
|
- annotation 조회는 Oracle dictionary 제약에 맞춰 `objectName`, `objectType`을 함께
|
||||||
|
바인드한다.
|
||||||
|
- 주석 DDL은 `COMMENT ON TABLE` 문법으로 테이블 또는 뷰에 적용한다.
|
||||||
|
- annotation DDL은 `TABLE`에만 허용한다. 뷰는 comment 편집만 제공한다.
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
- 환경변수로 테이블과 뷰를 섞은 카탈로그를 선언할 수 있다.
|
||||||
|
- metadata와 preview가 선언된 owner/object만 조회한다.
|
||||||
|
- 뷰의 comment/column comment는 조회·수정 가능하고, annotation 편집은 차단된다.
|
||||||
|
- 설정 파싱과 허용 목록 검증을 자동 테스트한다.
|
||||||
|
|
||||||
|
## 비범위
|
||||||
|
|
||||||
|
- Select AI profile 내부 object list를 자동으로 생성·변경하지 않는다.
|
||||||
74
docs/design/723-sgmp-qa-vector-retrieval/README.md
Normal file
74
docs/design/723-sgmp-qa-vector-retrieval/README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 723. SGMP QA Vector Retrieval
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Store curated question, answer SQL, answer text, and their combined retrieval
|
||||||
|
document in `SGMP_POC`. Retrieve the top-K closest examples for a new question
|
||||||
|
and pass the returned context to the Text2SQL prompt in a later application
|
||||||
|
integration.
|
||||||
|
|
||||||
|
## Security boundary
|
||||||
|
|
||||||
|
- IAM identity: `sgmp-qa-vector-api`
|
||||||
|
- IAM group: `sgmp-vector-embed-group`
|
||||||
|
- IAM policy: only `use generative-ai-text-embedding in tenancy`
|
||||||
|
- Database credential: `SGMP_POC_QA_VECTOR_CRED`, created from that dedicated
|
||||||
|
API signing key only. It does not reuse `SGMP_POC_OCI_DEFAULT_CRED`.
|
||||||
|
- Network: HTTPS only to OCI GenAI Chicago EmbedText endpoint on port 443. The
|
||||||
|
`SGMP_POC` ACE is provisioned once by an ADB `ADMIN` connection because an
|
||||||
|
application schema cannot administer network ACLs.
|
||||||
|
- The private key is read from `VECTOR_OCI_API_KEY_FILE`; it is never committed,
|
||||||
|
displayed, or persisted outside the encrypted database credential.
|
||||||
|
|
||||||
|
## Embedding contract
|
||||||
|
|
||||||
|
- Model: `cohere.embed-v4.0`
|
||||||
|
- Dimension: `1536` FLOAT32
|
||||||
|
- The current ADB `DBMS_VECTOR` OCI adapter does not forward Cohere Embed 4's
|
||||||
|
`input_type` field; both paths therefore use the provider's compatible
|
||||||
|
default request shape. The model and 1536-dimension vector contract remain
|
||||||
|
fixed. Once the adapter exposes Embed 4 `input_type`, switch stored examples
|
||||||
|
to `search_document` and incoming questions to `search_query`.
|
||||||
|
- `p_top_k` default: `3` (accepted range `1..20`)
|
||||||
|
|
||||||
|
Oracle recommends distinct document/query input types for Cohere Embed 4 RAG
|
||||||
|
flows and its default output size is 1536. See [Cohere Embed 4](https://docs.oracle.com/en-us/iaas/Content/generative-ai/cohere-embed-4.htm).
|
||||||
|
|
||||||
|
## Database API
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Stores question + answer SQL + optional answer and returns EXAMPLE_ID.
|
||||||
|
SELECT sg_qa_vector_store(:question, :answer_sql, :answer_text) FROM dual;
|
||||||
|
|
||||||
|
-- Returns EXAMPLE_ID, QUESTION, ANSWER_SQL, ANSWER_TEXT, MODEL and distance.
|
||||||
|
DECLARE
|
||||||
|
results SYS_REFCURSOR;
|
||||||
|
BEGIN
|
||||||
|
results := sg_qa_vector_search(:question); -- default top 3
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
-- Ready-to-insert textual context for a prompt.
|
||||||
|
SELECT sg_qa_vector_context(:question, 3) FROM dual;
|
||||||
|
```
|
||||||
|
|
||||||
|
`SG_QA_VECTOR_STORE`는 SQL `SELECT` 표현식으로 호출되는 저장 함수이므로,
|
||||||
|
함수 내부의 INSERT는 자율 트랜잭션으로 수행하고 성공 시 commit, 실패 시 rollback
|
||||||
|
한다. 이 처리가 없으면 Oracle은 `ORA-14551`로 DML을 거절한다.
|
||||||
|
|
||||||
|
## Apply
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SGMP_POC_DB_PASSWORD='...'
|
||||||
|
export SGMP_POC_WALLET_DIR='/path/to/Wallet_SGMPAIPOC'
|
||||||
|
export VECTOR_OCI_USER_OCID='...'
|
||||||
|
export VECTOR_OCI_TENANCY_OCID='...'
|
||||||
|
export VECTOR_OCI_COMPARTMENT_OCID='...'
|
||||||
|
export VECTOR_OCI_API_KEY_FILE='/secure/path/sgmp_qa_vector_api_key.pem'
|
||||||
|
export VECTOR_OCI_API_KEY_FINGERPRINT='...'
|
||||||
|
./scripts/setup-sgmp-qa-vector.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For the initial small QA corpus, exact cosine search is deliberate: it makes
|
||||||
|
results immediately verifiable. Add a vector index only after the corpus size
|
||||||
|
and recall/latency target are measured.
|
||||||
52
docs/design/726-showprompt-diagnostic-mcp/README.md
Normal file
52
docs/design/726-showprompt-diagnostic-mcp/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Redmine #726 · Select AI SHOWPROMPT 진단 MCP
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
기존 Text2SQL 생성·실행 tool과 분리된 읽기 전용 SHOWPROMPT 진단 tool을
|
||||||
|
MCP `tools/list`에 추가한다. 일반 질문 처리 Agent는 포털 allowlist를 통해
|
||||||
|
기존 tool만 사용하고, FAIL 개선 제안 버튼만 진단 tool을 직접 호출한다.
|
||||||
|
|
||||||
|
## 외부 설정
|
||||||
|
|
||||||
|
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME`
|
||||||
|
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL`
|
||||||
|
- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION`
|
||||||
|
|
||||||
|
기본값은 제품 중립적인 `oracle.select_ai.data_showprompt`와 업무 데이터
|
||||||
|
표현을 사용한다. 스마일게이트 운영값은 외부 env에서 고객 전용 이름으로
|
||||||
|
설정한다.
|
||||||
|
|
||||||
|
## 동작
|
||||||
|
|
||||||
|
1. HTTP Bearer Token을 기존 업무 사용자 토큰으로 검증한다.
|
||||||
|
2. prompt를 기존 4,000자 제한으로 검증한다.
|
||||||
|
3. schema-owned Select AI 연결에서
|
||||||
|
`DBMS_CLOUD_AI.GENERATE(prompt, profile, 'showprompt')`를 호출한다.
|
||||||
|
4. SQL을 실행하지 않고 다음 JSON을 반환한다.
|
||||||
|
- `status=SHOWPROMPT`
|
||||||
|
- `profile`
|
||||||
|
- `selectAiPrompt`
|
||||||
|
|
||||||
|
## 변경 함수
|
||||||
|
|
||||||
|
| 파일/함수 | 변경 |
|
||||||
|
|---|---|
|
||||||
|
| `McpProperties` | SHOWPROMPT tool 이름·label·description 외부 설정 |
|
||||||
|
| `SelectAiService.generatePrompt` | 인증·prompt 검증 후 SHOWPROMPT 반환 |
|
||||||
|
| `SelectAiService.generate` | `showsql/showprompt` action을 bind하는 공통 생성 함수 |
|
||||||
|
| `McpSseService.toolsListResult` | Text2SQL과 SHOWPROMPT 두 tool 등록 |
|
||||||
|
| `McpSseService.toolsCallResult` | exact tool name에 따라 query/diagnostic 분기 |
|
||||||
|
|
||||||
|
## 안전 조건
|
||||||
|
|
||||||
|
- SHOWPROMPT tool은 생성 SQL을 실행하지 않는다.
|
||||||
|
- 임의 action 인자를 사용자에게 받지 않는다.
|
||||||
|
- 응답에 Bearer Token이나 DB 연결 정보를 포함하지 않는다.
|
||||||
|
- 기존 Text2SQL tool 이름과 계약은 유지한다.
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
- tools/list에 두 tool과 각 prompt schema가 존재한다.
|
||||||
|
- Text2SQL 호출은 기존 generate-and-execute 경로를 유지한다.
|
||||||
|
- SHOWPROMPT 호출은 generatePrompt만 실행한다.
|
||||||
|
- 누락/비활성 Bearer Token은 기존과 동일하게 거절한다.
|
||||||
65
docs/design/727-sgmp-qa-vector-mcp-tools/README.md
Normal file
65
docs/design/727-sgmp-qa-vector-mcp-tools/README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 731. SGMP QA Vector MCP 도구
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
백오피스 MCP에 저장된 QA 벡터 예제를 조회·저장하는 두 도구를 추가한다.
|
||||||
|
|
||||||
|
- 조회 도구는 현재 질문과 유사한 예제 SQL을 Select AI 호출 전에 확인하여
|
||||||
|
few-shot 컨텍스트로 사용할 수 있게 한다.
|
||||||
|
- 저장 도구는 검토된 Select AI 결과를 다음 질의 품질 개선용 예제 SQL로 저장한다.
|
||||||
|
|
||||||
|
## MCP 계약
|
||||||
|
|
||||||
|
| 도구 | 입력 | 반환 | 용도 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `oracle.select_ai.qa_vector_search` | `question`, 선택 `topK`(기본 3) | 예제 ID, 질문, 답 SQL, 답변, cosine distance | Select AI 실행 전 few-shot 후보 확인 |
|
||||||
|
| `oracle.select_ai.qa_vector_store` | `question`, `answerSql`, 선택 `answer` | 저장된 exampleId, 모델 | 검토된 Select AI 예제 SQL 축적 |
|
||||||
|
|
||||||
|
도구 이름·표시명·설명은 모두 `BACKOFFICE_MCP_QA_VECTOR_*` 환경 변수로
|
||||||
|
바꿀 수 있다. MCP의 공통 `prompt` 인자를 재사용하지 않아 검색과 저장의
|
||||||
|
입력 의미를 명확히 분리한다.
|
||||||
|
|
||||||
|
## 연결 및 보안
|
||||||
|
|
||||||
|
1. HTTP Bearer Token은 기존 업무 사용자 토큰 검증을 통과해야 한다.
|
||||||
|
2. 벡터 DB 호출은 `BACKOFFICE_SELECT_AI_DB_*`로 만든 SGMP_POC 연결만 사용한다.
|
||||||
|
3. API 서명 키, DB 비밀번호, credential 이름은 MCP 응답·로그에 포함하지 않는다.
|
||||||
|
4. 검색은 `SG_QA_VECTOR_SEARCH` DB 함수만 호출한다. 저장은
|
||||||
|
`SG_QA_VECTOR_STORE` DB 함수만 호출한다.
|
||||||
|
5. 저장 도구는 호출자가 검토한 결과만 보내는 운영 계약이다. Select AI 실행
|
||||||
|
결과를 자동으로 저장하지 않는다.
|
||||||
|
|
||||||
|
## Select AI 연계 순서
|
||||||
|
|
||||||
|
1. Agent가 사용자 질문으로 `qa_vector_search`를 호출한다.
|
||||||
|
2. 반환된 상위 2~3개 예제의 질문·답 SQL을 Select AI 프롬프트의 few-shot
|
||||||
|
컨텍스트로 사용한다.
|
||||||
|
3. 기존 Text2SQL 도구로 SQL을 생성·검토·실행한다.
|
||||||
|
4. 검토 통과한 질문·생성 SQL·필요 시 답변을 `qa_vector_store`로 저장한다.
|
||||||
|
|
||||||
|
Text2SQL은 `BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED`가 true일 때 검색 결과의
|
||||||
|
상위 `BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K`개(기본 3, 최대 3)를 내부 프롬프트에
|
||||||
|
자동 보강한다. 예제는 현재 object list·게임 별칭 해석·정책을 대체하지 않으며,
|
||||||
|
보강 실패 또는 일치 예제 없음은 기존 Text2SQL 경로를 중단시키지 않는다.
|
||||||
|
|
||||||
|
고객 질문 재평가에서 FAIL이 확인되면 기준 SQL을 검토한 뒤에만 저장하고, 같은
|
||||||
|
질문을 다시 실행해 `fewShotStatus=APPLIED` 및 판정 개선 여부를 기록한다.
|
||||||
|
|
||||||
|
## Smilegate 포털 allowlist
|
||||||
|
|
||||||
|
`poc4_active_source_20260714/config/mcp_servers.json`의
|
||||||
|
`smilegate_game_data_mcp` allowlist에는 다음 세 도구만 둔다.
|
||||||
|
|
||||||
|
- `oracle.select_ai.smilegate_game_text2sql`
|
||||||
|
- `oracle.select_ai.qa_vector_search`
|
||||||
|
- `oracle.select_ai.qa_vector_store`
|
||||||
|
|
||||||
|
포털은 이 목록 밖의 백오피스 MCP 도구를 발견하거나 호출하지 않는다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `tools/list`에 기존 두 도구와 새 두 도구가 함께 노출된다.
|
||||||
|
- 검색의 `topK` 기본값은 3이고 범위는 1~20이다.
|
||||||
|
- 저장 도구는 question·answerSql 없이는 호출되지 않는다.
|
||||||
|
- Bearer Token 누락 시 네 도구 모두 기존과 같은 권한 거절 응답을 반환한다.
|
||||||
|
- 서비스 단위 테스트는 DB 대신 캡처 구현으로 MCP 입력·응답 계약을 검증한다.
|
||||||
69
docs/design/731-sgmp-game-scope-resolver/README.md
Normal file
69
docs/design/731-sgmp-game-scope-resolver/README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# SGMP DB 기반 게임 범위 Resolver (#731)
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
복수 게임이 포함된 데이터 질문에서 애플리케이션 코드나 에이전트 지시문에 게임명, prefix,
|
||||||
|
테이블명을 넣지 않는다. DB가 제공하는 게임 범위 뷰를 먼저 조회하고, 조회 가능으로 판정된
|
||||||
|
게임에만 기존 Few-shot NL2SQL MCP를 호출한다.
|
||||||
|
|
||||||
|
## 범위와 원칙
|
||||||
|
|
||||||
|
- 기존 `oracle.select_ai.smilegate_fewshot_nl2sql`은 예제 검색, SQL 생성, 읽기 전용 실행을
|
||||||
|
담당하는 worker로 유지한다.
|
||||||
|
- 새 `game_scope_resolve` MCP는 SQL을 생성하거나 실행하지 않는다.
|
||||||
|
- 공통 백오피스는 환경변수로 지정된 DB view 이름과 MCP tool 이름만 안다.
|
||||||
|
- 게임명, alias, GAME_ID, GAME_PREFIX, 대상 object는 DB view의 데이터로만 결정한다.
|
||||||
|
- 지원 여부는 대상 날짜의 행 수가 아니라, 현재 승인된 조회 object가 존재하는지로 판정한다.
|
||||||
|
데이터가 0건인 날도 정상 조회 범위다.
|
||||||
|
|
||||||
|
## DB 공통 계약
|
||||||
|
|
||||||
|
고객 DB는 환경변수 `BACKOFFICE_GAME_SCOPE_VIEW`로 지정된 view를 제공한다. view는 아래
|
||||||
|
별칭(column alias)을 반환한다.
|
||||||
|
|
||||||
|
| Column | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| `GAME_KEY` | 내부 게임 식별자 |
|
||||||
|
| `PROFILE_NAME` | 승인 object list를 판정한 Select AI profile |
|
||||||
|
| `DISPLAY_NAME` | 화면 표시용 정식 게임명 |
|
||||||
|
| `GAME_ALIAS` | 질문에서 찾을 게임명 또는 별칭 |
|
||||||
|
| `QUERY_ALLOWED_YN` | 승인된 조회 object 존재 여부 (`Y`/`N`) |
|
||||||
|
| `REASON_CODE` | 미지원 또는 보류 사유 코드 |
|
||||||
|
| `ALIAS_PRIORITY` | 동일/중첩 alias 정렬 우선순위 |
|
||||||
|
| `SCOPE_VERSION` | object list 변경 시 함께 갱신되는 버전 |
|
||||||
|
|
||||||
|
Smilegate view는 전체 게임 마스터와 alias를 기준으로 하고, 현재 Select AI profile별 승인 object list와
|
||||||
|
실제 object 존재 여부를 조합해 `QUERY_ALLOWED_YN`을 계산한다. 따라서 등록 게임이지만 현재
|
||||||
|
조회 object가 없는 게임도 `N`으로 반환된다.
|
||||||
|
|
||||||
|
## MCP와 ReAct 계약
|
||||||
|
|
||||||
|
1. 포털 ReAct는 게임 데이터 질의 전에 `game_scope_resolve(question)`를 호출한다.
|
||||||
|
2. resolver는 질문 문자열과 `GAME_ALIAS`를 정규화해 포함 관계를 찾고, 우선순위와 alias 길이로
|
||||||
|
중복을 제거한다. 동일 우선순위의 복수 게임은 `AMBIGUOUS`로 반환한다.
|
||||||
|
3. `QUERY_ALLOWED_YN=Y`인 scope에는 서명·만료된 opaque `scopeToken`과 worker tool 이름을 반환한다.
|
||||||
|
4. ReAct는 `nextAction=CALL_WORKER`인 항목만 Few-shot NL2SQL에 전달한다. `UNSUPPORTED`와
|
||||||
|
`AMBIGUOUS`는 SQL 실행 없이 결과에 표시한다.
|
||||||
|
5. Few-shot worker는 scope token을 검증하고, token에 담긴 DB scope로만 prompt를 보강한다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- view가 지원 게임과 object list 미연결 게임을 각각 반환하는지 확인한다.
|
||||||
|
- resolver MCP의 결과에 구체 게임/테이블 하드코딩이 없는지 확인한다.
|
||||||
|
- STD-06에서 미지원 게임은 worker가 호출되지 않고, 지원 게임 결과에는 few-shot 예제, 생성 SQL,
|
||||||
|
실행 결과가 포함되는지 확인한다.
|
||||||
|
- 기존 단일 게임 Few-shot NL2SQL 및 미게임명 거절 guardrail 회귀를 확인한다.
|
||||||
|
|
||||||
|
## 논리 조인 메타데이터
|
||||||
|
|
||||||
|
`COMN_GAME_ALIAS_BAS`는 하나의 게임에 여러 alias 행을 갖기 때문에 `GAME_ID`가 유일키가
|
||||||
|
아니다. 따라서 공통 transaction table의 `GAME_ID`에 물리 FK를 추가하지 않는다. 대신
|
||||||
|
`78_sgmp_game_alias_logical_joins.sql`이 fact table에 `GAME_ALIAS_JOIN` annotation을 추가한다.
|
||||||
|
이 annotation은 게임명 필터에서 `EXISTS` 또는 `DISTINCT GAME_ID` alias subquery를 사용하고,
|
||||||
|
alias 원본을 직접 조인해 집계 행을 늘리지 않도록 설명한다. Prefix 전용 테이블은 가짜 FK 없이
|
||||||
|
기존 alias/prefix 소유 범위 annotation을 유지한다.
|
||||||
|
|
||||||
|
## 보류 항목
|
||||||
|
|
||||||
|
전체 게임 마스터에 미지원 게임이 없다면 DB만으로 그 이름을 게임으로 식별할 수 없다. 이 경우
|
||||||
|
고객 원천 게임 마스터를 view에 연결하는 작업이 선행되어야 하며, 모델 추측으로 보완하지 않는다.
|
||||||
42
docs/design/734-sgmp-annotation-api/README.md
Normal file
42
docs/design/734-sgmp-annotation-api/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# SGMP annotation 관리 API (#734)
|
||||||
|
|
||||||
|
> 상태: Approved
|
||||||
|
> 구현: `sql/adb/76_sgmp_annotation_api.sql`
|
||||||
|
> 목적: 백오피스와 운영 스크립트가 동일한 검증 규칙으로 테이블/뷰 annotation을 추가·수정하도록 한다.
|
||||||
|
|
||||||
|
## 입력 계약
|
||||||
|
|
||||||
|
`SGMP_SET_ANNOTATION`은 스키마, 대상 종류(`TABLE` 또는 `COLUMN`), 테이블/뷰 이름, 선택적 컬럼 이름, annotation 값, 선택적 annotation 이름을 받는다. annotation 이름 기본값은 `AI_GUIDANCE`이며, 이름을 명시하면 그 이름을 사용한다.
|
||||||
|
|
||||||
|
컬럼 이름은 `COLUMN` 대상에서만 필수다. 값은 4,000자 이내이며 빈 값은 허용하지 않는다. 객체와 컬럼은 `ALL_OBJECTS`/`ALL_TAB_COLUMNS`에서 확인하고 식별자는 `DBMS_ASSERT`로 제한한다.
|
||||||
|
|
||||||
|
## 동작
|
||||||
|
|
||||||
|
1. 대상 객체가 TABLE 또는 VIEW인지 확인한다.
|
||||||
|
2. VIEW의 COLUMN 대상은 Oracle에서 변경할 수 없으므로 명확한 오류로 거부한다.
|
||||||
|
3. `ALL_ANNOTATIONS_USAGE`에서 동일 annotation의 존재 여부를 확인한다.
|
||||||
|
4. 기존 값이 있으면 DROP 후 ADD, 없으면 ADD만 수행한다.
|
||||||
|
5. 수행 결과(`ADDED` 또는 `REPLACED`)와 정규화된 대상을 반환한다.
|
||||||
|
|
||||||
|
DDL은 Oracle의 implicit commit 특성이 있으므로 호출자는 별도 트랜잭션으로 간주하지 않는다. 이 함수는 게임명·prefix·특정 테이블 정책을 하드코딩하지 않으며, 입력 객체의 존재와 Oracle 문법만 검증한다.
|
||||||
|
|
||||||
|
## 호출 예
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT SGMP_SET_ANNOTATION(
|
||||||
|
'SGMP_POC', 'TABLE', 'CZN_COMN_USER_MST', NULL,
|
||||||
|
'AU는 최신 BASE_DT에서 AU_FLAG=1이고 EXPT_USER_YN=N인 활성 사용자 수다.',
|
||||||
|
'AI_GUIDANCE'
|
||||||
|
) FROM dual;
|
||||||
|
|
||||||
|
SELECT SGMP_SET_ANNOTATION(
|
||||||
|
'SGMP_POC', 'COLUMN', 'CZN_COMN_USER_MST', 'AU_FLAG',
|
||||||
|
'AU 집계용 활성 사용자 플래그(1=활성).', 'BUSINESS_DEFINITION'
|
||||||
|
) FROM dual;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 테스트 기준
|
||||||
|
|
||||||
|
- TABLE 신규 annotation은 `ADDED`를 반환한다.
|
||||||
|
- 같은 이름을 다시 호출하면 기존 값을 교체하고 `REPLACED`를 반환한다.
|
||||||
|
- 존재하지 않는 객체/컬럼, 잘못된 대상 종류, VIEW의 COLUMN 대상은 오류를 반환한다.
|
||||||
37
docs/design/735-sgmp-fewshot-nl2sql-mcp/README.md
Normal file
37
docs/design/735-sgmp-fewshot-nl2sql-mcp/README.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# SGMP Few-shot NL2SQL MCP (#735)
|
||||||
|
|
||||||
|
> 상태: Approved
|
||||||
|
> 구현: `McpSseService`, `SelectAiService`, `McpProperties`
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
기존 Select AI Text2SQL 경로와 분리된 검증용 MCP tool을 제공한다. 질문과 유사한 검토 완료 예제를 벡터 검색하고, 그 SQL 패턴을 prompt에 참고자료로 넣은 뒤 `SHOWSQL`로 생성한 SQL을 읽기 전용으로 실행한다.
|
||||||
|
|
||||||
|
## MCP 계약
|
||||||
|
|
||||||
|
- **tool name**: `oracle.select_ai.smilegate_fewshot_nl2sql` (환경변수로 재정의 가능)
|
||||||
|
- **입력**: `prompt` (최대 4,000자)
|
||||||
|
- **출력**: 벡터 Few-shot 예제(질문, SQL, 모델, cosine distance), 생성 SQL, 실행 상태, 행 수, 최대 100건 결과
|
||||||
|
|
||||||
|
Few-shot SQL은 실행하지 않는다. 현재 메타데이터·alias 정책을 우선하고, Select AI가 새로 생성한 SQL만 read-only 검증 후 실행한다.
|
||||||
|
|
||||||
|
## 안전 규칙
|
||||||
|
|
||||||
|
1. 벡터 검색 실패는 `UNAVAILABLE` 상태로 남기고 기존 정책 prompt로 폴백한다.
|
||||||
|
2. 생성 결과는 단일 `SELECT` 또는 `WITH`만 허용한다.
|
||||||
|
3. DDL, DML, PL/SQL, 시스템 객체, 잠금 구문, 다중 문장은 차단한다.
|
||||||
|
4. JDBC read-only 트랜잭션과 30초 query timeout, 최대 100행 제한을 적용한다.
|
||||||
|
5. 기존 `data_text2sql`, `data_showprompt`, `qa_vector_search`, `qa_vector_store` tool은 변경하지 않는다.
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
```text
|
||||||
|
BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_NAME
|
||||||
|
BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_LABEL
|
||||||
|
BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION
|
||||||
|
```
|
||||||
|
|
||||||
|
## 추적성
|
||||||
|
|
||||||
|
- Redmine: #735
|
||||||
|
- 테스트: `McpSseServiceTest`
|
||||||
14
docs/design/736-smilegate-main-fewshot-route/README.md
Normal file
14
docs/design/736-smilegate-main-fewshot-route/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Smilegate main MCP route: Few-shot NL2SQL (#736)
|
||||||
|
|
||||||
|
## 변경
|
||||||
|
|
||||||
|
Smilegate 포털의 `smilegate_game_data_mcp` 기본 tool과 선호 route를
|
||||||
|
`oracle.select_ai.smilegate_fewshot_nl2sql`로 전환한다. 기존 Text2SQL과 QA vector tool은
|
||||||
|
allowlist에 유지해 호환성과 진단 경로를 보존한다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tools/list에서 새 tool이 발견되는지 확인
|
||||||
|
- 포털의 기본 단일 호출 route가 새 tool인지 확인
|
||||||
|
- 새 tool 응답에 `fewShotExamples`, `generatedSql`, `items`가 존재하는지 확인
|
||||||
|
- 백오피스에 새 tool이 배포되지 않은 경우 포털에서 명확한 MCP 오류를 표시
|
||||||
@@ -11,7 +11,6 @@ with the Bearer token entered on the screen.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import html
|
import html
|
||||||
@@ -57,12 +56,13 @@ from src.agent_console.presentation import (
|
|||||||
render_login_brand,
|
render_login_brand,
|
||||||
)
|
)
|
||||||
from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile
|
from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile
|
||||||
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
|
from src.poc4.qa_history import QaJudgment, QaQuestion, evaluate_sql, load_benchmark_questions
|
||||||
|
from src.poc4.qa_history_store import QaHistoryStore, QaHistoryStoreError
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
MCP_PROTOCOL_VERSION = "2025-11-25"
|
MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||||
PREFERRED_TOOL = "ords.query.kb_select_ai_vpd"
|
PREFERRED_TOOL = "oracle.select_ai.smilegate_fewshot_nl2sql"
|
||||||
DEFAULT_QUESTION = ""
|
DEFAULT_QUESTION = ""
|
||||||
MAX_RESPONSE_BYTES = 1_000_000
|
MAX_RESPONSE_BYTES = 1_000_000
|
||||||
MAX_CONVERSATION_MESSAGES = 8
|
MAX_CONVERSATION_MESSAGES = 8
|
||||||
@@ -77,17 +77,20 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
|
|||||||
ENV_FILE = ROOT / ".env"
|
ENV_FILE = ROOT / ".env"
|
||||||
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
|
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
|
||||||
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
|
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
|
||||||
DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json"
|
QA_BENCHMARK_FILE = ROOT / "config" / "smilegate_qa_benchmark.json"
|
||||||
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
|
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
|
||||||
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
|
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
|
||||||
DEFAULT_VPD_USER_ID = "FC00789"
|
DEFAULT_VPD_USER_ID = "1001"
|
||||||
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/"
|
VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/"
|
||||||
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
|
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
|
||||||
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
|
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
|
||||||
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
|
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
|
||||||
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
|
PORTAL_AUTH_COOKIE_NAME = "poc4_portal_auth"
|
||||||
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
AUDIT_SCHEMA = "SGMP_POC"
|
||||||
AUDIT_SCHEMA = "POC_2"
|
REFERENCE_EVIDENCE_ENABLED = (
|
||||||
|
os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower()
|
||||||
|
in {"1", "true", "yes"}
|
||||||
|
)
|
||||||
AUDIT_DB_ENV_FILE = Path(
|
AUDIT_DB_ENV_FILE = Path(
|
||||||
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
|
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
|
||||||
).expanduser()
|
).expanduser()
|
||||||
@@ -1885,12 +1888,184 @@ def new_conversation_id() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _question_label(question: object) -> str:
|
def _question_label(question: object) -> str:
|
||||||
question_id = str(getattr(question, "question_id"))
|
question_id = str(
|
||||||
category = str(getattr(question, "category"))
|
getattr(question, "question_code", getattr(question, "question_id", ""))
|
||||||
title = str(getattr(question, "title", getattr(question, "text")))
|
)
|
||||||
|
category = str(getattr(question, "category", ""))
|
||||||
|
title = str(
|
||||||
|
getattr(question, "title", getattr(question, "question_text", ""))
|
||||||
|
)
|
||||||
return f"{question_id} · {category} · {title}"
|
return f"{question_id} · {category} · {title}"
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_resource(show_spinner=False)
|
||||||
|
def _qa_history_store() -> QaHistoryStore:
|
||||||
|
return QaHistoryStore()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_qa_questions() -> tuple[list[QaQuestion], QaHistoryStore | None, str]:
|
||||||
|
try:
|
||||||
|
return list(load_benchmark_questions(QA_BENCHMARK_FILE)), _qa_history_store(), ""
|
||||||
|
except Exception as exc:
|
||||||
|
return [], None, f"질답 기준 파일을 읽지 못했습니다: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _judgment_label(status: str) -> str:
|
||||||
|
labels = {
|
||||||
|
"PASS": "통과",
|
||||||
|
"WARN": "검토",
|
||||||
|
"FAIL": "실패",
|
||||||
|
"REVIEW": "수동 검토",
|
||||||
|
}
|
||||||
|
return labels.get(str(status or "").upper(), "미실행")
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_answer_preview(value: object, limit: int = 180) -> str:
|
||||||
|
text = str(value or "").strip().replace("\n", " ")
|
||||||
|
return text if len(text) <= limit else text[:limit] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _render_qa_benchmark_panel(
|
||||||
|
questions: list[QaQuestion],
|
||||||
|
store: QaHistoryStore | None,
|
||||||
|
store_error: str,
|
||||||
|
) -> QaQuestion | None:
|
||||||
|
st.markdown(
|
||||||
|
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||||
|
"고객 질답 검증 시나리오"
|
||||||
|
"</div>",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
st.caption(
|
||||||
|
"고객 제공 Excel에서 정리한 47개 후보입니다. 후보를 선택하면 기준 답변과 과거 검증 이력을 확인하고 바로 실행할 수 있습니다."
|
||||||
|
)
|
||||||
|
if store_error:
|
||||||
|
st.warning(
|
||||||
|
"현재는 파일 기준 후보만 표시합니다. ADB 이력 저장은 연결 복구 후 사용할 수 있습니다. "
|
||||||
|
+ store_error
|
||||||
|
)
|
||||||
|
if not questions:
|
||||||
|
st.error("표시할 고객 질답 기준이 없습니다.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
category_options = tuple(sorted({question.category for question in questions}))
|
||||||
|
category = st.selectbox(
|
||||||
|
"시나리오 구분",
|
||||||
|
options=("ALL", *category_options),
|
||||||
|
format_func=lambda item: "전체 47개" if item == "ALL" else item,
|
||||||
|
key="poc4_qa_category_filter",
|
||||||
|
)
|
||||||
|
visible_questions = [
|
||||||
|
question for question in questions if category == "ALL" or question.category == category
|
||||||
|
]
|
||||||
|
st.dataframe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"케이스": question.question_code,
|
||||||
|
"구분": question.category,
|
||||||
|
"제목": question.title,
|
||||||
|
"질문": question.question_text,
|
||||||
|
"기대 기준": _qa_answer_preview(question.expected_focus, 150),
|
||||||
|
"기준 상태": _judgment_label(question.last_judgment_status)
|
||||||
|
if question.last_judgment_status
|
||||||
|
else "과거 기준",
|
||||||
|
"최근 실행": question.last_evaluated_at or "후보 선택 시 조회",
|
||||||
|
}
|
||||||
|
for question in visible_questions
|
||||||
|
],
|
||||||
|
hide_index=True,
|
||||||
|
width="stretch",
|
||||||
|
height=min(520, 110 + 36 * len(visible_questions)),
|
||||||
|
column_config={
|
||||||
|
"질문": st.column_config.TextColumn(width="large"),
|
||||||
|
"기대 기준": st.column_config.TextColumn(width="large"),
|
||||||
|
"최근 실행": st.column_config.TextColumn(width="medium"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
by_code = {question.question_code: question for question in visible_questions}
|
||||||
|
selected_code = st.selectbox(
|
||||||
|
"실행할 검증 후보 선택",
|
||||||
|
options=("", *by_code),
|
||||||
|
format_func=lambda code: "선택 안 함 · 자유 질의" if not code else _question_label(by_code[code]),
|
||||||
|
key="poc4_qa_question_code",
|
||||||
|
)
|
||||||
|
selected = by_code.get(selected_code)
|
||||||
|
if selected is None:
|
||||||
|
st.info("자유 텍스트 질문도 실행하고 이력으로 남길 수 있습니다. 자유 질의는 정답 기준이 없어 수동 검토로 표시됩니다.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if store is not None:
|
||||||
|
try:
|
||||||
|
stored_question = store.get_question_by_code(selected.question_code)
|
||||||
|
if stored_question is not None:
|
||||||
|
selected = stored_question
|
||||||
|
else:
|
||||||
|
st.warning("선택한 후보가 ADB 이력 테이블에 아직 적재되지 않았습니다.")
|
||||||
|
except QaHistoryStoreError as exc:
|
||||||
|
st.warning("선택한 후보의 ADB 이력을 불러오지 못했습니다. " + str(exc))
|
||||||
|
|
||||||
|
with st.expander("선택한 질문의 정답 기준·원본·과거 답변", expanded=True):
|
||||||
|
left, right = st.columns(2)
|
||||||
|
with left:
|
||||||
|
st.markdown("**정답 기준**")
|
||||||
|
st.write(selected.expected_focus or "정답 기준 설명이 없습니다.")
|
||||||
|
st.markdown("**기준 답변**")
|
||||||
|
st.code(selected.baseline_answer or "기준 실행 결과가 없습니다.", language=None)
|
||||||
|
with right:
|
||||||
|
st.markdown("**고객 원본 출처**")
|
||||||
|
st.write(
|
||||||
|
f"{selected.source_document} / {selected.source_sheet} / 행 {selected.source_row or '-'}"
|
||||||
|
)
|
||||||
|
if selected.source_scenario:
|
||||||
|
st.caption(selected.source_scenario)
|
||||||
|
st.markdown("**기준 SQL**")
|
||||||
|
st.code(selected.baseline_sql or selected.sample_sql or "기준 SQL이 없습니다.", language="sql")
|
||||||
|
|
||||||
|
if store is not None and selected.question_id is not None:
|
||||||
|
try:
|
||||||
|
answers = store.list_answers(selected.question_id, limit=30)
|
||||||
|
except QaHistoryStoreError as exc:
|
||||||
|
st.warning(str(exc))
|
||||||
|
answers = []
|
||||||
|
if answers:
|
||||||
|
st.markdown("**과거 답변 이력**")
|
||||||
|
st.dataframe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"순번": item["answer_seq"],
|
||||||
|
"구분": "과거 기준" if item["answer_kind"] == "HISTORICAL" else "이번 실행",
|
||||||
|
"판정": _judgment_label(item["judgment_status"]),
|
||||||
|
"실행 상태": item["execution_status"],
|
||||||
|
"실행 시각": item["requested_at"],
|
||||||
|
"답변": _qa_answer_preview(item["answer_text"]),
|
||||||
|
}
|
||||||
|
for item in answers
|
||||||
|
],
|
||||||
|
hide_index=True,
|
||||||
|
width="stretch",
|
||||||
|
)
|
||||||
|
answer_by_seq = {str(item["answer_seq"]): item for item in answers}
|
||||||
|
detail_seq = st.selectbox(
|
||||||
|
"답변 이력 상세",
|
||||||
|
options=tuple(answer_by_seq),
|
||||||
|
format_func=lambda value: (
|
||||||
|
f"#{value} · {_judgment_label(answer_by_seq[value]['judgment_status'])} · "
|
||||||
|
f"{answer_by_seq[value]['requested_at']}"
|
||||||
|
),
|
||||||
|
key=f"poc4_qa_answer_detail_{selected.question_id}",
|
||||||
|
)
|
||||||
|
detail = answer_by_seq[detail_seq]
|
||||||
|
st.markdown("**판정 근거**")
|
||||||
|
st.write(detail["judgment_reason"] or "판정 근거가 없습니다.")
|
||||||
|
st.markdown("**생성 SQL**")
|
||||||
|
st.code(detail["generated_sql"] or "생성 SQL이 없습니다.", language="sql")
|
||||||
|
st.markdown("**실행 답변**")
|
||||||
|
st.write(detail["answer_text"] or "답변이 없습니다.")
|
||||||
|
else:
|
||||||
|
st.info("아직 저장된 답변 이력이 없습니다.")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
def _apply_console_theme(profile: AppProfile) -> None:
|
def _apply_console_theme(profile: AppProfile) -> None:
|
||||||
apply_console_theme(st, profile)
|
apply_console_theme(st, profile)
|
||||||
|
|
||||||
@@ -1933,61 +2108,41 @@ def _portal_credentials_are_valid(username: str, password: str) -> bool:
|
|||||||
return username_matches and password_matches
|
return username_matches and password_matches
|
||||||
|
|
||||||
|
|
||||||
def _portal_remember_secret() -> str:
|
def _portal_cookie_user() -> str:
|
||||||
return _portal_auth_value("POC4_LOGIN_REMEMBER_SECRET")
|
"""Return the verified portal user stored in the HttpOnly auth cookie."""
|
||||||
|
try:
|
||||||
|
token = str(st.context.cookies.get(PORTAL_AUTH_COOKIE_NAME, "")).strip()
|
||||||
def _portal_remember_token(username: str) -> str:
|
encoded_claims, supplied_signature = token.rsplit(".", 1)
|
||||||
secret = _portal_remember_secret()
|
secret = _portal_auth_value("POC4_LOGIN_REMEMBER_SECRET")
|
||||||
if not secret:
|
expected_signature = hmac.new(
|
||||||
|
secret.encode("utf-8"), encoded_claims.encode("ascii"), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
if not secret or not hmac.compare_digest(supplied_signature, expected_signature):
|
||||||
|
return ""
|
||||||
|
padding = "=" * (-len(encoded_claims) % 4)
|
||||||
|
claims = json.loads(
|
||||||
|
base64.urlsafe_b64decode((encoded_claims + padding).encode("ascii")).decode("utf-8")
|
||||||
|
)
|
||||||
|
username = str(claims.get("u", "")).strip()
|
||||||
|
expires_at = int(claims.get("e", 0))
|
||||||
|
expected_username = _portal_auth_value("POC4_LOGIN_USER")
|
||||||
|
if claims.get("v") != 1 or expires_at <= int(datetime.now(timezone.utc).timestamp()):
|
||||||
|
return ""
|
||||||
|
if not username or not hmac.compare_digest(username, expected_username):
|
||||||
|
return ""
|
||||||
|
return username
|
||||||
|
except (AttributeError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError):
|
||||||
return ""
|
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:
|
def _restore_portal_session_from_cookie() -> None:
|
||||||
if st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
if st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
||||||
return
|
return
|
||||||
token = st.query_params.get(PORTAL_REMEMBER_TOKEN_PARAM, "")
|
username = _portal_cookie_user()
|
||||||
if not isinstance(token, str) or not token or len(token) > 2048:
|
if username:
|
||||||
return
|
st.session_state[PORTAL_AUTHENTICATED_KEY] = True
|
||||||
secret = _portal_remember_secret()
|
st.session_state[PORTAL_AUTH_USER_KEY] = username
|
||||||
expected_username = _portal_auth_value("POC4_LOGIN_USER")
|
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False
|
||||||
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:
|
def _render_portal_login(profile: AppProfile) -> None:
|
||||||
@@ -1996,39 +2151,35 @@ def _render_portal_login(profile: AppProfile) -> None:
|
|||||||
if not _portal_auth_configured():
|
if not _portal_auth_configured():
|
||||||
st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.")
|
st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.")
|
||||||
return
|
return
|
||||||
with st.form("poc4_portal_login_form", clear_on_submit=True):
|
if st.query_params.get("login") == "failed":
|
||||||
username = st.text_input(
|
|
||||||
"사용자 ID",
|
|
||||||
max_chars=80,
|
|
||||||
placeholder="사용자 ID를 입력하세요.",
|
|
||||||
)
|
|
||||||
password = st.text_input(
|
|
||||||
"비밀번호",
|
|
||||||
type="password",
|
|
||||||
max_chars=200,
|
|
||||||
placeholder="비밀번호를 입력하세요.",
|
|
||||||
)
|
|
||||||
remember_login = st.checkbox(
|
|
||||||
"로그인 유지 (7일)",
|
|
||||||
disabled=not bool(_portal_remember_secret()),
|
|
||||||
help="이 브라우저에서 7일 동안 로그인 상태를 유지합니다.",
|
|
||||||
)
|
|
||||||
submitted = st.form_submit_button("로그인", use_container_width=True)
|
|
||||||
if submitted:
|
|
||||||
if _portal_credentials_are_valid(username, password):
|
|
||||||
st.session_state[PORTAL_AUTHENTICATED_KEY] = True
|
|
||||||
st.session_state[PORTAL_AUTH_USER_KEY] = username.strip()
|
|
||||||
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False
|
|
||||||
if remember_login:
|
|
||||||
token = _portal_remember_token(username)
|
|
||||||
if token:
|
|
||||||
st.query_params[PORTAL_REMEMBER_TOKEN_PARAM] = token
|
|
||||||
else:
|
|
||||||
_clear_portal_remembered_session()
|
|
||||||
st.rerun()
|
|
||||||
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = True
|
|
||||||
if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False):
|
|
||||||
st.error("사용자 ID 또는 비밀번호를 확인해 주세요.")
|
st.error("사용자 ID 또는 비밀번호를 확인해 주세요.")
|
||||||
|
components.html(
|
||||||
|
"""
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: sans-serif; color: #15293a; }
|
||||||
|
form { display: grid; gap: 10px; }
|
||||||
|
label { font-size: 13px; font-weight: 700; color: #365064; }
|
||||||
|
input { width: 100%; border: 1px solid #afc4d3; border-radius: 7px;
|
||||||
|
padding: 11px 12px; font-size: 15px; color: #15293a; }
|
||||||
|
button { margin-top: 6px; width: 100%; border: 0; border-radius: 7px;
|
||||||
|
padding: 12px; background: #005c97; color: white; cursor: pointer;
|
||||||
|
font-size: 15px; font-weight: 700; }
|
||||||
|
button:hover { background: #004b7c; }
|
||||||
|
</style>
|
||||||
|
<form action="/_poc4/auth/login" method="post" target="_parent">
|
||||||
|
<label>사용자 ID
|
||||||
|
<input name="username" autocomplete="username" maxlength="80" required>
|
||||||
|
</label>
|
||||||
|
<label>비밀번호
|
||||||
|
<input name="password" type="password" autocomplete="current-password"
|
||||||
|
maxlength="200" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">로그인</button>
|
||||||
|
</form>
|
||||||
|
""",
|
||||||
|
height=220,
|
||||||
|
)
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f'<p class="console-muted">{html.escape(profile.login_footer)}</p>',
|
f'<p class="console-muted">{html.escape(profile.login_footer)}</p>',
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
@@ -2036,11 +2187,13 @@ def _render_portal_login(profile: AppProfile) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _logout_portal() -> None:
|
def _logout_portal() -> None:
|
||||||
_clear_portal_remembered_session()
|
|
||||||
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
|
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
|
||||||
st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
|
st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
|
||||||
st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None)
|
st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None)
|
||||||
st.rerun()
|
components.html(
|
||||||
|
'<script>window.parent.location.assign("/_poc4/auth/logout");</script>',
|
||||||
|
height=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_app_header(profile: AppProfile) -> None:
|
def _render_app_header(profile: AppProfile) -> None:
|
||||||
@@ -2374,6 +2527,8 @@ def collect_security_evidence(
|
|||||||
) -> Mapping[str, Any]:
|
) -> Mapping[str, Any]:
|
||||||
"""Collect predefined, token-scoped security evidence without exposing secrets."""
|
"""Collect predefined, token-scoped security evidence without exposing secrets."""
|
||||||
|
|
||||||
|
if not REFERENCE_EVIDENCE_ENABLED:
|
||||||
|
return {}
|
||||||
kind = _security_evidence_kind(question)
|
kind = _security_evidence_kind(question)
|
||||||
if not kind or token_preset is None:
|
if not kind or token_preset is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -2938,6 +3093,8 @@ def collect_business_evidence(
|
|||||||
) -> Mapping[str, Any]:
|
) -> Mapping[str, Any]:
|
||||||
"""Collect token-validated, explicitly scoped business and catalog evidence."""
|
"""Collect token-validated, explicitly scoped business and catalog evidence."""
|
||||||
|
|
||||||
|
if not REFERENCE_EVIDENCE_ENABLED:
|
||||||
|
return {}
|
||||||
kind = _business_evidence_kind(question)
|
kind = _business_evidence_kind(question)
|
||||||
if not kind or token_preset is None:
|
if not kind or token_preset is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -3636,6 +3793,9 @@ def load_vpd_token_presets(
|
|||||||
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
|
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
|
||||||
continue
|
continue
|
||||||
token = _normalized_bearer(item.get("token"))
|
token = _normalized_bearer(item.get("token"))
|
||||||
|
if not token:
|
||||||
|
token_env = str(item.get("token_env") or "").strip()
|
||||||
|
token = _normalized_bearer(_runtime_env_value(token_env)) if token_env else ""
|
||||||
user_id = str(item.get("user_id") or "").strip()
|
user_id = str(item.get("user_id") or "").strip()
|
||||||
if not token or not user_id or user_id in seen:
|
if not token or not user_id or user_id in seen:
|
||||||
continue
|
continue
|
||||||
@@ -5138,15 +5298,17 @@ def _render_mcp_result_sections(
|
|||||||
st.json(step.get("result_summary", {}))
|
st.json(step.get("result_summary", {}))
|
||||||
|
|
||||||
if generated_sql:
|
if generated_sql:
|
||||||
with st.expander("생성 SQL"):
|
with st.expander("실행 SQL", expanded=True):
|
||||||
st.code(generated_sql, language="sql")
|
st.code(generated_sql, language="sql")
|
||||||
|
|
||||||
if items:
|
if items:
|
||||||
with st.expander(f"조회 결과 테이블 · {len(items)}건"):
|
with st.expander(f"조회 결과 · {len(items)}건", expanded=True):
|
||||||
display_items = items[:100]
|
display_items = items[:100]
|
||||||
st.dataframe(display_items, use_container_width=True)
|
st.dataframe(display_items, use_container_width=True)
|
||||||
if len(items) > len(display_items):
|
if len(items) > len(display_items):
|
||||||
st.caption(f"화면에는 최초 {len(display_items)}건만 표시합니다.")
|
st.caption(f"화면에는 최초 {len(display_items)}건만 표시합니다.")
|
||||||
|
elif generated_sql:
|
||||||
|
st.info("실행 SQL은 생성되었지만 반환된 조회 결과 행이 없습니다.")
|
||||||
|
|
||||||
with st.expander("MCP 호출 상세"):
|
with st.expander("MCP 호출 상세"):
|
||||||
st.write(f"route: {details.get('server_id')} / {details.get('tool_name')}")
|
st.write(f"route: {details.get('server_id')} / {details.get('tool_name')}")
|
||||||
@@ -6323,6 +6485,89 @@ def _render_vpd_operations_tab() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_ai_execution_summary(payload: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
return {
|
||||||
|
"generated_sql": "",
|
||||||
|
"execution_status": "UNKNOWN",
|
||||||
|
"execution_succeeded": False,
|
||||||
|
"result": {},
|
||||||
|
}
|
||||||
|
generated_sql = str(
|
||||||
|
payload.get("generatedSql") or payload.get("generated_sql") or ""
|
||||||
|
).strip()
|
||||||
|
status = str(payload.get("status") or "").strip().upper()
|
||||||
|
execution = str(payload.get("execution") or "").strip().upper()
|
||||||
|
execution_succeeded = (
|
||||||
|
status == "SHOWSQL_AND_EXECUTED" and execution == "READ_ONLY_EXECUTED"
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
key: payload.get(key)
|
||||||
|
for key in (
|
||||||
|
"status",
|
||||||
|
"execution",
|
||||||
|
"rowCount",
|
||||||
|
"truncated",
|
||||||
|
"columns",
|
||||||
|
"items",
|
||||||
|
"generatedSql",
|
||||||
|
)
|
||||||
|
if key in payload
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"generated_sql": generated_sql,
|
||||||
|
"execution_status": status or execution or "UNKNOWN",
|
||||||
|
"execution_succeeded": execution_succeeded,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_qa_history(
|
||||||
|
*,
|
||||||
|
store: QaHistoryStore | None,
|
||||||
|
benchmark_question: QaQuestion | None,
|
||||||
|
question_text: str,
|
||||||
|
conversation_id: str,
|
||||||
|
requested_by: str,
|
||||||
|
model_profile: str,
|
||||||
|
answer_text: str,
|
||||||
|
mcp_result: Any,
|
||||||
|
duration_ms: int,
|
||||||
|
) -> tuple[QaQuestion | None, QaJudgment | None]:
|
||||||
|
if store is None:
|
||||||
|
return benchmark_question, None
|
||||||
|
try:
|
||||||
|
history_question = benchmark_question or store.find_or_create_free_text_question(
|
||||||
|
question_text
|
||||||
|
)
|
||||||
|
execution = _select_ai_execution_summary(mcp_result)
|
||||||
|
judgment = evaluate_sql(
|
||||||
|
benchmark_question,
|
||||||
|
execution["generated_sql"],
|
||||||
|
execution_succeeded=bool(execution["execution_succeeded"]),
|
||||||
|
error_text=_bounded_json(mcp_result, max_chars=8000),
|
||||||
|
)
|
||||||
|
store.record_answer(
|
||||||
|
question_id=int(history_question.question_id or 0),
|
||||||
|
answer_kind="LIVE",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
requested_by=requested_by,
|
||||||
|
model_profile=model_profile,
|
||||||
|
generated_sql=execution["generated_sql"],
|
||||||
|
answer_text=answer_text,
|
||||||
|
result=execution["result"],
|
||||||
|
execution_output=_bounded_json(mcp_result, max_chars=24_000),
|
||||||
|
execution_status=str(execution["execution_status"]),
|
||||||
|
judgment_status=judgment.status,
|
||||||
|
judgment_reason=judgment.reason,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
return history_question, judgment
|
||||||
|
except QaHistoryStoreError as exc:
|
||||||
|
st.warning(f"질답 이력 저장에 실패했습니다: {exc}")
|
||||||
|
return benchmark_question, None
|
||||||
|
|
||||||
|
|
||||||
def _process_submitted_question(
|
def _process_submitted_question(
|
||||||
*,
|
*,
|
||||||
question: str,
|
question: str,
|
||||||
@@ -6338,6 +6583,8 @@ def _process_submitted_question(
|
|||||||
limit: int,
|
limit: int,
|
||||||
selected_token_preset: VpdTokenPreset | None,
|
selected_token_preset: VpdTokenPreset | None,
|
||||||
execution_mode_override: str,
|
execution_mode_override: str,
|
||||||
|
benchmark_question: QaQuestion | None,
|
||||||
|
qa_history_store: QaHistoryStore | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
normalized_question = question.strip()
|
normalized_question = question.strip()
|
||||||
if not normalized_question:
|
if not normalized_question:
|
||||||
@@ -6880,6 +7127,32 @@ def _process_submitted_question(
|
|||||||
],
|
],
|
||||||
"discovery_failures": discovery_failures,
|
"discovery_failures": discovery_failures,
|
||||||
}
|
}
|
||||||
|
history_question, history_judgment = _record_qa_history(
|
||||||
|
store=qa_history_store,
|
||||||
|
benchmark_question=benchmark_question,
|
||||||
|
question_text=normalized_question,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
requested_by=(
|
||||||
|
selected_token_preset.user_id
|
||||||
|
if selected_token_preset is not None
|
||||||
|
else "portal-user"
|
||||||
|
),
|
||||||
|
model_profile=answer_model_profile,
|
||||||
|
answer_text=str(assistant_message["content"]),
|
||||||
|
mcp_result=answer_source,
|
||||||
|
duration_ms=round((perf_counter() - process_started) * 1000),
|
||||||
|
)
|
||||||
|
if history_judgment is not None:
|
||||||
|
assistant_message["details"]["qa_history"] = {
|
||||||
|
"question_id": history_question.question_id if history_question else None,
|
||||||
|
"question_code": history_question.question_code if history_question else "",
|
||||||
|
"judgment_status": history_judgment.status,
|
||||||
|
"judgment_reason": history_judgment.reason,
|
||||||
|
}
|
||||||
|
st.info(
|
||||||
|
"질답 검증 판정: "
|
||||||
|
f"{_judgment_label(history_judgment.status)} · {history_judgment.reason}"
|
||||||
|
)
|
||||||
refresh_progress(98, "질의 결과를 저장하고 있습니다.")
|
refresh_progress(98, "질의 결과를 저장하고 있습니다.")
|
||||||
save_chat_turn(
|
save_chat_turn(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
@@ -6930,31 +7203,17 @@ def main() -> None:
|
|||||||
page_title=profile.page_title, page_icon=profile.page_icon, layout="wide"
|
page_title=profile.page_title, page_icon=profile.page_icon, layout="wide"
|
||||||
)
|
)
|
||||||
_apply_console_theme(profile)
|
_apply_console_theme(profile)
|
||||||
_restore_portal_remembered_session()
|
_restore_portal_session_from_cookie()
|
||||||
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
||||||
_render_portal_login(profile)
|
_render_portal_login(profile)
|
||||||
return
|
return
|
||||||
try:
|
qa_questions, qa_history_store, qa_store_error = _load_qa_questions()
|
||||||
questions = load_demo_scenarios(DEMO_SCENARIOS_FILE)
|
|
||||||
except ScenarioConfigError as exc:
|
|
||||||
st.error(str(exc))
|
|
||||||
return
|
|
||||||
scenario_key = "poc4_mcp_discovery_scenario"
|
|
||||||
question_text_key = "poc4_mcp_discovery_question_text"
|
question_text_key = "poc4_mcp_discovery_question_text"
|
||||||
loaded_scenario_key = "poc4_mcp_discovery_loaded_scenario_id"
|
loaded_qa_question_key = "poc4_mcp_discovery_loaded_qa_question_code"
|
||||||
conversation_id_key = "poc4_mcp_discovery_conversation_id"
|
conversation_id_key = "poc4_mcp_discovery_conversation_id"
|
||||||
chat_page_key = "poc4_mcp_discovery_page"
|
chat_page_key = "poc4_mcp_discovery_page"
|
||||||
query_progress_notice_key = "poc4_query_progress_notice"
|
query_progress_notice_key = "poc4_query_progress_notice"
|
||||||
mcp_cache_generation_key = "poc4_mcp_tools_cache_generation"
|
mcp_cache_generation_key = "poc4_mcp_tools_cache_generation"
|
||||||
selected_scenario_state = st.session_state.get(scenario_key)
|
|
||||||
scenario_ids = {question.question_id for question in questions}
|
|
||||||
if str(getattr(selected_scenario_state, "question_id", "")).strip().upper() not in (
|
|
||||||
"",
|
|
||||||
*scenario_ids,
|
|
||||||
):
|
|
||||||
st.session_state.pop(scenario_key, None)
|
|
||||||
st.session_state.pop(loaded_scenario_key, None)
|
|
||||||
st.session_state[question_text_key] = DEFAULT_QUESTION
|
|
||||||
init_chat_store()
|
init_chat_store()
|
||||||
if conversation_id_key not in st.session_state:
|
if conversation_id_key not in st.session_state:
|
||||||
st.session_state[conversation_id_key] = new_conversation_id()
|
st.session_state[conversation_id_key] = new_conversation_id()
|
||||||
@@ -7069,8 +7328,8 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
selected_token_preset = None
|
selected_token_preset = None
|
||||||
if configured_mcp_bearer:
|
if configured_mcp_bearer:
|
||||||
# The HMM MCP gateway uses its own server-side credential. VPD user
|
# The Smilegate MCP gateway uses a server-side credential. UI-only
|
||||||
# tokens remain UI context only and must never be forwarded to it.
|
# context must never be forwarded as a second bearer token.
|
||||||
st.caption("MCP 인증: 서버 관리 토큰 적용")
|
st.caption("MCP 인증: 서버 관리 토큰 적용")
|
||||||
manual_bearer_token = ""
|
manual_bearer_token = ""
|
||||||
elif selected_token_preset is not None:
|
elif selected_token_preset is not None:
|
||||||
@@ -7243,7 +7502,7 @@ def main() -> None:
|
|||||||
except (OSError, UnicodeError, ValueError):
|
except (OSError, UnicodeError, ValueError):
|
||||||
st.warning("MCP 설정 JSON을 읽지 못했습니다.")
|
st.warning("MCP 설정 JSON을 읽지 못했습니다.")
|
||||||
st.caption(f"config: {MCP_SERVERS_FILE}")
|
st.caption(f"config: {MCP_SERVERS_FILE}")
|
||||||
st.caption(f"scenario config: {DEMO_SCENARIOS_FILE}")
|
st.caption(f"QA benchmark: {QA_BENCHMARK_FILE}")
|
||||||
st.caption(f"token presets: {VPD_TOKEN_PRESETS_FILE}")
|
st.caption(f"token presets: {VPD_TOKEN_PRESETS_FILE}")
|
||||||
st.caption(f"selected LLM model: {selected_query_model_profile}")
|
st.caption(f"selected LLM model: {selected_query_model_profile}")
|
||||||
st.caption(f"default model: {default_query_model_profile}")
|
st.caption(f"default model: {default_query_model_profile}")
|
||||||
@@ -7256,42 +7515,41 @@ def main() -> None:
|
|||||||
'<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>',
|
'<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>',
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs(
|
architecture_tab, scenario_tab = st.tabs(["아키텍처", "시나리오"])
|
||||||
["아키텍처", "시나리오", "감사로그", "보안관리"]
|
|
||||||
)
|
|
||||||
with architecture_tab:
|
with architecture_tab:
|
||||||
_render_architecture_tab()
|
_render_architecture_tab()
|
||||||
|
|
||||||
with scenario_tab:
|
with scenario_tab:
|
||||||
st.markdown(
|
selected_qa_question = _render_qa_benchmark_panel(
|
||||||
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
qa_questions,
|
||||||
"질문 입력"
|
qa_history_store,
|
||||||
"</div>",
|
qa_store_error,
|
||||||
unsafe_allow_html=True,
|
|
||||||
)
|
|
||||||
selected_scenario = st.selectbox(
|
|
||||||
"업무 데모 질의 샘플",
|
|
||||||
options=(None, *questions),
|
|
||||||
format_func=lambda item: (
|
|
||||||
"선택 안 함 · 직접 질문" if item is None else _question_label(item)
|
|
||||||
),
|
|
||||||
key=scenario_key,
|
|
||||||
)
|
|
||||||
selected_scenario_id = (
|
|
||||||
None
|
|
||||||
if selected_scenario is None
|
|
||||||
else str(getattr(selected_scenario, "question_id"))
|
|
||||||
)
|
)
|
||||||
if question_text_key not in st.session_state:
|
if question_text_key not in st.session_state:
|
||||||
st.session_state[question_text_key] = DEFAULT_QUESTION
|
st.session_state[question_text_key] = DEFAULT_QUESTION
|
||||||
if st.session_state.get(loaded_scenario_key, "") != str(
|
selected_qa_code = (
|
||||||
selected_scenario_id or ""
|
selected_qa_question.question_code if selected_qa_question is not None else ""
|
||||||
):
|
)
|
||||||
if selected_scenario is not None:
|
if st.session_state.get(loaded_qa_question_key, "") != selected_qa_code:
|
||||||
st.session_state[question_text_key] = str(
|
st.session_state[question_text_key] = (
|
||||||
getattr(selected_scenario, "text")
|
selected_qa_question.question_text
|
||||||
)
|
if selected_qa_question is not None
|
||||||
st.session_state[loaded_scenario_key] = str(selected_scenario_id or "")
|
else DEFAULT_QUESTION
|
||||||
|
)
|
||||||
|
st.session_state[loaded_qa_question_key] = selected_qa_code
|
||||||
|
|
||||||
|
st.markdown(
|
||||||
|
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||||
|
"질문 실행"
|
||||||
|
"</div>",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
if selected_qa_question is not None:
|
||||||
|
st.caption(
|
||||||
|
"선택한 후보 문장을 수정하지 않고 실행하면 자동 판정합니다. 문장을 수정하면 자유 질의로 저장되어 수동 검토 대상이 됩니다."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.caption("자유 질의는 실행 이력으로 남기며, 고객 기준 정답과 자동 비교하지 않습니다.")
|
||||||
|
|
||||||
with st.form("poc4_mcp_question_form"):
|
with st.form("poc4_mcp_question_form"):
|
||||||
question = st.text_area(
|
question = st.text_area(
|
||||||
@@ -7300,7 +7558,7 @@ def main() -> None:
|
|||||||
key=question_text_key,
|
key=question_text_key,
|
||||||
height=120,
|
height=120,
|
||||||
max_chars=1_000,
|
max_chars=1_000,
|
||||||
placeholder="업무 질문을 직접 입력하거나 위 질의 샘플을 선택하세요.",
|
placeholder="고객 검증 후보를 선택하거나 업무 질문을 직접 입력하세요.",
|
||||||
)
|
)
|
||||||
submitted = st.form_submit_button(
|
submitted = st.form_submit_button(
|
||||||
"질문 전송",
|
"질문 전송",
|
||||||
@@ -7359,12 +7617,6 @@ def main() -> None:
|
|||||||
height=0,
|
height=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
with audit_tab:
|
|
||||||
_render_fga_audit_tab()
|
|
||||||
|
|
||||||
with operations_tab:
|
|
||||||
_render_vpd_operations_tab()
|
|
||||||
|
|
||||||
if not submitted:
|
if not submitted:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -7383,6 +7635,13 @@ def main() -> None:
|
|||||||
limit=int(limit),
|
limit=int(limit),
|
||||||
selected_token_preset=selected_token_preset,
|
selected_token_preset=selected_token_preset,
|
||||||
execution_mode_override=execution_mode_override,
|
execution_mode_override=execution_mode_override,
|
||||||
|
benchmark_question=(
|
||||||
|
selected_qa_question
|
||||||
|
if selected_qa_question is not None
|
||||||
|
and question.strip() == selected_qa_question.question_text.strip()
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
qa_history_store=qa_history_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"product": {
|
"product": {
|
||||||
"name": "AI 업무 에이전트",
|
"name": "SMILEGATE DATA & AI POC",
|
||||||
"short_name": "AGENT",
|
"short_name": "SMILEGATE",
|
||||||
"page_title": "AI 업무 에이전트",
|
"page_title": "SMILEGATE DATA & AI POC",
|
||||||
"page_icon": "🤖",
|
"page_icon": "🤖",
|
||||||
"header_title": "AI 업무 에이전트",
|
"header_title": "스마일게이트 게임 데이터 AI 에이전트",
|
||||||
"header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.",
|
"header_description": "게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.",
|
||||||
"login_kicker": "DATA & AI DEMO",
|
"login_kicker": "SMILEGATE DATA & AI POC",
|
||||||
"login_title": "AI 업무 에이전트",
|
"login_title": "스마일게이트 게임 데이터 AI 에이전트",
|
||||||
"login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.",
|
"login_description": "사용자 인증 후 게임 데이터 AI 질의와 보안 관리 기능을 이용할 수 있습니다.",
|
||||||
"login_footer": "인증된 DEMO 사용자만 접근할 수 있습니다."
|
"login_footer": "승인된 Data & AI PoC 사용자만 접근할 수 있습니다."
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"primary_color": "#003b70",
|
"primary_color": "#113F67",
|
||||||
"text_color": "#172b3a",
|
"text_color": "#15283B",
|
||||||
"muted_color": "#667785",
|
"muted_color": "#5D6C7C",
|
||||||
"border_color": "#dfe7ed"
|
"border_color": "#D7E0E8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
{
|
{
|
||||||
"default_server_id": "hmm_hr_mcp",
|
"default_server_id": "smilegate_game_data_mcp",
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"id": "hmm_hr_mcp",
|
"id": "smilegate_game_data_mcp",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "hmm_compat_mcp",
|
"provider": "smilegate_select_ai_mcp",
|
||||||
"transport": "http",
|
"transport": "http",
|
||||||
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
|
"endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp",
|
||||||
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
|
"auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN",
|
||||||
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
|
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
|
||||||
"default_tool": "search_hr_data",
|
"default_tool": "oracle.select_ai.smilegate_fewshot_nl2sql",
|
||||||
"router_model_profile": "gpt54_mini_oci",
|
"router_model_profile": "gpt54_mini_oci",
|
||||||
"tool_allowlist": [
|
"tool_allowlist": [
|
||||||
"search_hr_data",
|
"oracle.select_ai.smilegate_fewshot_nl2sql",
|
||||||
"resolve_hr_term",
|
"oracle.select_ai.smilegate_game_text2sql",
|
||||||
"search_hr_policy"
|
"oracle.select_ai.qa_vector_search",
|
||||||
|
"oracle.select_ai.qa_vector_store"
|
||||||
],
|
],
|
||||||
"description": "HMM HR knowledge and data MCP server"
|
"description": "Smilegate game-data Text2SQL MCP server"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "최신 월 기준 게임별 신규 사용자 수를 보여줘"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1832
poc4_active_source_20260714/config/smilegate_qa_benchmark.json
Normal file
1832
poc4_active_source_20260714/config/smilegate_qa_benchmark.json
Normal file
File diff suppressed because one or more lines are too long
@@ -3,12 +3,22 @@
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"default": true,
|
"default": true,
|
||||||
"token": "vpd_live_REPLACE_WITH_USER_TOKEN",
|
"user_id": "1001",
|
||||||
"user_id": "FC00789",
|
"name": "Data & AI TF 팀장",
|
||||||
"name": "김설계",
|
"role": "DATA_AI_POC_ADMIN",
|
||||||
"role": "설계사",
|
"channel": "DATA_AI_TF",
|
||||||
"channel": "설계사",
|
"scope": "SGMP_POC 게임 데이터 전체",
|
||||||
"scope": "본인 담당 계약 고객"
|
"token_env": "SMILEGATE_TEAMLEAD_BEARER_TOKEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"default": false,
|
||||||
|
"user_id": "1002",
|
||||||
|
"name": "Data & AI TF 팀원",
|
||||||
|
"role": "DATA_AI_POC_ADMIN",
|
||||||
|
"channel": "DATA_AI_TF",
|
||||||
|
"scope": "SGMP_POC 게임 데이터 전체",
|
||||||
|
"token_env": "SMILEGATE_TEAMMEMBER_BEARER_TOKEN"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
openpyxl>=3.1,<4
|
openpyxl>=3.1,<4
|
||||||
|
oci>=2.180,<3
|
||||||
oracledb>=2,<4
|
oracledb>=2,<4
|
||||||
pandas>=2,<3
|
pandas>=2,<3
|
||||||
streamlit>=1.35,<2
|
streamlit>=1.35,<2
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -33,8 +33,54 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
|||||||
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
|
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
|
||||||
background:#fff !important; border:1px solid var(--console-border) !important;
|
background:#fff !important; border:1px solid var(--console-border) !important;
|
||||||
border-radius:4px !important; box-shadow:none !important; }}
|
border-radius:4px !important; box-shadow:none !important; }}
|
||||||
|
/* Streamlit JSON uses an independently styled code surface. Keep the
|
||||||
|
MCP detail payload readable even when the browser/system prefers a
|
||||||
|
dark code theme. */
|
||||||
|
[data-testid="stJson"], [data-testid="stJson"] > div,
|
||||||
|
[data-testid="stJson"] .react-json-view, [data-testid="stJson"] pre {{
|
||||||
|
background:#f8fafc !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||||
|
[data-testid="stJson"] *, [data-testid="stJson"] pre *,
|
||||||
|
[data-testid="stJson"] code {{
|
||||||
|
background:transparent !important; color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
/* Chat responses are rendered in a separate Streamlit surface. Without
|
||||||
|
these rules a dark browser theme can leave the answer card dark while
|
||||||
|
its Markdown keeps the light-theme text color. */
|
||||||
|
div[data-testid="stChatMessage"], div[data-testid="stChatMessageContent"] {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"],
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] *,
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"],
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"] * {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
/* Streamlit expanders use a dark summary bar in dark browser themes. */
|
||||||
|
details, details > summary {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||||
|
details > summary *, details > summary::marker {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
[data-testid="stExpander"] > details,
|
||||||
|
[data-testid="stExpander"] > details > summary,
|
||||||
|
[data-testid="stExpander"] > details > summary > div,
|
||||||
|
[data-testid="stExpander"] > details > summary > div > div {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; }}
|
||||||
|
[data-testid="stExpander"] > details > summary *,
|
||||||
|
[data-testid="stExpander"] > details > summary svg {{
|
||||||
|
color:var(--console-text) !important; fill:var(--console-text) !important;
|
||||||
|
stroke:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
||||||
border-radius:4px !important; box-shadow:none !important; }}
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important;
|
||||||
|
border:1px solid var(--console-border) !important;
|
||||||
|
border-radius:4px !important; box-shadow:none !important; color-scheme:light !important; }}
|
||||||
|
div[data-testid="stButton"] > button *, div[data-testid="stFormSubmitButton"] > button * {{
|
||||||
|
color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
div[data-testid="stButton"] > button[kind="primary"],
|
div[data-testid="stButton"] > button[kind="primary"],
|
||||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
||||||
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ ALLOWED_OCI_SETTINGS = frozenset(
|
|||||||
"OCI_PROFILE",
|
"OCI_PROFILE",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_COMPARTMENT_ID = re.compile(r"^ocid1\.compartment\.[A-Za-z0-9._-]+$")
|
# OCI permits the tenancy OCID when the root compartment is selected.
|
||||||
|
_COMPARTMENT_OR_ROOT_ID = re.compile(
|
||||||
|
r"^ocid1\.(?:compartment|tenancy)\.[A-Za-z0-9._-]+$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CompletionClient(Protocol):
|
class CompletionClient(Protocol):
|
||||||
@@ -95,7 +98,7 @@ def load_oci_settings() -> OCISettings:
|
|||||||
raise ValueError("unsupported OCI authentication mode")
|
raise ValueError("unsupported OCI authentication mode")
|
||||||
|
|
||||||
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
|
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
|
||||||
if not _COMPARTMENT_ID.fullmatch(compartment_id):
|
if not _COMPARTMENT_OR_ROOT_ID.fullmatch(compartment_id):
|
||||||
raise ValueError("OCI Generative AI compartment is not configured")
|
raise ValueError("OCI Generative AI compartment is not configured")
|
||||||
return OCISettings(
|
return OCISettings(
|
||||||
auth_type=auth_type,
|
auth_type=auth_type,
|
||||||
|
|||||||
289
poc4_active_source_20260714/src/poc4/qa_history.py
Normal file
289
poc4_active_source_20260714/src/poc4/qa_history.py
Normal 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에는 레벨·접속일·플레이타임 지표가 포함되면 안 됩니다.")
|
||||||
566
poc4_active_source_20260714/src/poc4/qa_history_store.py
Normal file
566
poc4_active_source_20260714/src/poc4/qa_history_store.py
Normal 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")
|
||||||
51
poc4_active_source_20260714/tests/test_oci_genai_settings.py
Normal file
51
poc4_active_source_20260714/tests/test_oci_genai_settings.py
Normal 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()
|
||||||
80
poc4_active_source_20260714/tests/test_qa_history.py
Normal file
80
poc4_active_source_20260714/tests/test_qa_history.py
Normal 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()
|
||||||
@@ -16,29 +16,29 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
|||||||
with patch.dict(
|
with patch.dict(
|
||||||
"os.environ",
|
"os.environ",
|
||||||
{
|
{
|
||||||
"AGENT_CONSOLE_SHORT_NAME": "HMM",
|
"AGENT_CONSOLE_SHORT_NAME": "SMILEGATE",
|
||||||
"AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트",
|
"AGENT_CONSOLE_PAGE_TITLE": "SMILEGATE DATA & AI POC",
|
||||||
"AGENT_CONSOLE_PRIMARY_COLOR": "#003b70",
|
"AGENT_CONSOLE_PRIMARY_COLOR": "#113F67",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
profile = load_app_profile(path)
|
profile = load_app_profile(path)
|
||||||
|
|
||||||
self.assertEqual(profile.short_name, "HMM")
|
self.assertEqual(profile.short_name, "SMILEGATE")
|
||||||
self.assertEqual(profile.page_title, "HMM AI 업무 에이전트")
|
self.assertEqual(profile.page_title, "SMILEGATE DATA & AI POC")
|
||||||
self.assertEqual(profile.primary_color, "#003b70")
|
self.assertEqual(profile.primary_color, "#113F67")
|
||||||
|
|
||||||
def test_profile_reads_dotenv_values(self) -> None:
|
def test_profile_reads_dotenv_values(self) -> None:
|
||||||
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
env_file = Path(temp_dir) / ".env"
|
env_file = Path(temp_dir) / ".env"
|
||||||
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=HMM\n", encoding="utf-8")
|
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=SMILEGATE\n", encoding="utf-8")
|
||||||
profile = load_app_profile(path, env_file)
|
profile = load_app_profile(path, env_file)
|
||||||
|
|
||||||
self.assertEqual(profile.short_name, "HMM")
|
self.assertEqual(profile.short_name, "SMILEGATE")
|
||||||
|
|
||||||
def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
|
def test_smilegate_scenarios_are_enabled_and_unique(self) -> None:
|
||||||
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"
|
path = Path(__file__).parents[1] / "config" / "smilegate_demo_scenarios.json"
|
||||||
scenarios = load_demo_scenarios(path)
|
scenarios = load_demo_scenarios(path)
|
||||||
|
|
||||||
self.assertGreaterEqual(len(scenarios), 3)
|
self.assertGreaterEqual(len(scenarios), 3)
|
||||||
|
|||||||
12
pom.xml
12
pom.xml
@@ -92,18 +92,12 @@
|
|||||||
<resource>
|
<resource>
|
||||||
<directory>src/main/resources</directory>
|
<directory>src/main/resources</directory>
|
||||||
</resource>
|
</resource>
|
||||||
<!-- Keep the reviewed SQL in sql/adb as the sole Git source, and bundle
|
<!-- Keep reviewed SQL in sql/adb. Runtime exposure remains restricted by
|
||||||
only the curated read-only viewer set into the deployed JAR. -->
|
BACKOFFICE_SECURITY_SQL_SCRIPTS, never by a request path. -->
|
||||||
<resource>
|
<resource>
|
||||||
<directory>sql/adb</directory>
|
<directory>sql/adb</directory>
|
||||||
<targetPath>sql/adb</targetPath>
|
<targetPath>sql/adb</targetPath>
|
||||||
<includes>
|
<includes><include>**/*.sql</include></includes>
|
||||||
<include>62_kb_aso_masking_backoffice_metadata.sql</include>
|
|
||||||
<include>63_kb_aso_masking_rule_runtime.sql</include>
|
|
||||||
<include>64_kb_aso_masking_default_column_rules.sql</include>
|
|
||||||
<include>65_kb_select_ai_vpd_query_api.sql</include>
|
|
||||||
<include>66_kb_select_ai_vpd_query_ords.sql</include>
|
|
||||||
</includes>
|
|
||||||
</resource>
|
</resource>
|
||||||
</resources>
|
</resources>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
|||||||
61
scripts/setup-sgmp-qa-vector.sh
Executable file
61
scripts/setup-sgmp-qa-vector.sh
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Registers only the dedicated OCI API signing key for the QA vector store,
|
||||||
|
# then applies sql/adb/75_*.sql. The narrow outbound HTTPS ACL is an ADMIN
|
||||||
|
# operation and must be applied before this script runs.
|
||||||
|
# Secrets stay in environment variables and are never written to this repo.
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
: "${SGMP_POC_DB_PASSWORD:?SGMP_POC_DB_PASSWORD is required}"
|
||||||
|
|
||||||
|
SQLCL_BIN="${SQLCL_BIN:-sql}"
|
||||||
|
SGMP_POC_DB_USER="${SGMP_POC_DB_USER:-SGMP_POC}"
|
||||||
|
SGMP_POC_DB_SERVICE="${SGMP_POC_DB_SERVICE:-sgmpaipoc_low}"
|
||||||
|
SGMP_POC_WALLET_DIR="${SGMP_POC_WALLET_DIR:?SGMP_POC_WALLET_DIR is required}"
|
||||||
|
VECTOR_OCI_USER_OCID="${VECTOR_OCI_USER_OCID:?VECTOR_OCI_USER_OCID is required}"
|
||||||
|
VECTOR_OCI_TENANCY_OCID="${VECTOR_OCI_TENANCY_OCID:?VECTOR_OCI_TENANCY_OCID is required}"
|
||||||
|
VECTOR_OCI_COMPARTMENT_OCID="${VECTOR_OCI_COMPARTMENT_OCID:?VECTOR_OCI_COMPARTMENT_OCID is required}"
|
||||||
|
VECTOR_OCI_API_KEY_FILE="${VECTOR_OCI_API_KEY_FILE:?VECTOR_OCI_API_KEY_FILE is required}"
|
||||||
|
VECTOR_OCI_API_KEY_FINGERPRINT="${VECTOR_OCI_API_KEY_FINGERPRINT:?VECTOR_OCI_API_KEY_FINGERPRINT is required}"
|
||||||
|
|
||||||
|
command -v "$SQLCL_BIN" >/dev/null
|
||||||
|
command -v jq >/dev/null
|
||||||
|
[[ -f "$SGMP_POC_WALLET_DIR/tnsnames.ora" ]]
|
||||||
|
[[ -f "$VECTOR_OCI_API_KEY_FILE" ]]
|
||||||
|
|
||||||
|
private_key_json="$(jq -Rs . "$VECTOR_OCI_API_KEY_FILE")"
|
||||||
|
credential_params="$(jq -cn \
|
||||||
|
--arg user_ocid "$VECTOR_OCI_USER_OCID" \
|
||||||
|
--arg tenancy_ocid "$VECTOR_OCI_TENANCY_OCID" \
|
||||||
|
--arg compartment_ocid "$VECTOR_OCI_COMPARTMENT_OCID" \
|
||||||
|
--argjson private_key "$private_key_json" \
|
||||||
|
--arg fingerprint "$VECTOR_OCI_API_KEY_FINGERPRINT" \
|
||||||
|
'{user_ocid:$user_ocid, tenancy_ocid:$tenancy_ocid, compartment_ocid:$compartment_ocid, private_key:$private_key, fingerprint:$fingerprint}')"
|
||||||
|
credential_params_b64="$(printf '%s' "$credential_params" | base64 | tr -d '\n')"
|
||||||
|
|
||||||
|
"$SQLCL_BIN" -thin -L -S -tnsadmin "$SGMP_POC_WALLET_DIR" /nolog <<SQL
|
||||||
|
whenever oserror exit failure
|
||||||
|
connect ${SGMP_POC_DB_USER}/"${SGMP_POC_DB_PASSWORD}"@${SGMP_POC_DB_SERVICE}
|
||||||
|
whenever sqlerror exit sql.sqlcode
|
||||||
|
DECLARE
|
||||||
|
v_params CLOB := utl_i18n.raw_to_char(
|
||||||
|
utl_encode.base64_decode(utl_raw.cast_to_raw('${credential_params_b64}')),
|
||||||
|
'AL32UTF8'
|
||||||
|
);
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
DBMS_VECTOR.DROP_CREDENTIAL('SGMP_POC_QA_VECTOR_CRED');
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
IF SQLCODE != -20004 THEN RAISE; END IF;
|
||||||
|
END;
|
||||||
|
DBMS_VECTOR.CREATE_CREDENTIAL(
|
||||||
|
credential_name => 'SGMP_POC_QA_VECTOR_CRED',
|
||||||
|
params => JSON(v_params)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
@${ROOT}/sql/adb/75_sgmp_qa_vector_retrieval.sql
|
||||||
|
SELECT config_key, config_value FROM sg_qa_vector_config ORDER BY config_key;
|
||||||
|
exit success
|
||||||
|
SQL
|
||||||
62
sql/adb/47_smilegate_qa_history.sql
Normal file
62
sql/adb/47_smilegate_qa_history.sql
Normal 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);
|
||||||
74
sql/adb/72_sgmp_select_ai_oci_genai_profile.sql
Normal file
74
sql/adb/72_sgmp_select_ai_oci_genai_profile.sql
Normal 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;
|
||||||
199
sql/adb/75_sgmp_qa_vector_retrieval.sql
Normal file
199
sql/adb/75_sgmp_qa_vector_retrieval.sql
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
-- SGMP QA example vector store.
|
||||||
|
--
|
||||||
|
-- Run as SGMP_POC after scripts/setup-sgmp-qa-vector.sh has registered the
|
||||||
|
-- DBMS_VECTOR credential and granted the HTTPS ACL. No API key material is
|
||||||
|
-- stored in this file.
|
||||||
|
--
|
||||||
|
-- Cohere Embed 4 is intentionally fixed to 1536 dimensions. Stored examples
|
||||||
|
-- use search_document; incoming questions use search_query.
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_count PLS_INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO v_count
|
||||||
|
FROM user_tables
|
||||||
|
WHERE table_name = 'SG_QA_VECTOR_CONFIG';
|
||||||
|
|
||||||
|
IF v_count = 0 THEN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
CREATE TABLE sg_qa_vector_config (
|
||||||
|
config_key VARCHAR2(64) PRIMARY KEY,
|
||||||
|
config_value VARCHAR2(4000) NOT NULL,
|
||||||
|
updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL
|
||||||
|
)]';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
MERGE INTO sg_qa_vector_config c
|
||||||
|
USING (
|
||||||
|
SELECT 'CREDENTIAL_NAME' AS config_key, 'SGMP_POC_QA_VECTOR_CRED' AS config_value FROM dual
|
||||||
|
UNION ALL SELECT 'ENDPOINT_URL', 'https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/embedText' FROM dual
|
||||||
|
UNION ALL SELECT 'MODEL_NAME', 'cohere.embed-v4.0' FROM dual
|
||||||
|
UNION ALL SELECT 'DIMENSION', '1536' FROM dual
|
||||||
|
) s
|
||||||
|
ON (c.config_key = s.config_key)
|
||||||
|
WHEN MATCHED THEN UPDATE SET c.config_value = s.config_value, c.updated_at = SYSTIMESTAMP
|
||||||
|
WHEN NOT MATCHED THEN INSERT (config_key, config_value) VALUES (s.config_key, s.config_value);
|
||||||
|
/
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_count PLS_INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO v_count
|
||||||
|
FROM user_tables
|
||||||
|
WHERE table_name = 'SG_QA_VECTOR_EXAMPLE';
|
||||||
|
|
||||||
|
IF v_count = 0 THEN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
CREATE TABLE sg_qa_vector_example (
|
||||||
|
example_id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
question CLOB NOT NULL,
|
||||||
|
answer_sql CLOB NOT NULL,
|
||||||
|
answer_text CLOB,
|
||||||
|
embedding_input CLOB NOT NULL,
|
||||||
|
embedding VECTOR(1536, FLOAT32) NOT NULL,
|
||||||
|
embedding_model VARCHAR2(128) DEFAULT 'cohere.embed-v4.0' NOT NULL,
|
||||||
|
created_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL
|
||||||
|
)]';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION sg_qa_vector_params(p_input_type IN VARCHAR2)
|
||||||
|
RETURN CLOB
|
||||||
|
AUTHID DEFINER
|
||||||
|
IS
|
||||||
|
v_credential VARCHAR2(4000);
|
||||||
|
v_endpoint VARCHAR2(4000);
|
||||||
|
v_model VARCHAR2(4000);
|
||||||
|
BEGIN
|
||||||
|
SELECT MAX(CASE WHEN config_key = 'CREDENTIAL_NAME' THEN config_value END),
|
||||||
|
MAX(CASE WHEN config_key = 'ENDPOINT_URL' THEN config_value END),
|
||||||
|
MAX(CASE WHEN config_key = 'MODEL_NAME' THEN config_value END)
|
||||||
|
INTO v_credential, v_endpoint, v_model
|
||||||
|
FROM sg_qa_vector_config;
|
||||||
|
|
||||||
|
IF v_credential IS NULL OR v_endpoint IS NULL OR v_model IS NULL THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20001, 'SG QA vector configuration is incomplete.');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN TO_CLOB('{"provider":"ocigenai","credential_name":"')
|
||||||
|
|| v_credential
|
||||||
|
|| '","url":"' || v_endpoint
|
||||||
|
|| '","model":"' || v_model
|
||||||
|
|| '","truncate":"END"}';
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION sg_qa_vector_store(
|
||||||
|
p_question IN CLOB,
|
||||||
|
p_answer_sql IN CLOB,
|
||||||
|
p_answer IN CLOB DEFAULT NULL
|
||||||
|
) RETURN NUMBER
|
||||||
|
AUTHID DEFINER
|
||||||
|
IS
|
||||||
|
PRAGMA AUTONOMOUS_TRANSACTION;
|
||||||
|
v_input CLOB;
|
||||||
|
v_embedding VECTOR;
|
||||||
|
v_example_id NUMBER;
|
||||||
|
BEGIN
|
||||||
|
IF p_question IS NULL OR p_answer_sql IS NULL THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20002, 'question and answer_sql are required.');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_input := TO_CLOB('Question: ') || p_question
|
||||||
|
|| TO_CLOB(CHR(10) || 'Answer SQL: ') || p_answer_sql
|
||||||
|
|| CASE WHEN p_answer IS NULL THEN NULL ELSE TO_CLOB(CHR(10) || 'Answer: ') || p_answer END;
|
||||||
|
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||||
|
v_input,
|
||||||
|
JSON(sg_qa_vector_params('search_document'))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sg_qa_vector_example (
|
||||||
|
question, answer_sql, answer_text, embedding_input, embedding, embedding_model
|
||||||
|
) VALUES (
|
||||||
|
p_question, p_answer_sql, p_answer, v_input, v_embedding, 'cohere.embed-v4.0'
|
||||||
|
) RETURNING example_id INTO v_example_id;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
RETURN v_example_id;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
ROLLBACK;
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
|
||||||
|
p_question IN CLOB,
|
||||||
|
p_top_k IN PLS_INTEGER DEFAULT 3
|
||||||
|
) RETURN SYS_REFCURSOR
|
||||||
|
AUTHID DEFINER
|
||||||
|
IS
|
||||||
|
v_query_vector VECTOR;
|
||||||
|
v_results SYS_REFCURSOR;
|
||||||
|
BEGIN
|
||||||
|
IF p_question IS NULL THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
|
||||||
|
END IF;
|
||||||
|
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||||
|
p_question,
|
||||||
|
JSON(sg_qa_vector_params('search_query'))
|
||||||
|
);
|
||||||
|
|
||||||
|
OPEN v_results FOR
|
||||||
|
SELECT example_id,
|
||||||
|
question,
|
||||||
|
answer_sql,
|
||||||
|
answer_text,
|
||||||
|
embedding_model,
|
||||||
|
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
|
||||||
|
FROM sg_qa_vector_example
|
||||||
|
ORDER BY vector_distance(embedding, v_query_vector, COSINE), example_id
|
||||||
|
FETCH FIRST p_top_k ROWS ONLY;
|
||||||
|
RETURN v_results;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION sg_qa_vector_context(
|
||||||
|
p_question IN CLOB,
|
||||||
|
p_top_k IN PLS_INTEGER DEFAULT 3
|
||||||
|
) RETURN CLOB
|
||||||
|
AUTHID DEFINER
|
||||||
|
IS
|
||||||
|
v_results SYS_REFCURSOR;
|
||||||
|
v_id NUMBER;
|
||||||
|
v_q CLOB;
|
||||||
|
v_sql CLOB;
|
||||||
|
v_answer CLOB;
|
||||||
|
v_model VARCHAR2(128);
|
||||||
|
v_dist NUMBER;
|
||||||
|
v_context CLOB := EMPTY_CLOB();
|
||||||
|
BEGIN
|
||||||
|
v_results := sg_qa_vector_search(p_question, p_top_k);
|
||||||
|
LOOP
|
||||||
|
FETCH v_results INTO v_id, v_q, v_sql, v_answer, v_model, v_dist;
|
||||||
|
EXIT WHEN v_results%NOTFOUND;
|
||||||
|
v_context := v_context
|
||||||
|
|| CASE WHEN DBMS_LOB.GETLENGTH(v_context) = 0 THEN NULL ELSE CHR(10) || CHR(10) END
|
||||||
|
|| '[Example ' || v_id || ', cosine_distance=' || TO_CHAR(v_dist, 'FM0D000000') || ']' || CHR(10)
|
||||||
|
|| 'Question: ' || v_q || CHR(10)
|
||||||
|
|| 'Answer SQL: ' || v_sql
|
||||||
|
|| CASE WHEN v_answer IS NULL THEN NULL ELSE CHR(10) || 'Answer: ' || v_answer END;
|
||||||
|
END LOOP;
|
||||||
|
CLOSE v_results;
|
||||||
|
RETURN v_context;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
COMMENT ON TABLE sg_qa_vector_example IS
|
||||||
|
'Question-to-SQL QA examples embedded with OCI GenAI Cohere Embed 4 for retrieval-augmented prompt context.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN sg_qa_vector_example.embedding IS
|
||||||
|
'1536-dimensional Cohere Embed 4 document embedding; generated through the dedicated SGMP vector API credential.';
|
||||||
148
sql/adb/76_sgmp_annotation_api.sql
Normal file
148
sql/adb/76_sgmp_annotation_api.sql
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
-- SGMP PoC: validated table/view and column annotation API
|
||||||
|
-- Issue: #734
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION sgmp_set_annotation(
|
||||||
|
p_schema_name IN VARCHAR2,
|
||||||
|
p_target_kind IN VARCHAR2,
|
||||||
|
p_object_name IN VARCHAR2,
|
||||||
|
p_column_name IN VARCHAR2 DEFAULT NULL,
|
||||||
|
p_change_text IN VARCHAR2 DEFAULT NULL,
|
||||||
|
p_annotation_name IN VARCHAR2 DEFAULT 'AI_GUIDANCE'
|
||||||
|
) RETURN VARCHAR2
|
||||||
|
AUTHID DEFINER
|
||||||
|
IS
|
||||||
|
l_schema_name VARCHAR2(128);
|
||||||
|
l_object_name VARCHAR2(128);
|
||||||
|
l_column_name VARCHAR2(128);
|
||||||
|
l_annotation_name VARCHAR2(128);
|
||||||
|
l_target_kind VARCHAR2(20);
|
||||||
|
l_object_type VARCHAR2(30);
|
||||||
|
l_exists PLS_INTEGER := 0;
|
||||||
|
l_action VARCHAR2(20);
|
||||||
|
l_sql VARCHAR2(32767);
|
||||||
|
|
||||||
|
FUNCTION simple_name(p_value VARCHAR2, p_label VARCHAR2) RETURN VARCHAR2 IS
|
||||||
|
l_value VARCHAR2(128) := UPPER(TRIM(p_value));
|
||||||
|
BEGIN
|
||||||
|
IF l_value IS NULL OR NOT REGEXP_LIKE(l_value, '^[A-Z][A-Z0-9_$#]{0,127}$') THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20001, p_label || ' has an invalid format.');
|
||||||
|
END IF;
|
||||||
|
RETURN l_value;
|
||||||
|
END;
|
||||||
|
|
||||||
|
FUNCTION qname(p_value VARCHAR2) RETURN VARCHAR2 IS
|
||||||
|
BEGIN
|
||||||
|
RETURN DBMS_ASSERT.ENQUOTE_NAME(p_value, FALSE);
|
||||||
|
END;
|
||||||
|
|
||||||
|
FUNCTION literal(p_value VARCHAR2) RETURN VARCHAR2 IS
|
||||||
|
BEGIN
|
||||||
|
RETURN DBMS_ASSERT.ENQUOTE_LITERAL(p_value);
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
l_schema_name := simple_name(p_schema_name, 'schema_name');
|
||||||
|
l_object_name := simple_name(p_object_name, 'object_name');
|
||||||
|
l_target_kind := UPPER(TRIM(p_target_kind));
|
||||||
|
IF l_target_kind NOT IN ('TABLE', 'COLUMN') THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20002, 'target_kind must be TABLE or COLUMN.');
|
||||||
|
END IF;
|
||||||
|
l_annotation_name := simple_name(p_annotation_name, 'annotation_name');
|
||||||
|
IF p_change_text IS NULL OR LENGTH(p_change_text) = 0 THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20003, 'change_text must not be empty.');
|
||||||
|
END IF;
|
||||||
|
IF LENGTH(p_change_text) > 4000 THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20004, 'change_text must be 4000 characters or less.');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
SELECT object_type
|
||||||
|
INTO l_object_type
|
||||||
|
FROM all_objects
|
||||||
|
WHERE owner = l_schema_name
|
||||||
|
AND object_name = l_object_name
|
||||||
|
AND object_type IN ('TABLE', 'VIEW')
|
||||||
|
AND ROWNUM = 1;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN NO_DATA_FOUND THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20005, 'TABLE or VIEW object was not found.');
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF l_target_kind = 'COLUMN' THEN
|
||||||
|
IF p_column_name IS NULL THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20006, 'column_name is required for COLUMN target.');
|
||||||
|
END IF;
|
||||||
|
IF l_object_type = 'VIEW' THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20007, 'VIEW column annotations cannot be altered by Oracle.');
|
||||||
|
END IF;
|
||||||
|
l_column_name := simple_name(p_column_name, 'column_name');
|
||||||
|
BEGIN
|
||||||
|
SELECT 1 INTO l_exists
|
||||||
|
FROM all_tab_columns
|
||||||
|
WHERE owner = l_schema_name
|
||||||
|
AND table_name = l_object_name
|
||||||
|
AND column_name = l_column_name
|
||||||
|
AND ROWNUM = 1;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN NO_DATA_FOUND THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20008, 'column_name does not exist on the table.');
|
||||||
|
END;
|
||||||
|
ELSIF p_column_name IS NOT NULL THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20009, 'column_name is not allowed for TABLE target.');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO l_exists
|
||||||
|
FROM all_annotations_usage
|
||||||
|
WHERE annotation_owner = l_schema_name
|
||||||
|
AND object_name = l_object_name
|
||||||
|
AND object_type = l_object_type
|
||||||
|
AND annotation_name = l_annotation_name
|
||||||
|
AND (l_target_kind = 'TABLE' AND column_name IS NULL
|
||||||
|
OR l_target_kind = 'COLUMN' AND column_name = l_column_name);
|
||||||
|
|
||||||
|
IF l_exists > 0 THEN
|
||||||
|
IF l_target_kind = 'COLUMN' THEN
|
||||||
|
l_sql := 'ALTER TABLE ' || qname(l_schema_name) || '.' || qname(l_object_name)
|
||||||
|
|| ' MODIFY ' || qname(l_column_name) || ' ANNOTATIONS (DROP '
|
||||||
|
|| qname(l_annotation_name) || ')';
|
||||||
|
ELSIF l_object_type = 'VIEW' THEN
|
||||||
|
l_sql := 'ALTER VIEW ' || qname(l_schema_name) || '.' || qname(l_object_name)
|
||||||
|
|| ' ANNOTATIONS (DROP ' || qname(l_annotation_name) || ')';
|
||||||
|
ELSE
|
||||||
|
l_sql := 'ALTER TABLE ' || qname(l_schema_name) || '.' || qname(l_object_name)
|
||||||
|
|| ' ANNOTATIONS (DROP ' || qname(l_annotation_name) || ')';
|
||||||
|
END IF;
|
||||||
|
EXECUTE IMMEDIATE l_sql;
|
||||||
|
l_action := 'REPLACED';
|
||||||
|
ELSE
|
||||||
|
l_action := 'ADDED';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF l_target_kind = 'COLUMN' THEN
|
||||||
|
l_sql := 'ALTER TABLE ' || qname(l_schema_name) || '.' || qname(l_object_name)
|
||||||
|
|| ' MODIFY ' || qname(l_column_name) || ' ANNOTATIONS (ADD '
|
||||||
|
|| qname(l_annotation_name) || ' ' || literal(p_change_text) || ')';
|
||||||
|
ELSIF l_object_type = 'VIEW' THEN
|
||||||
|
l_sql := 'ALTER VIEW ' || qname(l_schema_name) || '.' || qname(l_object_name)
|
||||||
|
|| ' ANNOTATIONS (ADD ' || qname(l_annotation_name) || ' '
|
||||||
|
|| literal(p_change_text) || ')';
|
||||||
|
ELSE
|
||||||
|
l_sql := 'ALTER TABLE ' || qname(l_schema_name) || '.' || qname(l_object_name)
|
||||||
|
|| ' ANNOTATIONS (ADD ' || qname(l_annotation_name) || ' '
|
||||||
|
|| literal(p_change_text) || ')';
|
||||||
|
END IF;
|
||||||
|
EXECUTE IMMEDIATE l_sql;
|
||||||
|
|
||||||
|
RETURN l_action || ': ' || l_schema_name || '.' || l_object_name
|
||||||
|
|| CASE WHEN l_column_name IS NULL THEN '' ELSE '.' || l_column_name END
|
||||||
|
|| ' [' || l_annotation_name || ']';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
IF SQLCODE BETWEEN -20099 AND -20000 THEN
|
||||||
|
RAISE;
|
||||||
|
END IF;
|
||||||
|
RAISE_APPLICATION_ERROR(-20099, 'annotation change failed: ' || SQLERRM);
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
SHOW ERRORS FUNCTION sgmp_set_annotation;
|
||||||
175
sql/adb/77_sgmp_game_scope_resolver.sql
Normal file
175
sql/adb/77_sgmp_game_scope_resolver.sql
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
-- DB-backed game query scope contract for MCP orchestration.
|
||||||
|
--
|
||||||
|
-- This script deliberately keeps game facts in database rows, not in application
|
||||||
|
-- code or Select AI instructions. The view combines the active game alias source
|
||||||
|
-- with an operator-maintained registry for known games that currently have no
|
||||||
|
-- approved query object. A zero-row business result is still queryable; only the
|
||||||
|
-- absence of an approved object makes a game scope unavailable.
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_count PLS_INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO v_count
|
||||||
|
FROM user_tables
|
||||||
|
WHERE table_name = 'SG_GAME_SCOPE_REGISTRY';
|
||||||
|
|
||||||
|
IF v_count = 0 THEN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
CREATE TABLE sg_game_scope_registry (
|
||||||
|
game_key VARCHAR2(128) NOT NULL,
|
||||||
|
display_name VARCHAR2(200) NOT NULL,
|
||||||
|
game_alias VARCHAR2(200) NOT NULL,
|
||||||
|
game_prefix VARCHAR2(30),
|
||||||
|
active_yn VARCHAR2(1) DEFAULT 'Y' NOT NULL,
|
||||||
|
alias_priority NUMBER(10) DEFAULT 100 NOT NULL,
|
||||||
|
source_type VARCHAR2(30) DEFAULT 'OPERATOR' NOT NULL,
|
||||||
|
work_dtm TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT sg_game_scope_registry_pk PRIMARY KEY (game_key, game_alias),
|
||||||
|
CONSTRAINT sg_game_scope_registry_active_ck CHECK (active_yn IN ('Y', 'N'))
|
||||||
|
)]';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
-- Seed only database facts needed to recognise currently unavailable games in
|
||||||
|
-- the customer QA catalogue. Customer game-master synchronization can replace
|
||||||
|
-- these rows without an application deployment.
|
||||||
|
MERGE INTO sg_game_scope_registry t
|
||||||
|
USING (
|
||||||
|
SELECT 'LORDNINE' AS game_key,
|
||||||
|
utl_i18n.raw_to_char(
|
||||||
|
utl_encode.base64_decode(utl_raw.cast_to_raw('66Gc65Oc64KY7J24')),
|
||||||
|
'AL32UTF8'
|
||||||
|
) AS display_name,
|
||||||
|
utl_i18n.raw_to_char(
|
||||||
|
utl_encode.base64_decode(utl_raw.cast_to_raw('66Gc65Oc64KY7J24')),
|
||||||
|
'AL32UTF8'
|
||||||
|
) AS game_alias,
|
||||||
|
CAST(NULL AS VARCHAR2(30)) AS game_prefix,
|
||||||
|
100 AS alias_priority
|
||||||
|
FROM dual
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'BUBBLYZ', 'Bubblyz', 'Bubblyz', CAST(NULL AS VARCHAR2(30)), 100 FROM dual
|
||||||
|
) s
|
||||||
|
ON (t.game_key = s.game_key AND t.game_alias = s.game_alias)
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
t.display_name = s.display_name,
|
||||||
|
t.game_prefix = s.game_prefix,
|
||||||
|
t.active_yn = 'Y',
|
||||||
|
t.alias_priority = s.alias_priority,
|
||||||
|
t.source_type = 'OPERATOR',
|
||||||
|
t.work_dtm = SYSTIMESTAMP
|
||||||
|
WHEN NOT MATCHED THEN INSERT (
|
||||||
|
game_key, display_name, game_alias, game_prefix, active_yn, alias_priority, source_type
|
||||||
|
) VALUES (
|
||||||
|
s.game_key, s.display_name, s.game_alias, s.game_prefix, 'Y', s.alias_priority, 'OPERATOR'
|
||||||
|
);
|
||||||
|
/
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW sg_game_query_scope_v AS
|
||||||
|
WITH existing_query_objects AS (
|
||||||
|
-- USER_OBJECTS is the authoritative current-schema inventory. The profile
|
||||||
|
-- object list alone is not enough because a stale entry must not make a game
|
||||||
|
-- executable after its table or view has been removed or invalidated.
|
||||||
|
SELECT object_name
|
||||||
|
FROM user_objects
|
||||||
|
WHERE object_type IN ('TABLE', 'VIEW')
|
||||||
|
AND status = 'VALID'
|
||||||
|
),
|
||||||
|
profile_names AS (
|
||||||
|
SELECT DISTINCT profile_name
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE attribute_name = 'object_list'
|
||||||
|
),
|
||||||
|
profile_objects AS (
|
||||||
|
SELECT DISTINCT p.profile_name, o.object_name
|
||||||
|
FROM user_cloud_ai_profile_attributes p,
|
||||||
|
JSON_TABLE(
|
||||||
|
p.attribute_value,
|
||||||
|
'$[*]' COLUMNS (object_name VARCHAR2(128) PATH '$.name')
|
||||||
|
) o
|
||||||
|
INNER JOIN existing_query_objects e
|
||||||
|
ON e.object_name = o.object_name
|
||||||
|
WHERE p.attribute_name = 'object_list'
|
||||||
|
),
|
||||||
|
source_alias AS (
|
||||||
|
SELECT game_id AS game_key,
|
||||||
|
game_nm AS display_name,
|
||||||
|
game_alias_nm AS game_alias,
|
||||||
|
game_prefix,
|
||||||
|
use_yn AS active_yn,
|
||||||
|
NVL(sort_order, 100) AS alias_priority,
|
||||||
|
'GAME_ALIAS' AS source_type
|
||||||
|
FROM comn_game_alias_bas
|
||||||
|
),
|
||||||
|
all_alias AS (
|
||||||
|
SELECT game_key, display_name, game_alias, game_prefix, active_yn, alias_priority, source_type
|
||||||
|
FROM source_alias
|
||||||
|
UNION ALL
|
||||||
|
SELECT r.game_key, r.display_name, r.game_alias, r.game_prefix,
|
||||||
|
r.active_yn, r.alias_priority, r.source_type
|
||||||
|
FROM sg_game_scope_registry r
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM source_alias a
|
||||||
|
WHERE a.game_key = r.game_key
|
||||||
|
AND a.game_alias = r.game_alias
|
||||||
|
)
|
||||||
|
),
|
||||||
|
scope_object AS (
|
||||||
|
SELECT n.profile_name,
|
||||||
|
a.game_key,
|
||||||
|
a.game_alias,
|
||||||
|
COUNT(p.object_name) AS approved_object_count
|
||||||
|
FROM profile_names n
|
||||||
|
CROSS JOIN all_alias a
|
||||||
|
LEFT JOIN profile_objects p
|
||||||
|
ON p.profile_name = n.profile_name
|
||||||
|
AND a.game_prefix IS NOT NULL
|
||||||
|
AND SUBSTR(p.object_name, 1, LENGTH(a.game_prefix) + 1) = a.game_prefix || '_'
|
||||||
|
GROUP BY n.profile_name, a.game_key, a.game_alias
|
||||||
|
)
|
||||||
|
SELECT o.profile_name,
|
||||||
|
a.game_key,
|
||||||
|
a.display_name,
|
||||||
|
a.game_alias,
|
||||||
|
a.game_prefix,
|
||||||
|
a.active_yn,
|
||||||
|
NVL(o.approved_object_count, 0) AS approved_object_count,
|
||||||
|
CASE
|
||||||
|
WHEN a.active_yn <> 'Y' THEN 'N'
|
||||||
|
WHEN NVL(o.approved_object_count, 0) > 0 THEN 'Y'
|
||||||
|
ELSE 'N'
|
||||||
|
END AS query_allowed_yn,
|
||||||
|
CASE
|
||||||
|
WHEN a.active_yn <> 'Y' THEN 'GAME_INACTIVE'
|
||||||
|
WHEN NVL(o.approved_object_count, 0) > 0 THEN 'APPROVED_OBJECT_AVAILABLE'
|
||||||
|
ELSE 'OBJECT_LIST_NOT_AVAILABLE'
|
||||||
|
END AS reason_code,
|
||||||
|
a.alias_priority,
|
||||||
|
a.source_type,
|
||||||
|
TO_CHAR(MAX(a.alias_priority) OVER (PARTITION BY a.game_key), 'FM999999990') AS scope_version
|
||||||
|
FROM all_alias a
|
||||||
|
LEFT JOIN scope_object o
|
||||||
|
ON o.game_key = a.game_key
|
||||||
|
AND o.game_alias = a.game_alias;
|
||||||
|
/
|
||||||
|
|
||||||
|
COMMENT ON TABLE sg_game_scope_registry IS
|
||||||
|
'Operator-managed game aliases retained for scope resolution when a game has no approved query object.';
|
||||||
|
COMMENT ON COLUMN sg_game_scope_registry.game_key IS
|
||||||
|
'Stable game identifier used only by the DB-backed scope contract.';
|
||||||
|
COMMENT ON COLUMN sg_game_scope_registry.game_alias IS
|
||||||
|
'Question text alias matched by the resolver before any SQL worker is called.';
|
||||||
|
COMMENT ON COLUMN sg_game_scope_registry.game_prefix IS
|
||||||
|
'Optional data-object prefix. The scope view derives approved object availability from it.';
|
||||||
|
COMMENT ON COLUMN sg_game_scope_registry.active_yn IS
|
||||||
|
'Whether the game is eligible for scope resolution; inactive games are never executable.';
|
||||||
|
COMMENT ON COLUMN sg_game_scope_registry.alias_priority IS
|
||||||
|
'Database-defined ordering used to resolve overlapping aliases without application constants.';
|
||||||
|
COMMENT ON COLUMN sg_game_query_scope_v.profile_name IS
|
||||||
|
'Select AI profile whose current approved object list was used for this scope decision.';
|
||||||
|
COMMENT ON COLUMN sg_game_query_scope_v.query_allowed_yn IS
|
||||||
|
'Y only when the active game has at least one current Select AI approved and valid prefix-specific table or view.';
|
||||||
|
COMMENT ON COLUMN sg_game_query_scope_v.reason_code IS
|
||||||
|
'Database-derived explanation for scope availability returned to the MCP agent.';
|
||||||
36
sql/adb/78_sgmp_game_alias_logical_joins.sql
Normal file
36
sql/adb/78_sgmp_game_alias_logical_joins.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- Logical game-alias join guidance for Select AI.
|
||||||
|
--
|
||||||
|
-- COMN_GAME_ALIAS_BAS intentionally has multiple alias rows per GAME_ID, so
|
||||||
|
-- GAME_ID cannot be modelled as a physical foreign key to that table. These
|
||||||
|
-- annotations describe the safe semantic relationship without asserting a
|
||||||
|
-- false database constraint or creating fan-out aggregation errors.
|
||||||
|
|
||||||
|
DECLARE v_result VARCHAR2(4000); BEGIN
|
||||||
|
v_result := sgmp_set_annotation(
|
||||||
|
'SGMP_POC', 'TABLE', 'COMN_SALES_TXN', NULL,
|
||||||
|
'Logical game filter: COMN_SALES_TXN.GAME_ID is resolved through COMN_GAME_ALIAS_BAS. GAME_ID is not unique in the alias table because one game can have multiple aliases. For a game-name filter, use EXISTS against active aliases or join a DISTINCT GAME_ID alias subquery. Do not directly join all alias rows before SUM or COUNT because that can multiply fact rows.',
|
||||||
|
'GAME_ALIAS_JOIN'
|
||||||
|
);
|
||||||
|
dbms_output.put_line(v_result);
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
DECLARE v_result VARCHAR2(4000); BEGIN
|
||||||
|
v_result := sgmp_set_annotation(
|
||||||
|
'SGMP_POC', 'TABLE', 'COMN_REFUND_TXN', NULL,
|
||||||
|
'Logical game filter: COMN_REFUND_TXN.GAME_ID is resolved through COMN_GAME_ALIAS_BAS. GAME_ID is not unique in the alias table because one game can have multiple aliases. For a game-name filter, use EXISTS against active aliases or join a DISTINCT GAME_ID alias subquery. Do not directly join all alias rows before SUM or COUNT because that can multiply fact rows.',
|
||||||
|
'GAME_ALIAS_JOIN'
|
||||||
|
);
|
||||||
|
dbms_output.put_line(v_result);
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
DECLARE v_result VARCHAR2(4000); BEGIN
|
||||||
|
v_result := sgmp_set_annotation(
|
||||||
|
'SGMP_POC', 'TABLE', 'COMN_SALES_PRODUCT_DISP_BAS', NULL,
|
||||||
|
'Logical product scope: COMN_SALES_PRODUCT_DISP_BAS is keyed by GAME_ID and PRODUCT_ID. Resolve a natural-language game through COMN_GAME_ALIAS_BAS using EXISTS or a DISTINCT GAME_ID alias subquery before joining product data to transaction facts. The alias table has multiple aliases per GAME_ID and is not a physical foreign-key parent.',
|
||||||
|
'GAME_ALIAS_JOIN'
|
||||||
|
);
|
||||||
|
dbms_output.put_line(v_result);
|
||||||
|
END;
|
||||||
|
/
|
||||||
@@ -130,6 +130,69 @@ begin
|
|||||||
)');
|
)');
|
||||||
create_if_missing('create sequence sg_permission_seq start with 1 increment by 1 nocache');
|
create_if_missing('create sequence sg_permission_seq start with 1 increment by 1 nocache');
|
||||||
create_if_missing('create sequence sg_permission_rule_seq start with 1 increment by 1 nocache');
|
create_if_missing('create sequence sg_permission_rule_seq start with 1 increment by 1 nocache');
|
||||||
|
create_if_missing('create table sg_vpd_policy_note (
|
||||||
|
object_owner varchar2(128) not null,
|
||||||
|
object_name varchar2(128) not null,
|
||||||
|
policy_name varchar2(128) not null,
|
||||||
|
description varchar2(2000),
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_vpd_policy_note_pk primary key (object_owner, object_name, policy_name)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_vpd_filter_note (
|
||||||
|
function_owner varchar2(128) not null,
|
||||||
|
function_name varchar2(128) not null,
|
||||||
|
description varchar2(2000),
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_vpd_filter_note_pk primary key (function_owner, function_name)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_masking_rule (
|
||||||
|
rule_id number primary key,
|
||||||
|
rule_code varchar2(100) not null unique,
|
||||||
|
rule_name varchar2(200) not null,
|
||||||
|
template_code varchar2(100) not null,
|
||||||
|
description varchar2(2000),
|
||||||
|
enabled_yn char(1) default ''Y'' not null,
|
||||||
|
created_at timestamp default systimestamp not null,
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_masking_rule_enabled_ck check (enabled_yn in (''Y'', ''N''))
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_access_bearer_token (
|
||||||
|
key_id number primary key, user_id number not null, key_prefix varchar2(100) not null,
|
||||||
|
key_hash varchar2(256) not null unique, expires_at timestamp not null, revoked_at timestamp,
|
||||||
|
description varchar2(500), created_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_access_bearer_token_user_fk foreign key (user_id) references sg_app_user(user_id)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_backoffice_setting (
|
||||||
|
setting_key varchar2(200) primary key, setting_value varchar2(4000),
|
||||||
|
updated_at timestamp default systimestamp not null
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_vector_document_chunk (
|
||||||
|
chunk_id number primary key, document_id varchar2(200) not null, chunk_no number not null,
|
||||||
|
content clob not null, embedding vector(1536, float32), created_at timestamp default systimestamp not null
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_vector_document_tag (
|
||||||
|
chunk_id number not null, tech_tag varchar2(200) not null,
|
||||||
|
constraint sg_vector_document_tag_pk primary key (chunk_id, tech_tag),
|
||||||
|
constraint sg_vector_document_tag_chunk_fk foreign key (chunk_id) references sg_vector_document_chunk(chunk_id)
|
||||||
|
)');
|
||||||
|
create_if_missing('create sequence sg_vector_chunk_seq start with 1 increment by 1 nocache');
|
||||||
|
create_if_missing('create table sg_column_masking_rule (
|
||||||
|
column_id number primary key,
|
||||||
|
rule_id number not null,
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_column_masking_rule_column_fk foreign key (column_id) references sg_protected_column(column_id),
|
||||||
|
constraint sg_column_masking_rule_rule_fk foreign key (rule_id) references sg_masking_rule(rule_id)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_user_masking_rule (
|
||||||
|
user_id number not null,
|
||||||
|
column_id number not null,
|
||||||
|
decision varchar2(30) not null,
|
||||||
|
active_yn char(1) default ''Y'' not null,
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_user_masking_rule_pk primary key (user_id, column_id),
|
||||||
|
constraint sg_user_masking_rule_user_fk foreign key (user_id) references sg_app_user(user_id),
|
||||||
|
constraint sg_user_masking_rule_column_fk foreign key (column_id) references sg_protected_column(column_id)
|
||||||
|
)');
|
||||||
end;
|
end;
|
||||||
/
|
/
|
||||||
|
|
||||||
@@ -168,3 +231,99 @@ merge into sg_group_role t using (select 2001 group_id, 3002 role_id from dual)
|
|||||||
on (t.group_id=s.group_id and t.role_id=s.role_id) when not matched then insert (group_id, role_id) values (s.group_id, s.role_id);
|
on (t.group_id=s.group_id and t.role_id=s.role_id) when not matched then insert (group_id, role_id) values (s.group_id, s.role_id);
|
||||||
|
|
||||||
commit;
|
commit;
|
||||||
|
|
||||||
|
-- Compatibility layer for remaining backoffice modules. The data is stored
|
||||||
|
-- only in SG_* tables; these views prevent older controller paths from
|
||||||
|
-- querying non-existent CB_* physical tables during the Smilegate transition.
|
||||||
|
create or replace view cb_app_user as
|
||||||
|
select user_id, user_name, employee_no, dept_code, can_read_contents, active from sg_app_user;
|
||||||
|
create or replace view cb_app_group as
|
||||||
|
select group_id, group_code, group_name, description, active_yn from sg_app_group;
|
||||||
|
create or replace view cb_app_role as
|
||||||
|
select role_id, role_name, max_sensitivity_level from sg_app_role;
|
||||||
|
create or replace view cb_user_role as select user_id, role_id from sg_user_role;
|
||||||
|
create or replace view cb_user_group as select group_id, user_id from sg_user_group;
|
||||||
|
create or replace view cb_group_role as select group_id, role_id from sg_group_role;
|
||||||
|
create or replace view cb_protected_object as
|
||||||
|
select object_id, owner, object_name, ords_path, enabled_yn, description from sg_protected_object;
|
||||||
|
create or replace view cb_protected_column as
|
||||||
|
select column_id, object_id, column_name, sensitive_yn, visible_role_id, sensitivity_level, redaction_method from sg_protected_column;
|
||||||
|
create or replace view cb_permission as
|
||||||
|
select perm_id, role_id, target_name, action_name, permission_effect from sg_permission;
|
||||||
|
create or replace view cb_permission_rule as
|
||||||
|
select rule_id, perm_id, rule_column, rule_type, rule_value from sg_permission_rule;
|
||||||
|
create or replace view cb_permission_column as
|
||||||
|
select permission_id, column_name from sg_permission_column;
|
||||||
|
create or replace view cb_vpd_policy_note as
|
||||||
|
select object_owner, object_name, policy_name, description, updated_at from sg_vpd_policy_note;
|
||||||
|
create or replace view cb_vpd_filter_note as
|
||||||
|
select function_owner, function_name, description, updated_at from sg_vpd_filter_note;
|
||||||
|
create or replace view cb_masking_rule as
|
||||||
|
select rule_id, rule_code, rule_name, template_code, description, enabled_yn from sg_masking_rule;
|
||||||
|
create or replace view cb_column_masking_rule as
|
||||||
|
select column_id, rule_id, updated_at from sg_column_masking_rule;
|
||||||
|
create or replace view cb_user_masking_rule as
|
||||||
|
select user_id, column_id, decision, active_yn, updated_at from sg_user_masking_rule;
|
||||||
|
create or replace view cb_ords_probe_audit as
|
||||||
|
select audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at from sg_audit_event;
|
||||||
|
create or replace view cb_backoffice_setting as
|
||||||
|
select setting_key, setting_value, updated_at from sg_backoffice_setting;
|
||||||
|
|
||||||
|
-- PoC administrator access: both demo operators can manage and query every
|
||||||
|
-- Smilegate game-data object registered in SGMP_POC.
|
||||||
|
merge into sg_app_role t
|
||||||
|
using (select 3099 role_id, 'DATA_AI_POC_ADMIN' role_name,
|
||||||
|
'Full access to all Smilegate PoC game-data objects' description,
|
||||||
|
'RESTRICTED' max_sensitivity_level from dual) s
|
||||||
|
on (t.role_id = s.role_id)
|
||||||
|
when matched then update set t.role_name=s.role_name, t.description=s.description,
|
||||||
|
t.max_sensitivity_level=s.max_sensitivity_level, t.updated_at=systimestamp
|
||||||
|
when not matched then insert (role_id, role_name, description, max_sensitivity_level)
|
||||||
|
values (s.role_id, s.role_name, s.description, s.max_sensitivity_level);
|
||||||
|
|
||||||
|
merge into sg_user_role t
|
||||||
|
using (select 1001 user_id, 3099 role_id from dual union all select 1002, 3099 from dual) s
|
||||||
|
on (t.user_id=s.user_id and t.role_id=s.role_id)
|
||||||
|
when not matched then insert (user_id, role_id) values (s.user_id, s.role_id);
|
||||||
|
|
||||||
|
declare
|
||||||
|
l_object_id number;
|
||||||
|
l_permission_id number;
|
||||||
|
begin
|
||||||
|
for source_object in (
|
||||||
|
select table_name
|
||||||
|
from all_tables
|
||||||
|
where owner = 'SGMP_POC'
|
||||||
|
and table_name not in ('SEMANTIC_METADATA_CHANGE_LOG', 'SGMP_TERM_CONTEXT_CACHE',
|
||||||
|
'SGMP_TERM_DICTIONARY', 'SGMP_TERM_SEARCH_LOG', 'SGMP_TERM_SYNONYM')
|
||||||
|
order by table_name
|
||||||
|
) loop
|
||||||
|
begin
|
||||||
|
select object_id into l_object_id
|
||||||
|
from sg_protected_object
|
||||||
|
where owner = 'SGMP_POC' and object_name = source_object.table_name;
|
||||||
|
exception
|
||||||
|
when no_data_found then
|
||||||
|
select nvl(max(object_id), 0) + 1 into l_object_id from sg_protected_object;
|
||||||
|
insert into sg_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description)
|
||||||
|
values (l_object_id, 'SGMP_POC', source_object.table_name,
|
||||||
|
'/sgmp-poc/' || lower(source_object.table_name), 'Y',
|
||||||
|
'Smilegate PoC game-data object');
|
||||||
|
end;
|
||||||
|
|
||||||
|
begin
|
||||||
|
select perm_id into l_permission_id
|
||||||
|
from sg_permission
|
||||||
|
where role_id = 3099 and target_name = source_object.table_name;
|
||||||
|
exception
|
||||||
|
when no_data_found then
|
||||||
|
l_permission_id := sg_permission_seq.nextval;
|
||||||
|
insert into sg_permission (perm_id, role_id, target_name, action_name, permission_effect)
|
||||||
|
values (l_permission_id, 3099, source_object.table_name, 'SELECT', 'ALLOW');
|
||||||
|
insert into sg_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||||
|
values (sg_permission_rule_seq.nextval, l_permission_id, null, 'ALL', null);
|
||||||
|
end;
|
||||||
|
end loop;
|
||||||
|
commit;
|
||||||
|
end;
|
||||||
|
/
|
||||||
@@ -6,7 +6,15 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(BackofficeProperties.class)
|
@EnableConfigurationProperties({
|
||||||
|
BackofficeProperties.class,
|
||||||
|
CatalogProperties.class,
|
||||||
|
GameScopeProperties.class,
|
||||||
|
MaskingProperties.class,
|
||||||
|
McpProperties.class,
|
||||||
|
ProductProperties.class,
|
||||||
|
SecuritySqlScriptProperties.class
|
||||||
|
})
|
||||||
public class AppConfig {
|
public class AppConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ public record BackofficeProperties(
|
|||||||
Security security,
|
Security security,
|
||||||
Token token,
|
Token token,
|
||||||
Ords ords,
|
Ords ords,
|
||||||
Ai ai
|
Ai ai,
|
||||||
|
SelectAi selectAi
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public record Security(
|
public record Security(
|
||||||
@@ -72,4 +73,22 @@ public record BackofficeProperties(
|
|||||||
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
|
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */
|
||||||
|
public record SelectAi(
|
||||||
|
String dbUrl,
|
||||||
|
String dbUsername,
|
||||||
|
String dbPassword,
|
||||||
|
String profile,
|
||||||
|
Boolean fewShotEnabled,
|
||||||
|
Integer fewShotTopK
|
||||||
|
) {
|
||||||
|
|
||||||
|
public boolean configured() {
|
||||||
|
return dbUrl != null && !dbUrl.isBlank()
|
||||||
|
&& dbUsername != null && !dbUsername.isBlank()
|
||||||
|
&& dbPassword != null && !dbPassword.isBlank()
|
||||||
|
&& profile != null && !profile.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "backoffice.catalog")
|
||||||
|
public record CatalogProperties(String owner, String objects) {
|
||||||
|
}
|
||||||
@@ -51,6 +51,6 @@ public class DbPoolWarmup {
|
|||||||
groupService.findGroupRoles();
|
groupService.findGroupRoles();
|
||||||
permissionService.findRoles();
|
permissionService.findRoles();
|
||||||
permissionService.findPermissionViews();
|
permissionService.findPermissionViews();
|
||||||
log.info("HMM identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
|
log.info("Identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the customer-owned DB view used to resolve game query scopes.
|
||||||
|
*
|
||||||
|
* <p>The view is a stable integration contract, not a Smilegate-specific table.
|
||||||
|
* Each environment can expose its own game master, alias source, and approved
|
||||||
|
* object inventory behind this view without changing application code.</p>
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "backoffice.game-scope")
|
||||||
|
public record GameScopeProperties(
|
||||||
|
boolean enabled,
|
||||||
|
String viewName,
|
||||||
|
int maxScopes
|
||||||
|
) {
|
||||||
|
|
||||||
|
public boolean configured() {
|
||||||
|
return enabled && viewName != null && !viewName.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int resolvedMaxScopes() {
|
||||||
|
return maxScopes >= 1 && maxScopes <= 20 ? maxScopes : 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/** JSON configuration of database redaction policies this backoffice is allowed to manage. */
|
||||||
|
@ConfigurationProperties(prefix = "backoffice.masking")
|
||||||
|
public record MaskingProperties(String policies) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/** Product-neutral labels and endpoint details for the MCP Select AI tool. */
|
||||||
|
@ConfigurationProperties(prefix = "backoffice.mcp")
|
||||||
|
public record McpProperties(
|
||||||
|
String toolName,
|
||||||
|
String toolLabel,
|
||||||
|
String toolDescription,
|
||||||
|
String promptDescription,
|
||||||
|
String showpromptToolName,
|
||||||
|
String showpromptToolLabel,
|
||||||
|
String showpromptToolDescription,
|
||||||
|
String qaVectorSearchToolName,
|
||||||
|
String qaVectorSearchToolLabel,
|
||||||
|
String qaVectorSearchToolDescription,
|
||||||
|
String qaVectorStoreToolName,
|
||||||
|
String qaVectorStoreToolLabel,
|
||||||
|
String qaVectorStoreToolDescription,
|
||||||
|
String fewShotNl2SqlToolName,
|
||||||
|
String fewShotNl2SqlToolLabel,
|
||||||
|
String fewShotNl2SqlToolDescription,
|
||||||
|
String gameScopeToolName,
|
||||||
|
String gameScopeToolLabel,
|
||||||
|
String gameScopeToolDescription
|
||||||
|
) {
|
||||||
|
|
||||||
|
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
|
||||||
|
private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL";
|
||||||
|
private static final String DEFAULT_TOOL_DESCRIPTION =
|
||||||
|
"승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. "
|
||||||
|
+ "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.";
|
||||||
|
private static final String DEFAULT_PROMPT_DESCRIPTION =
|
||||||
|
"업무 데이터에서 조회할 내용을 자연어로 입력합니다.";
|
||||||
|
private static final String DEFAULT_SHOWPROMPT_TOOL_NAME =
|
||||||
|
"oracle.select_ai.data_showprompt";
|
||||||
|
private static final String DEFAULT_SHOWPROMPT_TOOL_LABEL =
|
||||||
|
"업무 데이터 SHOWPROMPT";
|
||||||
|
private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION =
|
||||||
|
"Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. "
|
||||||
|
+ "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.";
|
||||||
|
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME =
|
||||||
|
"oracle.select_ai.qa_vector_search";
|
||||||
|
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL = "Select AI 예제 SQL 조회";
|
||||||
|
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION =
|
||||||
|
"현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. "
|
||||||
|
+ "반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다.";
|
||||||
|
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_NAME =
|
||||||
|
"oracle.select_ai.qa_vector_store";
|
||||||
|
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_LABEL = "Select AI 예제 SQL 저장";
|
||||||
|
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION =
|
||||||
|
"검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. "
|
||||||
|
+ "질문과 읽기 전용 답 SQL이 필요합니다.";
|
||||||
|
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME =
|
||||||
|
"oracle.select_ai.smilegate_fewshot_nl2sql";
|
||||||
|
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL =
|
||||||
|
"Few-shot NL2SQL 실행";
|
||||||
|
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION =
|
||||||
|
"벡터 Few-shot 예제를 찾아 prompt에 반영하고, SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. "
|
||||||
|
+ "Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다.";
|
||||||
|
private static final String DEFAULT_GAME_SCOPE_TOOL_NAME =
|
||||||
|
"oracle.select_ai.game_scope_resolve";
|
||||||
|
private static final String DEFAULT_GAME_SCOPE_TOOL_LABEL = "게임 조회 범위 확인";
|
||||||
|
private static final String DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION =
|
||||||
|
"질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다. 데이터 SQL은 실행하지 않으며, "
|
||||||
|
+ "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.";
|
||||||
|
|
||||||
|
public String resolvedToolName() {
|
||||||
|
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedToolLabel() {
|
||||||
|
return requiredOrDefault(toolLabel, DEFAULT_TOOL_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedToolDescription() {
|
||||||
|
return requiredOrDefault(toolDescription, DEFAULT_TOOL_DESCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedPromptDescription() {
|
||||||
|
return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedShowpromptToolName() {
|
||||||
|
return requiredOrDefault(showpromptToolName, DEFAULT_SHOWPROMPT_TOOL_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedShowpromptToolLabel() {
|
||||||
|
return requiredOrDefault(showpromptToolLabel, DEFAULT_SHOWPROMPT_TOOL_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedShowpromptToolDescription() {
|
||||||
|
return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedQaVectorSearchToolName() {
|
||||||
|
return requiredOrDefault(qaVectorSearchToolName, DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedQaVectorSearchToolLabel() {
|
||||||
|
return requiredOrDefault(qaVectorSearchToolLabel, DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedQaVectorSearchToolDescription() {
|
||||||
|
return requiredOrDefault(qaVectorSearchToolDescription, DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedQaVectorStoreToolName() {
|
||||||
|
return requiredOrDefault(qaVectorStoreToolName, DEFAULT_QA_VECTOR_STORE_TOOL_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedQaVectorStoreToolLabel() {
|
||||||
|
return requiredOrDefault(qaVectorStoreToolLabel, DEFAULT_QA_VECTOR_STORE_TOOL_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedQaVectorStoreToolDescription() {
|
||||||
|
return requiredOrDefault(qaVectorStoreToolDescription, DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedFewShotNl2SqlToolName() {
|
||||||
|
return requiredOrDefault(fewShotNl2SqlToolName, DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedFewShotNl2SqlToolLabel() {
|
||||||
|
return requiredOrDefault(fewShotNl2SqlToolLabel, DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedFewShotNl2SqlToolDescription() {
|
||||||
|
return requiredOrDefault(fewShotNl2SqlToolDescription, DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedGameScopeToolName() {
|
||||||
|
return requiredOrDefault(gameScopeToolName, DEFAULT_GAME_SCOPE_TOOL_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedGameScopeToolLabel() {
|
||||||
|
return requiredOrDefault(gameScopeToolLabel, DEFAULT_GAME_SCOPE_TOOL_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvedGameScopeToolDescription() {
|
||||||
|
return requiredOrDefault(gameScopeToolDescription, DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredOrDefault(String value, String fallback) {
|
||||||
|
return value == null || value.isBlank() ? fallback : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "backoffice.product")
|
||||||
|
public record ProductProperties(String name, String title, String dataLabel) {
|
||||||
|
public String displayName() { return name == null || name.isBlank() ? "Data & AI Backoffice" : name; }
|
||||||
|
public String pageTitle() { return title == null || title.isBlank() ? displayName() : title; }
|
||||||
|
public String dataName() { return dataLabel == null || dataLabel.isBlank() ? "업무 데이터" : dataLabel; }
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ public class SecurityConfig {
|
|||||||
.rememberMeCookieName("VPD_REMEMBER_ME")
|
.rememberMeCookieName("VPD_REMEMBER_ME")
|
||||||
.tokenValiditySeconds(security.rememberMeValiditySeconds())
|
.tokenValiditySeconds(security.rememberMeValiditySeconds())
|
||||||
.useSecureCookie(true)
|
.useSecureCookie(true)
|
||||||
.alwaysRemember(false));
|
.alwaysRemember(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
return http
|
return http
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/** Deployment-provided allow-list for bundled security SQL shown by the backoffice. */
|
||||||
|
@ConfigurationProperties(prefix = "backoffice.security-sql-scripts")
|
||||||
|
public record SecuritySqlScriptProperties(String scripts) {
|
||||||
|
}
|
||||||
@@ -27,10 +27,10 @@ public enum MaskingTemplate {
|
|||||||
"A******** (예시)"),
|
"A******** (예시)"),
|
||||||
RRN_PARTIAL(
|
RRN_PARTIAL(
|
||||||
"RRN_PARTIAL",
|
"RRN_PARTIAL",
|
||||||
"주민등록번호 부분 마스킹",
|
"식별번호 부분 마스킹",
|
||||||
"앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.",
|
"앞 6자리만 표시하고 나머지를 가리는 사전 정의 식별번호 규칙입니다.",
|
||||||
"DBMS_REDACT.REGEXP",
|
"DBMS_REDACT.REGEXP",
|
||||||
"900101-******* (예시)");
|
"123456-******* (예시)");
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|||||||
@@ -50,16 +50,16 @@ public record PermissionView(
|
|||||||
return "토큰으로 식별된 이해관계자 본인 행";
|
return "토큰으로 식별된 이해관계자 본인 행";
|
||||||
}
|
}
|
||||||
if (upper.contains(" OWN_CONTRACT")) {
|
if (upper.contains(" OWN_CONTRACT")) {
|
||||||
return "담당 설계사 본인 계약";
|
return "담당 게임 서비스 범위";
|
||||||
}
|
}
|
||||||
if (upper.contains(" CHANNEL_CONTRACT")) {
|
if (upper.contains(" CHANNEL_CONTRACT")) {
|
||||||
return "토큰 사용자의 채널 계약";
|
return "토큰 채널 게임 서비스 범위";
|
||||||
}
|
}
|
||||||
if (upper.contains(" OWN_CUSTOMER")) {
|
if (upper.contains(" OWN_CUSTOMER")) {
|
||||||
return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유";
|
return "담당 게임 사용자·거래 데이터 범위";
|
||||||
}
|
}
|
||||||
if (upper.contains(" CHANNEL_CUSTOMER")) {
|
if (upper.contains(" CHANNEL_CUSTOMER")) {
|
||||||
return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유";
|
return "토큰 채널 게임 사용자·거래 데이터 범위";
|
||||||
}
|
}
|
||||||
if (upper.contains(" STATIC_SQL ")) {
|
if (upper.contains(" STATIC_SQL ")) {
|
||||||
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
|
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.domain.structured;
|
|||||||
public record StructuredDataTable(
|
public record StructuredDataTable(
|
||||||
String key,
|
String key,
|
||||||
String tableName,
|
String tableName,
|
||||||
|
String objectType,
|
||||||
String businessName,
|
String businessName,
|
||||||
String description
|
String description
|
||||||
) {
|
) {
|
||||||
|
public StructuredDataTable(String key, String tableName, String businessName, String description) {
|
||||||
|
this(key, tableName, "TABLE", businessName, description);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
|
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
@@ -28,7 +29,10 @@ public interface MaskingRuleMapper {
|
|||||||
|
|
||||||
List<ColumnMaskingRule> findColumnRules();
|
List<ColumnMaskingRule> findColumnRules();
|
||||||
|
|
||||||
List<MaskingPolicyStatus> findPolicyStatuses();
|
List<MaskingPolicyStatus> findPolicyStatuses(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("targets") List<MaskingPolicyTarget> targets
|
||||||
|
);
|
||||||
|
|
||||||
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
|
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -258,8 +258,8 @@ public class BackofficeSchemaService {
|
|||||||
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
|
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
|
||||||
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
|
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
|
||||||
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
|
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
|
||||||
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
|
new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
|
||||||
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
|
"앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
|
||||||
);
|
);
|
||||||
|
|
||||||
private static final String MASKING_RULE_SEED_SQL = """
|
private static final String MASKING_RULE_SEED_SQL = """
|
||||||
@@ -272,10 +272,16 @@ public class BackofficeSchemaService {
|
|||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final BackofficeProperties properties;
|
private final BackofficeProperties properties;
|
||||||
|
private final DataCatalog dataCatalog;
|
||||||
|
|
||||||
public BackofficeSchemaService(JdbcTemplate jdbcTemplate, BackofficeProperties properties) {
|
public BackofficeSchemaService(
|
||||||
|
JdbcTemplate jdbcTemplate,
|
||||||
|
BackofficeProperties properties,
|
||||||
|
DataCatalog dataCatalog
|
||||||
|
) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
|
this.dataCatalog = dataCatalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SchemaPreflightView preflight() {
|
public SchemaPreflightView preflight() {
|
||||||
@@ -705,7 +711,7 @@ public class BackofficeSchemaService {
|
|||||||
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
|
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
|
||||||
@sql/adb/25_agent_ords_security_backoffice_support.sql
|
@sql/adb/25_agent_ords_security_backoffice_support.sql
|
||||||
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
|
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
|
||||||
@sql/adb/62_kb_aso_masking_backoffice_metadata.sql
|
-- 4. 배포 환경에서 선택한 사용자·권한 초기화 SQL을 별도로 실행
|
||||||
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
|
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
|
||||||
|
|
||||||
-- 2. ORDS parsing schema로 접속
|
-- 2. ORDS parsing schema로 접속
|
||||||
@@ -717,10 +723,9 @@ public class BackofficeSchemaService {
|
|||||||
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
||||||
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
|
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
|
||||||
|
|
||||||
-- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행
|
-- 5. 마스킹 규칙을 UI에서 등록된 업무 데이터 컬럼에 연결
|
||||||
@sql/adb/64_kb_aso_masking_default_column_rules.sql
|
-- DBMS_REDACT 정책은 백오피스가 %s 대상에 자동 동기화합니다.
|
||||||
@sql/adb/63_kb_aso_masking_rule_runtime.sql
|
""".formatted(owner.toLowerCase(), dataCatalog.owner());
|
||||||
""".formatted(owner.toLowerCase());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void appendSql(StringBuilder builder, String sql) {
|
private void appendSql(StringBuilder builder, String sql) {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface DataCatalog {
|
||||||
|
String owner();
|
||||||
|
List<StructuredDataTable> objects();
|
||||||
|
StructuredDataTable require(String key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EnvironmentDataCatalog implements DataCatalog {
|
||||||
|
private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||||
|
private static final Pattern KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}");
|
||||||
|
private final String owner;
|
||||||
|
private final List<StructuredDataTable> objects;
|
||||||
|
|
||||||
|
public EnvironmentDataCatalog(CatalogProperties properties, ObjectMapper mapper) {
|
||||||
|
owner = requireName(properties.owner());
|
||||||
|
objects = parse(properties.objects(), mapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public String owner() { return owner; }
|
||||||
|
@Override public List<StructuredDataTable> objects() { return objects; }
|
||||||
|
@Override public StructuredDataTable require(String key) {
|
||||||
|
return objects.stream().filter(item -> item.key().equals(key)).findFirst()
|
||||||
|
.orElseThrow(() -> new AppException("선택할 수 없는 카탈로그 객체입니다."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<StructuredDataTable> parse(String raw, ObjectMapper mapper) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<StructuredDataTable> values = mapper.readValue(raw, new TypeReference<>() {});
|
||||||
|
if (values.isEmpty() || values.stream().map(StructuredDataTable::key).distinct().count() != values.size()) throw new IllegalArgumentException();
|
||||||
|
values.forEach(this::validate);
|
||||||
|
return List.copyOf(values);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void validate(StructuredDataTable value) {
|
||||||
|
if (value == null || value.key() == null || !KEY.matcher(value.key()).matches()
|
||||||
|
|| !NAME.matcher(value.tableName().toUpperCase(Locale.ROOT)).matches()
|
||||||
|
|| !("TABLE".equalsIgnoreCase(value.objectType()) || "VIEW".equalsIgnoreCase(value.objectType()))) throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
private String requireName(String value) {
|
||||||
|
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (!NAME.matcher(normalized).matches()) throw new IllegalStateException("BACKOFFICE_CATALOG_OWNER 설정을 확인하세요.");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.MaskingProperties;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Loads the managed redaction policy allow-list from BACKOFFICE_MASKING_POLICIES. */
|
||||||
|
@Service
|
||||||
|
public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog {
|
||||||
|
private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||||
|
private final List<MaskingPolicyTarget> targets;
|
||||||
|
|
||||||
|
public EnvironmentMaskingPolicyCatalog(MaskingProperties properties, ObjectMapper objectMapper) {
|
||||||
|
targets = parse(properties.policies(), objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<MaskingPolicyTarget> targets() {
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MaskingPolicyTarget> parse(String raw, ObjectMapper objectMapper) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<MaskingPolicyTarget> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||||
|
if (parsed.isEmpty()
|
||||||
|
|| parsed.stream().map(MaskingPolicyTarget::objectName).distinct().count() != parsed.size()) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
List<MaskingPolicyTarget> normalized = parsed.stream()
|
||||||
|
.map(item -> new MaskingPolicyTarget(normalize(item.objectName()), normalize(item.policyName())))
|
||||||
|
.toList();
|
||||||
|
return List.copyOf(normalized);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new IllegalStateException("BACKOFFICE_MASKING_POLICIES 설정을 확인하세요.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String value) {
|
||||||
|
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (!NAME.matcher(normalized).matches()) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.GameScopeProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves only DB-declared game scopes before an agent invokes a SQL worker.
|
||||||
|
*
|
||||||
|
* <p>The service never infers a game, table, prefix, or support status. It reads
|
||||||
|
* the configured customer view and returns only aliases physically present in the
|
||||||
|
* question. This makes unavailable games observable when they are retained in
|
||||||
|
* the customer's game master, while keeping product-specific data out of code.</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class GameScopeService {
|
||||||
|
|
||||||
|
private static final int MAX_QUESTION_LENGTH = 4_000;
|
||||||
|
private static final Pattern VIEW_NAME = Pattern.compile(
|
||||||
|
"^[A-Za-z][A-Za-z0-9_$#]*(?:\\.[A-Za-z][A-Za-z0-9_$#]*)?$");
|
||||||
|
|
||||||
|
private final BackofficeProperties properties;
|
||||||
|
private final GameScopeProperties gameScopeProperties;
|
||||||
|
private final BearerTokenService bearerTokenService;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public GameScopeService(
|
||||||
|
BackofficeProperties properties,
|
||||||
|
GameScopeProperties gameScopeProperties,
|
||||||
|
BearerTokenService bearerTokenService,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.gameScopeProperties = gameScopeProperties;
|
||||||
|
this.bearerTokenService = bearerTokenService;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameScopeResult resolve(String bearerToken, String question) {
|
||||||
|
requireActiveToken(bearerToken);
|
||||||
|
String normalizedQuestion = requiredQuestion(question);
|
||||||
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||||
|
String viewName = requiredViewName();
|
||||||
|
String comparableQuestion = comparable(normalizedQuestion);
|
||||||
|
|
||||||
|
List<GameScope> matches = new ArrayList<>();
|
||||||
|
String sql = "SELECT game_key, display_name, game_alias, query_allowed_yn, reason_code, "
|
||||||
|
+ "alias_priority, scope_version FROM " + viewName
|
||||||
|
+ " WHERE profile_name = ?"
|
||||||
|
+ " AND INSTR(?, REGEXP_REPLACE(UPPER(game_alias), '[[:space:][:punct:]]', '')) > 0"
|
||||||
|
+ " ORDER BY alias_priority DESC, LENGTH(game_alias) DESC, game_key";
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||||
|
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setString(1, selectAi.profile());
|
||||||
|
statement.setString(2, comparableQuestion);
|
||||||
|
try (ResultSet rows = statement.executeQuery()) {
|
||||||
|
while (rows.next()) {
|
||||||
|
matches.add(new GameScope(
|
||||||
|
rows.getString("GAME_KEY"),
|
||||||
|
rows.getString("DISPLAY_NAME"),
|
||||||
|
rows.getString("GAME_ALIAS"),
|
||||||
|
"Y".equalsIgnoreCase(rows.getString("QUERY_ALLOWED_YN")) ? "SUPPORTED" : "UNSUPPORTED",
|
||||||
|
rows.getString("REASON_CODE"),
|
||||||
|
rows.getInt("ALIAS_PRIORITY"),
|
||||||
|
rows.getString("SCOPE_VERSION")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("게임 범위 DB 조회 실패: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, GameScope> uniqueGames = new LinkedHashMap<>();
|
||||||
|
for (GameScope match : matches) {
|
||||||
|
uniqueGames.putIfAbsent(match.gameKey(), match);
|
||||||
|
}
|
||||||
|
List<GameScope> scopes = uniqueGames.values().stream()
|
||||||
|
.sorted(Comparator.comparing(GameScope::aliasPriority).reversed()
|
||||||
|
.thenComparing(GameScope::gameKey))
|
||||||
|
.limit(gameScopeProperties.resolvedMaxScopes())
|
||||||
|
.toList();
|
||||||
|
String status = scopes.isEmpty() ? "NO_MATCH" : "RESOLVED";
|
||||||
|
return new GameScopeResult(normalizedQuestion, status, scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when a generated SQL references a DB-declared game-prefix
|
||||||
|
* object. Prefixes are read from the configured scope view; no game or table
|
||||||
|
* name is embedded in application code.
|
||||||
|
*/
|
||||||
|
public boolean referencesGameScopedObject(String bearerToken, String sql) {
|
||||||
|
requireActiveToken(bearerToken);
|
||||||
|
if (!gameScopeProperties.configured() || sql == null || sql.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||||
|
String viewName = requiredViewName();
|
||||||
|
String prefixSql = "SELECT DISTINCT game_prefix FROM " + viewName
|
||||||
|
+ " WHERE profile_name = ? AND active_yn = 'Y'"
|
||||||
|
+ " AND query_allowed_yn = 'Y' AND game_prefix IS NOT NULL";
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||||
|
PreparedStatement statement = connection.prepareStatement(prefixSql)) {
|
||||||
|
statement.setString(1, selectAi.profile());
|
||||||
|
try (ResultSet rows = statement.executeQuery()) {
|
||||||
|
String normalizedSql = sql.toUpperCase(Locale.ROOT);
|
||||||
|
while (rows.next()) {
|
||||||
|
String prefix = rows.getString("GAME_PREFIX");
|
||||||
|
if (prefix != null && Pattern.compile(
|
||||||
|
"(?<![A-Z0-9_$#])" + Pattern.quote(prefix.toUpperCase(Locale.ROOT))
|
||||||
|
+ "_[A-Z0-9_$#]+(?![A-Z0-9_$#])").matcher(normalizedSql).find()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("게임 범위 prefix 검증 실패: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredViewName() {
|
||||||
|
String value = gameScopeProperties == null ? "" : gameScopeProperties.viewName();
|
||||||
|
if (gameScopeProperties == null || !gameScopeProperties.configured() || !VIEW_NAME.matcher(value.trim()).matches()) {
|
||||||
|
throw new AppException("게임 범위 view 설정이 필요합니다. BACKOFFICE_GAME_SCOPE_VIEW를 확인하세요.");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BackofficeProperties.SelectAi requiredSelectAi() {
|
||||||
|
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||||
|
if (selectAi == null || !selectAi.configured()) {
|
||||||
|
throw new AppException("게임 범위 DB 연결 설정이 필요합니다.");
|
||||||
|
}
|
||||||
|
return selectAi;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireActiveToken(String bearerToken) {
|
||||||
|
if (bearerToken == null || bearerToken.isBlank()) {
|
||||||
|
throw new VpdTokenAccessDeniedException();
|
||||||
|
}
|
||||||
|
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
||||||
|
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||||
|
if (token == null || !token.active(now)) {
|
||||||
|
throw new VpdTokenAccessDeniedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredQuestion(String question) {
|
||||||
|
String value = question == null ? "" : question.trim();
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
throw new AppException("question은 필수입니다.");
|
||||||
|
}
|
||||||
|
if (value.length() > MAX_QUESTION_LENGTH) {
|
||||||
|
throw new AppException("question은 " + MAX_QUESTION_LENGTH + "자 이하여야 합니다.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String comparable(String value) {
|
||||||
|
return value.toUpperCase(Locale.ROOT).replaceAll("[\\s\\p{Punct}]", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GameScope(
|
||||||
|
String gameKey,
|
||||||
|
String displayName,
|
||||||
|
String matchedAlias,
|
||||||
|
String status,
|
||||||
|
String reasonCode,
|
||||||
|
int aliasPriority,
|
||||||
|
String scopeVersion
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record GameScopeResult(String question, String status, List<GameScope> scopes) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public interface MaskingPolicyCatalog {
|
||||||
|
List<MaskingPolicyTarget> targets();
|
||||||
|
|
||||||
|
default Set<String> objectNames() {
|
||||||
|
return targets().stream().map(MaskingPolicyTarget::objectName).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
default boolean containsObject(String objectName) {
|
||||||
|
return objectName != null && objectNames().contains(objectName.trim().toUpperCase(java.util.Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
|||||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -24,37 +23,31 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class MaskingPolicySynchronizer {
|
public class MaskingPolicySynchronizer {
|
||||||
|
|
||||||
private static final String OWNER = "POC_2";
|
|
||||||
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||||
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final MaskingRuleMapper mapper;
|
private final MaskingRuleMapper mapper;
|
||||||
|
private final DataCatalog dataCatalog;
|
||||||
|
private final MaskingPolicyCatalog policyCatalog;
|
||||||
|
|
||||||
public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) {
|
public MaskingPolicySynchronizer(
|
||||||
|
JdbcTemplate jdbcTemplate,
|
||||||
|
MaskingRuleMapper mapper,
|
||||||
|
DataCatalog dataCatalog,
|
||||||
|
MaskingPolicyCatalog policyCatalog
|
||||||
|
) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
}
|
this.dataCatalog = dataCatalog;
|
||||||
|
this.policyCatalog = policyCatalog;
|
||||||
private static Map<String, String> managedPolicyMap() {
|
|
||||||
Map<String, String> policies = new LinkedHashMap<>();
|
|
||||||
policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT");
|
|
||||||
policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT");
|
|
||||||
policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT");
|
|
||||||
policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT");
|
|
||||||
return Collections.unmodifiableMap(policies);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<String> managedObjectNames() {
|
public Set<String> managedObjectNames() {
|
||||||
return MANAGED_POLICIES.keySet();
|
return policyCatalog.objectNames();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isManagedObject(String objectName) {
|
public boolean isManagedObject(String objectName) {
|
||||||
return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
|
return policyCatalog.containsObject(objectName);
|
||||||
}
|
|
||||||
|
|
||||||
static String managedPolicyName(String objectName) {
|
|
||||||
return MANAGED_POLICIES.get(objectName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,9 +60,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
public MaskingPolicySyncResult synchronize() {
|
public MaskingPolicySyncResult synchronize() {
|
||||||
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
||||||
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
||||||
if (OWNER.equalsIgnoreCase(rule.owner())
|
if (dataCatalog.owner().equalsIgnoreCase(rule.owner())
|
||||||
&& rule.ruleEnabled()
|
&& rule.ruleEnabled()
|
||||||
&& MANAGED_POLICIES.containsKey(rule.objectName())) {
|
&& policyCatalog.containsObject(rule.objectName())) {
|
||||||
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,9 +72,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
int addedColumns = 0;
|
int addedColumns = 0;
|
||||||
int modifiedColumns = 0;
|
int modifiedColumns = 0;
|
||||||
int droppedColumns = 0;
|
int droppedColumns = 0;
|
||||||
for (Map.Entry<String, String> policy : MANAGED_POLICIES.entrySet()) {
|
for (MaskingPolicyTarget policy : policyCatalog.targets()) {
|
||||||
String objectName = policy.getKey();
|
String objectName = policy.objectName();
|
||||||
String policyName = policy.getValue();
|
String policyName = policy.policyName();
|
||||||
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
|
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
|
||||||
String enableStatus = policyEnableStatus(objectName, policyName);
|
String enableStatus = policyEnableStatus(objectName, policyName);
|
||||||
if (desired.isEmpty()) {
|
if (desired.isEmpty()) {
|
||||||
@@ -141,7 +134,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
SELECT enable
|
SELECT enable
|
||||||
FROM redaction_policies
|
FROM redaction_policies
|
||||||
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
|
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
|
||||||
""", String.class, OWNER, objectName, policyName);
|
""", String.class, dataCatalog.owner(), objectName, policyName);
|
||||||
return statuses.isEmpty() ? null : statuses.getFirst();
|
return statuses.isEmpty() ? null : statuses.getFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +143,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
SELECT column_name
|
SELECT column_name
|
||||||
FROM redaction_columns
|
FROM redaction_columns
|
||||||
WHERE object_owner = ? AND object_name = ?
|
WHERE object_owner = ? AND object_name = ?
|
||||||
""", String.class, OWNER, objectName).stream()
|
""", String.class, dataCatalog.owner(), objectName).stream()
|
||||||
.map(this::requiredColumnName)
|
.map(this::requiredColumnName)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -160,7 +153,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
BEGIN
|
BEGIN
|
||||||
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||||
END;
|
END;
|
||||||
""", OWNER, objectName, policyName);
|
""", dataCatalog.owner(), objectName, policyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void enablePolicy(String objectName, String policyName) {
|
private void enablePolicy(String objectName, String policyName) {
|
||||||
@@ -168,7 +161,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
BEGIN
|
BEGIN
|
||||||
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||||
END;
|
END;
|
||||||
""", OWNER, objectName, policyName);
|
""", dataCatalog.owner(), objectName, policyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void dropColumn(String objectName, String policyName, String columnName) {
|
private void dropColumn(String objectName, String policyName, String columnName) {
|
||||||
@@ -179,7 +172,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
""", OWNER, objectName, policyName, columnName);
|
""", dataCatalog.owner(), objectName, policyName, columnName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addPolicy(
|
private void addPolicy(
|
||||||
@@ -251,9 +244,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
END;
|
END;
|
||||||
""".formatted(functionConstant);
|
""".formatted(functionConstant);
|
||||||
if (regexPattern == null) {
|
if (regexPattern == null) {
|
||||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||||
} else {
|
} else {
|
||||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -276,9 +269,9 @@ public class MaskingPolicySynchronizer {
|
|||||||
END;
|
END;
|
||||||
""".formatted(actionConstant, functionConstant);
|
""".formatted(actionConstant, functionConstant);
|
||||||
if (regexPattern == null) {
|
if (regexPattern == null) {
|
||||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||||
} else {
|
} else {
|
||||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +307,7 @@ public class MaskingPolicySynchronizer {
|
|||||||
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
|
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
""", OWNER, objectName, columnName, expressionName);
|
""", dataCatalog.owner(), objectName, columnName, expressionName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
/** A validated object-to-redaction-policy mapping supplied by deployment configuration. */
|
||||||
|
public record MaskingPolicyTarget(String objectName, String policyName) {
|
||||||
|
}
|
||||||
@@ -28,19 +28,25 @@ public class MaskingRuleService {
|
|||||||
private final ProtectedObjectService protectedObjectService;
|
private final ProtectedObjectService protectedObjectService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final MaskingPolicySynchronizer maskingPolicySynchronizer;
|
private final MaskingPolicySynchronizer maskingPolicySynchronizer;
|
||||||
|
private final DataCatalog dataCatalog;
|
||||||
|
private final MaskingPolicyCatalog maskingPolicyCatalog;
|
||||||
|
|
||||||
public MaskingRuleService(
|
public MaskingRuleService(
|
||||||
MaskingRuleMapper mapper,
|
MaskingRuleMapper mapper,
|
||||||
UserMapper userMapper,
|
UserMapper userMapper,
|
||||||
ProtectedObjectService protectedObjectService,
|
ProtectedObjectService protectedObjectService,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
MaskingPolicySynchronizer maskingPolicySynchronizer
|
MaskingPolicySynchronizer maskingPolicySynchronizer,
|
||||||
|
DataCatalog dataCatalog,
|
||||||
|
MaskingPolicyCatalog maskingPolicyCatalog
|
||||||
) {
|
) {
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
this.protectedObjectService = protectedObjectService;
|
this.protectedObjectService = protectedObjectService;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
this.maskingPolicySynchronizer = maskingPolicySynchronizer;
|
this.maskingPolicySynchronizer = maskingPolicySynchronizer;
|
||||||
|
this.dataCatalog = dataCatalog;
|
||||||
|
this.maskingPolicyCatalog = maskingPolicyCatalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<MaskingRule> findAllRules() {
|
public List<MaskingRule> findAllRules() {
|
||||||
@@ -55,9 +61,12 @@ public class MaskingRuleService {
|
|||||||
return mapper.findColumnRules();
|
return mapper.findColumnRules();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the actual Oracle Data Redaction state for the three managed KB objects. */
|
/** Reads the actual Oracle Data Redaction state for configured managed objects. */
|
||||||
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
||||||
return mapper.findPolicyStatuses();
|
if (maskingPolicyCatalog.targets().isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return mapper.findPolicyStatuses(dataCatalog.owner(), maskingPolicyCatalog.targets());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<String> managedObjectNames() {
|
public Set<String> managedObjectNames() {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -8,31 +10,33 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */
|
/** MCP boundary exposing Select AI query and read-only prompt diagnostic tools. */
|
||||||
@Service
|
@Service
|
||||||
public class McpSseService {
|
public class McpSseService {
|
||||||
|
|
||||||
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd";
|
private static final String SELECT_AI_TOOL_PATH = "/mcp (tools/call)";
|
||||||
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
|
|
||||||
private static final String 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 final SelectAiAgentOrdsService selectAiAgentOrdsService;
|
private final SelectAiService selectAiService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final BackofficeProperties properties;
|
||||||
|
private final McpProperties mcpProperties;
|
||||||
|
private final QaVectorService qaVectorService;
|
||||||
|
private final GameScopeService gameScopeService;
|
||||||
|
|
||||||
public McpSseService(
|
public McpSseService(
|
||||||
SelectAiAgentOrdsService selectAiAgentOrdsService,
|
SelectAiService selectAiService,
|
||||||
ObjectMapper objectMapper
|
ObjectMapper objectMapper,
|
||||||
|
BackofficeProperties properties,
|
||||||
|
McpProperties mcpProperties,
|
||||||
|
QaVectorService qaVectorService,
|
||||||
|
GameScopeService gameScopeService
|
||||||
) {
|
) {
|
||||||
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
|
this.selectAiService = selectAiService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.properties = properties;
|
||||||
|
this.mcpProperties = mcpProperties;
|
||||||
|
this.qaVectorService = qaVectorService;
|
||||||
|
this.gameScopeService = gameScopeService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObjectNode handle(String contextPath, JsonNode request) {
|
public ObjectNode handle(String contextPath, JsonNode request) {
|
||||||
@@ -69,9 +73,11 @@ public class McpSseService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The only tool registered by this MCP server. */
|
/** Tools registered by this MCP server. */
|
||||||
public List<McpToolView> registeredTools() {
|
public List<McpToolView> registeredTools() {
|
||||||
return List.of(SELECT_AI_VPD_QUERY_VIEW);
|
return List.of(
|
||||||
|
selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(),
|
||||||
|
qaVectorStoreView(), fewShotNl2SqlView(), gameScopeView());
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode initializeResult(String contextPath) {
|
private ObjectNode initializeResult(String contextPath) {
|
||||||
@@ -90,46 +96,74 @@ public class McpSseService {
|
|||||||
private ObjectNode toolsListResult() {
|
private ObjectNode toolsListResult() {
|
||||||
ObjectNode result = objectMapper.createObjectNode();
|
ObjectNode result = objectMapper.createObjectNode();
|
||||||
ArrayNode tools = objectMapper.createArrayNode();
|
ArrayNode tools = objectMapper.createArrayNode();
|
||||||
tools.add(selectAiVpdQueryTool());
|
tools.add(toolDefinition(selectAiQueryView()));
|
||||||
|
tools.add(toolDefinition(selectAiShowpromptView()));
|
||||||
|
tools.add(toolDefinition(qaVectorSearchView()));
|
||||||
|
tools.add(toolDefinition(qaVectorStoreView()));
|
||||||
|
tools.add(toolDefinition(fewShotNl2SqlView()));
|
||||||
|
tools.add(toolDefinition(gameScopeView()));
|
||||||
result.set("tools", tools);
|
result.set("tools", tools);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode selectAiVpdQueryTool() {
|
private ObjectNode toolDefinition(McpToolView toolView) {
|
||||||
ObjectNode item = objectMapper.createObjectNode();
|
ObjectNode item = objectMapper.createObjectNode();
|
||||||
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
|
item.put("name", toolView.name());
|
||||||
item.put("description", SELECT_AI_VPD_QUERY_VIEW.description());
|
item.put("description", toolView.description());
|
||||||
|
|
||||||
ObjectNode schema = objectMapper.createObjectNode();
|
ObjectNode schema = objectMapper.createObjectNode();
|
||||||
schema.put("type", "object");
|
schema.put("type", "object");
|
||||||
ObjectNode properties = objectMapper.createObjectNode();
|
ObjectNode properties = objectMapper.createObjectNode();
|
||||||
|
|
||||||
ObjectNode prompt = objectMapper.createObjectNode();
|
|
||||||
prompt.put("type", "string");
|
|
||||||
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
|
|
||||||
prompt.put("maxLength", 4000);
|
|
||||||
properties.set("prompt", prompt);
|
|
||||||
|
|
||||||
ObjectNode limit = objectMapper.createObjectNode();
|
|
||||||
limit.put("type", "integer");
|
|
||||||
limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다.");
|
|
||||||
limit.put("minimum", 1);
|
|
||||||
limit.put("maximum", 100);
|
|
||||||
properties.set("limit", limit);
|
|
||||||
|
|
||||||
schema.set("properties", properties);
|
|
||||||
ArrayNode required = objectMapper.createArrayNode();
|
ArrayNode required = objectMapper.createArrayNode();
|
||||||
required.add("prompt");
|
if (qaVectorSearchToolName().equals(toolView.name()) || gameScopeToolName().equals(toolView.name())) {
|
||||||
|
addStringProperty(properties, "question", "few-shot 예제 SQL을 찾을 현재 질문입니다.", 4000);
|
||||||
|
ObjectNode topK = properties.putObject("topK");
|
||||||
|
topK.put("type", "integer");
|
||||||
|
topK.put("description", "반환할 유사 예제 수입니다. 기본값은 3입니다.");
|
||||||
|
topK.put("minimum", 1);
|
||||||
|
topK.put("maximum", 20);
|
||||||
|
topK.put("default", 3);
|
||||||
|
required.add("question");
|
||||||
|
} else if (qaVectorStoreToolName().equals(toolView.name())) {
|
||||||
|
addStringProperty(properties, "question", "검토된 Select AI 예제가 답한 업무 질문입니다.", 4000);
|
||||||
|
addStringProperty(properties, "answerSql", "검토된 단일 읽기 전용 SELECT/WITH SQL입니다.", 20000);
|
||||||
|
addStringProperty(properties, "answer", "선택 사항인 답변 또는 검토 메모입니다.", 20000);
|
||||||
|
required.add("question");
|
||||||
|
required.add("answerSql");
|
||||||
|
} else {
|
||||||
|
addStringProperty(properties, "prompt", promptDescription(), 4000);
|
||||||
|
if (fewShotNl2SqlToolName().equals(toolView.name())) {
|
||||||
|
addStringProperty(properties, "scopeGameKey",
|
||||||
|
"선택 사항입니다. game_scope_resolve가 반환한 SUPPORTED gameKey만 전달하세요.", 128);
|
||||||
|
}
|
||||||
|
required.add("prompt");
|
||||||
|
}
|
||||||
|
schema.set("properties", properties);
|
||||||
schema.set("required", required);
|
schema.set("required", required);
|
||||||
schema.put("additionalProperties", false);
|
schema.put("additionalProperties", false);
|
||||||
item.set("inputSchema", schema);
|
item.set("inputSchema", schema);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void addStringProperty(ObjectNode properties, String name, String description, int maxLength) {
|
||||||
|
ObjectNode property = properties.putObject(name);
|
||||||
|
property.put("type", "string");
|
||||||
|
property.put("description", description);
|
||||||
|
property.put("maxLength", maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
|
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
|
||||||
String toolName = params.path("name").asText("");
|
String calledToolName = params.path("name").asText("");
|
||||||
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
|
boolean queryTool = toolName().equals(calledToolName);
|
||||||
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
|
boolean showpromptTool = showpromptToolName().equals(calledToolName);
|
||||||
|
boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName);
|
||||||
|
boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName);
|
||||||
|
boolean fewShotNl2SqlTool = fewShotNl2SqlToolName().equals(calledToolName);
|
||||||
|
boolean gameScopeTool = gameScopeToolName().equals(calledToolName);
|
||||||
|
if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool && !fewShotNl2SqlTool
|
||||||
|
&& !gameScopeTool) {
|
||||||
|
throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonNode arguments = params.path("arguments");
|
JsonNode arguments = params.path("arguments");
|
||||||
@@ -139,19 +173,36 @@ public class McpSseService {
|
|||||||
}
|
}
|
||||||
JsonNode response;
|
JsonNode response;
|
||||||
try {
|
try {
|
||||||
response = selectAiAgentOrdsService.run(
|
if (qaVectorSearchTool) {
|
||||||
token,
|
response = qaVectorSearchResponse(
|
||||||
arguments.path("prompt").asText(""),
|
qaVectorService.search(token, arguments.path("question").asText(""), arguments.path("topK").asInt(3)));
|
||||||
normalizeLimit(arguments.path("limit").asInt(50))
|
} else if (qaVectorStoreTool) {
|
||||||
);
|
response = qaVectorStoreResponse(qaVectorService.store(
|
||||||
|
token,
|
||||||
|
arguments.path("question").asText(""),
|
||||||
|
arguments.path("answerSql").asText(""),
|
||||||
|
arguments.path("answer").isMissingNode() ? null : arguments.path("answer").asText(null)
|
||||||
|
));
|
||||||
|
} else if (gameScopeTool) {
|
||||||
|
response = gameScopeResponse(gameScopeService.resolve(
|
||||||
|
token, arguments.path("question").asText("")));
|
||||||
|
} else {
|
||||||
|
String prompt = arguments.path("prompt").asText("");
|
||||||
|
response = queryTool
|
||||||
|
? selectAiService.generateAndExecute(token, prompt)
|
||||||
|
: fewShotNl2SqlTool
|
||||||
|
? selectAiService.generateAndExecute(token, prompt,
|
||||||
|
arguments.path("scopeGameKey").asText(""))
|
||||||
|
: selectAiService.generatePrompt(token, prompt);
|
||||||
|
}
|
||||||
} catch (VpdTokenAccessDeniedException ignored) {
|
} catch (VpdTokenAccessDeniedException ignored) {
|
||||||
return tokenAccessDeniedResult();
|
return tokenAccessDeniedResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
ObjectNode payload = objectMapper.createObjectNode();
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
|
payload.put("toolName", calledToolName);
|
||||||
payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE);
|
payload.put("profile", selectAiProfile());
|
||||||
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
|
payload.put("ordsPath", SELECT_AI_TOOL_PATH);
|
||||||
payload.set("response", response);
|
payload.set("response", response);
|
||||||
|
|
||||||
ObjectNode result = objectMapper.createObjectNode();
|
ObjectNode result = objectMapper.createObjectNode();
|
||||||
@@ -165,6 +216,59 @@ public class McpSseService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ObjectNode qaVectorSearchResponse(QaVectorService.VectorSearchResult result) {
|
||||||
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
|
response.put("status", "QA_VECTOR_SEARCH");
|
||||||
|
response.put("instruction", "Select AI SQL 생성 전에 few-shot 예제 SQL 후보를 확인합니다.");
|
||||||
|
response.put("question", result.question());
|
||||||
|
response.put("topK", result.topK());
|
||||||
|
ArrayNode examples = response.putArray("examples");
|
||||||
|
for (QaVectorService.VectorExample example : result.examples()) {
|
||||||
|
ObjectNode item = examples.addObject();
|
||||||
|
item.put("exampleId", example.exampleId());
|
||||||
|
item.put("question", example.question());
|
||||||
|
item.put("answerSql", example.answerSql());
|
||||||
|
if (example.answer() != null) {
|
||||||
|
item.put("answer", example.answer());
|
||||||
|
}
|
||||||
|
item.put("embeddingModel", example.embeddingModel());
|
||||||
|
item.put("cosineDistance", example.cosineDistance());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) {
|
||||||
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
|
response.put("status", "QA_VECTOR_STORED");
|
||||||
|
response.put("instruction", "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장했습니다.");
|
||||||
|
response.put("exampleId", stored.exampleId());
|
||||||
|
response.put("question", stored.question());
|
||||||
|
response.put("embeddingModel", stored.embeddingModel());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts DB-derived scope facts to a tool response; this tool never executes query SQL. */
|
||||||
|
private ObjectNode gameScopeResponse(GameScopeService.GameScopeResult result) {
|
||||||
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
|
response.put("status", result.status());
|
||||||
|
response.put("instruction", "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.");
|
||||||
|
response.put("question", result.question());
|
||||||
|
ArrayNode scopes = response.putArray("scopes");
|
||||||
|
for (GameScopeService.GameScope scope : result.scopes()) {
|
||||||
|
ObjectNode item = scopes.addObject();
|
||||||
|
item.put("gameKey", scope.gameKey());
|
||||||
|
item.put("displayName", scope.displayName());
|
||||||
|
item.put("matchedAlias", scope.matchedAlias());
|
||||||
|
item.put("status", scope.status());
|
||||||
|
item.put("reasonCode", scope.reasonCode());
|
||||||
|
item.put("aliasPriority", scope.aliasPriority());
|
||||||
|
item.put("scopeVersion", scope.scopeVersion());
|
||||||
|
item.put("nextAction", "SUPPORTED".equals(scope.status())
|
||||||
|
? "CALL_FEW_SHOT_NL2SQL" : "REPORT_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
private ObjectNode tokenAccessDeniedResult() {
|
private ObjectNode tokenAccessDeniedResult() {
|
||||||
ObjectNode payload = objectMapper.createObjectNode();
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
payload.put("status", "VPD_TOKEN_DENIED");
|
payload.put("status", "VPD_TOKEN_DENIED");
|
||||||
@@ -181,11 +285,157 @@ public class McpSseService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int normalizeLimit(int limit) {
|
private McpToolView selectAiQueryView() {
|
||||||
if (limit < 1) {
|
String profile = selectAiProfile();
|
||||||
return 50;
|
return new McpToolView(
|
||||||
|
toolName(),
|
||||||
|
profile + " 프로파일로 " + toolDescription(),
|
||||||
|
-1L,
|
||||||
|
toolLabel(),
|
||||||
|
SELECT_AI_TOOL_PATH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpToolView selectAiShowpromptView() {
|
||||||
|
String profile = selectAiProfile();
|
||||||
|
return new McpToolView(
|
||||||
|
showpromptToolName(),
|
||||||
|
profile + " 프로파일로 " + showpromptToolDescription(),
|
||||||
|
-1L,
|
||||||
|
showpromptToolLabel(),
|
||||||
|
SELECT_AI_TOOL_PATH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpToolView qaVectorSearchView() {
|
||||||
|
return new McpToolView(
|
||||||
|
qaVectorSearchToolName(), qaVectorSearchToolDescription(), -1L,
|
||||||
|
qaVectorSearchToolLabel(), SELECT_AI_TOOL_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpToolView qaVectorStoreView() {
|
||||||
|
return new McpToolView(
|
||||||
|
qaVectorStoreToolName(), qaVectorStoreToolDescription(), -1L,
|
||||||
|
qaVectorStoreToolLabel(), SELECT_AI_TOOL_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpToolView fewShotNl2SqlView() {
|
||||||
|
return new McpToolView(
|
||||||
|
fewShotNl2SqlToolName(), fewShotNl2SqlToolDescription(), -1L,
|
||||||
|
fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpToolView gameScopeView() {
|
||||||
|
return new McpToolView(
|
||||||
|
gameScopeToolName(), gameScopeToolDescription(), -1L,
|
||||||
|
gameScopeToolLabel(), SELECT_AI_TOOL_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String selectAiProfile() {
|
||||||
|
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||||
|
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
return Math.min(limit, 100);
|
return selectAi.profile().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toolName() {
|
||||||
|
return mcpProperties == null ? "oracle.select_ai.data_text2sql" : mcpProperties.resolvedToolName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toolLabel() {
|
||||||
|
return mcpProperties == null ? "업무 데이터 Text2SQL" : mcpProperties.resolvedToolLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toolDescription() {
|
||||||
|
return mcpProperties == null
|
||||||
|
? "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다."
|
||||||
|
: mcpProperties.resolvedToolDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String promptDescription() {
|
||||||
|
return mcpProperties == null ? "업무 데이터에서 조회할 내용을 자연어로 입력합니다." : mcpProperties.resolvedPromptDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String showpromptToolName() {
|
||||||
|
return mcpProperties == null
|
||||||
|
? "oracle.select_ai.data_showprompt"
|
||||||
|
: mcpProperties.resolvedShowpromptToolName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String showpromptToolLabel() {
|
||||||
|
return mcpProperties == null
|
||||||
|
? "업무 데이터 SHOWPROMPT"
|
||||||
|
: mcpProperties.resolvedShowpromptToolLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String showpromptToolDescription() {
|
||||||
|
return mcpProperties == null
|
||||||
|
? "Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다."
|
||||||
|
: mcpProperties.resolvedShowpromptToolDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String qaVectorSearchToolName() {
|
||||||
|
return mcpProperties == null ? "oracle.select_ai.qa_vector_search"
|
||||||
|
: mcpProperties.resolvedQaVectorSearchToolName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String qaVectorSearchToolLabel() {
|
||||||
|
return mcpProperties == null ? "Select AI 예제 SQL 조회"
|
||||||
|
: mcpProperties.resolvedQaVectorSearchToolLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String qaVectorSearchToolDescription() {
|
||||||
|
return mcpProperties == null
|
||||||
|
? "현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다."
|
||||||
|
: mcpProperties.resolvedQaVectorSearchToolDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String qaVectorStoreToolName() {
|
||||||
|
return mcpProperties == null ? "oracle.select_ai.qa_vector_store"
|
||||||
|
: mcpProperties.resolvedQaVectorStoreToolName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String qaVectorStoreToolLabel() {
|
||||||
|
return mcpProperties == null ? "Select AI 예제 SQL 저장"
|
||||||
|
: mcpProperties.resolvedQaVectorStoreToolLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String qaVectorStoreToolDescription() {
|
||||||
|
return mcpProperties == null
|
||||||
|
? "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다."
|
||||||
|
: mcpProperties.resolvedQaVectorStoreToolDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fewShotNl2SqlToolName() {
|
||||||
|
return mcpProperties == null ? "oracle.select_ai.smilegate_fewshot_nl2sql"
|
||||||
|
: mcpProperties.resolvedFewShotNl2SqlToolName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fewShotNl2SqlToolLabel() {
|
||||||
|
return mcpProperties == null ? "Few-shot NL2SQL 실행"
|
||||||
|
: mcpProperties.resolvedFewShotNl2SqlToolLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fewShotNl2SqlToolDescription() {
|
||||||
|
return mcpProperties == null
|
||||||
|
? "벡터 Few-shot 예제를 찾아 prompt에 반영하고 SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다."
|
||||||
|
: mcpProperties.resolvedFewShotNl2SqlToolDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String gameScopeToolName() {
|
||||||
|
return mcpProperties == null ? "oracle.select_ai.game_scope_resolve"
|
||||||
|
: mcpProperties.resolvedGameScopeToolName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String gameScopeToolLabel() {
|
||||||
|
return mcpProperties == null ? "게임 조회 범위 확인" : mcpProperties.resolvedGameScopeToolLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String gameScopeToolDescription() {
|
||||||
|
return mcpProperties == null
|
||||||
|
? "질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다."
|
||||||
|
: mcpProperties.resolvedGameScopeToolDescription();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String pretty(Object value) {
|
private String pretty(Object value) {
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ public class OrdsProbeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// APEX_JSON omits a ref-cursor column whose value is NULL. The KB
|
// APEX_JSON omits a ref-cursor column whose value is NULL. The
|
||||||
// object handlers always select every registered protected column, so
|
// object handlers always select every registered protected column, so
|
||||||
// a sensitive column missing from every JSON row is also a NULL result
|
// a sensitive column missing from every JSON row is also a NULL result
|
||||||
// and must be reported as masked to the MCP/UI caller.
|
// and must be reported as masked to the MCP/UI caller.
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||||
|
import java.sql.CallableStatement;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Calls the SGMP_POC QA-vector database API through the schema-owned Select AI connection. */
|
||||||
|
@Service
|
||||||
|
public class QaVectorService {
|
||||||
|
|
||||||
|
private static final int MAX_QUESTION_LENGTH = 4_000;
|
||||||
|
private static final int MAX_ANSWER_SQL_LENGTH = 20_000;
|
||||||
|
private static final int MAX_ANSWER_LENGTH = 20_000;
|
||||||
|
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||||
|
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||||
|
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|dbms_[a-z0-9_]*|"
|
||||||
|
+ "utl_[a-z0-9_]*|sys\\s*\\.)\\b"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final BackofficeProperties properties;
|
||||||
|
private final BearerTokenService bearerTokenService;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public QaVectorService(
|
||||||
|
BackofficeProperties properties,
|
||||||
|
BearerTokenService bearerTokenService,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.bearerTokenService = bearerTokenService;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VectorSearchResult search(String bearerToken, String question, int topK) {
|
||||||
|
requireActiveToken(bearerToken);
|
||||||
|
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
|
||||||
|
if (topK < 1 || topK > 20) {
|
||||||
|
throw new AppException("topK는 1에서 20 사이여야 합니다.");
|
||||||
|
}
|
||||||
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||||
|
|
||||||
|
List<VectorExample> examples = new ArrayList<>();
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||||
|
CallableStatement statement = connection.prepareCall("{ ? = call sg_qa_vector_search(?, ?) }")) {
|
||||||
|
statement.registerOutParameter(1, Types.REF_CURSOR);
|
||||||
|
statement.setString(2, normalizedQuestion);
|
||||||
|
statement.setInt(3, topK);
|
||||||
|
statement.execute();
|
||||||
|
try (ResultSet resultSet = (ResultSet) statement.getObject(1)) {
|
||||||
|
while (resultSet.next()) {
|
||||||
|
examples.add(new VectorExample(
|
||||||
|
resultSet.getLong("EXAMPLE_ID"),
|
||||||
|
resultSet.getString("QUESTION"),
|
||||||
|
resultSet.getString("ANSWER_SQL"),
|
||||||
|
resultSet.getString("ANSWER_TEXT"),
|
||||||
|
resultSet.getString("EMBEDDING_MODEL"),
|
||||||
|
resultSet.getDouble("COSINE_DISTANCE")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("QA 벡터 예제 SQL 조회 실패: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
return new VectorSearchResult(normalizedQuestion, topK, examples);
|
||||||
|
}
|
||||||
|
|
||||||
|
public VectorStoreResult store(String bearerToken, String question, String answerSql, String answer) {
|
||||||
|
requireActiveToken(bearerToken);
|
||||||
|
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
|
||||||
|
String normalizedAnswerSql = requiredReadOnlySql(answerSql);
|
||||||
|
String normalizedAnswer = optionalText(answer, "answer", MAX_ANSWER_LENGTH);
|
||||||
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||||
|
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||||
|
PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"SELECT sg_qa_vector_store(?, ?, ?) AS example_id FROM dual")) {
|
||||||
|
statement.setString(1, normalizedQuestion);
|
||||||
|
statement.setString(2, normalizedAnswerSql);
|
||||||
|
statement.setString(3, normalizedAnswer);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
if (!resultSet.next()) {
|
||||||
|
throw new AppException("QA 벡터 예제 SQL 저장 결과가 없습니다.");
|
||||||
|
}
|
||||||
|
return new VectorStoreResult(
|
||||||
|
resultSet.getLong("EXAMPLE_ID"), normalizedQuestion, "cohere.embed-v4.0");
|
||||||
|
}
|
||||||
|
} catch (AppException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("QA 벡터 예제 SQL 저장 실패: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BackofficeProperties.SelectAi requiredSelectAi() {
|
||||||
|
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||||
|
if (selectAi == null || !selectAi.configured()) {
|
||||||
|
throw new AppException("QA 벡터 연결 설정이 필요합니다. "
|
||||||
|
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||||
|
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
||||||
|
}
|
||||||
|
return selectAi;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireActiveToken(String bearerToken) {
|
||||||
|
if (bearerToken == null || bearerToken.isBlank()) {
|
||||||
|
throw new VpdTokenAccessDeniedException();
|
||||||
|
}
|
||||||
|
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
||||||
|
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||||
|
if (token == null || !token.active(now)) {
|
||||||
|
throw new VpdTokenAccessDeniedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredReadOnlySql(String answerSql) {
|
||||||
|
String normalized = requiredText(answerSql, "answerSql", MAX_ANSWER_SQL_LENGTH)
|
||||||
|
.replaceFirst(";\\s*$", "").trim();
|
||||||
|
if (!normalized.matches("(?is)^(select|with)\\b.*") || normalized.contains(";")
|
||||||
|
|| normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|
||||||
|
|| UNSAFE_SQL.matcher(normalized).find()) {
|
||||||
|
throw new AppException("answerSql은 단일 읽기 전용 SELECT/WITH SQL이어야 합니다.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredText(String value, String fieldName, int maximumLength) {
|
||||||
|
String normalized = optionalText(value, fieldName, maximumLength);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw new AppException(fieldName + "는 필수입니다.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String optionalText(String value, String fieldName, int maximumLength) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (normalized.length() > maximumLength) {
|
||||||
|
throw new AppException(fieldName + "는 " + maximumLength + "자 이하여야 합니다.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record VectorExample(
|
||||||
|
long exampleId,
|
||||||
|
String question,
|
||||||
|
String answerSql,
|
||||||
|
String answer,
|
||||||
|
String embeddingModel,
|
||||||
|
double cosineDistance
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record VectorSearchResult(String question, int topK, List<VectorExample> examples) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record VectorStoreResult(long exampleId, String question, String embeddingModel) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,43 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.dao.DataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen-level orchestration for schema metadata. Database reads and DDL are
|
||||||
|
* intentionally delegated only to {@link SchemaMetadataMapper}.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class SchemaMetadataService {
|
public class SchemaMetadataService {
|
||||||
|
|
||||||
private static final String OWNER = "POC_2";
|
|
||||||
private static final int MAX_COMMENT_LENGTH = 4000;
|
private static final int MAX_COMMENT_LENGTH = 4000;
|
||||||
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
|
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
|
||||||
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final SchemaMetadataMapper mapper;
|
||||||
private final StructuredDataService structuredDataService;
|
private final StructuredDataService structuredDataService;
|
||||||
|
private final DataCatalog catalog;
|
||||||
|
|
||||||
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) {
|
public SchemaMetadataService(
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
SchemaMetadataMapper mapper,
|
||||||
|
StructuredDataService structuredDataService, DataCatalog catalog
|
||||||
|
) {
|
||||||
|
this.mapper = mapper;
|
||||||
this.structuredDataService = structuredDataService;
|
this.structuredDataService = structuredDataService;
|
||||||
|
this.catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<StructuredDataTable> tables() {
|
public List<StructuredDataTable> tables() {
|
||||||
@@ -41,13 +50,12 @@ public class SchemaMetadataService {
|
|||||||
|
|
||||||
public SchemaMetadataView find(String tableKey) {
|
public SchemaMetadataView find(String tableKey) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String tableName = table.tableName();
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
String tableComment = tableComment(tableName);
|
|
||||||
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
|
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
|
||||||
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
||||||
return new SchemaMetadataView(
|
return new SchemaMetadataView(
|
||||||
table,
|
table,
|
||||||
nullToEmpty(tableComment),
|
nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)),
|
||||||
annotations.getOrDefault(tableTargetKey(), List.of()),
|
annotations.getOrDefault(tableTargetKey(), List.of()),
|
||||||
columns
|
columns
|
||||||
);
|
);
|
||||||
@@ -56,24 +64,24 @@ public class SchemaMetadataService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void updateTableComment(String tableKey, String comment) {
|
public void updateTableComment(String tableKey, String comment) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
||||||
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName())
|
mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment));
|
||||||
+ " IS " + quoteLiteral(normalizedComment));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateColumnComment(String tableKey, String columnName, String comment) {
|
public void updateColumnComment(String tableKey, String columnName, String comment) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String column = requireColumn(table.tableName(), columnName);
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
|
String column = requireColumn(tableName, columnName);
|
||||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
||||||
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "."
|
mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment));
|
||||||
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
|
updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -84,8 +92,9 @@ public class SchemaMetadataService {
|
|||||||
String annotationValue
|
String annotationValue
|
||||||
) {
|
) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String column = requireColumn(table.tableName(), columnName);
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
|
String column = requireColumn(tableName, columnName);
|
||||||
|
updateAnnotation(tableName, column, annotationName, annotationValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateAnnotation(
|
private void updateAnnotation(
|
||||||
@@ -97,75 +106,55 @@ public class SchemaMetadataService {
|
|||||||
String key = requireSimpleName(annotationName, "annotation name");
|
String key = requireSimpleName(annotationName, "annotation name");
|
||||||
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
||||||
if (annotationExists(tableName, columnName, key)) {
|
if (annotationExists(tableName, columnName, key)) {
|
||||||
jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key)));
|
if (columnName == null) {
|
||||||
|
mapper.dropTableAnnotation(catalog.owner(), tableName, key);
|
||||||
|
} else {
|
||||||
|
mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!value.isBlank()) {
|
if (!value.isBlank()) {
|
||||||
jdbcTemplate.execute(annotationSql(tableName, columnName,
|
if (columnName == null) {
|
||||||
"ADD " + quoteName(key) + " " + quoteLiteral(value)));
|
mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value));
|
||||||
|
} else {
|
||||||
|
mapper.addColumnAnnotation(catalog.owner(), tableName, columnName, key, quoteLiteral(value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String tableComment(String tableName) {
|
|
||||||
List<String> values = jdbcTemplate.query("""
|
|
||||||
SELECT comments
|
|
||||||
FROM all_tab_comments
|
|
||||||
WHERE owner = ?
|
|
||||||
AND table_name = ?
|
|
||||||
""", (rs, rowNum) -> rs.getString(1), OWNER, tableName);
|
|
||||||
return values.isEmpty() ? "" : values.getFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<SchemaMetadataColumn> columns(
|
private List<SchemaMetadataColumn> columns(
|
||||||
String tableName,
|
String tableName,
|
||||||
Map<String, List<SchemaAnnotation>> annotations
|
Map<String, List<SchemaAnnotation>> annotations
|
||||||
) {
|
) {
|
||||||
return jdbcTemplate.query("""
|
return mapper.findColumns(catalog.owner(), tableName).stream()
|
||||||
SELECT c.column_name,
|
.map(row -> toColumn(row, annotations))
|
||||||
CASE
|
.toList();
|
||||||
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
|
}
|
||||||
THEN c.data_type || '(' || c.char_length || ')'
|
|
||||||
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
|
private SchemaMetadataColumn toColumn(
|
||||||
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
|
SchemaMetadataColumnRow row,
|
||||||
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
|
Map<String, List<SchemaAnnotation>> annotations
|
||||||
THEN c.data_type || '(' || c.data_precision || ')'
|
) {
|
||||||
ELSE c.data_type
|
String columnName = requireSimpleName(row.columnName(), "column name");
|
||||||
END AS display_type,
|
return new SchemaMetadataColumn(
|
||||||
c.nullable,
|
columnName,
|
||||||
cc.comments
|
row.dataType(),
|
||||||
FROM all_tab_columns c
|
"Y".equalsIgnoreCase(row.nullable()),
|
||||||
LEFT JOIN all_col_comments cc
|
nullToEmpty(row.comment()),
|
||||||
ON cc.owner = c.owner
|
annotations.getOrDefault(columnTargetKey(columnName), List.of())
|
||||||
AND cc.table_name = c.table_name
|
);
|
||||||
AND cc.column_name = c.column_name
|
|
||||||
WHERE c.owner = ?
|
|
||||||
AND c.table_name = ?
|
|
||||||
ORDER BY c.column_id
|
|
||||||
""", (rs, rowNum) -> new SchemaMetadataColumn(
|
|
||||||
rs.getString("column_name"),
|
|
||||||
rs.getString("display_type"),
|
|
||||||
"Y".equalsIgnoreCase(rs.getString("nullable")),
|
|
||||||
nullToEmpty(rs.getString("comments")),
|
|
||||||
annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of())
|
|
||||||
), OWNER, tableName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
|
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
|
||||||
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
|
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
|
||||||
jdbcTemplate.query("""
|
for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(tableName)) {
|
||||||
SELECT column_name, annotation_name, annotation_value
|
String target = row.columnName() == null
|
||||||
FROM all_annotations_usage
|
? tableTargetKey()
|
||||||
WHERE object_name = ?
|
: columnTargetKey(row.columnName());
|
||||||
AND object_type = 'TABLE'
|
grouped
|
||||||
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
|
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
|
||||||
""", rs -> {
|
.computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>())
|
||||||
String target = rs.getString("column_name") == null
|
.add(nullToEmpty(row.annotationValue()));
|
||||||
? 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);
|
|
||||||
|
|
||||||
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
|
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
|
||||||
grouped.forEach((target, valuesByName) -> {
|
grouped.forEach((target, valuesByName) -> {
|
||||||
@@ -180,44 +169,14 @@ public class SchemaMetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean annotationExists(String tableName, String columnName, String annotationName) {
|
private boolean annotationExists(String tableName, String columnName, String annotationName) {
|
||||||
Integer count = columnName == null
|
return columnName == null
|
||||||
? jdbcTemplate.queryForObject("""
|
? mapper.countTableAnnotation(tableName, annotationName) > 0
|
||||||
SELECT COUNT(*)
|
: mapper.countColumnAnnotation(tableName, columnName, annotationName) > 0;
|
||||||
FROM all_annotations_usage
|
|
||||||
WHERE object_name = ?
|
|
||||||
AND object_type = 'TABLE'
|
|
||||||
AND annotation_name = ?
|
|
||||||
AND column_name IS NULL
|
|
||||||
""", Integer.class, tableName, annotationName)
|
|
||||||
: jdbcTemplate.queryForObject("""
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM all_annotations_usage
|
|
||||||
WHERE object_name = ?
|
|
||||||
AND object_type = 'TABLE'
|
|
||||||
AND annotation_name = ?
|
|
||||||
AND column_name = ?
|
|
||||||
""", Integer.class, tableName, annotationName, columnName);
|
|
||||||
return count != null && count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String annotationSql(String tableName, String columnName, String operation) {
|
|
||||||
if (columnName == null) {
|
|
||||||
return "ALTER TABLE " + qualifiedTable(tableName) + " ANNOTATIONS (" + operation + ")";
|
|
||||||
}
|
|
||||||
return "ALTER TABLE " + qualifiedTable(tableName) + " MODIFY " + quoteName(columnName)
|
|
||||||
+ " ANNOTATIONS (" + operation + ")";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String requireColumn(String tableName, String columnName) {
|
private String requireColumn(String tableName, String columnName) {
|
||||||
String column = requireSimpleName(columnName, "column name");
|
String column = requireSimpleName(columnName, "column name");
|
||||||
Integer count = jdbcTemplate.queryForObject("""
|
if (mapper.countColumn(catalog.owner(), tableName, column) == 0) {
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM all_tab_columns
|
|
||||||
WHERE owner = ?
|
|
||||||
AND table_name = ?
|
|
||||||
AND column_name = ?
|
|
||||||
""", Integer.class, OWNER, tableName, column);
|
|
||||||
if (count == null || count == 0) {
|
|
||||||
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
||||||
}
|
}
|
||||||
return column;
|
return column;
|
||||||
@@ -242,14 +201,6 @@ public class SchemaMetadataService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String qualifiedTable(String tableName) {
|
|
||||||
return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String quoteName(String value) {
|
|
||||||
return "\"" + value.replace("\"", "\"\"") + "\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String quoteLiteral(String value) {
|
private String quoteLiteral(String value) {
|
||||||
return "'" + value.replace("'", "''") + "'";
|
return "'" + value.replace("'", "''") + "'";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
|
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
|
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -17,46 +21,18 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class SecuritySqlScriptService {
|
public class SecuritySqlScriptService {
|
||||||
|
|
||||||
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
|
private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}");
|
||||||
new ScriptDefinition(
|
private static final Pattern RESOURCE_PATH = Pattern.compile(
|
||||||
"aso-masking-metadata",
|
"(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql"
|
||||||
"ASO / 마스킹",
|
|
||||||
"62_kb_aso_masking_backoffice_metadata.sql",
|
|
||||||
"컬럼 마스킹 규칙 메타데이터",
|
|
||||||
"ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
|
|
||||||
),
|
|
||||||
new ScriptDefinition(
|
|
||||||
"aso-masking-runtime",
|
|
||||||
"ASO / 마스킹",
|
|
||||||
"63_kb_aso_masking_rule_runtime.sql",
|
|
||||||
"ASO 마스킹 런타임 적용",
|
|
||||||
"백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다."
|
|
||||||
),
|
|
||||||
new ScriptDefinition(
|
|
||||||
"aso-masking-default-columns",
|
|
||||||
"ASO / 마스킹",
|
|
||||||
"64_kb_aso_masking_default_column_rules.sql",
|
|
||||||
"ASO 기본 대상 컬럼",
|
|
||||||
"주민번호·청구/지급금·타사보유 컬럼의 마스킹 블랙리스트 초기값을 연결합니다."
|
|
||||||
),
|
|
||||||
new ScriptDefinition(
|
|
||||||
"select-ai-vpd-api",
|
|
||||||
"Select AI / 행 접근",
|
|
||||||
"65_kb_select_ai_vpd_query_api.sql",
|
|
||||||
"행 접근 적용 Select AI 조회 API",
|
|
||||||
"생성 SQL을 KB 업무 테이블의 단일 읽기 전용 SELECT/WITH로 검증해 행 접근 컨텍스트에서 실행합니다."
|
|
||||||
),
|
|
||||||
new ScriptDefinition(
|
|
||||||
"select-ai-vpd-ords",
|
|
||||||
"ORDS / Select AI",
|
|
||||||
"66_kb_select_ai_vpd_query_ords.sql",
|
|
||||||
"Select AI 행 접근 ORDS Endpoint",
|
|
||||||
"Bearer 토큰을 검증해 행 접근 컨텍스트를 설정한 뒤 Select AI 조회 API를 노출합니다."
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
private final List<ScriptDefinition> scripts;
|
||||||
|
|
||||||
|
public SecuritySqlScriptService(SecuritySqlScriptProperties properties, ObjectMapper objectMapper) {
|
||||||
|
scripts = parse(properties.scripts(), objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
public List<SecuritySqlScriptSummary> list() {
|
public List<SecuritySqlScriptSummary> list() {
|
||||||
return CURATED_SCRIPTS.stream()
|
return scripts.stream()
|
||||||
.map(definition -> new SecuritySqlScriptSummary(
|
.map(definition -> new SecuritySqlScriptSummary(
|
||||||
definition.scriptId(),
|
definition.scriptId(),
|
||||||
definition.category(),
|
definition.category(),
|
||||||
@@ -68,7 +44,7 @@ public class SecuritySqlScriptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public SecuritySqlScript find(String scriptId) {
|
public SecuritySqlScript find(String scriptId) {
|
||||||
ScriptDefinition definition = CURATED_SCRIPTS.stream()
|
ScriptDefinition definition = scripts.stream()
|
||||||
.filter(candidate -> candidate.scriptId().equals(scriptId))
|
.filter(candidate -> candidate.scriptId().equals(scriptId))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
|
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
|
||||||
@@ -91,7 +67,36 @@ public class SecuritySqlScriptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ScriptDefinition(
|
private List<ScriptDefinition> parse(String raw, ObjectMapper objectMapper) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<ScriptDefinition> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||||
|
if (parsed.isEmpty() || parsed.stream().map(ScriptDefinition::scriptId).distinct().count() != parsed.size()) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
parsed.forEach(this::validate);
|
||||||
|
return List.copyOf(parsed);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new IllegalStateException("BACKOFFICE_SECURITY_SQL_SCRIPTS 설정을 확인하세요.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(ScriptDefinition definition) {
|
||||||
|
if (definition == null
|
||||||
|
|| definition.scriptId() == null || !SCRIPT_ID.matcher(definition.scriptId()).matches()
|
||||||
|
|| definition.fileName() == null || !RESOURCE_PATH.matcher(definition.fileName()).matches()
|
||||||
|
|| blank(definition.category()) || blank(definition.title()) || blank(definition.description())) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean blank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ScriptDefinition(
|
||||||
String scriptId,
|
String scriptId,
|
||||||
String category,
|
String category,
|
||||||
String fileName,
|
String fileName,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
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.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SelectAiService {
|
||||||
|
|
||||||
|
private static final int MAX_PROMPT_LENGTH = 4_000;
|
||||||
|
private static final int MAX_RESULT_ROWS = 100;
|
||||||
|
private static final int QUERY_TIMEOUT_SECONDS = 30;
|
||||||
|
private static final int MAX_FEW_SHOT_EXAMPLES = 3;
|
||||||
|
private static final int MAX_FEW_SHOT_SQL_CHARS = 4_000;
|
||||||
|
private static final int MAX_ENRICHED_PROMPT_LENGTH = 16_000;
|
||||||
|
private static final String POLICY_PREFIX =
|
||||||
|
"Answer the original user question using the current approved object list and policy. "
|
||||||
|
+ "If no game identifier resolves through game-alias metadata, do not select a prefix-specific object "
|
||||||
|
+ "and do not infer a default game. State that the required game identifier is missing instead.\n\n";
|
||||||
|
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||||
|
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||||
|
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
||||||
|
+ "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final BackofficeProperties properties;
|
||||||
|
private final BearerTokenService bearerTokenService;
|
||||||
|
private final Clock clock;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final QaVectorService qaVectorService;
|
||||||
|
private final GameScopeService gameScopeService;
|
||||||
|
|
||||||
|
public SelectAiService(
|
||||||
|
BackofficeProperties properties,
|
||||||
|
BearerTokenService bearerTokenService,
|
||||||
|
Clock clock,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
QaVectorService qaVectorService,
|
||||||
|
GameScopeService gameScopeService
|
||||||
|
) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.bearerTokenService = bearerTokenService;
|
||||||
|
this.clock = clock;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.qaVectorService = qaVectorService;
|
||||||
|
this.gameScopeService = gameScopeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
||||||
|
return generateAndExecute(bearerToken, prompt, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes one DB-resolved game scope. The optional key is revalidated for
|
||||||
|
* the original question; it is never a caller-provided table or prefix.
|
||||||
|
*/
|
||||||
|
public JsonNode generateAndExecute(String bearerToken, String prompt, String scopeGameKey) {
|
||||||
|
requireActiveToken(bearerToken);
|
||||||
|
String normalizedPrompt = requiredPrompt(prompt);
|
||||||
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||||
|
ResolvedExecutionScope scope = resolveExecutionScope(bearerToken, normalizedPrompt, scopeGameKey);
|
||||||
|
|
||||||
|
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, scope.prompt());
|
||||||
|
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
|
||||||
|
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("originalPrompt", normalizedPrompt);
|
||||||
|
if (scope.gameKey() != null) {
|
||||||
|
response.put("scopeGameKey", scope.gameKey());
|
||||||
|
response.put("scopeDisplayName", scope.displayName());
|
||||||
|
response.put("scopeStatus", "DB_REVALIDATED");
|
||||||
|
}
|
||||||
|
response.put("fewShotStatus", enrichedPrompt.status());
|
||||||
|
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
|
||||||
|
addFewShotExamples(response, enrichedPrompt.examples());
|
||||||
|
response.put("generatedSql", normalizedSql);
|
||||||
|
response.put("execution", "READ_ONLY_EXECUTED");
|
||||||
|
response.put("rowCount", execution.items().size());
|
||||||
|
response.put("truncated", execution.truncated());
|
||||||
|
response.set("items", execution.items());
|
||||||
|
response.put("nextStep", execution.truncated()
|
||||||
|
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다."
|
||||||
|
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolvedExecutionScope resolveExecutionScope(
|
||||||
|
String bearerToken, String originalPrompt, String scopeGameKey
|
||||||
|
) {
|
||||||
|
String requestedKey = scopeGameKey == null ? "" : scopeGameKey.trim();
|
||||||
|
if (requestedKey.isEmpty()) {
|
||||||
|
return new ResolvedExecutionScope(originalPrompt, null, null);
|
||||||
|
}
|
||||||
|
if (gameScopeService == null) {
|
||||||
|
throw new AppException("게임 범위 검증 서비스를 사용할 수 없습니다.");
|
||||||
|
}
|
||||||
|
GameScopeService.GameScope scope = gameScopeService.resolve(bearerToken, originalPrompt).scopes().stream()
|
||||||
|
.filter(item -> requestedKey.equals(item.gameKey()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AppException("요청한 게임 범위가 DB 조회 결과에 없습니다."));
|
||||||
|
if (!"SUPPORTED".equals(scope.status())) {
|
||||||
|
throw new AppException("DB 게임 범위가 조회 실행을 허용하지 않습니다: " + scope.reasonCode());
|
||||||
|
}
|
||||||
|
String scopedPrompt = "Use only the DB-resolved game scope below. Do not generate SQL for any other "
|
||||||
|
+ "game mentioned in the original question. The scope was validated through current game alias "
|
||||||
|
+ "metadata and approved object availability.\n"
|
||||||
|
+ "Resolved game key: " + scope.gameKey() + "\n"
|
||||||
|
+ "Resolved display name: " + scope.displayName() + "\n"
|
||||||
|
+ "Matched alias: " + scope.matchedAlias() + "\n\n"
|
||||||
|
+ "Original user question:\n" + originalPrompt;
|
||||||
|
return new ResolvedExecutionScope(scopedPrompt, scope.gameKey(), scope.displayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addFewShotExamples(ObjectNode response, List<QaVectorService.VectorExample> examples) {
|
||||||
|
ArrayNode items = response.putArray("fewShotExamples");
|
||||||
|
for (QaVectorService.VectorExample example : examples) {
|
||||||
|
ObjectNode item = items.addObject();
|
||||||
|
item.put("exampleId", example.exampleId());
|
||||||
|
item.put("question", example.question());
|
||||||
|
item.put("answerSql", example.answerSql());
|
||||||
|
if (example.answer() != null) {
|
||||||
|
item.put("answer", example.answer());
|
||||||
|
}
|
||||||
|
item.put("embeddingModel", example.embeddingModel());
|
||||||
|
item.put("cosineDistance", example.cosineDistance());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the prompt Select AI assembled for SQL generation without executing generated SQL. */
|
||||||
|
public JsonNode generatePrompt(String bearerToken, String prompt) {
|
||||||
|
requireActiveToken(bearerToken);
|
||||||
|
String normalizedPrompt = requiredPrompt(prompt);
|
||||||
|
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||||
|
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt);
|
||||||
|
String selectAiPrompt = generate(selectAi, enrichedPrompt.prompt(), "showprompt");
|
||||||
|
|
||||||
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
|
response.put("status", "SHOWPROMPT");
|
||||||
|
response.put("profile", selectAi.profile());
|
||||||
|
response.put("originalPrompt", normalizedPrompt);
|
||||||
|
response.put("fewShotStatus", enrichedPrompt.status());
|
||||||
|
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
|
||||||
|
response.put("selectAiPrompt", selectAiPrompt);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BackofficeProperties.SelectAi requiredSelectAi() {
|
||||||
|
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||||
|
if (selectAi == null || !selectAi.configured()) {
|
||||||
|
throw new AppException("Select AI 연결 설정이 필요합니다. "
|
||||||
|
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||||
|
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
||||||
|
}
|
||||||
|
return selectAi;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireActiveToken(String bearerToken) {
|
||||||
|
if (bearerToken == null || bearerToken.isBlank()) {
|
||||||
|
throw new VpdTokenAccessDeniedException();
|
||||||
|
}
|
||||||
|
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
||||||
|
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||||
|
if (token == null || !token.active(now)) {
|
||||||
|
throw new VpdTokenAccessDeniedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredPrompt(String prompt) {
|
||||||
|
String normalized = prompt == null ? "" : prompt.trim();
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
throw new AppException("prompt는 필수입니다.");
|
||||||
|
}
|
||||||
|
if (normalized.length() > MAX_PROMPT_LENGTH) {
|
||||||
|
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generate(
|
||||||
|
BackofficeProperties.SelectAi selectAi,
|
||||||
|
String prompt,
|
||||||
|
String action
|
||||||
|
) {
|
||||||
|
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, ?) FROM dual";
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||||
|
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setString(1, prompt);
|
||||||
|
statement.setString(2, selectAi.profile());
|
||||||
|
statement.setString(3, action);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
if (!resultSet.next() || resultSet.getString(1) == null) {
|
||||||
|
throw new AppException("Select AI가 " + action.toUpperCase() + " 결과를 반환하지 않았습니다.");
|
||||||
|
}
|
||||||
|
return resultSet.getString(1);
|
||||||
|
}
|
||||||
|
} catch (AppException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("Select AI " + action.toUpperCase() + " 생성 실패: "
|
||||||
|
+ exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private EnrichedPrompt enrichWithFewShot(
|
||||||
|
String bearerToken,
|
||||||
|
BackofficeProperties.SelectAi selectAi,
|
||||||
|
String prompt
|
||||||
|
) {
|
||||||
|
if (!fewShotEnabled(selectAi) || qaVectorService == null) {
|
||||||
|
return new EnrichedPrompt(prompt, "DISABLED", 0, List.of());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<QaVectorService.VectorExample> examples = qaVectorService
|
||||||
|
.search(bearerToken, prompt, fewShotTopK(selectAi))
|
||||||
|
.examples();
|
||||||
|
if (examples.isEmpty()) {
|
||||||
|
return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of());
|
||||||
|
}
|
||||||
|
return new EnrichedPrompt(
|
||||||
|
composeFewShotPrompt(prompt, examples), "APPLIED", Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES),
|
||||||
|
examples.subList(0, Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES)));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Vector retrieval is an optional prompt aid; preserve the normal Text2SQL path on failure.
|
||||||
|
return new EnrichedPrompt(composePolicyPrompt(prompt), "UNAVAILABLE", 0, List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String composePolicyPrompt(String prompt) {
|
||||||
|
return POLICY_PREFIX
|
||||||
|
+ "Resolve business terms and game names from the approved game-alias metadata before selecting a "
|
||||||
|
+ "game-scoped object. A generic term such as common user means no particular game. If no game alias "
|
||||||
|
+ "is resolved, do not substitute an arbitrary game-scoped object. Return a read-only no-match result "
|
||||||
|
+ "and preserve the resolver status for the answer layer; do not invent a user-facing explanation.\n"
|
||||||
|
+ "Original user question:\n" + prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String composeFewShotPrompt(String prompt, List<QaVectorService.VectorExample> examples) {
|
||||||
|
StringBuilder enriched = new StringBuilder(POLICY_PREFIX
|
||||||
|
+ "The verified examples below are guidance only: use only relevant SQL patterns, do not invent "
|
||||||
|
+ "identifiers, and do not override current metadata or game-alias resolution policy. "
|
||||||
|
+ "When examples use a game-specific object, reuse that pattern only after the current question "
|
||||||
|
+ "resolves the same game alias; otherwise keep the query unscoped or request clarification.\n\n"
|
||||||
|
+ "Verified few-shot examples:\n");
|
||||||
|
int included = 0;
|
||||||
|
for (QaVectorService.VectorExample example : examples) {
|
||||||
|
if (included >= MAX_FEW_SHOT_EXAMPLES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
|
||||||
|
if (answerSql.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String candidate = "Example " + (included + 1) + " question:\n" + example.question()
|
||||||
|
+ "\nExample " + (included + 1) + " verified SQL:\n" + answerSql + "\n\n";
|
||||||
|
if (enriched.length() + candidate.length() + prompt.length() > MAX_ENRICHED_PROMPT_LENGTH) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
enriched.append(candidate);
|
||||||
|
included++;
|
||||||
|
}
|
||||||
|
if (included == 0) {
|
||||||
|
return composePolicyPrompt(prompt);
|
||||||
|
}
|
||||||
|
return enriched.append("Original user question:\n").append(prompt).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String value, int maxLength) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean fewShotEnabled(BackofficeProperties.SelectAi selectAi) {
|
||||||
|
return selectAi.fewShotEnabled() == null || selectAi.fewShotEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int fewShotTopK(BackofficeProperties.SelectAi selectAi) {
|
||||||
|
Integer configured = selectAi.fewShotTopK();
|
||||||
|
if (configured == null) {
|
||||||
|
return MAX_FEW_SHOT_EXAMPLES;
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.min(configured, MAX_FEW_SHOT_EXAMPLES));
|
||||||
|
}
|
||||||
|
|
||||||
|
private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) {
|
||||||
|
ArrayNode items = objectMapper.createArrayNode();
|
||||||
|
boolean truncated = false;
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||||
|
Statement transaction = connection.createStatement()) {
|
||||||
|
connection.setAutoCommit(false);
|
||||||
|
connection.setReadOnly(true);
|
||||||
|
transaction.execute("SET TRANSACTION READ ONLY");
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
|
||||||
|
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||||
|
statement.setFetchSize(MAX_RESULT_ROWS + 1);
|
||||||
|
statement.setMaxRows(MAX_RESULT_ROWS + 1);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
ResultSetMetaData metadata = resultSet.getMetaData();
|
||||||
|
while (resultSet.next()) {
|
||||||
|
if (items.size() >= MAX_RESULT_ROWS) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ObjectNode row = items.addObject();
|
||||||
|
for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) {
|
||||||
|
String column = metadata.getColumnLabel(columnIndex);
|
||||||
|
if (column == null || column.isBlank()) {
|
||||||
|
column = metadata.getColumnName(columnIndex);
|
||||||
|
}
|
||||||
|
putResultValue(row, column, resultSet.getObject(columnIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection.rollback();
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
return new QueryExecution(items, truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void putResultValue(ObjectNode row, String column, Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
row.putNull(column);
|
||||||
|
} else if (value instanceof BigDecimal number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof BigInteger number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Integer number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Long number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Short number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Float number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Double number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Boolean bool) {
|
||||||
|
row.put(column, bool);
|
||||||
|
} else {
|
||||||
|
row.put(column, String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateReadOnlySql(String generatedSql) {
|
||||||
|
String normalized = generatedSql == null ? "" : generatedSql.trim();
|
||||||
|
if (normalized.startsWith("```")) {
|
||||||
|
int firstLineEnd = normalized.indexOf('\n');
|
||||||
|
int closingFence = normalized.lastIndexOf("```");
|
||||||
|
if (firstLineEnd >= 0 && closingFence > firstLineEnd) {
|
||||||
|
normalized = normalized.substring(firstLineEnd + 1, closingFence).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized = normalized.replaceFirst(";\\s*$", "").trim();
|
||||||
|
if (!normalized.matches("(?is)^(select|with)\\b.*")) {
|
||||||
|
throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
|
||||||
|
}
|
||||||
|
if (normalized.contains(";")) {
|
||||||
|
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
|
||||||
|
}
|
||||||
|
if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|
||||||
|
|| UNSAFE_SQL.matcher(normalized).find()) {
|
||||||
|
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record QueryExecution(ArrayNode items, boolean truncated) {}
|
||||||
|
|
||||||
|
private record EnrichedPrompt(
|
||||||
|
String prompt, String status, int exampleCount, List<QaVectorService.VectorExample> examples) {}
|
||||||
|
|
||||||
|
private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {}
|
||||||
|
}
|
||||||
@@ -11,36 +11,25 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class StructuredDataService {
|
public class StructuredDataService {
|
||||||
|
|
||||||
private static final String OWNER = "POC_2";
|
|
||||||
private static final int ROW_LIMIT = 50;
|
private static final int ROW_LIMIT = 50;
|
||||||
private static final List<StructuredDataTable> TABLES = List.of(
|
|
||||||
new StructuredDataTable("customers", "KB_CUSTOMERS", "고객원장", "고객 기본정보"),
|
|
||||||
new StructuredDataTable("products", "KB_PRODUCTS", "상품원장", "보험상품 마스터"),
|
|
||||||
new StructuredDataTable("contracts", "KB_CONTRACTS", "계약원장", "보험계약 정보"),
|
|
||||||
new StructuredDataTable("coverages", "KB_COVERAGES", "담보원장", "보장·특약 정보"),
|
|
||||||
new StructuredDataTable("claims", "KB_CLAIMS", "청구원장", "보험금 청구·지급 정보"),
|
|
||||||
new StructuredDataTable("external-holdings", "KB_EXTERNAL_HOLDINGS", "외부보유정보 원장", "타사·외부 가입·보유 정보"),
|
|
||||||
new StructuredDataTable("stakeholders", "KB_STAKEHOLDERS", "이해관계자 원장", "역할·담당자 매핑"));
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final DataCatalog catalog;
|
||||||
|
|
||||||
public StructuredDataService(JdbcTemplate jdbcTemplate) {
|
public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<StructuredDataTable> tables() {
|
public List<StructuredDataTable> tables() {
|
||||||
return TABLES;
|
return catalog.objects();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String defaultKey() {
|
public String defaultKey() {
|
||||||
return TABLES.getFirst().key();
|
return catalog.objects().getFirst().key();
|
||||||
}
|
}
|
||||||
|
|
||||||
public StructuredDataTable requireTable(String key) {
|
public StructuredDataTable requireTable(String key) {
|
||||||
return TABLES.stream()
|
return catalog.require(key);
|
||||||
.filter(table -> table.key().equals(key))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public StructuredDataPreview preview(String key) {
|
public StructuredDataPreview preview(String key) {
|
||||||
@@ -54,7 +43,7 @@ public class StructuredDataService {
|
|||||||
AND table_name = ?
|
AND table_name = ?
|
||||||
ORDER BY column_id
|
ORDER BY column_id
|
||||||
""",
|
""",
|
||||||
(resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName());
|
(resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName());
|
||||||
if (columns.isEmpty()) {
|
if (columns.isEmpty()) {
|
||||||
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
@@ -63,24 +52,11 @@ public class StructuredDataService {
|
|||||||
previewSql(table), ROW_LIMIT);
|
previewSql(table), ROW_LIMIT);
|
||||||
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
||||||
} catch (DataAccessException exception) {
|
} catch (DataAccessException exception) {
|
||||||
throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요.");
|
throw new AppException("카탈로그 데이터를 조회할 수 없습니다. DB 권한과 대상 객체 상태를 확인하세요.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The table is selected from a closed application whitelist, so the query
|
|
||||||
* text remains fixed and no request value can become a SQL identifier.
|
|
||||||
*/
|
|
||||||
private String previewSql(StructuredDataTable table) {
|
private String previewSql(StructuredDataTable table) {
|
||||||
return switch (table.key()) {
|
return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?";
|
||||||
case "customers" -> "SELECT * FROM POC_2.KB_CUSTOMERS WHERE ROWNUM <= ?";
|
|
||||||
case "products" -> "SELECT * FROM POC_2.KB_PRODUCTS WHERE ROWNUM <= ?";
|
|
||||||
case "contracts" -> "SELECT * FROM POC_2.KB_CONTRACTS WHERE ROWNUM <= ?";
|
|
||||||
case "coverages" -> "SELECT * FROM POC_2.KB_COVERAGES WHERE ROWNUM <= ?";
|
|
||||||
case "claims" -> "SELECT * FROM POC_2.KB_CLAIMS WHERE ROWNUM <= ?";
|
|
||||||
case "external-holdings" -> "SELECT * FROM POC_2.KB_EXTERNAL_HOLDINGS WHERE ROWNUM <= ?";
|
|
||||||
case "stakeholders" -> "SELECT * FROM POC_2.KB_STAKEHOLDERS WHERE ROWNUM <= ?";
|
|
||||||
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ public class VectorKnowledgeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public VectorKnowledgeSummary summary() {
|
public VectorKnowledgeSummary summary() {
|
||||||
int documents = count("SELECT COUNT(DISTINCT document_id) FROM cb_vector_document_chunk");
|
int documents = count("SELECT COUNT(DISTINCT document_id) FROM sg_vector_document_chunk");
|
||||||
int chunks = count("SELECT COUNT(*) FROM cb_vector_document_chunk");
|
int chunks = count("SELECT COUNT(*) FROM sg_vector_document_chunk");
|
||||||
int tags = count("SELECT COUNT(*) FROM cb_vector_document_tag");
|
int tags = count("SELECT COUNT(*) FROM sg_vector_document_tag");
|
||||||
boolean registered = protectedObjectService.findEnabled().stream()
|
boolean registered = protectedObjectService.findEnabled().stream()
|
||||||
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
|
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
|
||||||
return new VectorKnowledgeSummary(
|
return new VectorKnowledgeSummary(
|
||||||
@@ -107,13 +107,13 @@ public class VectorKnowledgeService {
|
|||||||
String embeddingJson = json(embedding);
|
String embeddingJson = json(embedding);
|
||||||
long chunkId = nextChunkId++;
|
long chunkId = nextChunkId++;
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO cb_vector_document_chunk
|
INSERT INTO sg_vector_document_chunk
|
||||||
(chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
|
(chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
|
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
|
||||||
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
|
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
|
||||||
for (String tag : tags) {
|
for (String tag : tags) {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO cb_vector_document_tag (chunk_id, tech_tag)
|
INSERT INTO sg_vector_document_tag (chunk_id, tech_tag)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
""", chunkId, tag);
|
""", chunkId, tag);
|
||||||
tagCount++;
|
tagCount++;
|
||||||
@@ -143,18 +143,18 @@ public class VectorKnowledgeService {
|
|||||||
|
|
||||||
private void replaceDocument(String documentId) {
|
private void replaceDocument(String documentId) {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
DELETE FROM cb_vector_document_tag
|
DELETE FROM sg_vector_document_tag
|
||||||
WHERE chunk_id IN (
|
WHERE chunk_id IN (
|
||||||
SELECT chunk_id FROM cb_vector_document_chunk WHERE document_id = ?
|
SELECT chunk_id FROM sg_vector_document_chunk WHERE document_id = ?
|
||||||
)
|
)
|
||||||
""", documentId);
|
""", documentId);
|
||||||
jdbcTemplate.update("DELETE FROM cb_vector_document_chunk WHERE document_id = ?", documentId);
|
jdbcTemplate.update("DELETE FROM sg_vector_document_chunk WHERE document_id = ?", documentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private long nextChunkId(int chunkCount) {
|
private long nextChunkId(int chunkCount) {
|
||||||
try {
|
try {
|
||||||
Long sequenceValue = jdbcTemplate.queryForObject(
|
Long sequenceValue = jdbcTemplate.queryForObject(
|
||||||
"SELECT cb_vector_chunk_seq.NEXTVAL FROM dual", Long.class);
|
"SELECT sg_vector_chunk_seq.NEXTVAL FROM dual", Long.class);
|
||||||
if (sequenceValue != null) {
|
if (sequenceValue != null) {
|
||||||
return sequenceValue;
|
return sequenceValue;
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ public class VectorKnowledgeService {
|
|||||||
// fallback keeps the demonstration usable; the setup SQL creates it.
|
// fallback keeps the demonstration usable; the setup SQL creates it.
|
||||||
}
|
}
|
||||||
Long max = jdbcTemplate.queryForObject(
|
Long max = jdbcTemplate.queryForObject(
|
||||||
"SELECT NVL(MAX(chunk_id), 28000) FROM cb_vector_document_chunk", Long.class);
|
"SELECT NVL(MAX(chunk_id), 28000) FROM sg_vector_document_chunk", Long.class);
|
||||||
return (max == null ? 28000 : max) + 1;
|
return (max == null ? 28000 : max) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class DashboardController {
|
|||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
public String dashboard(Model model) {
|
public String dashboard(Model model) {
|
||||||
// The Smilegate PoC home is an identity-administration landing page.
|
// The backoffice home is an identity-administration landing page.
|
||||||
// It intentionally does not query legacy CB_* VPD catalog objects.
|
// It intentionally does not query legacy CB_* VPD catalog objects.
|
||||||
model.addAttribute("users", userService.findAll());
|
model.addAttribute("users", userService.findAll());
|
||||||
model.addAttribute("groups", groupService.findAll());
|
model.addAttribute("groups", groupService.findAll());
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.ProductProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.DataCatalog;
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
|
||||||
|
@ControllerAdvice
|
||||||
|
public class ProductModelAdvice {
|
||||||
|
private final ProductProperties product;
|
||||||
|
private final DataCatalog catalog;
|
||||||
|
private final McpProperties mcp;
|
||||||
|
public ProductModelAdvice(ProductProperties product, DataCatalog catalog, McpProperties mcp) {
|
||||||
|
this.product = product;
|
||||||
|
this.catalog = catalog;
|
||||||
|
this.mcp = mcp;
|
||||||
|
}
|
||||||
|
@ModelAttribute("product")
|
||||||
|
ProductProperties product() { return product; }
|
||||||
|
@ModelAttribute("catalogOwner")
|
||||||
|
String catalogOwner() { return catalog.owner(); }
|
||||||
|
@ModelAttribute("mcp")
|
||||||
|
McpProperties mcp() { return mcp; }
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.cloudhandson.vpdbackoffice.web;
|
|||||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||||
import com.cloudhandson.vpdbackoffice.service.SchemaMetadataService;
|
import com.cloudhandson.vpdbackoffice.service.SchemaMetadataService;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -13,6 +15,8 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
|||||||
@Controller
|
@Controller
|
||||||
public class SchemaMetadataController {
|
public class SchemaMetadataController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SchemaMetadataController.class);
|
||||||
|
|
||||||
private final SchemaMetadataService schemaMetadataService;
|
private final SchemaMetadataService schemaMetadataService;
|
||||||
|
|
||||||
public SchemaMetadataController(SchemaMetadataService schemaMetadataService) {
|
public SchemaMetadataController(SchemaMetadataService schemaMetadataService) {
|
||||||
@@ -27,7 +31,8 @@ public class SchemaMetadataController {
|
|||||||
try {
|
try {
|
||||||
model.addAttribute("metadata", schemaMetadataService.find(selectedKey));
|
model.addAttribute("metadata", schemaMetadataService.find(selectedKey));
|
||||||
} catch (AppException | DataAccessException exception) {
|
} catch (AppException | DataAccessException exception) {
|
||||||
model.addAttribute("errorMessage", safeMessage(exception));
|
log.warn("Schema metadata lookup failed for table key={}", selectedKey, exception);
|
||||||
|
model.addAttribute("errorMessage", readMessage(exception));
|
||||||
}
|
}
|
||||||
return "schema-metadata";
|
return "schema-metadata";
|
||||||
}
|
}
|
||||||
@@ -105,4 +110,10 @@ public class SchemaMetadataController {
|
|||||||
? exception.getMessage()
|
? exception.getMessage()
|
||||||
: "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요.";
|
: "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String readMessage(Exception exception) {
|
||||||
|
return exception instanceof AppException
|
||||||
|
? exception.getMessage()
|
||||||
|
: "DB 메타데이터를 조회하지 못했습니다. 카탈로그 소유자 조회 권한과 대상 객체 상태를 확인하세요.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,3 +68,49 @@ backoffice:
|
|||||||
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
|
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
|
||||||
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
||||||
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
|
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
|
||||||
|
select-ai:
|
||||||
|
# Cloud AI profiles are schema-owned. This connection must use the profile owner's account,
|
||||||
|
# not the control-plane account used by the backoffice.
|
||||||
|
db-url: ${BACKOFFICE_SELECT_AI_DB_URL:}
|
||||||
|
db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
|
||||||
|
db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
|
||||||
|
profile: ${BACKOFFICE_SELECT_AI_PROFILE:}
|
||||||
|
few-shot-enabled: ${BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED:true}
|
||||||
|
few-shot-top-k: ${BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K:3}
|
||||||
|
game-scope:
|
||||||
|
# Customer DB view contract: aliases + profile object list + Oracle objects.
|
||||||
|
# No customer game or table identifier is embedded in application code.
|
||||||
|
enabled: ${BACKOFFICE_GAME_SCOPE_ENABLED:false}
|
||||||
|
view-name: ${BACKOFFICE_GAME_SCOPE_VIEW:}
|
||||||
|
max-scopes: ${BACKOFFICE_GAME_SCOPE_MAX_SCOPES:8}
|
||||||
|
catalog:
|
||||||
|
owner: ${BACKOFFICE_CATALOG_OWNER:}
|
||||||
|
objects: ${BACKOFFICE_CATALOG_OBJECTS:}
|
||||||
|
product:
|
||||||
|
name: ${BACKOFFICE_PRODUCT_NAME:Data & AI Backoffice}
|
||||||
|
title: ${BACKOFFICE_PRODUCT_TITLE:Data & AI Backoffice}
|
||||||
|
data-label: ${BACKOFFICE_PRODUCT_DATA_LABEL:업무 데이터}
|
||||||
|
mcp:
|
||||||
|
tool-name: ${BACKOFFICE_MCP_TOOL_NAME:oracle.select_ai.data_text2sql}
|
||||||
|
tool-label: ${BACKOFFICE_MCP_TOOL_LABEL:업무 데이터 Text2SQL}
|
||||||
|
tool-description: ${BACKOFFICE_MCP_TOOL_DESCRIPTION:승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.}
|
||||||
|
prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.}
|
||||||
|
showprompt-tool-name: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME:oracle.select_ai.data_showprompt}
|
||||||
|
showprompt-tool-label: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL:업무 데이터 SHOWPROMPT}
|
||||||
|
showprompt-tool-description: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION:Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. 생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.}
|
||||||
|
qa-vector-search-tool-name: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_NAME:oracle.select_ai.qa_vector_search}
|
||||||
|
qa-vector-search-tool-label: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_LABEL:Select AI 예제 SQL 조회}
|
||||||
|
qa-vector-search-tool-description: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION:현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. 반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다.}
|
||||||
|
qa-vector-store-tool-name: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME:oracle.select_ai.qa_vector_store}
|
||||||
|
qa-vector-store-tool-label: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL:Select AI 예제 SQL 저장}
|
||||||
|
qa-vector-store-tool-description: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION:검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. 질문과 읽기 전용 답 SQL이 필요합니다.}
|
||||||
|
few-shot-nl2sql-tool-name: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_NAME:oracle.select_ai.smilegate_fewshot_nl2sql}
|
||||||
|
few-shot-nl2sql-tool-label: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_LABEL:Few-shot NL2SQL 실행}
|
||||||
|
few-shot-nl2sql-tool-description: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION:벡터 Few-shot 예제를 찾아 prompt에 반영하고 SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다.}
|
||||||
|
game-scope-tool-name: ${BACKOFFICE_MCP_GAME_SCOPE_TOOL_NAME:oracle.select_ai.game_scope_resolve}
|
||||||
|
game-scope-tool-label: ${BACKOFFICE_MCP_GAME_SCOPE_TOOL_LABEL:게임 조회 범위 확인}
|
||||||
|
game-scope-tool-description: ${BACKOFFICE_MCP_GAME_SCOPE_TOOL_DESCRIPTION:질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다. 데이터 SQL은 실행하지 않으며, 반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.}
|
||||||
|
masking:
|
||||||
|
policies: ${BACKOFFICE_MASKING_POLICIES:}
|
||||||
|
security-sql-scripts:
|
||||||
|
scripts: ${BACKOFFICE_SECURITY_SQL_SCRIPTS:}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
|
||||||
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
|
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
|
||||||
INSERT INTO hmm_access_audit (
|
INSERT INTO sg_audit_event (
|
||||||
event_type, key_id, object_id, status, row_count, error_code, message, created_at
|
audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
|
sg_audit_event_seq.NEXTVAL,
|
||||||
#{eventType,jdbcType=VARCHAR},
|
#{eventType,jdbcType=VARCHAR},
|
||||||
#{keyId,jdbcType=NUMERIC},
|
#{keyId,jdbcType=NUMERIC},
|
||||||
#{objectId,jdbcType=NUMERIC},
|
#{objectId,jdbcType=NUMERIC},
|
||||||
|
|||||||
@@ -3,47 +3,44 @@
|
|||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper">
|
||||||
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||||
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username,
|
SELECT k.key_id, k.user_id, u.user_name AS username,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
||||||
k.key_prefix, k.key_hash,
|
k.key_prefix, k.key_hash,
|
||||||
k.expires_at, k.revoked_at, k.description
|
k.expires_at, k.revoked_at, k.description
|
||||||
FROM hmm_access_bearer_tokens k
|
FROM sg_access_bearer_token k JOIN sg_app_user u ON u.user_id = k.user_id
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
|
|
||||||
WHERE #{includeInactive} = 1
|
WHERE #{includeInactive} = 1
|
||||||
OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP)
|
OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP)
|
||||||
ORDER BY k.key_id DESC
|
ORDER BY k.key_id DESC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||||
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username,
|
SELECT k.key_id, k.user_id, u.user_name AS username,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
||||||
k.key_prefix, k.key_hash,
|
k.key_prefix, k.key_hash,
|
||||||
k.expires_at, k.revoked_at, k.description
|
k.expires_at, k.revoked_at, k.description
|
||||||
FROM hmm_access_bearer_tokens k
|
FROM sg_access_bearer_token k JOIN sg_app_user u ON u.user_id = k.user_id
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
|
|
||||||
WHERE k.key_id = #{keyId}
|
WHERE k.key_id = #{keyId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findByHash" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
<select id="findByHash" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||||
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username,
|
SELECT k.key_id, k.user_id, u.user_name AS username,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
||||||
k.key_prefix, k.key_hash,
|
k.key_prefix, k.key_hash,
|
||||||
k.expires_at, k.revoked_at, k.description
|
k.expires_at, k.revoked_at, k.description
|
||||||
FROM hmm_access_bearer_tokens k
|
FROM sg_access_bearer_token k JOIN sg_app_user u ON u.user_id = k.user_id
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
|
|
||||||
WHERE k.key_hash = #{keyHash,jdbcType=VARCHAR}
|
WHERE k.key_hash = #{keyHash,jdbcType=VARCHAR}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextKeyId" resultType="long">
|
<select id="nextKeyId" resultType="long">
|
||||||
SELECT NVL(MAX(key_id), 0) + 1 FROM hmm_access_bearer_tokens
|
SELECT NVL(MAX(key_id), 0) + 1 FROM sg_access_bearer_token
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||||
INSERT INTO hmm_access_bearer_tokens (
|
INSERT INTO sg_access_bearer_token (
|
||||||
key_id, employee_id, key_prefix, key_hash, expires_at, revoked_at, description
|
key_id, user_id, key_prefix, key_hash, expires_at, revoked_at, description
|
||||||
) VALUES (
|
) VALUES (
|
||||||
#{keyId,jdbcType=NUMERIC},
|
#{keyId,jdbcType=NUMERIC},
|
||||||
#{userId,jdbcType=NUMERIC},
|
#{userId,jdbcType=NUMERIC},
|
||||||
@@ -56,7 +53,7 @@
|
|||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="revokeToken">
|
<update id="revokeToken">
|
||||||
UPDATE hmm_access_bearer_tokens
|
UPDATE sg_access_bearer_token
|
||||||
SET revoked_at = #{revokedAt,jdbcType=TIMESTAMP},
|
SET revoked_at = #{revokedAt,jdbcType=TIMESTAMP},
|
||||||
description = CASE
|
description = CASE
|
||||||
WHEN #{reason,jdbcType=VARCHAR} IS NULL THEN description
|
WHEN #{reason,jdbcType=VARCHAR} IS NULL THEN description
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
group_name,
|
group_name,
|
||||||
description,
|
description,
|
||||||
active_yn
|
active_yn
|
||||||
FROM hmm_access_groups
|
FROM sg_app_group
|
||||||
ORDER BY group_code
|
ORDER BY group_code
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
SELECT g.group_id,
|
SELECT g.group_id,
|
||||||
g.group_code,
|
g.group_code,
|
||||||
g.group_name,
|
g.group_name,
|
||||||
u.employee_id AS user_id,
|
u.user_id,
|
||||||
u.full_name AS username
|
u.user_name AS username
|
||||||
FROM hmm_access_group_members ug
|
FROM sg_user_group ug
|
||||||
JOIN hmm_access_groups g ON g.group_id = ug.group_id
|
JOIN sg_app_group g ON g.group_id = ug.group_id
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = ug.employee_id
|
JOIN sg_app_user u ON u.user_id = ug.user_id
|
||||||
ORDER BY g.group_code, u.full_name
|
ORDER BY g.group_code, u.user_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findGroupRoles" resultType="com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView">
|
<select id="findGroupRoles" resultType="com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView">
|
||||||
@@ -30,18 +30,18 @@
|
|||||||
g.group_name,
|
g.group_name,
|
||||||
r.role_id,
|
r.role_id,
|
||||||
r.role_name
|
r.role_name
|
||||||
FROM hmm_group_access_roles gr
|
FROM sg_group_role gr
|
||||||
JOIN hmm_access_groups g ON g.group_id = gr.group_id
|
JOIN sg_app_group g ON g.group_id = gr.group_id
|
||||||
JOIN hmm_access_roles r ON r.role_id = gr.role_id
|
JOIN sg_app_role r ON r.role_id = gr.role_id
|
||||||
ORDER BY g.group_code, r.role_name
|
ORDER BY g.group_code, r.role_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextGroupId" resultType="long">
|
<select id="nextGroupId" resultType="long">
|
||||||
SELECT NVL(MAX(group_id), 0) + 1 FROM hmm_access_groups
|
SELECT NVL(MAX(group_id), 0) + 1 FROM sg_app_group
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertGroup">
|
<insert id="insertGroup">
|
||||||
INSERT INTO hmm_access_groups (
|
INSERT INTO sg_app_group (
|
||||||
group_id, group_code, group_name, description, active_yn
|
group_id, group_code, group_name, description, active_yn
|
||||||
) VALUES (
|
) VALUES (
|
||||||
#{groupId,jdbcType=NUMERIC},
|
#{groupId,jdbcType=NUMERIC},
|
||||||
@@ -53,29 +53,29 @@
|
|||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="updateActive">
|
<update id="updateActive">
|
||||||
UPDATE hmm_access_groups
|
UPDATE sg_app_group
|
||||||
SET active_yn = #{activeYn,jdbcType=VARCHAR}
|
SET active_yn = #{activeYn,jdbcType=VARCHAR}
|
||||||
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<insert id="insertGroupUser">
|
<insert id="insertGroupUser">
|
||||||
INSERT INTO hmm_access_group_members (group_id, employee_id)
|
INSERT INTO sg_user_group (group_id, user_id)
|
||||||
VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC})
|
VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC})
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteGroupUser">
|
<delete id="deleteGroupUser">
|
||||||
DELETE FROM hmm_access_group_members
|
DELETE FROM sg_user_group
|
||||||
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
||||||
AND employee_id = #{userId,jdbcType=NUMERIC}
|
AND user_id = #{userId,jdbcType=NUMERIC}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
<insert id="insertGroupRole">
|
<insert id="insertGroupRole">
|
||||||
INSERT INTO hmm_group_access_roles (group_id, role_id)
|
INSERT INTO sg_group_role (group_id, role_id)
|
||||||
VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC})
|
VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC})
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteGroupRole">
|
<delete id="deleteGroupRole">
|
||||||
DELETE FROM hmm_group_access_roles
|
DELETE FROM sg_group_role
|
||||||
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
||||||
AND role_id = #{roleId,jdbcType=NUMERIC}
|
AND role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</delete>
|
</delete>
|
||||||
|
|||||||
@@ -4,41 +4,41 @@
|
|||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper">
|
||||||
<select id="findAllRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
<select id="findAllRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
||||||
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
||||||
FROM cb_masking_rule
|
FROM sg_masking_rule
|
||||||
ORDER BY enabled_yn DESC, rule_name, rule_id
|
ORDER BY enabled_yn DESC, rule_name, rule_id
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findEnabledRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
<select id="findEnabledRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
||||||
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
||||||
FROM cb_masking_rule
|
FROM sg_masking_rule
|
||||||
WHERE enabled_yn = 'Y'
|
WHERE enabled_yn = 'Y'
|
||||||
ORDER BY rule_name, rule_id
|
ORDER BY rule_name, rule_id
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findRuleById" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
<select id="findRuleById" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
||||||
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
||||||
FROM cb_masking_rule
|
FROM sg_masking_rule
|
||||||
WHERE rule_id = #{ruleId}
|
WHERE rule_id = #{ruleId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findRuleByCode" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
<select id="findRuleByCode" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
||||||
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
||||||
FROM cb_masking_rule
|
FROM sg_masking_rule
|
||||||
WHERE rule_code = #{ruleCode}
|
WHERE rule_code = #{ruleCode}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextRuleId" resultType="long">
|
<select id="nextRuleId" resultType="long">
|
||||||
SELECT NVL(MAX(rule_id), 0) + 1 FROM cb_masking_rule
|
SELECT NVL(MAX(rule_id), 0) + 1 FROM sg_masking_rule
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertRule">
|
<insert id="insertRule">
|
||||||
INSERT INTO cb_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn)
|
INSERT INTO sg_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn)
|
||||||
VALUES (#{ruleId}, #{command.ruleCode}, #{command.ruleName}, #{command.templateCode},
|
VALUES (#{ruleId}, #{command.ruleCode}, #{command.ruleName}, #{command.templateCode},
|
||||||
#{command.description}, 'Y')
|
#{command.description}, 'Y')
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="updateRuleActive">
|
<update id="updateRuleActive">
|
||||||
UPDATE cb_masking_rule
|
UPDATE sg_masking_rule
|
||||||
SET enabled_yn = #{enabledYn}
|
SET enabled_yn = #{enabledYn}
|
||||||
WHERE rule_id = #{ruleId}
|
WHERE rule_id = #{ruleId}
|
||||||
</update>
|
</update>
|
||||||
@@ -54,10 +54,10 @@
|
|||||||
r.rule_name,
|
r.rule_name,
|
||||||
r.template_code,
|
r.template_code,
|
||||||
r.enabled_yn AS rule_enabled_yn
|
r.enabled_yn AS rule_enabled_yn
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_protected_column c ON c.column_id = link.column_id
|
JOIN sg_protected_column c ON c.column_id = link.column_id
|
||||||
JOIN cb_protected_object o ON o.object_id = c.object_id
|
JOIN sg_protected_object o ON o.object_id = c.object_id
|
||||||
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
|
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
|
||||||
ORDER BY o.owner, o.object_name, c.column_name
|
ORDER BY o.owner, o.object_name, c.column_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -68,19 +68,18 @@
|
|||||||
-->
|
-->
|
||||||
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
|
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
|
||||||
WITH managed_policy AS (
|
WITH managed_policy AS (
|
||||||
SELECT 'KB_CUSTOMERS' AS object_name, 'KB_CUSTOMER_PII_REDACT' AS policy_name FROM dual
|
<foreach collection="targets" item="target" separator=" UNION ALL ">
|
||||||
UNION ALL SELECT 'KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT' FROM dual
|
SELECT #{target.objectName} AS object_name, #{target.policyName} AS policy_name FROM dual
|
||||||
UNION ALL SELECT 'KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT' FROM dual
|
</foreach>
|
||||||
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT' FROM dual
|
|
||||||
),
|
),
|
||||||
configured AS (
|
configured AS (
|
||||||
SELECT protected_object.object_name,
|
SELECT protected_object.object_name,
|
||||||
COUNT(*) AS configured_column_count
|
COUNT(*) AS configured_column_count
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
|
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||||
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
|
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||||
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||||
WHERE protected_object.owner = 'POC_2'
|
WHERE protected_object.owner = #{owner}
|
||||||
AND rule.enabled_yn = 'Y'
|
AND rule.enabled_yn = 'Y'
|
||||||
GROUP BY protected_object.object_name
|
GROUP BY protected_object.object_name
|
||||||
),
|
),
|
||||||
@@ -96,21 +95,21 @@
|
|||||||
LEFT JOIN redaction_columns policy_column
|
LEFT JOIN redaction_columns policy_column
|
||||||
ON policy_column.object_owner = policy.object_owner
|
ON policy_column.object_owner = policy.object_owner
|
||||||
AND policy_column.object_name = policy.object_name
|
AND policy_column.object_name = policy.object_name
|
||||||
WHERE policy.object_owner = 'POC_2'
|
WHERE policy.object_owner = #{owner}
|
||||||
GROUP BY policy.object_name, policy.policy_name, policy.enable
|
GROUP BY policy.object_name, policy.policy_name, policy.enable
|
||||||
),
|
),
|
||||||
missing_columns AS (
|
missing_columns AS (
|
||||||
SELECT protected_object.object_name,
|
SELECT protected_object.object_name,
|
||||||
COUNT(*) AS missing_column_count
|
COUNT(*) AS missing_column_count
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
|
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||||
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
|
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||||
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||||
LEFT JOIN redaction_columns policy_column
|
LEFT JOIN redaction_columns policy_column
|
||||||
ON policy_column.object_owner = protected_object.owner
|
ON policy_column.object_owner = protected_object.owner
|
||||||
AND policy_column.object_name = protected_object.object_name
|
AND policy_column.object_name = protected_object.object_name
|
||||||
AND policy_column.column_name = protected_column.column_name
|
AND policy_column.column_name = protected_column.column_name
|
||||||
WHERE protected_object.owner = 'POC_2'
|
WHERE protected_object.owner = #{owner}
|
||||||
AND rule.enabled_yn = 'Y'
|
AND rule.enabled_yn = 'Y'
|
||||||
AND policy_column.column_name IS NULL
|
AND policy_column.column_name IS NULL
|
||||||
GROUP BY protected_object.object_name
|
GROUP BY protected_object.object_name
|
||||||
@@ -120,14 +119,14 @@
|
|||||||
COUNT(*) AS extra_column_count
|
COUNT(*) AS extra_column_count
|
||||||
FROM redaction_columns policy_column
|
FROM redaction_columns policy_column
|
||||||
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
|
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
|
||||||
WHERE policy_column.object_owner = 'POC_2'
|
WHERE policy_column.object_owner = #{owner}
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
|
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||||
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
|
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||||
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||||
WHERE protected_object.owner = 'POC_2'
|
WHERE protected_object.owner = #{owner}
|
||||||
AND protected_object.object_name = policy_column.object_name
|
AND protected_object.object_name = policy_column.object_name
|
||||||
AND protected_column.column_name = policy_column.column_name
|
AND protected_column.column_name = policy_column.column_name
|
||||||
AND rule.enabled_yn = 'Y'
|
AND rule.enabled_yn = 'Y'
|
||||||
@@ -138,18 +137,14 @@
|
|||||||
SELECT policy.object_name,
|
SELECT policy.object_name,
|
||||||
COUNT(*) AS legacy_vpd_column_policy_count
|
COUNT(*) AS legacy_vpd_column_policy_count
|
||||||
FROM all_policies policy
|
FROM all_policies policy
|
||||||
WHERE policy.object_owner = 'POC_2'
|
JOIN managed_policy managed
|
||||||
|
ON managed.object_name = policy.object_name
|
||||||
|
AND managed.policy_name = policy.policy_name
|
||||||
|
WHERE policy.object_owner = #{owner}
|
||||||
AND policy.enable = 'YES'
|
AND policy.enable = 'YES'
|
||||||
AND policy.policy_name IN (
|
|
||||||
'KB_CUST_NM_CLS_POLICY', 'KB_RRN_CLS_POLICY',
|
|
||||||
'KB_PREMIUM_CLS_POLICY',
|
|
||||||
'KB_CLAIM_AMT_CLS_POLICY', 'KB_PAID_AMT_CLS_POLICY',
|
|
||||||
'KB_EXT_INSURER_CLS_POLICY', 'KB_EXT_PRODUCT_GRP_CLS_POLICY',
|
|
||||||
'KB_EXT_PRODUCT_TYPE_CLS_POLICY'
|
|
||||||
)
|
|
||||||
GROUP BY policy.object_name
|
GROUP BY policy.object_name
|
||||||
)
|
)
|
||||||
SELECT 'POC_2' AS owner,
|
SELECT #{owner} AS owner,
|
||||||
managed.object_name,
|
managed.object_name,
|
||||||
managed.policy_name,
|
managed.policy_name,
|
||||||
database_policy.enable AS enabled,
|
database_policy.enable AS enabled,
|
||||||
@@ -180,15 +175,15 @@
|
|||||||
r.rule_name,
|
r.rule_name,
|
||||||
r.template_code,
|
r.template_code,
|
||||||
r.enabled_yn AS rule_enabled_yn
|
r.enabled_yn AS rule_enabled_yn
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_protected_column c ON c.column_id = link.column_id
|
JOIN sg_protected_column c ON c.column_id = link.column_id
|
||||||
JOIN cb_protected_object o ON o.object_id = c.object_id
|
JOIN sg_protected_object o ON o.object_id = c.object_id
|
||||||
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
|
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
|
||||||
WHERE c.column_id = #{columnId}
|
WHERE c.column_id = #{columnId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="upsertColumnRule">
|
<insert id="upsertColumnRule">
|
||||||
MERGE INTO cb_column_masking_rule dst
|
MERGE INTO sg_column_masking_rule dst
|
||||||
USING (SELECT #{columnId} column_id, #{ruleId} rule_id FROM dual) src
|
USING (SELECT #{columnId} column_id, #{ruleId} rule_id FROM dual) src
|
||||||
ON (dst.column_id = src.column_id)
|
ON (dst.column_id = src.column_id)
|
||||||
WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP
|
WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP
|
||||||
@@ -197,12 +192,12 @@
|
|||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteColumnRule">
|
<delete id="deleteColumnRule">
|
||||||
DELETE FROM cb_column_masking_rule
|
DELETE FROM sg_column_masking_rule
|
||||||
WHERE column_id = #{columnId}
|
WHERE column_id = #{columnId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
<delete id="deleteUserRulesForColumn">
|
<delete id="deleteUserRulesForColumn">
|
||||||
DELETE FROM cb_user_masking_rule
|
DELETE FROM sg_user_masking_rule
|
||||||
WHERE column_id = #{columnId}
|
WHERE column_id = #{columnId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
@@ -217,12 +212,12 @@
|
|||||||
r.template_code,
|
r.template_code,
|
||||||
assignment.decision,
|
assignment.decision,
|
||||||
assignment.active_yn
|
assignment.active_yn
|
||||||
FROM cb_user_masking_rule assignment
|
FROM sg_user_masking_rule assignment
|
||||||
JOIN cb_app_user u ON u.user_id = assignment.user_id
|
JOIN sg_app_user u ON u.user_id = assignment.user_id
|
||||||
JOIN cb_protected_column c ON c.column_id = assignment.column_id
|
JOIN sg_protected_column c ON c.column_id = assignment.column_id
|
||||||
JOIN cb_protected_object o ON o.object_id = c.object_id
|
JOIN sg_protected_object o ON o.object_id = c.object_id
|
||||||
JOIN cb_column_masking_rule link ON link.column_id = c.column_id
|
JOIN sg_column_masking_rule link ON link.column_id = c.column_id
|
||||||
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
|
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
|
||||||
ORDER BY u.user_name, o.owner, o.object_name, c.column_name
|
ORDER BY u.user_name, o.owner, o.object_name, c.column_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -237,18 +232,18 @@
|
|||||||
r.template_code,
|
r.template_code,
|
||||||
assignment.decision,
|
assignment.decision,
|
||||||
assignment.active_yn
|
assignment.active_yn
|
||||||
FROM cb_user_masking_rule assignment
|
FROM sg_user_masking_rule assignment
|
||||||
JOIN cb_app_user u ON u.user_id = assignment.user_id
|
JOIN sg_app_user u ON u.user_id = assignment.user_id
|
||||||
JOIN cb_protected_column c ON c.column_id = assignment.column_id
|
JOIN sg_protected_column c ON c.column_id = assignment.column_id
|
||||||
JOIN cb_protected_object o ON o.object_id = c.object_id
|
JOIN sg_protected_object o ON o.object_id = c.object_id
|
||||||
JOIN cb_column_masking_rule link ON link.column_id = c.column_id
|
JOIN sg_column_masking_rule link ON link.column_id = c.column_id
|
||||||
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
|
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
|
||||||
WHERE assignment.user_id = #{userId}
|
WHERE assignment.user_id = #{userId}
|
||||||
AND assignment.column_id = #{columnId}
|
AND assignment.column_id = #{columnId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="upsertUserRule">
|
<insert id="upsertUserRule">
|
||||||
MERGE INTO cb_user_masking_rule dst
|
MERGE INTO sg_user_masking_rule dst
|
||||||
USING (SELECT #{userId} user_id, #{columnId} column_id, #{decision} decision FROM dual) src
|
USING (SELECT #{userId} user_id, #{columnId} column_id, #{decision} decision FROM dual) src
|
||||||
ON (dst.user_id = src.user_id AND dst.column_id = src.column_id)
|
ON (dst.user_id = src.user_id AND dst.column_id = src.column_id)
|
||||||
WHEN MATCHED THEN UPDATE SET dst.decision = src.decision, dst.active_yn = 'Y', dst.updated_at = SYSTIMESTAMP
|
WHEN MATCHED THEN UPDATE SET dst.decision = src.decision, dst.active_yn = 'Y', dst.updated_at = SYSTIMESTAMP
|
||||||
@@ -257,7 +252,7 @@
|
|||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteUserRule">
|
<delete id="deleteUserRule">
|
||||||
DELETE FROM cb_user_masking_rule
|
DELETE FROM sg_user_masking_rule
|
||||||
WHERE user_id = #{userId}
|
WHERE user_id = #{userId}
|
||||||
AND column_id = #{columnId}
|
AND column_id = #{columnId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
role_name,
|
role_name,
|
||||||
description,
|
description,
|
||||||
max_sensitivity_level
|
max_sensitivity_level
|
||||||
FROM hmm_access_roles
|
FROM sg_app_role
|
||||||
WHERE active_yn = 'Y'
|
|
||||||
ORDER BY role_name
|
ORDER BY role_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -17,45 +16,44 @@
|
|||||||
role_name,
|
role_name,
|
||||||
description,
|
description,
|
||||||
max_sensitivity_level
|
max_sensitivity_level
|
||||||
FROM hmm_access_roles
|
FROM sg_app_role
|
||||||
WHERE role_id = #{roleId}
|
WHERE role_id = #{roleId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextRoleId" resultType="long">
|
<select id="nextRoleId" resultType="long">
|
||||||
SELECT NVL(MAX(role_id), 0) + 1 FROM hmm_access_roles
|
SELECT NVL(MAX(role_id), 0) + 1 FROM sg_app_role
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertRole">
|
<insert id="insertRole">
|
||||||
INSERT INTO hmm_access_roles (role_id, role_name, description, max_sensitivity_level, active_yn)
|
INSERT INTO sg_app_role (role_id, role_name, description, max_sensitivity_level)
|
||||||
VALUES (
|
VALUES (
|
||||||
#{roleId,jdbcType=NUMERIC},
|
#{roleId,jdbcType=NUMERIC},
|
||||||
UPPER(#{roleName,jdbcType=VARCHAR}),
|
UPPER(#{roleName,jdbcType=VARCHAR}),
|
||||||
#{description,jdbcType=VARCHAR},
|
#{description,jdbcType=VARCHAR},
|
||||||
#{maxSensitivityLevel,jdbcType=VARCHAR},
|
#{maxSensitivityLevel,jdbcType=VARCHAR}
|
||||||
'Y'
|
|
||||||
)
|
)
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="updateRoleMaxSensitivity">
|
<update id="updateRoleMaxSensitivity">
|
||||||
UPDATE hmm_access_roles
|
UPDATE sg_app_role
|
||||||
SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR}
|
SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR}
|
||||||
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<delete id="deleteRole">
|
<delete id="deleteRole">
|
||||||
DELETE FROM hmm_access_roles
|
DELETE FROM sg_app_role
|
||||||
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
<select id="countUserRolesByRoleId" resultType="int">
|
<select id="countUserRolesByRoleId" resultType="int">
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM hmm_employee_access_roles
|
FROM sg_user_role
|
||||||
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="countGroupRolesByRoleId" resultType="int">
|
<select id="countGroupRolesByRoleId" resultType="int">
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM hmm_group_access_roles
|
FROM sg_group_role
|
||||||
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
100
src/main/resources/mapper/SchemaMetadataMapper.xml
Normal file
100
src/main/resources/mapper/SchemaMetadataMapper.xml
Normal 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>
|
||||||
@@ -4,12 +4,12 @@
|
|||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.SettingMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.SettingMapper">
|
||||||
<select id="findByKey" resultType="com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting">
|
<select id="findByKey" resultType="com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting">
|
||||||
SELECT setting_key, setting_value
|
SELECT setting_key, setting_value
|
||||||
FROM cb_backoffice_setting
|
FROM sg_backoffice_setting
|
||||||
WHERE setting_key = #{settingKey,jdbcType=VARCHAR}
|
WHERE setting_key = #{settingKey,jdbcType=VARCHAR}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<update id="upsert">
|
<update id="upsert">
|
||||||
MERGE INTO cb_backoffice_setting dst
|
MERGE INTO sg_backoffice_setting dst
|
||||||
USING (
|
USING (
|
||||||
SELECT #{settingKey,jdbcType=VARCHAR} setting_key,
|
SELECT #{settingKey,jdbcType=VARCHAR} setting_key,
|
||||||
#{settingValue,jdbcType=VARCHAR} setting_value
|
#{settingValue,jdbcType=VARCHAR} setting_value
|
||||||
|
|||||||
@@ -3,31 +3,36 @@
|
|||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper">
|
||||||
<select id="findTokenSubjects" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
<select id="findTokenSubjects" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
||||||
SELECT s.user_id AS stakeholder_user_id,
|
SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
|
||||||
s.user_nm AS username,
|
u.user_name AS username,
|
||||||
s.role,
|
COALESCE((
|
||||||
s.channel,
|
SELECT MIN(r.role_name)
|
||||||
s.access_scope,
|
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
|
u.user_id AS app_user_id
|
||||||
FROM poc_2.kb_stakeholders s
|
FROM sg_app_user u
|
||||||
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
|
|
||||||
WHERE u.active = 'Y'
|
WHERE u.active = 'Y'
|
||||||
ORDER BY CASE s.role WHEN '지점장' THEN 1 WHEN '설계사' THEN 2 ELSE 3 END,
|
ORDER BY role, u.dept_code, u.user_name, u.user_id
|
||||||
s.channel,
|
|
||||||
s.user_nm,
|
|
||||||
s.user_id
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findTokenSubject" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
<select id="findTokenSubject" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
||||||
SELECT s.user_id AS stakeholder_user_id,
|
SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
|
||||||
s.user_nm AS username,
|
u.user_name AS username,
|
||||||
s.role,
|
COALESCE((
|
||||||
s.channel,
|
SELECT MIN(r.role_name)
|
||||||
s.access_scope,
|
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
|
u.user_id AS app_user_id
|
||||||
FROM poc_2.kb_stakeholders s
|
FROM sg_app_user u
|
||||||
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
|
WHERE TO_CHAR(u.user_id) = #{stakeholderUserId,jdbcType=VARCHAR}
|
||||||
WHERE s.user_id = #{stakeholderUserId,jdbcType=VARCHAR}
|
|
||||||
AND u.active = 'Y'
|
AND u.active = 'Y'
|
||||||
</select>
|
</select>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -3,27 +3,16 @@
|
|||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.UserMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.UserMapper">
|
||||||
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
||||||
SELECT e.employee_id AS user_id,
|
SELECT user_id, user_name AS username, employee_no AS emp_no, dept_code,
|
||||||
e.full_name AS username,
|
can_read_contents, active AS active_yn
|
||||||
e.employee_code AS emp_no,
|
FROM sg_app_user
|
||||||
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
|
|
||||||
ORDER BY username
|
ORDER BY username
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
||||||
SELECT e.employee_id AS user_id,
|
SELECT user_id, user_name AS username, employee_no AS emp_no, dept_code,
|
||||||
e.full_name AS username,
|
can_read_contents, active AS active_yn
|
||||||
e.employee_code AS emp_no,
|
FROM sg_app_user WHERE user_id = #{userId}
|
||||||
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>
|
</select>
|
||||||
|
|
||||||
<select id="findUserRoles" resultType="com.cloudhandson.vpdbackoffice.domain.user.UserRoleView">
|
<select id="findUserRoles" resultType="com.cloudhandson.vpdbackoffice.domain.user.UserRoleView">
|
||||||
@@ -31,46 +20,32 @@
|
|||||||
u.user_name AS username,
|
u.user_name AS username,
|
||||||
r.role_id,
|
r.role_id,
|
||||||
r.role_name
|
r.role_name
|
||||||
FROM hmm_employee_access_roles ur
|
FROM sg_user_role ur
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = ur.employee_id
|
JOIN sg_app_user u ON u.user_id = ur.user_id
|
||||||
JOIN hmm_access_roles r ON r.role_id = ur.role_id
|
JOIN sg_app_role r ON r.role_id = ur.role_id
|
||||||
ORDER BY u.full_name, r.role_name
|
ORDER BY u.user_name, r.role_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextUserId" resultType="long">
|
<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>
|
</select>
|
||||||
|
|
||||||
<insert id="insertUser">
|
<insert id="insertUser">
|
||||||
INSERT INTO hmm_hr_employees (
|
INSERT INTO sg_app_user (user_id, login_id, user_name, employee_no, dept_code, can_read_contents, active)
|
||||||
employee_id, employee_code, full_name, email, job_title, team_id, hire_date, employment_status
|
VALUES (#{userId}, LOWER(#{command.empNo}), #{command.username}, #{command.empNo},
|
||||||
)
|
#{command.deptCode}, 'N', 'Y')
|
||||||
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>
|
</insert>
|
||||||
|
|
||||||
<update id="updateActive">
|
<update id="updateActive">
|
||||||
UPDATE hmm_hr_employees
|
UPDATE sg_app_user SET active = #{activeYn} WHERE user_id = #{userId}
|
||||||
SET employment_status = CASE WHEN #{activeYn} = 'Y' THEN 'ACTIVE' ELSE 'INACTIVE' END
|
|
||||||
WHERE employee_id = #{userId}
|
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<insert id="insertUserRole">
|
<insert id="insertUserRole">
|
||||||
INSERT INTO hmm_employee_access_roles (employee_id, role_id)
|
INSERT INTO sg_user_role (user_id, role_id) VALUES (#{userId}, #{roleId})
|
||||||
VALUES (#{userId}, #{roleId})
|
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteUserRole">
|
<delete id="deleteUserRole">
|
||||||
DELETE FROM hmm_employee_access_roles
|
DELETE FROM sg_user_role WHERE user_id = #{userId}
|
||||||
WHERE employee_id = #{userId}
|
|
||||||
AND role_id = #{roleId}
|
AND role_id = #{roleId}
|
||||||
</delete>
|
</delete>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -728,16 +728,16 @@ function permissionRuleBusinessLabel(type, column, value) {
|
|||||||
return `토큰 이해관계자 본인 행 (${displayColumn})`;
|
return `토큰 이해관계자 본인 행 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'OWN_CONTRACT') {
|
if (type === 'OWN_CONTRACT') {
|
||||||
return `담당 설계사 본인 계약 (${displayColumn})`;
|
return `담당 게임 서비스 범위 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'CHANNEL_CONTRACT') {
|
if (type === 'CHANNEL_CONTRACT') {
|
||||||
return `토큰 사용자 채널 계약 (${displayColumn})`;
|
return `토큰 채널 게임 서비스 범위 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'OWN_CUSTOMER') {
|
if (type === 'OWN_CUSTOMER') {
|
||||||
return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
|
return `담당 게임 사용자·거래 데이터 범위 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'CHANNEL_CUSTOMER') {
|
if (type === 'CHANNEL_CUSTOMER') {
|
||||||
return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
|
return `토큰 채널 게임 사용자·거래 데이터 범위 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'STATIC_SQL') {
|
if (type === 'STATIC_SQL') {
|
||||||
return `정적 SQL 조건: ${value || '조건식 미입력'}`;
|
return `정적 SQL 조건: ${value || '조건식 미입력'}`;
|
||||||
@@ -813,10 +813,10 @@ function collectWizardPredicates(root) {
|
|||||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`;
|
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`;
|
||||||
}
|
}
|
||||||
if (type === 'STAKEHOLDER_SELF') {
|
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') {
|
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') {
|
if (type === 'TOKEN_SUBJECT') {
|
||||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`;
|
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')`;
|
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`;
|
||||||
}
|
}
|
||||||
if (type === 'OWN_CUSTOMER') {
|
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') {
|
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') {
|
if (type === 'DEPT' || type === 'EMP_NO') {
|
||||||
return `${displayColumn} = ${sqlLiteral(value)}`;
|
return `${displayColumn} = ${sqlLiteral(value)}`;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<p>업무 사용자와 데이터 접근 기준을 관리하고, DB가 적용한 결과까지 확인합니다.</p>
|
<p>업무 사용자와 데이터 접근 기준을 관리하고, DB가 적용한 결과까지 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-hero-actions" aria-label="주요 작업">
|
<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>
|
<a class="btn rw-btn-secondary" href="/permissions">행 접근 규칙</a>
|
||||||
</div>
|
</div>
|
||||||
<details class="explanation-details dashboard-explanation">
|
<details class="explanation-details dashboard-explanation">
|
||||||
@@ -152,10 +152,10 @@
|
|||||||
<h2 id="menu-flow-title">설정은 접근 제어에서, 적용 확인은 접근 검증에서</h2>
|
<h2 id="menu-flow-title">설정은 접근 제어에서, 적용 확인은 접근 검증에서</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-flow-grid">
|
<div class="dashboard-flow-grid">
|
||||||
<a class="dashboard-flow-card" href="/permissions"><strong>행 접근 규칙</strong><span>역할별 객체·행 조건</span></a>
|
<a class="dashboard-flow-card" href="/users"><strong>사용자 관리</strong><span>PoC 운영 사용자를 관리</span></a>
|
||||||
<a class="dashboard-flow-card" href="/vpd-policies"><strong>보호·검증</strong><span>보호 연결·검증 세션·접근 확인</span></a>
|
<a class="dashboard-flow-card" href="/groups"><strong>그룹 관리</strong><span>Data & AI TF 구성</span></a>
|
||||||
<a class="dashboard-flow-card" href="/objects"><strong>연동 도구</strong><span>조회 대상·지식 검색·MCP 연동</span></a>
|
<a class="dashboard-flow-card" href="/roles"><strong>역할 관리</strong><span>운영 역할을 관리</span></a>
|
||||||
<a class="dashboard-flow-card" href="/operation-status"><strong>운영</strong><span>상태와 실행 결과 확인</span></a>
|
<a class="dashboard-flow-card" href="/permissions"><strong>행 접근 규칙</strong><span>게임 데이터 객체별 접근 규칙</span></a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,20 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="backoffice-can-mutate" th:content="${canMutate}">
|
<meta name="backoffice-can-mutate" th:content="${canMutate}">
|
||||||
<meta name="backoffice-read-only" th:content="${readOnlyMode}">
|
<meta name="backoffice-read-only" th:content="${readOnlyMode}">
|
||||||
<title th:text="${title == 'SMILEGATE DATA & AI POC' ? title : title + ' · SMILEGATE DATA & AI POC'}">SMILEGATE DATA & AI POC</title>
|
<title th:text="${title == (product?.pageTitle() ?: 'Data & AI Backoffice') ? title : title + ' · ' + (product?.pageTitle() ?: 'Data & AI Backoffice')}">Data & AI Backoffice</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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">
|
<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="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||||
<script defer src="/js/app.js"></script>
|
<script defer src="/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
|
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
|
||||||
<div class="container rw-nav-top">
|
<div class="container rw-nav-top">
|
||||||
<a class="navbar-brand" href="/">SMILEGATE DATA & AI POC</a>
|
<a class="navbar-brand" href="/" th:text="${product?.displayName() ?: 'Data & AI Backoffice'}">Data & AI Backoffice</a>
|
||||||
<div class="rw-menu" aria-label="주요 메뉴">
|
<div class="rw-menu" aria-label="주요 메뉴">
|
||||||
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">운영 사용자 관리</button>
|
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">운영 사용자 관리</button>
|
||||||
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>
|
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>
|
||||||
|
|||||||
@@ -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>
|
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="status : ${policyStatuses}">
|
<tr th:each="status : ${policyStatuses}">
|
||||||
<td><code th:text="${status.targetLabel()}">POC_2.KB_CUSTOMERS</code></td>
|
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
|
||||||
<td><code th:text="${status.policyName()}">KB_CUSTOMER_PII_REDACT</code></td>
|
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
|
||||||
<td th:text="${status.configuredColumnCount()}">0</td>
|
<td th:text="${status.configuredColumnCount()}">0</td>
|
||||||
<td th:text="${status.appliedColumnCount()}">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>
|
<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">
|
<section class="content-band">
|
||||||
<h2>컬럼 마스킹 규칙 등록</h2>
|
<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">
|
<form method="post" action="/masking-rules" class="form-grid">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<label>
|
<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>
|
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
규칙명
|
규칙명
|
||||||
<input class="form-control" name="ruleName" maxlength="100" required placeholder="주민번호 기본 마스킹">
|
<input class="form-control" name="ruleName" maxlength="100" required placeholder="게임 사용자 식별자 기본 마스킹">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
마스킹 템플릿
|
마스킹 템플릿
|
||||||
@@ -176,8 +176,8 @@
|
|||||||
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
|
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="rule : ${rules}">
|
<tr th:each="rule : ${rules}">
|
||||||
<td><code th:text="${rule.ruleCode()}">KB_RRN_STANDARD</code></td>
|
<td><code th:text="${rule.ruleCode()}">GAME_USER_ID_MASK</code></td>
|
||||||
<td th:text="${rule.ruleName()}">주민번호 기본 마스킹</td>
|
<td th:text="${rule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
|
||||||
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
|
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
|
||||||
<td th:text="${rule.description() ?: '-'}">설명</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>
|
<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()])}">
|
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
|
||||||
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
|
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
|
||||||
th:value="|${object.objectId()}:${columnName}|"
|
th:value="|${object.objectId()}:${columnName}|"
|
||||||
th:text="${object.displayName() + '.' + columnName}">POC_2.KB_CONTRACTS.PREMIUM</option>
|
th:text="${object.displayName() + '.' + columnName}">OWNER.OBJECT_NAME.COLUMN_NAME</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</th:block>
|
</th:block>
|
||||||
</select>
|
</select>
|
||||||
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 객체만 표시됩니다. 예: <code>POC_2.KB_CONTRACTS.PREMIUM</code>.</span>
|
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 등록 업무 데이터 객체만 표시됩니다. 예: <code>OWNER.OBJECT_NAME.COLUMN_NAME</code>.</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
|
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -253,9 +253,9 @@
|
|||||||
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
|
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="columnRule : ${columnRules}">
|
<tr th:each="columnRule : ${columnRules}">
|
||||||
<td><code th:text="${columnRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
|
<td><code th:text="${columnRule.targetLabel()}">OWNER.OBJECT_NAME.COLUMN_NAME</code></td>
|
||||||
<td th:text="${columnRule.ruleName()}">주민번호 기본 마스킹</td>
|
<td th:text="${columnRule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
|
||||||
<td th:text="${columnRule.template().label()}">주민등록번호 부분 마스킹</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><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()]}">
|
<td th:with="policyStatus=${policyStatusByObjectName[columnRule.objectName()]}">
|
||||||
<span class="badge"
|
<span class="badge"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<h1>MCP 연동</h1>
|
<h1>MCP 연동</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>사용자 Bearer Token을 전달해 GPT-5.4-mini Select AI 자연어 조회를 실행하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하고, DB에서는 토큰 기준 행 접근 context가 적용됩니다.</p>
|
<p>사용자 Bearer Token을 검증한 뒤 구성된 Select AI 프로파일의 Text2SQL·SHOWPROMPT와 QA 벡터 예제 SQL 조회·저장 MCP tool을 제공합니다. QA 조회는 SQL 생성 전에 few-shot 후보를 확인하는 용도이며, 저장은 검토된 예제를 축적하는 용도입니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
HTTP <code>Authorization</code> → 행 접근 컨텍스트 · <code>prompt</code> → GPT-5.4-mini Select AI 자연어 질의 · <code>limit</code> → 최대 반환 행 수
|
HTTP <code>Authorization</code> → 활성 사용자 토큰 검증 · 도구별 <code>prompt</code> 또는 <code>question</code> 입력 · QA 조회 결과는 few-shot 예제 SQL 검토용
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -115,11 +115,19 @@
|
|||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>tools/call parameter 예시 보기</summary>
|
<summary>tools/call parameter 예시 보기</summary>
|
||||||
<h2>tools/call Arguments</h2>
|
<h2>tools/call Arguments</h2>
|
||||||
<pre class="code-block">{
|
<pre class="code-block">// qa_vector_search
|
||||||
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
|
{
|
||||||
"limit": 50
|
"question": "최신 기준 활성 사용자 수를 조회해줘.",
|
||||||
|
"topK": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// qa_vector_store
|
||||||
|
{
|
||||||
|
"question": "최신 기준 활성 사용자 수를 조회해줘.",
|
||||||
|
"answerSql": "SELECT COUNT(*) AS AU_COUNT FROM ...",
|
||||||
|
"answer": "AU_COUNT=123"
|
||||||
}</pre>
|
}</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">Text2SQL·SHOWPROMPT는 <code>prompt</code>를 사용하고, <code th:text="${mcp?.resolvedQaVectorSearchToolName() ?: 'oracle.select_ai.qa_vector_search'}">oracle.select_ai.qa_vector_search</code>는 few-shot 후보를 조회하며 <code th:text="${mcp?.resolvedQaVectorStoreToolName() ?: 'oracle.select_ai.qa_vector_store'}">oracle.select_ai.qa_vector_store</code>는 검토된 읽기 전용 예제 SQL을 저장합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -153,10 +161,9 @@
|
|||||||
"id": 3,
|
"id": 3,
|
||||||
"method": "tools/call",
|
"method": "tools/call",
|
||||||
"params": {
|
"params": {
|
||||||
"name": "ords.query.kb_select_ai_vpd",
|
"name": "oracle.select_ai.data_text2sql",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
|
"prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘."
|
||||||
"limit": 50
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}</pre>
|
}</pre>
|
||||||
|
|||||||
@@ -159,8 +159,8 @@
|
|||||||
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
|
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="status : ${maskingPolicyStatuses}">
|
<tr th:each="status : ${maskingPolicyStatuses}">
|
||||||
<td><code th:text="${status.targetLabel()}">POC_2.KB_CLAIMS</code></td>
|
<td><code th:text="${status.targetLabel()}">OWNER.OBJECT_NAME</code></td>
|
||||||
<td><code th:text="${status.policyName()}">KB_CLAIM_AMOUNT_REDACT</code></td>
|
<td><code th:text="${status.policyName()}">REDACTION_POLICY</code></td>
|
||||||
<td th:text="${status.configuredColumnCount()}">0</td>
|
<td th:text="${status.configuredColumnCount()}">0</td>
|
||||||
<td th:text="${status.appliedColumnCount()}">0</td>
|
<td th:text="${status.appliedColumnCount()}">0</td>
|
||||||
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>
|
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. 저장값은 최종 SQL이 아니라 <code>CB_AGENT_DOC_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.</p>
|
<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>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,15 +158,15 @@
|
|||||||
</select>
|
</select>
|
||||||
<select class="form-select rule-type-select" name="ruleType">
|
<select class="form-select rule-type-select" name="ruleType">
|
||||||
<option value="ALL">ALL</option>
|
<option value="ALL">ALL</option>
|
||||||
<optgroup label="KB · 본인 범위">
|
<optgroup label="게임 데이터 · 담당자 범위">
|
||||||
<option value="OWN_CONTRACT">본인 담당 계약</option>
|
<option value="OWN_CONTRACT">담당 게임 서비스</option>
|
||||||
<option value="OWN_CUSTOMER">본인 담당 고객</option>
|
<option value="OWN_CUSTOMER">담당 게임 사용자</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="KB · 채널 범위">
|
<optgroup label="게임 데이터 · 채널 범위">
|
||||||
<option value="CHANNEL_CONTRACT">토큰 채널 계약</option>
|
<option value="CHANNEL_CONTRACT">토큰 채널 게임 서비스</option>
|
||||||
<option value="CHANNEL_CUSTOMER">토큰 채널 고객</option>
|
<option value="CHANNEL_CUSTOMER">토큰 채널 게임 사용자</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="KB · 토큰 식별">
|
<optgroup label="게임 데이터 · 토큰 식별">
|
||||||
<option value="TOKEN_SUBJECT">토큰 사용자 본인</option>
|
<option value="TOKEN_SUBJECT">토큰 사용자 본인</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="SQL · 정적 조건">
|
<optgroup label="SQL · 정적 조건">
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>행 규칙의 두 가지 적용 방식 보기</summary>
|
<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>
|
<p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
@@ -196,28 +196,28 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>TOKEN_SUBJECT</code></td>
|
<td><code>TOKEN_SUBJECT</code></td>
|
||||||
<td>토큰으로 식별된 이해관계자 본인 행</td>
|
<td>토큰으로 식별된 운영 사용자 범위</td>
|
||||||
<td><code>USER_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
|
<td>대상 객체가 토큰 주체 식별 컬럼을 가질 때만 DB 행 접근 함수가 생성</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>OWN_CONTRACT</code></td>
|
<td><code>OWN_CONTRACT</code></td>
|
||||||
<td>토큰 사용자가 담당 설계사인 계약</td>
|
<td>토큰 사용자가 담당하는 게임 서비스 범위</td>
|
||||||
<td><code>FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
|
<td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>CHANNEL_CONTRACT</code></td>
|
<td><code>CHANNEL_CONTRACT</code></td>
|
||||||
<td>토큰 사용자의 채널에 속한 계약</td>
|
<td>토큰 채널에 속한 게임 서비스 범위</td>
|
||||||
<td><code>FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')</code></td>
|
<td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td>
|
<td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td>
|
||||||
<td>계약원장에서 담당자 또는 채널 기준으로 연결되는 고객·청구·담보·외부보유 행</td>
|
<td>게임 서비스·사용자 기준으로 연결되는 게임 로그·판매·환불 데이터 범위</td>
|
||||||
<td><code>EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS ...)</code></td>
|
<td>대상 테이블의 실제 키 관계를 DB 행 접근 함수가 검증한 뒤 생성</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>STATIC_SQL</code></td>
|
<td><code>STATIC_SQL</code></td>
|
||||||
<td>현재 객체 컬럼으로 표현한 고정 조건</td>
|
<td>현재 객체 컬럼으로 표현한 고정 조건</td>
|
||||||
<td><code>CONTRACT_STATUS = '정상'</code>처럼 검증된 현재 객체 컬럼 조건</td>
|
<td><code>GAME_ID = 'CZN'</code>처럼 검증된 현재 객체 컬럼 조건</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>
|
<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 생성 근거로 들어갑니다.
|
Select AI profile의 <code>comments=true</code>, <code>annotations=true</code> 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, 백오피스가 승인한 7개 업무 테이블만 대상으로 합니다.</p>
|
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, 백오피스가 승인한 7개 업무 테이블만 대상으로 합니다.</p>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>테이블 선택</h2>
|
<h2>테이블 선택</h2>
|
||||||
<p class="section-subtitle">정형 MCP/Select AI가 참조하는 KB 업무 원장 7개만 표시합니다.</p>
|
<p class="section-subtitle">정형 MCP/Select AI가 참조하는 게임 데이터 테이블만 표시합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">7</span>
|
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">7</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
class="structured-table-card"
|
class="structured-table-card"
|
||||||
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
||||||
th:href="@{/schema-metadata(table=${entry.key()})}">
|
th:href="@{/schema-metadata(table=${entry.key()})}">
|
||||||
<strong th:text="${entry.businessName()}">고객원장</strong>
|
<strong th:text="${entry.businessName()}">게임 사용자</strong>
|
||||||
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
|
<code th:text="${entry.tableName()}">CZN_COMN_USER_MST</code>
|
||||||
<small th:text="${entry.description()}">고객 기본정보</small>
|
<small th:text="${entry.description()}">게임 사용자 마스터</small>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -44,9 +44,9 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge text-bg-secondary">TABLE</span>
|
<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">
|
<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>
|
<span th:text="${' · ' + metadata.table().description()}"> · 설명</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,9 +29,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="item : ${scripts}" th:classappend="${selectedScript != null and item.scriptId() == selectedScript.scriptId()} ? ' table-primary'">
|
<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><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()}">category/script.sql</code></td>
|
||||||
<td>
|
<td>
|
||||||
<strong th:text="${item.title()}">컬럼 마스킹 규칙 메타데이터</strong>
|
<strong th:text="${item.title()}">사용자·그룹·역할 관리 모델</strong>
|
||||||
<div class="form-hint" th:text="${item.description()}">설명</div>
|
<div class="form-hint" th:text="${item.description()}">설명</div>
|
||||||
</td>
|
</td>
|
||||||
<td><a class="btn btn-sm rw-btn-secondary" th:href="@{/security-sql-scripts(script=${item.scriptId()})}">원문 보기</a></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}">
|
<section class="content-band" th:if="${selectedScript}">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">ASO / 마스킹</span>
|
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">권한</span>
|
||||||
<h2 class="mt-2" th:text="${selectedScript.title()}">컬럼 마스킹 규칙 메타데이터</h2>
|
<h2 class="mt-2" th:text="${selectedScript.title()}">사용자·그룹·역할 관리 모델</h2>
|
||||||
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
|
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
|
||||||
</div>
|
</div>
|
||||||
<code th:text="${selectedScript.fileName()}">62_kb_aso_masking_backoffice_metadata.sql</code>
|
<code th:text="${selectedScript.fileName()}">category/script.sql</code>
|
||||||
</div>
|
</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/category/script.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">
|
<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" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}">
|
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}">
|
||||||
|
|||||||
@@ -8,19 +8,19 @@
|
|||||||
<h1>정형 데이터 조회</h1>
|
<h1>정형 데이터 조회</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>보험 원장 7개만 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
|
<p>등록된 업무 데이터 객체를 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
이 화면은 관리자용 원장 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
|
이 화면은 관리자용 업무 데이터 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>조회할 원장 선택</h2>
|
<h2>조회할 업무 데이터 선택</h2>
|
||||||
<p class="section-subtitle">KBAIPOC의 <code>POC_2</code> 스키마에서 승인된 정형 테이블만 표시합니다.</p>
|
<p class="section-subtitle"><code th:text="${catalogOwner}">OWNER</code> 스키마에서 등록한 <span th:text="${product.dataName()}">업무 데이터</span> 객체만 표시합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="structured-table-grid">
|
<div class="structured-table-grid">
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
class="structured-table-card"
|
class="structured-table-card"
|
||||||
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
||||||
th:href="@{/structured-data(table=${entry.key()})}">
|
th:href="@{/structured-data(table=${entry.key()})}">
|
||||||
<strong th:text="${entry.businessName()}">고객원장</strong>
|
<strong th:text="${entry.businessName()}">게임 사용자</strong>
|
||||||
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
|
<code th:text="${entry.tableName()}">OBJECT_NAME</code>
|
||||||
<small th:text="${entry.description()}">고객 기본정보</small>
|
<small th:text="${entry.description()}">게임 사용자 마스터</small>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
<section class="content-band" th:if="${preview}">
|
<section class="content-band" th:if="${preview}">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2 th:text="${preview.table().businessName()}">고객원장</h2>
|
<h2 th:text="${preview.table().businessName()}">게임 사용자</h2>
|
||||||
<p class="section-subtitle">
|
<p class="section-subtitle">
|
||||||
<code th:text="${'POC_2.' + preview.table().tableName()}">POC_2.KB_CUSTOMERS</code>
|
<code th:text="${catalogOwner + '.' + preview.table().tableName()}">OWNER.TABLE_NAME</code>
|
||||||
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
|
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head th:replace="~{fragments/layout :: head('HMM 액세스 토큰')}"></head>
|
<head th:replace="~{fragments/layout :: head('액세스 토큰')}"></head>
|
||||||
<body>
|
<body>
|
||||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h1>HMM 액세스 토큰</h1>
|
<h1>액세스 토큰</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>HMM HR 직원에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
|
<p>Data & AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -27,14 +27,14 @@
|
|||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>직원 토큰 발급</h2>
|
<h2>도구 사용자 토큰 발급</h2>
|
||||||
<p class="section-subtitle">활성 HMM 직원에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p>
|
<p class="section-subtitle">활성 Data & AI PoC 사용자에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/tokens" class="form-grid">
|
<form method="post" action="/tokens" class="form-grid">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<label>
|
<label>
|
||||||
HMM 직원
|
도구 사용자
|
||||||
<select class="form-select" name="userId" required>
|
<select class="form-select" name="userId" required>
|
||||||
<option value="" selected disabled>토큰을 발급할 직원 선택</option>
|
<option value="" selected disabled>토큰을 발급할 직원 선택</option>
|
||||||
<option th:each="user : ${users}"
|
<option th:each="user : ${users}"
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
용도 메모
|
용도 메모
|
||||||
<input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인">
|
<input class="form-control" name="description" maxlength="200" placeholder="예: 게임 데이터 AI 질의 검증">
|
||||||
</label>
|
</label>
|
||||||
<button class="btn rw-btn-primary" type="submit">검증 세션 발급</button>
|
<button class="btn rw-btn-primary" type="submit">검증 세션 발급</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user