Compare commits
67 Commits
smilegate
...
016d1af025
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
016d1af025 | ||
|
|
8b04fb4e89 | ||
|
|
271f764f45 | ||
|
|
9d2c75459f | ||
|
|
041ab4f287 | ||
|
|
98c8ccde85 | ||
|
|
987ea44f32 | ||
|
|
5409a29297 | ||
|
|
154572c885 | ||
|
|
6d98931ce1 | ||
|
|
d13d6d48ca | ||
|
|
57f6677afb | ||
|
|
00a939f9a1 | ||
|
|
8dffa2f515 | ||
|
|
3cc033e4e6 | ||
|
|
f31292ecb3 | ||
|
|
040d4f3d4b | ||
|
|
fbb04a55a2 | ||
|
|
956693bb23 | ||
|
|
ec5d731bf5 | ||
|
|
3a7e2ced00 | ||
|
|
55faaa0794 | ||
|
|
10581dc605 | ||
|
|
beaef2d171 | ||
|
|
ed3139f9f7 | ||
|
|
bd651eb66f | ||
|
|
2e26e3261f | ||
|
|
97cd5c9f25 | ||
|
|
7ddb8ef0fc | ||
|
|
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 |
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_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 탭) ---
|
||||
export BACKOFFICE_AI_ENABLED="false"
|
||||
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,6 +17,10 @@ logs/
|
||||
# Java / Maven
|
||||
target/
|
||||
|
||||
# Python / Streamlit
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Locally downloaded development tools (for example SQLcl)
|
||||
.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의 두 테이블에 저장한다.
|
||||
- 자유 질의에 임의의 정답을 부여하지 않는다.
|
||||
112
docs/design/708-smilegate-select-ai-oci-genai/README.md
Normal file
112
docs/design/708-smilegate-select-ai-oci-genai/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# #708 Smilegate Select AI OCI GenAI 전환
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
Smilegate DATA & AI PoC는 Autonomous Database의 게임 데이터에서 Select AI Text2SQL을 생성하고, MCP와 백오피스를 통해 결과를 조회한다. 현재 운영 프로파일은 외부 OpenRouter 경유 Claude를 사용한다.
|
||||
|
||||
## 목표
|
||||
|
||||
`SGMP_POC_HAIKU45`가 가진 게임 데이터 object list와 메타데이터 활용 범위는 유지하면서, LLM 호출 경로만 OCI Generative AI `openai.gpt-5.4-mini`로 전환한다. ADB Resource Principal이 아닌 이 머신의 `~/.oci/config` DEFAULT API signing credential을 ADB credential으로 등록한다.
|
||||
|
||||
## 전환 설계
|
||||
|
||||
| 구분 | 기존 | 전환 후 |
|
||||
| --- | --- | --- |
|
||||
| 프로파일 | `SGMP_POC_HAIKU45` | `SGMP_POC_OCI_GPT54MINI` |
|
||||
| 제공자 | OpenAI 호환 외부 경로 | OCI Generative AI (`provider: oci`) |
|
||||
| 모델 | Claude Haiku 4.5 | `openai.gpt-5.4-mini` |
|
||||
| 인증 | 외부 API credential | DEFAULT API signing key 기반 `SGMP_POC_OCI_DEFAULT_CRED` |
|
||||
| OCI 리전 | 외부 서비스 | `us-chicago-1` (GPT-5.4 Mini OCI inference route) |
|
||||
| 게임 데이터 범위 | 기존 object list | 기존 profile attributes에서 복제 |
|
||||
| MCP 설정 | 기존 프로파일명 | `BACKOFFICE_SELECT_AI_PROFILE=SGMP_POC_OCI_GPT54MINI` |
|
||||
|
||||
신규 프로파일은 기존 프로파일의 metadata 관련 attributes를 복사하고, 외부 endpoint·credential·model은 OCI 값으로 새로 설정한다. 따라서 object list, comment, annotation, constraint 기반 Text2SQL 문맥은 유지된다. 기존 외부 프로파일은 삭제하지 않으며, 전환 실패 시 환경변수만 원래 값으로 되돌린다.
|
||||
|
||||
## 사전 조건
|
||||
|
||||
1. `~/.oci/config` DEFAULT의 user, tenancy, fingerprint, key_file이 유효한 OCI API signing key여야 한다.
|
||||
2. DEFAULT API signing user가 Chicago 리전 root compartment에서 OCI Generative AI `openai.gpt-5.4-mini` 호출 권한을 가져야 한다. DEFAULT config의 signing region과 GPT inference route는 독립적이므로 profile attribute `region`은 `us-chicago-1`로 명시한다.
|
||||
3. 스크립트는 `SGMP_POC` 프로파일 소유자로 실행한다.
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. `sql/adb/72_sgmp_select_ai_oci_genai_profile.sql`로 신규 OCI 프로파일을 만든다.
|
||||
2. 새 프로파일로 한글 Text2SQL `SHOWSQL`과 생성 SQL의 읽기 전용 실행을 검증한다.
|
||||
3. 운영 서버의 `BACKOFFICE_SELECT_AI_PROFILE`만 새 프로파일로 교체하고 백오피스를 재기동한다.
|
||||
4. 운영 MCP의 `oracle.select_ai.smilegate_game_text2sql` 응답 profile과 집계 결과를 검증한다.
|
||||
|
||||
## 롤백
|
||||
|
||||
새 프로파일을 삭제하지 않는다. MCP에서 오류가 나거나 SQL 품질이 허용 기준을 충족하지 않으면 `/etc/smilegate/backoffice.env`의 `BACKOFFICE_SELECT_AI_PROFILE`을 `SGMP_POC_HAIKU45`로 되돌린 뒤 서비스를 재기동한다.
|
||||
|
||||
## 검증 기준
|
||||
|
||||
- 새 프로파일 provider=`oci`, model=`openai.gpt-5.4-mini`, credential=`SGMP_POC_OCI_DEFAULT_CRED`, region=`us-chicago-1`
|
||||
- 한글 질의는 SQLcl UTF-8 base64 복원식으로 전달한다.
|
||||
- `DBMS_CLOUD_AI.GENERATE(..., 'showsql')`이 읽기 전용 `SELECT` 또는 `WITH` SQL을 생성한다.
|
||||
- 해당 SQL을 읽기 전용 트랜잭션에서 실행해 결과를 반환한다.
|
||||
- 운영 MCP 응답의 `profile`이 `SGMP_POC_OCI_GPT54MINI`이다.
|
||||
|
||||
## Llama 4 Scout 범위 판정 성능 비교
|
||||
|
||||
게임 범위 판정은 Text2SQL 실행 전 OCI Chat을 사용한다. 이 단계의 응답시간을
|
||||
비교하기 위해 운영 프로파일을 교체하지 않고 별도 테스트 프로파일
|
||||
`SGMP_POC_OCI_LLAMA4SCOUT`을 만든다.
|
||||
|
||||
| 구분 | 운영 | 비교 대상 |
|
||||
| --- | --- | --- |
|
||||
| 프로파일 | `SGMP_POC_OCI_GPT54MINI` | `SGMP_POC_OCI_LLAMA4SCOUT` |
|
||||
| 모델 | `openai.gpt-5.4-mini` | `meta.llama-4-scout-17b-16e-instruct` |
|
||||
| 인증·리전 | 기존 OCI credential·inference region 유지 | 동일 |
|
||||
| object list·metadata | 운영 값 | 운영 값 복제 |
|
||||
| 운영 트래픽 | 사용 | 사용하지 않음 |
|
||||
|
||||
`sql/adb/112_sgmp_select_ai_oci_llama4scout_profile.sql`은 기존 OCI 프로파일의
|
||||
credential, region, compartment, object list와 metadata instructions를 복제하고
|
||||
모델만 교체한다. 같은 질문을 두 프로파일에 각각 Chat 호출해 경과시간과 반환 JSON
|
||||
형식을 기록한다. 품질·지연시간 결과를 검토하기 전에는
|
||||
`sg_game_extract_mentions`와 `sg_game_match_candidate`의 운영 프로파일을 바꾸지
|
||||
않는다.
|
||||
|
||||
후보 모델 비교는 `sql/adb/114_sgmp_create_scope_chat_candidate_profiles.sql`과
|
||||
`sql/adb/115_sgmp_scope_chat_candidate_benchmark.sql`으로 수행한다. 후보는
|
||||
운영과 분리된 `SGMP_SCOPE_*` 프로파일로 생성한다. 각 모델은 `NONE`, `SINGLE`,
|
||||
`MULTI`, `ALL` 질문에서 다음을 기록한다.
|
||||
|
||||
- 초 단위 Chat 응답시간
|
||||
- 설명문·Markdown 없이 raw JSON만 반환했는지
|
||||
- `scope_hint`와 `game_mentions` 수가 기대값과 일치하는지
|
||||
- 생성 또는 Chat 호출 오류
|
||||
|
||||
## 게임명 추출과 벡터 점수 판정
|
||||
|
||||
게임명과 조회 범위 추출은 짧은 JSON Chat 작업이므로 운영 프로파일
|
||||
`SGMP_POC_OCI_COHERE_COMMAND` (`cohere.command-latest`)을 사용한다.
|
||||
게임 카탈로그의 `ALIASES_JSON`에는 게임명, 영문명, 약칭, 게임 ID, prefix와
|
||||
운영 등록 별칭을 JSON 배열로 저장한다. 이 JSON 배열 전체를 게임당 하나의
|
||||
임베딩으로 생성한다. 추출된 각 게임명은 해당 게임 벡터의 cosine distance와
|
||||
`SG_GAME_SCOPE_POLICY`에 저장된 최대값을 비교해 `MATCHED` 또는 `UNMATCHED`로
|
||||
판정한다. 후보 동일성 확인을 위한 별도 LLM 호출은 사용하지 않는다.
|
||||
|
||||
정책값은 코드가 아니라 DB 설정 테이블에 저장한다. 게임 카탈로그·임베딩 모델이
|
||||
변경되면 운영자가 그 기준값을 조정할 수 있다. `NONE`/`SINGLE`/`MULTI`/`ALL`
|
||||
계약, Few-shot NL2SQL 전달 형식, Text2SQL 프로파일은 유지한다.
|
||||
|
||||
### 검증 기준
|
||||
|
||||
- 추출 결과는 JSON 파싱 가능하고 `game_mentions`, `scope_hint`만 반환한다.
|
||||
- `NONE`, `SINGLE`, `MULTI`, `ALL`에서 canonical `targets`,
|
||||
`dataEligibleTargets`, `unresolvedTargets`가 유지된다.
|
||||
- 동일성 판정을 위한 추가 OCI Chat 호출이 발생하지 않는다.
|
||||
|
||||
### 후보 프로파일 비교 결과
|
||||
|
||||
동일한 JSON 추출 프롬프트로 `NONE`, `SINGLE`, `MULTI`, `ALL`을 호출한 결과,
|
||||
`cohere.command-latest`, `cohere.command-plus-latest`, `cohere.command-a-vision`은
|
||||
기본 4건에서 모두 범위와 게임명 수를 맞췄다. `google.gemini-2.5-flash-lite`는
|
||||
JSON 파싱이 일관되지 않았고, Grok과 Llama Maverick은 게임 미지정 또는 전체 게임
|
||||
질의에서 범위 오류가 있었다.
|
||||
|
||||
게임명 추출은 짧은 단일 JSON 요청이므로 `cohere.command-latest`를 선택했다.
|
||||
이 측정값은 ADB 내부 Chat 호출 시간이며, Portal의 MCP·ReAct·답변 합성 시간을
|
||||
포함한 전체 체감시간과는 별도로 관리한다.
|
||||
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 입력·응답 계약을 검증한다.
|
||||
99
docs/design/731-sgmp-game-scope-resolver/README.md
Normal file
99
docs/design/731-sgmp-game-scope-resolver/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# SGMP DB 기반 게임 범위 Resolver (#731)
|
||||
|
||||
## 목표
|
||||
|
||||
복수 게임이 포함된 데이터 질문에서 애플리케이션 코드나 에이전트 지시문에 게임명, prefix,
|
||||
테이블명을 넣지 않는다. DB가 제공하는 게임 범위 뷰를 먼저 조회하고, 조회 가능으로 판정된
|
||||
게임에만 기존 Few-shot NL2SQL MCP를 호출한다.
|
||||
|
||||
## 범위와 원칙
|
||||
|
||||
- 기존 `oracle.select_ai.smilegate_fewshot_nl2sql`은 예제 검색, SQL 생성, 읽기 전용 실행을
|
||||
담당하는 worker로 유지한다.
|
||||
- 새 `game_scope_resolve` MCP는 SQL을 생성하거나 실행하지 않는다.
|
||||
- 공통 백오피스는 환경변수로 지정된 DB view 이름과 MCP tool 이름만 안다.
|
||||
- 게임명, alias, GAME_ID, GAME_PREFIX, 대상 object는 DB view의 데이터로만 결정한다.
|
||||
- 지원 여부는 대상 날짜의 행 수가 아니라, 현재 승인된 조회 object가 존재하는지로 판정한다.
|
||||
데이터가 0건인 날도 정상 조회 범위다.
|
||||
|
||||
## DB 공통 계약
|
||||
|
||||
고객 DB는 환경변수 `BACKOFFICE_GAME_SCOPE_VIEW`로 지정된 view를 제공한다. view는 아래
|
||||
별칭(column alias)을 반환한다.
|
||||
|
||||
| Column | 의미 |
|
||||
|---|---|
|
||||
| `GAME_KEY` | 내부 게임 식별자 |
|
||||
| `PROFILE_NAME` | 승인 object list를 판정한 Select AI profile |
|
||||
| `DISPLAY_NAME` | 화면 표시용 정식 게임명 |
|
||||
| `GAME_ALIAS` | 질문에서 찾을 게임명 또는 별칭 |
|
||||
| `QUERY_ALLOWED_YN` | 승인된 조회 object 존재 여부 (`Y`/`N`) |
|
||||
| `REASON_CODE` | 미지원 또는 보류 사유 코드 |
|
||||
| `ALIAS_PRIORITY` | 동일/중첩 alias 정렬 우선순위 |
|
||||
| `SCOPE_VERSION` | object list 변경 시 함께 갱신되는 버전 |
|
||||
|
||||
Smilegate view는 전체 게임 마스터와 alias를 기준으로 하고, 현재 Select AI profile별 승인 object list와
|
||||
실제 object 존재 여부를 조합해 `QUERY_ALLOWED_YN`을 계산한다. 따라서 등록 게임이지만 현재
|
||||
조회 object가 없는 게임도 `N`으로 반환된다.
|
||||
|
||||
## MCP와 ReAct 계약
|
||||
|
||||
1. 포털 ReAct는 게임 데이터 질의 전에 `game_scope_resolve(question)`를 호출한다.
|
||||
2. resolver는 질문 문자열과 `GAME_ALIAS`를 정규화해 포함 관계를 찾고, 우선순위와 alias 길이로
|
||||
중복을 제거한다. 동일 우선순위의 복수 게임은 `AMBIGUOUS`로 반환한다.
|
||||
3. `QUERY_ALLOWED_YN=Y`인 scope에는 서명·만료된 opaque `scopeToken`과 worker tool 이름을 반환한다.
|
||||
4. ReAct는 `nextAction=CALL_WORKER`인 항목만 Few-shot NL2SQL에 전달한다. `UNSUPPORTED`와
|
||||
`AMBIGUOUS`는 SQL 실행 없이 결과에 표시한다.
|
||||
5. Few-shot worker는 scope token을 검증하고, token에 담긴 DB scope로만 prompt를 보강한다.
|
||||
|
||||
## 추출 게임명별 판정 계약
|
||||
|
||||
- ADB Chat이 반환한 `game_mentions`의 각 항목은 서로 독립적으로 판정한다. 벡터 검색 결과를
|
||||
하나의 목록으로 합쳐 모든 후보를 지원 게임으로 취급하지 않는다.
|
||||
- 벡터 검색은 후보를 찾는 단계다. ADB OCI GenAI가 추출 명칭과 후보의 카탈로그 명칭,
|
||||
별칭, `GAME_ID`, `GAME_PREFIX`를 비교해 후보 중 하나를 선택하거나 전체를 거절한다.
|
||||
- 애플리케이션은 모델이 반환한 `gameKey`가 실제 후보 목록에 있을 때만
|
||||
`supportedGames`에 넣는다. 후보 목록에 없는 식별자는 거절한다.
|
||||
- 모델이 모든 후보를 거절하면 해당 원문 명칭을 `unmatchedGames`에 남긴다. 유사도 순위가
|
||||
높다는 이유만으로 다른 게임에 대입하지 않는다.
|
||||
- 게임 판정에 정규식, 부분문자열 매칭, 유사도 임계값을 사용하지 않는다.
|
||||
- 동일 게임이 여러 명칭으로 검색되더라도 `supportedGames`는 `gameKey` 기준으로 중복을
|
||||
제거한다. `matchedGames`와 `unmatchedGames`에는 mention별 판정 근거를 유지한다.
|
||||
- 일부만 지원되는 복수 게임 질문은 `status=PARTIAL`로 반환하고, 지원 게임의 worker 실행과
|
||||
미매칭 게임 안내를 함께 수행한다.
|
||||
|
||||
## 검증
|
||||
|
||||
- view가 지원 게임과 object list 미연결 게임을 각각 반환하는지 확인한다.
|
||||
- resolver MCP의 결과에 구체 게임/테이블 하드코딩이 없는지 확인한다.
|
||||
- STD-06에서 미지원 게임은 worker가 호출되지 않고, 지원 게임 결과에는 few-shot 예제, 생성 SQL,
|
||||
실행 결과가 포함되는지 확인한다.
|
||||
- STD-06 판정은 미매칭 게임과 지원 게임을 독립적으로 평가한다. 미매칭 게임을 답변에 명시하고
|
||||
지원 게임의 요청 일자와 AU 집계를 반환하면 `PARTIAL` 성공을 `PASS`로 판정하며, 미매칭
|
||||
게임 때문에 지원 게임 결과를 폐기하거나 전체 결과 없음으로 만들지 않는다.
|
||||
- 기존 단일 게임 Few-shot NL2SQL 및 미게임명 거절 guardrail 회귀를 확인한다.
|
||||
|
||||
## 논리 조인 메타데이터
|
||||
|
||||
`COMN_GAME_ALIAS_BAS`는 하나의 게임에 여러 alias 행을 갖기 때문에 `GAME_ID`가 유일키가
|
||||
아니다. 따라서 공통 transaction table의 `GAME_ID`에 물리 FK를 추가하지 않는다. 대신
|
||||
`78_sgmp_game_alias_logical_joins.sql`이 fact table에 `GAME_ALIAS_JOIN` annotation을 추가한다.
|
||||
이 annotation은 게임명 필터에서 `EXISTS` 또는 `DISTINCT GAME_ID` alias subquery를 사용하고,
|
||||
alias 원본을 직접 조인해 집계 행을 늘리지 않도록 설명한다. Prefix 전용 테이블은 가짜 FK 없이
|
||||
기존 alias/prefix 소유 범위 annotation을 유지한다.
|
||||
|
||||
## 보류 항목
|
||||
|
||||
전체 게임 마스터에 미지원 게임이 없다면 DB만으로 그 이름을 게임으로 식별할 수 없다. 이 경우
|
||||
고객 원천 게임 마스터를 view에 연결하는 작업이 선행되어야 하며, 모델 추측으로 보완하지 않는다.
|
||||
|
||||
## 운영 배포 계약
|
||||
|
||||
- `GameScopeProperties` 클래스와 `application.yml`의 `backoffice.game-scope` 구역은 하나의
|
||||
배포 단위다. Java 클래스만 반영하면 환경변수가 존재해도 기본값(`enabled=false`,
|
||||
`viewName=""`)으로 바인딩되어 worker 호출이 차단된다.
|
||||
- 운영 배포 후 `BACKOFFICE_GAME_SCOPE_ENABLED`,
|
||||
`BACKOFFICE_GAME_SCOPE_VIEW`, `BACKOFFICE_GAME_SCOPE_MAX_SCOPES`가 실제 실행 JAR의
|
||||
설정 메타데이터에 연결되는지 MCP `game_scope_resolve` 호출로 확인한다.
|
||||
- 검증은 범위 판정 성공만으로 끝내지 않고, 지원 scope를 전달한 Few-shot NL2SQL이 읽기 전용
|
||||
SQL 생성과 실행까지 완료하는지 확인한다.
|
||||
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 오류를 표시
|
||||
250
docs/design/739-game-target-contract/README.md
Normal file
250
docs/design/739-game-target-contract/README.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Smilegate 게임 대상 계약 통합 설계
|
||||
|
||||
## 1. 배경
|
||||
|
||||
현재 `game_query_plan`은 게임을 찾지 못하면 `STOP_NO_MATCH`, 여러 게임이면
|
||||
`FAN_OUT`을 반환한다. Portal의 ReAct 모델이 이 지시를 종료 조건으로 해석하거나
|
||||
일부 게임만 다음 도구로 전달하면서 다음 문제가 발생했다.
|
||||
|
||||
- 게임명이 없는 전체 매출 질의가 SQL 생성 전에 중단된다.
|
||||
- 여러 게임 중 일부만 매칭되면 매칭된 게임의 조회도 생략될 수 있다.
|
||||
- 계획 결과를 전달해도 NL2SQL worker가 질문 전체를 다시 단일 게임 벡터검색하여
|
||||
범위를 축소할 수 있다.
|
||||
- 게임별 사용자 마스터 물리 객체를 모델이 추측해야 한다.
|
||||
|
||||
## 2. 목표
|
||||
|
||||
질문의 게임 대상을 `NONE`, `SINGLE`, `MULTI`, `ALL` 네 가지로 통일하고,
|
||||
모든 유형에서 원 질문과 전체 계획 결과를 `smilegate_fewshot_nl2sql`에 한 번
|
||||
전달한다. 계획 단계는 조회 중단이나 SQL 조립을 결정하지 않는다.
|
||||
|
||||
## 3. 계약
|
||||
|
||||
`game_query_plan`은 ReAct가 판단에 사용할 고정 JSON 계약을 반환한다. 자연어
|
||||
설명, 후보 검색 원문, 기존 호환 필드는 진단 영역으로 분리하며 실행 판단은 아래
|
||||
권위 필드만 사용한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"contractVersion": "1.0",
|
||||
"scopeType": "MULTI",
|
||||
"targets": [
|
||||
{
|
||||
"mention": "사용자가 언급한 게임명",
|
||||
"matchStatus": "MATCHED",
|
||||
"dataStatus": "AVAILABLE",
|
||||
"gameKey": "카탈로그 키 또는 null",
|
||||
"gameId": "카탈로그 식별자 또는 null",
|
||||
"userMasterObjectName": "승인 객체 또는 null"
|
||||
}
|
||||
],
|
||||
"dataEligibleTargets": [],
|
||||
"unresolvedTargets": []
|
||||
}
|
||||
```
|
||||
|
||||
고정 enum은 다음과 같다.
|
||||
|
||||
- `scopeType`: `NONE`, `SINGLE`, `MULTI`, `ALL`
|
||||
- `matchStatus`: `NOT_APPLICABLE`, `MATCHED`, `UNMATCHED`
|
||||
- `dataStatus`: `NOT_APPLICABLE`, `AVAILABLE`, `UNAVAILABLE`
|
||||
|
||||
`scopeType`은 질문에 언급된 범위만 나타낸다. 실행 가능성이나 SQL 조립 방식을
|
||||
암시하지 않는다. `targets`는 언급 순서를 유지하고, `dataEligibleTargets`는 현재
|
||||
질의에 사용할 수 있는 카탈로그 대상만, `unresolvedTargets`는 미매칭 또는 데이터
|
||||
객체가 없는 대상을 보존한다. ReAct는 이 계약 전체를 관찰한 뒤 공통 팩트 단일
|
||||
호출, 대상별 호출, 또는 빈 결과 응답을 선택한다.
|
||||
|
||||
기존 API 호환 기간에는 아래 필드도 반환할 수 있지만 ReAct의 실행 판단에 사용하지
|
||||
않는다: `targetType`, `status`, `matchedGames`, `supportedGames`,
|
||||
`mentionResults`, `dataIneligibleTargets`, `unmatchedGames`, `nextAction`,
|
||||
`executionMode`.
|
||||
|
||||
기존 필드의 의미는 다음과 같다.
|
||||
|
||||
| 필드 | 내용 |
|
||||
|---|---|
|
||||
| `targetType` | 호환용 `scopeType` 별칭 |
|
||||
| `status` | 호환용 요약 상태. ReAct 실행 분기에 사용하지 않음 |
|
||||
| `targets` | 언급 순서를 보존한 게임 대상 배열 |
|
||||
| `nextAction` | 호환용 안내. 실행을 강제하지 않음 |
|
||||
| `executionMode` | 호환용 안내. 실행을 강제하지 않음 |
|
||||
|
||||
각 `targets` 항목은 다음 값을 가진다.
|
||||
|
||||
| 필드 | 내용 |
|
||||
|---|---|
|
||||
| `mention` | 질문에서 추출한 게임명. `NONE`은 `null` |
|
||||
| `status` | 호환용 대상 상태 |
|
||||
| `matchStatus` | `NOT_APPLICABLE`, `MATCHED`, `UNMATCHED` |
|
||||
| `dataStatus` | `NOT_APPLICABLE`, `AVAILABLE`, `UNAVAILABLE` |
|
||||
| `gameKey`, `gameId` | DB 카탈로그가 반환한 게임 식별자 |
|
||||
| `gamePrefix` | DB 카탈로그가 반환한 prefix |
|
||||
| `gameName`, `aliases` | 표시명과 별칭 |
|
||||
| `userMasterObjectName` | 현재 Select AI 승인 객체 중 해당 게임의 사용자 마스터 물리 객체. 없으면 `null` |
|
||||
| `cosineDistance` | 후보 검색 거리. 적용되지 않으면 `null` |
|
||||
| `reasonCode`, `reason` | 매칭 또는 미매칭 근거 |
|
||||
|
||||
유형별 처리 방식은 다음과 같다.
|
||||
|
||||
- `NONE`: 게임 대상 항목 하나를 모든 게임 필드가 `null`인 상태로 전달한다.
|
||||
공통 거래·기준 객체 질의는 계속하되 prefix 전용 객체를 임의 선택하지 않는다.
|
||||
- `SINGLE`: 질문에서 언급된 대상 한 건을 전달한다. 미매칭이면 게임 필드가
|
||||
`null`인 대상도 그대로 다음 도구에 전달한다.
|
||||
- `MULTI`: 언급된 대상 모두를 전달한다. 매칭·미매칭을 함께 보존한다.
|
||||
- `ALL`: 게임 카탈로그의 활성 게임을 모두 반환한다. 사용자 마스터 물리 객체가
|
||||
없는 게임도 `null`로 보존한다.
|
||||
|
||||
## 4. DB 설계
|
||||
|
||||
`SG_GAME_CATALOG`에 `USER_MASTER_OBJECT_NAME`을 추가한다.
|
||||
|
||||
- 값은 `COMN_GAME_ALIAS_BAS`/운영 게임 registry의 `GAME_PREFIX`와 현재
|
||||
`USER_CLOUD_AI_PROFILE_ATTRIBUTES.object_list`, `USER_OBJECTS`를 조합해 계산한다.
|
||||
- 공통 정책이나 애플리케이션 코드에는 특정 게임명, prefix, 테이블명을 넣지 않는다.
|
||||
- 운영 registry에만 존재하는 게임도 카탈로그에 동기화하고 임베딩을 생성한다.
|
||||
- 게임별 물리 객체가 없으면 컬럼은 `null`이다.
|
||||
|
||||
`SG_GAME_CATALOG.ALIASES_JSON`에는 게임명, 영문명, 약칭, 게임 ID, prefix와
|
||||
운영 registry 별칭을 JSON 배열로 저장한다. 배열 전체를 게임당 하나의 벡터로
|
||||
임베딩한다. `SG_GAME_SCOPE_POLICY.GAME_ALIAS_MAX_COSINE_DISTANCE`는 벡터 검색
|
||||
점수의 신뢰도 기준이며, 후보·점수·원 질문은 `queryPlan`에 함께 보존해 다음
|
||||
Select AI가 이름 근거를 보조 판단할 수 있게 한다. 별도 LLM 후보 동일성 판정은
|
||||
사용하지 않는다.
|
||||
|
||||
`SG_GAME_CATALOG_SEARCH`는 물리 객체명을 후보 결과에 포함한다.
|
||||
`SG_GAME_QUERY_PLAN`은 추출된 mention마다 벡터 점수 기반 후보 결과를 `targets`와
|
||||
`mentionResults.candidateGames`로 만들며, `ALL`은 벡터 선택 없이 전체 카탈로그를
|
||||
사용한다.
|
||||
|
||||
## 5. MCP와 NL2SQL 설계
|
||||
|
||||
`game_query_plan` 도구 설명은 “항상 먼저 호출하고 결과를 원 질문과 함께 다음
|
||||
도구에 전달”하도록 바꾼다. `smilegate_fewshot_nl2sql`의 입력은 다음과 같다.
|
||||
|
||||
- `prompt`: 사용자가 입력한 원 질문
|
||||
- `queryPlan`: 바로 앞 `game_query_plan`의 전체 JSON
|
||||
- `scopeGameKey`: 하위 호환용 선택 값. 새 흐름에서는 사용하지 않는다.
|
||||
|
||||
NL2SQL worker는 전달된 `queryPlan`을 권위 있는 게임 범위로 사용한다.
|
||||
계획이 있으면 질문 전체를 다시 게임 카탈로그 벡터검색하지 않는다.
|
||||
|
||||
- 공통 객체에 게임 식별 컬럼이 있으면 질문 의미에 따라 `IN`과 `GROUP BY`를
|
||||
사용할 수 있다.
|
||||
- 대상별 물리 객체가 다르면 제공된 객체만 사용해 `UNION ALL`할 수 있다.
|
||||
- 물리 객체가 `null`인 대상을 임의 객체로 대체하지 않는다.
|
||||
- `NONE`에서 생성 SQL이 prefix 전용 객체를 참조하면 실행 전에 차단한다.
|
||||
- 최종 SQL은 기존과 같이 단일 `SELECT`/`WITH`, read-only transaction,
|
||||
최대 행수와 timeout 제한을 적용한다.
|
||||
|
||||
## 6. Portal ReAct 설계
|
||||
|
||||
Portal은 도구 호출 순서를 Java/Python 조건문으로 강제하지 않는다. 외부 환경변수의
|
||||
system prompt와 MCP 도구 설명으로 다음 절차를 안내한다.
|
||||
|
||||
1. `game_query_plan`을 호출한다.
|
||||
2. `targetType`과 상관없이 원 질문, 전체 `queryPlan`으로
|
||||
`smilegate_fewshot_nl2sql`을 한 번 호출한다.
|
||||
3. SQL, 실행 결과, 매칭·미매칭 대상을 함께 설명한다.
|
||||
|
||||
## 7. 검증
|
||||
|
||||
운영 MCP에서 다음 네 질문군을 실제 호출한다.
|
||||
|
||||
| 유형 | 검증 내용 |
|
||||
|---|---|
|
||||
| `NONE` | 게임명이 없는 전체 매출 질의가 공통 테이블로 실행되고 게임 필터가 임의 추가되지 않는다. |
|
||||
| `SINGLE` | 한 게임의 AU 질의가 해당 게임 식별자/물리 객체로 실행된다. |
|
||||
| `MULTI` | 지원·미지원 게임을 함께 질문해 지원 결과와 미지원 상태가 한 답변에 보존된다. |
|
||||
| `ALL` | 전체 게임 질의가 전체 카탈로그 대상을 받아 공통 객체 그룹 또는 게임별 객체 결합 SQL로 실행된다. |
|
||||
|
||||
추가 검증:
|
||||
|
||||
- Maven 테스트
|
||||
- Portal Python 테스트
|
||||
- MCP `tools/list` schema/description 확인
|
||||
- 생성 SQL에서 `NONE`의 prefix 전용 객체 차단 확인
|
||||
- 운영 서비스 health와 Portal ReAct 실제 응답 확인
|
||||
|
||||
## 8. Few-shot 참조 거버넌스
|
||||
|
||||
Few-shot 원문 SQL은 실행 이력 그대로 검색하지 않는다. 특히 특정 게임의 물리 객체명이
|
||||
포함된 예제는 다른 게임 또는 게임 미지정 질의의 벡터 후보가 될 수 있으므로, 예제는
|
||||
검증·정규화·승인 단계를 거쳐야 한다.
|
||||
|
||||
`SG_QA_VECTOR_EXAMPLE`에는 다음 참조 메타데이터를 둔다.
|
||||
|
||||
| 필드 | 내용 |
|
||||
|---|---|
|
||||
| `REFERENCE_STATUS` | `DRAFT`, `APPROVED`, `RETIRED`. 검색은 `APPROVED`만 사용한다. |
|
||||
| `REFERENCE_KIND` | `SQL_TEMPLATE`, `NO_TARGET`, `OBJECT_UNAVAILABLE`, `METADATA_POLICY` |
|
||||
| `TARGET_TYPE` | `NONE`, `SINGLE`, `MULTI`, `ALL`, `ANY`. 현재 game query plan과 일치하는 예제만 벡터 순위에 포함한다. |
|
||||
| `OBJECT_ROLE` | 물리 테이블명이 아닌 `GAME_USER_MASTER` 등 논리 객체 역할 |
|
||||
| `INSPECTION_STATUS`, `INSPECTION_NOTE` | 전수 실행·정책 검토 결과와 사유 |
|
||||
| `VERIFIED_AT`, `VERIFIED_BY` | 승인 감사 이력 |
|
||||
|
||||
물리 테이블명은 Few-shot의 의미 규칙으로 저장하지 않는다. `SQL_TEMPLATE`은
|
||||
`<RESOLVED_GAME_USER_MASTER>` 같은 논리 placeholder를 사용하며, 실제 객체는 오직
|
||||
현재 `queryPlan.targets[*].userMasterObjectName`에서만 해석한다. `NO_TARGET`과
|
||||
`OBJECT_UNAVAILABLE` 예제는 범위 경계 안내와 검증된 빈 결과 SQL 템플릿을 함께
|
||||
프롬프트에 포함한다. 이 템플릿은 애플리케이션에서 직접 실행하거나 답변으로
|
||||
반환하지 않으며, Select AI가 현재 질문의 범위를 해석해 SQL을 생성할 때만 Few-shot
|
||||
근거로 사용한다.
|
||||
|
||||
`NONE`은 게임이 선택되지 않았다는 범위 정보이며 질의 불가 상태가 아니다. 각 경계
|
||||
예제는 `OBJECT_ROLE`로 적용 대상을 밝힌다. 예를 들어 게임별 사용자 마스터 경계는
|
||||
공통 판매·환불 같은 공통 객체 질의에 적용하지 않는다. 공통 객체로 답할 수 있는
|
||||
질문은 게임 중립 SQL로 계속 실행하고, 게임별 객체가 본질적으로 필요한 질문만 빈
|
||||
결과 또는 게임 식별 요청으로 처리한다.
|
||||
|
||||
`UNMATCHED`와 `OBJECT_STATUS=UNAVAILABLE`는 `NONE`과 별도 계약이다. 요청한
|
||||
게임 전용 논리 객체를 현재 카탈로그에서 확인할 수 없다는 뜻이므로 다른 게임의
|
||||
객체로 대체하지 않는다. 해당 객체가 반드시 필요한 연산이면 실행 가능한 읽기 전용
|
||||
0행 결과로 종료한다. 이때 `NULL`을 담은 합성 1행은 빈 결과로 취급하지 않는다.
|
||||
이 규칙은 특정 게임·prefix·물리 객체에 의존하지 않으며, Java 분기가 아니라 Select
|
||||
AI 프로파일의 `additional_instructions`로 관리한다. 애플리케이션은 계획 전달과
|
||||
read-only·객체 범위 검증만 담당한다.
|
||||
|
||||
공통 거래처럼 게임 식별 컬럼이 있는 객체는 게임 식별자를 SQL 리터럴로 직접 넣지
|
||||
않는다. 활성 게임 별칭 카탈로그를 통해 허용 식별자를 구하는 서브쿼리 또는 `EXISTS`
|
||||
조건을 사용하며, 카탈로그에는 있으나 해당 공통 객체의 데이터가 없는 경우는 다른
|
||||
게임으로 대체하지 않고 빈 결과 또는 집계 `NULL`을 그대로 보존한다.
|
||||
|
||||
기존 예제는 삭제하지 않는다. 실행 가능성, 게임 범위 일치, 물리 객체 의존성을 전수
|
||||
검사해 `RETIRED`로 분리하고, 검증된 정규화 예제만 `APPROVED`로 전환한다.
|
||||
|
||||
### 고객 질답 47건 후보화·승인 규칙
|
||||
|
||||
`SG_AI_QA_QUESTION`의 고객 Excel 47건은 `SOURCE_TYPE=CUSTOMER_QA_BENCHMARK`,
|
||||
`SOURCE_CASE_ID`로 벡터 예제에 한 번씩 적재한다. 초기 상태는 모두 `DRAFT`이고,
|
||||
질문·기대 포인트·기준 SQL을 embedding 문서로 사용한다. 이 상태의 원문은 운영 검색
|
||||
결과에 절대 포함되지 않는다.
|
||||
|
||||
`SG_QA_VECTOR_APPROVE_VERIFIED_BENCHMARK`는 최신 Portal ReAct 실행이 다음을 모두
|
||||
만족한 후보만 자동으로 `APPROVED`로 전환한다.
|
||||
|
||||
1. 고객 지원 범위가 `SUPPORTED`일 것
|
||||
2. 최신 실행이 `COMPLETED`이며 고객 기준 판정이 `PASS`일 것
|
||||
3. 실행 가능한 읽기 전용 SQL이 있을 것
|
||||
|
||||
승인 시 최신 실행 SQL과 응답으로 embedding을 재생성하고, 실행 이력의 query plan에서
|
||||
확인된 `TARGET_TYPE`을 함께 보관한다. `WARN`·`FAIL`·미지원·SQL 미생성 후보는
|
||||
`DRAFT / REVIEW`로 남긴다. 따라서 개선 전 SQL이 Few-shot 근거로 노출되지 않으며,
|
||||
각 검색 결과는 `SOURCE_CASE_ID`로 고객 기준 항목까지 추적할 수 있다.
|
||||
|
||||
## 9. 변경 이력
|
||||
|
||||
- Redmine: `#739`
|
||||
- Backoffice branch: `smilegate`
|
||||
- Portal branch: `main`
|
||||
- 2026-07-28: `STD-03`의 `NO_TARGET` 예제에 검증된 빈 결과 SQL 템플릿을 Few-shot
|
||||
근거로 노출했다. 운영 Portal ReAct에서 `game_query_plan → fewshot_nl2sql` 경로로
|
||||
생성·실행한 SQL은 `SELECT CAST(NULL AS NUMBER) ... FROM DUAL WHERE 1 = 0`이었고,
|
||||
답변 이력 `422`는 `PASS`로 기록됐다. 직접 정답 실행 분기는 두지 않는다.
|
||||
- 2026-07-28: `NONE`을 질의 차단으로 해석하지 않도록 경계 예제에 논리 객체 역할을
|
||||
부여하고, 공통 객체 질의에는 경계 SQL을 일반화하지 않는 프롬프트 규칙을 추가했다.
|
||||
- 2026-07-29: ReAct가 자유 설명문이나 중복 요약 필드를 해석하지 않도록, 게임 범위
|
||||
계획의 권위 JSON 계약을 `contractVersion`, `scopeType`, `targets[*].matchStatus`,
|
||||
`targets[*].dataStatus`, `dataEligibleTargets`, `unresolvedTargets`로 정리했다.
|
||||
이 계약은 게임명·prefix·물리 객체를 애플리케이션에 고정하지 않으며, 다음 도구 호출
|
||||
횟수와 방식은 ReAct가 현재 질문과 계획 결과로 판단한다.
|
||||
30
docs/design/740-smilegate-fewshot-management-tab/README.md
Normal file
30
docs/design/740-smilegate-fewshot-management-tab/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Smilegate 질문별 Few-shot 관리 탭 (#740)
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
Smilegate Portal은 고객 질답 벤치마크를 실행하고, Oracle Select AI MCP가 승인된 few-shot 예제를 검색해 읽기 전용 SQL을 생성·실행하는 서비스다. 고객 질문, 실행 이력, few-shot 예제는 Oracle ADB의 `SG_AI_QA_QUESTION`, `SG_AI_QA_ANSWER`, `SG_QA_VECTOR_EXAMPLE`에 보관한다.
|
||||
|
||||
## 목표
|
||||
|
||||
Portal에 **Few-shot 관리** 탭을 추가해 운영자가 질문별 few-shot 내용을 확인하고 수정할 수 있게 한다.
|
||||
|
||||
## 설계
|
||||
|
||||
- 질문 목록은 기존 고객 QA 기준 파일을 사용하고, 예제는 `source_type=CUSTOMER_QA_BENCHMARK` 및 `source_case_id=질문 코드`로 연결한다.
|
||||
- 탭은 질문, 예제 SQL, 설명, 참조 종류, 적용 게임 범위, 논리 객체 역할, 검토 메모, 검색 상태를 표시한다.
|
||||
- 저장은 예제 본문과 메타데이터를 갱신하고 embedding 입력과 vector embedding을 함께 재생성한다. 따라서 수정 후 벡터 검색이 수정 전 내용과 어긋나지 않는다.
|
||||
- 저장 시 상태를 자동 승인하지 않는다. `APPROVED`는 기존 승인 정책에 맞는 운영자 선택으로만 유지·변경하며, `RETIRED` 예제는 검색에서 제외된다.
|
||||
- 물리 게임 테이블명은 예제의 정책 값으로 넣지 않고 논리 `object_role`만 보관한다. 게임 범위는 현재 query plan이 권위 있는 원천이다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
1. 인증된 Portal에서 기존 아키텍처·시나리오와 함께 Few-shot 관리 탭을 볼 수 있다.
|
||||
2. 질문을 고르면 해당 few-shot 예제의 전체 내용을 확인할 수 있다.
|
||||
3. 유효한 값으로 수정 후 저장하면 다시 열었을 때 저장 내용이 보인다.
|
||||
4. Python 단위 테스트가 저장 입력 검증과 DB 갱신 SQL을 확인한다.
|
||||
|
||||
## 검증 계획
|
||||
|
||||
- `python -m pytest`로 Portal 테스트를 실행한다.
|
||||
- Python compile 검사로 Streamlit 모듈 구문을 확인한다.
|
||||
- DB 접속 설정이 있는 환경에서는 질문별 예제 조회와 수정 후 재조회로 검증한다.
|
||||
@@ -11,7 +11,6 @@ with the Bearer token entered on the screen.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
@@ -57,12 +56,20 @@ from src.agent_console.presentation import (
|
||||
render_login_brand,
|
||||
)
|
||||
from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile
|
||||
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
|
||||
from src.poc4.qa_history import QaJudgment, QaQuestion, evaluate_sql, load_benchmark_questions
|
||||
from src.poc4.qa_history_store import (
|
||||
INSPECTION_STATUSES,
|
||||
REFERENCE_KINDS,
|
||||
REFERENCE_STATUSES,
|
||||
TARGET_TYPES,
|
||||
QaHistoryStore,
|
||||
QaHistoryStoreError,
|
||||
)
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||
PREFERRED_TOOL = "ords.query.kb_select_ai_vpd"
|
||||
PREFERRED_TOOL = "oracle.select_ai.smilegate_fewshot_nl2sql"
|
||||
DEFAULT_QUESTION = ""
|
||||
MAX_RESPONSE_BYTES = 1_000_000
|
||||
MAX_CONVERSATION_MESSAGES = 8
|
||||
@@ -77,17 +84,20 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
|
||||
ENV_FILE = ROOT / ".env"
|
||||
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
|
||||
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
|
||||
DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json"
|
||||
QA_BENCHMARK_FILE = ROOT / "config" / "smilegate_qa_benchmark.json"
|
||||
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
|
||||
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
|
||||
DEFAULT_VPD_USER_ID = "FC00789"
|
||||
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/"
|
||||
DEFAULT_VPD_USER_ID = "1001"
|
||||
VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/"
|
||||
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
|
||||
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
|
||||
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
|
||||
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
|
||||
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
||||
AUDIT_SCHEMA = "POC_2"
|
||||
PORTAL_AUTH_COOKIE_NAME = "poc4_portal_auth"
|
||||
AUDIT_SCHEMA = "SGMP_POC"
|
||||
REFERENCE_EVIDENCE_ENABLED = (
|
||||
os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower()
|
||||
in {"1", "true", "yes"}
|
||||
)
|
||||
AUDIT_DB_ENV_FILE = Path(
|
||||
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
|
||||
).expanduser()
|
||||
@@ -1885,12 +1895,329 @@ def new_conversation_id() -> str:
|
||||
|
||||
|
||||
def _question_label(question: object) -> str:
|
||||
question_id = str(getattr(question, "question_id"))
|
||||
category = str(getattr(question, "category"))
|
||||
title = str(getattr(question, "title", getattr(question, "text")))
|
||||
question_id = str(
|
||||
getattr(question, "question_code", getattr(question, "question_id", ""))
|
||||
)
|
||||
category = str(getattr(question, "category", ""))
|
||||
title = str(
|
||||
getattr(question, "title", getattr(question, "question_text", ""))
|
||||
)
|
||||
return f"{question_id} · {category} · {title}"
|
||||
|
||||
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def _qa_history_store() -> QaHistoryStore:
|
||||
return QaHistoryStore()
|
||||
|
||||
|
||||
def _load_qa_questions() -> tuple[list[QaQuestion], QaHistoryStore | None, str]:
|
||||
try:
|
||||
return list(load_benchmark_questions(QA_BENCHMARK_FILE)), _qa_history_store(), ""
|
||||
except Exception as exc:
|
||||
return [], None, f"질답 기준 파일을 읽지 못했습니다: {exc}"
|
||||
|
||||
|
||||
def _judgment_label(status: str) -> str:
|
||||
labels = {
|
||||
"PASS": "통과",
|
||||
"WARN": "검토",
|
||||
"FAIL": "실패",
|
||||
"REVIEW": "수동 검토",
|
||||
}
|
||||
return labels.get(str(status or "").upper(), "미실행")
|
||||
|
||||
|
||||
def _qa_answer_preview(value: object, limit: int = 180) -> str:
|
||||
text = str(value or "").strip().replace("\n", " ")
|
||||
return text if len(text) <= limit else text[:limit] + "..."
|
||||
|
||||
|
||||
def _render_qa_benchmark_panel(
|
||||
questions: list[QaQuestion],
|
||||
store: QaHistoryStore | None,
|
||||
store_error: str,
|
||||
) -> QaQuestion | None:
|
||||
st.markdown(
|
||||
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||
"고객 질답 검증 시나리오"
|
||||
"</div>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
st.caption(
|
||||
"고객 제공 Excel에서 정리한 47개 후보입니다. 후보를 선택하면 기준 답변과 과거 검증 이력을 확인하고 바로 실행할 수 있습니다."
|
||||
)
|
||||
if store_error:
|
||||
st.warning(
|
||||
"현재는 파일 기준 후보만 표시합니다. ADB 이력 저장은 연결 복구 후 사용할 수 있습니다. "
|
||||
+ store_error
|
||||
)
|
||||
if not questions:
|
||||
st.error("표시할 고객 질답 기준이 없습니다.")
|
||||
return None
|
||||
|
||||
category_options = tuple(sorted({question.category for question in questions}))
|
||||
category = st.selectbox(
|
||||
"시나리오 구분",
|
||||
options=("ALL", *category_options),
|
||||
format_func=lambda item: "전체 47개" if item == "ALL" else item,
|
||||
key="poc4_qa_category_filter",
|
||||
)
|
||||
visible_questions = [
|
||||
question for question in questions if category == "ALL" or question.category == category
|
||||
]
|
||||
st.dataframe(
|
||||
[
|
||||
{
|
||||
"케이스": question.question_code,
|
||||
"구분": question.category,
|
||||
"제목": question.title,
|
||||
"질문": question.question_text,
|
||||
"기대 기준": _qa_answer_preview(question.expected_focus, 150),
|
||||
"기준 상태": _judgment_label(question.last_judgment_status)
|
||||
if question.last_judgment_status
|
||||
else "과거 기준",
|
||||
"최근 실행": question.last_evaluated_at or "후보 선택 시 조회",
|
||||
}
|
||||
for question in visible_questions
|
||||
],
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
height=min(520, 110 + 36 * len(visible_questions)),
|
||||
column_config={
|
||||
"질문": st.column_config.TextColumn(width="large"),
|
||||
"기대 기준": st.column_config.TextColumn(width="large"),
|
||||
"최근 실행": st.column_config.TextColumn(width="medium"),
|
||||
},
|
||||
)
|
||||
by_code = {question.question_code: question for question in visible_questions}
|
||||
selected_code = st.selectbox(
|
||||
"실행할 검증 후보 선택",
|
||||
options=("", *by_code),
|
||||
format_func=lambda code: "선택 안 함 · 자유 질의" if not code else _question_label(by_code[code]),
|
||||
key="poc4_qa_question_code",
|
||||
)
|
||||
selected = by_code.get(selected_code)
|
||||
if selected is None:
|
||||
st.info("자유 텍스트 질문도 실행하고 이력으로 남길 수 있습니다. 자유 질의는 정답 기준이 없어 수동 검토로 표시됩니다.")
|
||||
return None
|
||||
|
||||
if store is not None:
|
||||
try:
|
||||
stored_question = store.get_question_by_code(selected.question_code)
|
||||
if stored_question is not None:
|
||||
selected = stored_question
|
||||
else:
|
||||
st.warning("선택한 후보가 ADB 이력 테이블에 아직 적재되지 않았습니다.")
|
||||
except QaHistoryStoreError as exc:
|
||||
st.warning("선택한 후보의 ADB 이력을 불러오지 못했습니다. " + str(exc))
|
||||
|
||||
with st.expander("선택한 질문의 정답 기준·원본·과거 답변", expanded=True):
|
||||
left, right = st.columns(2)
|
||||
with left:
|
||||
st.markdown("**정답 기준**")
|
||||
st.write(selected.expected_focus or "정답 기준 설명이 없습니다.")
|
||||
st.markdown("**기준 답변**")
|
||||
st.code(selected.baseline_answer or "기준 실행 결과가 없습니다.", language=None)
|
||||
with right:
|
||||
st.markdown("**고객 원본 출처**")
|
||||
st.write(
|
||||
f"{selected.source_document} / {selected.source_sheet} / 행 {selected.source_row or '-'}"
|
||||
)
|
||||
if selected.source_scenario:
|
||||
st.caption(selected.source_scenario)
|
||||
st.markdown("**기준 SQL**")
|
||||
st.code(selected.baseline_sql or selected.sample_sql or "기준 SQL이 없습니다.", language="sql")
|
||||
|
||||
if store is not None and selected.question_id is not None:
|
||||
try:
|
||||
answers = store.list_answers(selected.question_id, limit=30)
|
||||
except QaHistoryStoreError as exc:
|
||||
st.warning(str(exc))
|
||||
answers = []
|
||||
if answers:
|
||||
st.markdown("**과거 답변 이력**")
|
||||
st.dataframe(
|
||||
[
|
||||
{
|
||||
"순번": item["answer_seq"],
|
||||
"구분": "과거 기준" if item["answer_kind"] == "HISTORICAL" else "이번 실행",
|
||||
"판정": _judgment_label(item["judgment_status"]),
|
||||
"실행 상태": item["execution_status"],
|
||||
"실행 시각": item["requested_at"],
|
||||
"답변": _qa_answer_preview(item["answer_text"]),
|
||||
}
|
||||
for item in answers
|
||||
],
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
)
|
||||
answer_by_seq = {str(item["answer_seq"]): item for item in answers}
|
||||
detail_seq = st.selectbox(
|
||||
"답변 이력 상세",
|
||||
options=tuple(answer_by_seq),
|
||||
format_func=lambda value: (
|
||||
f"#{value} · {_judgment_label(answer_by_seq[value]['judgment_status'])} · "
|
||||
f"{answer_by_seq[value]['requested_at']}"
|
||||
),
|
||||
key=f"poc4_qa_answer_detail_{selected.question_id}",
|
||||
)
|
||||
detail = answer_by_seq[detail_seq]
|
||||
st.markdown("**판정 근거**")
|
||||
st.write(detail["judgment_reason"] or "판정 근거가 없습니다.")
|
||||
st.markdown("**생성 SQL**")
|
||||
st.code(detail["generated_sql"] or "생성 SQL이 없습니다.", language="sql")
|
||||
st.markdown("**실행 답변**")
|
||||
st.write(detail["answer_text"] or "답변이 없습니다.")
|
||||
else:
|
||||
st.info("아직 저장된 답변 이력이 없습니다.")
|
||||
return selected
|
||||
|
||||
|
||||
def _render_few_shot_management_tab(
|
||||
questions: list[QaQuestion],
|
||||
store: QaHistoryStore | None,
|
||||
store_error: str,
|
||||
) -> None:
|
||||
st.subheader("질문별 Few-shot 관리")
|
||||
st.caption(
|
||||
"고객 질답 질문에 연결된 few-shot 예제를 검토하고 수정합니다. 저장하면 검색용 embedding도 함께 다시 생성됩니다."
|
||||
)
|
||||
if store_error or store is None:
|
||||
st.warning(
|
||||
"Few-shot 관리 DB에 연결하지 못했습니다. "
|
||||
+ (store_error or "질답 이력 DB 접속 설정을 확인해 주세요.")
|
||||
)
|
||||
return
|
||||
if not questions:
|
||||
st.info("표시할 고객 질답 질문이 없습니다.")
|
||||
return
|
||||
|
||||
by_code = {question.question_code: question for question in questions}
|
||||
selected_code = st.selectbox(
|
||||
"질문 선택",
|
||||
options=tuple(by_code),
|
||||
format_func=lambda code: _question_label(by_code[code]),
|
||||
key="poc4_few_shot_question_code",
|
||||
)
|
||||
selected_question = by_code[selected_code]
|
||||
st.markdown("**선택한 질문**")
|
||||
st.write(selected_question.question_text)
|
||||
try:
|
||||
examples = store.list_vector_examples_by_case(selected_question.question_code)
|
||||
except QaHistoryStoreError as exc:
|
||||
st.error(str(exc))
|
||||
return
|
||||
if not examples:
|
||||
st.info(
|
||||
"이 질문에는 아직 연결된 few-shot 후보가 없습니다. "
|
||||
"후보 적재 작업 후 이 화면에서 검토할 수 있습니다."
|
||||
)
|
||||
return
|
||||
|
||||
example_by_id = {str(item["example_id"]): item for item in examples}
|
||||
selected_example_id = st.selectbox(
|
||||
"Few-shot 예제",
|
||||
options=tuple(example_by_id),
|
||||
format_func=lambda example_id: (
|
||||
f"예제 #{example_id} · "
|
||||
f"{example_by_id[example_id]['reference_status']} · "
|
||||
f"{example_by_id[example_id]['inspection_status']}"
|
||||
),
|
||||
key=f"poc4_few_shot_example_{selected_question.question_code}",
|
||||
)
|
||||
example = example_by_id[selected_example_id]
|
||||
st.caption(
|
||||
"검색 대상은 APPROVED 예제뿐입니다. 게임별 물리 테이블은 여기에 넣지 않고, "
|
||||
"현재 질문의 query plan이 해석하는 논리 객체 역할만 관리합니다."
|
||||
)
|
||||
if example["verified_at"]:
|
||||
st.caption(
|
||||
f"마지막 승인: {example['verified_at']} · {example['verified_by'] or '확인자 미기록'}"
|
||||
)
|
||||
|
||||
form_key = f"poc4_few_shot_form_{example['example_id']}"
|
||||
with st.form(form_key):
|
||||
question = st.text_area(
|
||||
"Few-shot 질문",
|
||||
value=example["question"],
|
||||
height=100,
|
||||
max_chars=4_000,
|
||||
)
|
||||
answer_sql = st.text_area(
|
||||
"Few-shot SQL 템플릿",
|
||||
value=example["answer_sql"],
|
||||
height=220,
|
||||
max_chars=12_000,
|
||||
help="읽기 전용 SQL 템플릿을 사용하세요. 게임별 물리 객체는 논리 placeholder로 표현합니다.",
|
||||
)
|
||||
answer_text = st.text_area(
|
||||
"설명 / 답변 보조 문구",
|
||||
value=example["answer_text"],
|
||||
height=120,
|
||||
max_chars=8_000,
|
||||
)
|
||||
left, right = st.columns(2)
|
||||
with left:
|
||||
reference_status = st.selectbox(
|
||||
"검색 상태",
|
||||
options=REFERENCE_STATUSES,
|
||||
index=REFERENCE_STATUSES.index(example["reference_status"]),
|
||||
help="APPROVED만 실제 few-shot 검색에 사용됩니다.",
|
||||
)
|
||||
reference_kind = st.selectbox(
|
||||
"참조 종류",
|
||||
options=REFERENCE_KINDS,
|
||||
index=REFERENCE_KINDS.index(example["reference_kind"]),
|
||||
)
|
||||
with right:
|
||||
target_type = st.selectbox(
|
||||
"적용 게임 범위",
|
||||
options=TARGET_TYPES,
|
||||
index=TARGET_TYPES.index(example["target_type"]),
|
||||
)
|
||||
inspection_status = st.selectbox(
|
||||
"검토 상태",
|
||||
options=INSPECTION_STATUSES,
|
||||
index=INSPECTION_STATUSES.index(example["inspection_status"]),
|
||||
)
|
||||
object_role = st.text_input(
|
||||
"논리 객체 역할",
|
||||
value=example["object_role"],
|
||||
max_chars=64,
|
||||
placeholder="예: GAME_USER_MASTER",
|
||||
)
|
||||
inspection_note = st.text_area(
|
||||
"검토 메모",
|
||||
value=example["inspection_note"],
|
||||
height=100,
|
||||
max_chars=4_000,
|
||||
)
|
||||
submitted = st.form_submit_button("Few-shot 변경 저장", type="primary")
|
||||
|
||||
if not submitted:
|
||||
return
|
||||
try:
|
||||
store.update_vector_example(
|
||||
example_id=example["example_id"],
|
||||
source_case_id=selected_question.question_code,
|
||||
question=question,
|
||||
answer_sql=answer_sql,
|
||||
answer_text=answer_text,
|
||||
reference_status=reference_status,
|
||||
reference_kind=reference_kind,
|
||||
target_type=target_type,
|
||||
object_role=object_role,
|
||||
inspection_status=inspection_status,
|
||||
inspection_note=inspection_note,
|
||||
verified_by=str(st.session_state.get(PORTAL_AUTH_USER_KEY) or "PORTAL_OPERATOR"),
|
||||
)
|
||||
except QaHistoryStoreError as exc:
|
||||
st.error(str(exc))
|
||||
return
|
||||
st.success("Few-shot 예제와 검색용 embedding을 저장했습니다.")
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _apply_console_theme(profile: AppProfile) -> None:
|
||||
apply_console_theme(st, profile)
|
||||
|
||||
@@ -1933,61 +2260,41 @@ def _portal_credentials_are_valid(username: str, password: str) -> bool:
|
||||
return username_matches and password_matches
|
||||
|
||||
|
||||
def _portal_remember_secret() -> str:
|
||||
return _portal_auth_value("POC4_LOGIN_REMEMBER_SECRET")
|
||||
|
||||
|
||||
def _portal_remember_token(username: str) -> str:
|
||||
secret = _portal_remember_secret()
|
||||
if not secret:
|
||||
def _portal_cookie_user() -> str:
|
||||
"""Return the verified portal user stored in the HttpOnly auth cookie."""
|
||||
try:
|
||||
token = str(st.context.cookies.get(PORTAL_AUTH_COOKIE_NAME, "")).strip()
|
||||
encoded_claims, supplied_signature = token.rsplit(".", 1)
|
||||
secret = _portal_auth_value("POC4_LOGIN_REMEMBER_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 ""
|
||||
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):
|
||||
return
|
||||
token = st.query_params.get(PORTAL_REMEMBER_TOKEN_PARAM, "")
|
||||
if not isinstance(token, str) or not token or len(token) > 2048:
|
||||
return
|
||||
secret = _portal_remember_secret()
|
||||
expected_username = _portal_auth_value("POC4_LOGIN_USER")
|
||||
try:
|
||||
encoded, supplied_signature = token.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
padded = encoded + "=" * (-len(encoded) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
||||
expires_at = int(payload["e"])
|
||||
username = str(payload["u"])
|
||||
except (binascii.Error, KeyError, TypeError, ValueError, UnicodeDecodeError):
|
||||
return
|
||||
if not secret or not hmac.compare_digest(supplied_signature, expected_signature):
|
||||
return
|
||||
if expires_at < int(datetime.now(timezone.utc).timestamp()):
|
||||
return
|
||||
if not hmac.compare_digest(username, expected_username):
|
||||
return
|
||||
st.session_state[PORTAL_AUTHENTICATED_KEY] = True
|
||||
st.session_state[PORTAL_AUTH_USER_KEY] = username
|
||||
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False
|
||||
|
||||
|
||||
def _clear_portal_remembered_session() -> None:
|
||||
if PORTAL_REMEMBER_TOKEN_PARAM in st.query_params:
|
||||
del st.query_params[PORTAL_REMEMBER_TOKEN_PARAM]
|
||||
username = _portal_cookie_user()
|
||||
if username:
|
||||
st.session_state[PORTAL_AUTHENTICATED_KEY] = True
|
||||
st.session_state[PORTAL_AUTH_USER_KEY] = username
|
||||
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False
|
||||
|
||||
|
||||
def _render_portal_login(profile: AppProfile) -> None:
|
||||
@@ -1996,39 +2303,35 @@ def _render_portal_login(profile: AppProfile) -> None:
|
||||
if not _portal_auth_configured():
|
||||
st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.")
|
||||
return
|
||||
with st.form("poc4_portal_login_form", clear_on_submit=True):
|
||||
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):
|
||||
if st.query_params.get("login") == "failed":
|
||||
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(
|
||||
f'<p class="console-muted">{html.escape(profile.login_footer)}</p>',
|
||||
unsafe_allow_html=True,
|
||||
@@ -2036,11 +2339,13 @@ def _render_portal_login(profile: AppProfile) -> None:
|
||||
|
||||
|
||||
def _logout_portal() -> None:
|
||||
_clear_portal_remembered_session()
|
||||
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
|
||||
st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
|
||||
st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None)
|
||||
st.rerun()
|
||||
components.html(
|
||||
'<script>window.parent.location.assign("/_poc4/auth/logout");</script>',
|
||||
height=0,
|
||||
)
|
||||
|
||||
|
||||
def _render_app_header(profile: AppProfile) -> None:
|
||||
@@ -2374,6 +2679,8 @@ def collect_security_evidence(
|
||||
) -> Mapping[str, Any]:
|
||||
"""Collect predefined, token-scoped security evidence without exposing secrets."""
|
||||
|
||||
if not REFERENCE_EVIDENCE_ENABLED:
|
||||
return {}
|
||||
kind = _security_evidence_kind(question)
|
||||
if not kind or token_preset is None:
|
||||
return {}
|
||||
@@ -2938,6 +3245,8 @@ def collect_business_evidence(
|
||||
) -> Mapping[str, Any]:
|
||||
"""Collect token-validated, explicitly scoped business and catalog evidence."""
|
||||
|
||||
if not REFERENCE_EVIDENCE_ENABLED:
|
||||
return {}
|
||||
kind = _business_evidence_kind(question)
|
||||
if not kind or token_preset is None:
|
||||
return {}
|
||||
@@ -3636,6 +3945,9 @@ def load_vpd_token_presets(
|
||||
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
|
||||
continue
|
||||
token = _normalized_bearer(item.get("token"))
|
||||
if not token:
|
||||
token_env = str(item.get("token_env") or "").strip()
|
||||
token = _normalized_bearer(_runtime_env_value(token_env)) if token_env else ""
|
||||
user_id = str(item.get("user_id") or "").strip()
|
||||
if not token or not user_id or user_id in seen:
|
||||
continue
|
||||
@@ -5138,15 +5450,17 @@ def _render_mcp_result_sections(
|
||||
st.json(step.get("result_summary", {}))
|
||||
|
||||
if generated_sql:
|
||||
with st.expander("생성 SQL"):
|
||||
with st.expander("실행 SQL", expanded=True):
|
||||
st.code(generated_sql, language="sql")
|
||||
|
||||
if items:
|
||||
with st.expander(f"조회 결과 테이블 · {len(items)}건"):
|
||||
with st.expander(f"조회 결과 · {len(items)}건", expanded=True):
|
||||
display_items = items[:100]
|
||||
st.dataframe(display_items, use_container_width=True)
|
||||
if len(items) > len(display_items):
|
||||
st.caption(f"화면에는 최초 {len(display_items)}건만 표시합니다.")
|
||||
elif generated_sql:
|
||||
st.info("실행 SQL은 생성되었지만 반환된 조회 결과 행이 없습니다.")
|
||||
|
||||
with st.expander("MCP 호출 상세"):
|
||||
st.write(f"route: {details.get('server_id')} / {details.get('tool_name')}")
|
||||
@@ -6323,6 +6637,89 @@ def _render_vpd_operations_tab() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _select_ai_execution_summary(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, Mapping):
|
||||
return {
|
||||
"generated_sql": "",
|
||||
"execution_status": "UNKNOWN",
|
||||
"execution_succeeded": False,
|
||||
"result": {},
|
||||
}
|
||||
generated_sql = str(
|
||||
payload.get("generatedSql") or payload.get("generated_sql") or ""
|
||||
).strip()
|
||||
status = str(payload.get("status") or "").strip().upper()
|
||||
execution = str(payload.get("execution") or "").strip().upper()
|
||||
execution_succeeded = (
|
||||
status == "SHOWSQL_AND_EXECUTED" and execution == "READ_ONLY_EXECUTED"
|
||||
)
|
||||
result = {
|
||||
key: payload.get(key)
|
||||
for key in (
|
||||
"status",
|
||||
"execution",
|
||||
"rowCount",
|
||||
"truncated",
|
||||
"columns",
|
||||
"items",
|
||||
"generatedSql",
|
||||
)
|
||||
if key in payload
|
||||
}
|
||||
return {
|
||||
"generated_sql": generated_sql,
|
||||
"execution_status": status or execution or "UNKNOWN",
|
||||
"execution_succeeded": execution_succeeded,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
|
||||
def _record_qa_history(
|
||||
*,
|
||||
store: QaHistoryStore | None,
|
||||
benchmark_question: QaQuestion | None,
|
||||
question_text: str,
|
||||
conversation_id: str,
|
||||
requested_by: str,
|
||||
model_profile: str,
|
||||
answer_text: str,
|
||||
mcp_result: Any,
|
||||
duration_ms: int,
|
||||
) -> tuple[QaQuestion | None, QaJudgment | None]:
|
||||
if store is None:
|
||||
return benchmark_question, None
|
||||
try:
|
||||
history_question = benchmark_question or store.find_or_create_free_text_question(
|
||||
question_text
|
||||
)
|
||||
execution = _select_ai_execution_summary(mcp_result)
|
||||
judgment = evaluate_sql(
|
||||
benchmark_question,
|
||||
execution["generated_sql"],
|
||||
execution_succeeded=bool(execution["execution_succeeded"]),
|
||||
error_text=_bounded_json(mcp_result, max_chars=8000),
|
||||
)
|
||||
store.record_answer(
|
||||
question_id=int(history_question.question_id or 0),
|
||||
answer_kind="LIVE",
|
||||
conversation_id=conversation_id,
|
||||
requested_by=requested_by,
|
||||
model_profile=model_profile,
|
||||
generated_sql=execution["generated_sql"],
|
||||
answer_text=answer_text,
|
||||
result=execution["result"],
|
||||
execution_output=_bounded_json(mcp_result, max_chars=24_000),
|
||||
execution_status=str(execution["execution_status"]),
|
||||
judgment_status=judgment.status,
|
||||
judgment_reason=judgment.reason,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
return history_question, judgment
|
||||
except QaHistoryStoreError as exc:
|
||||
st.warning(f"질답 이력 저장에 실패했습니다: {exc}")
|
||||
return benchmark_question, None
|
||||
|
||||
|
||||
def _process_submitted_question(
|
||||
*,
|
||||
question: str,
|
||||
@@ -6338,6 +6735,8 @@ def _process_submitted_question(
|
||||
limit: int,
|
||||
selected_token_preset: VpdTokenPreset | None,
|
||||
execution_mode_override: str,
|
||||
benchmark_question: QaQuestion | None,
|
||||
qa_history_store: QaHistoryStore | None,
|
||||
) -> None:
|
||||
normalized_question = question.strip()
|
||||
if not normalized_question:
|
||||
@@ -6880,6 +7279,32 @@ def _process_submitted_question(
|
||||
],
|
||||
"discovery_failures": discovery_failures,
|
||||
}
|
||||
history_question, history_judgment = _record_qa_history(
|
||||
store=qa_history_store,
|
||||
benchmark_question=benchmark_question,
|
||||
question_text=normalized_question,
|
||||
conversation_id=conversation_id,
|
||||
requested_by=(
|
||||
selected_token_preset.user_id
|
||||
if selected_token_preset is not None
|
||||
else "portal-user"
|
||||
),
|
||||
model_profile=answer_model_profile,
|
||||
answer_text=str(assistant_message["content"]),
|
||||
mcp_result=answer_source,
|
||||
duration_ms=round((perf_counter() - process_started) * 1000),
|
||||
)
|
||||
if history_judgment is not None:
|
||||
assistant_message["details"]["qa_history"] = {
|
||||
"question_id": history_question.question_id if history_question else None,
|
||||
"question_code": history_question.question_code if history_question else "",
|
||||
"judgment_status": history_judgment.status,
|
||||
"judgment_reason": history_judgment.reason,
|
||||
}
|
||||
st.info(
|
||||
"질답 검증 판정: "
|
||||
f"{_judgment_label(history_judgment.status)} · {history_judgment.reason}"
|
||||
)
|
||||
refresh_progress(98, "질의 결과를 저장하고 있습니다.")
|
||||
save_chat_turn(
|
||||
conversation_id=conversation_id,
|
||||
@@ -6930,31 +7355,17 @@ def main() -> None:
|
||||
page_title=profile.page_title, page_icon=profile.page_icon, layout="wide"
|
||||
)
|
||||
_apply_console_theme(profile)
|
||||
_restore_portal_remembered_session()
|
||||
_restore_portal_session_from_cookie()
|
||||
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
||||
_render_portal_login(profile)
|
||||
return
|
||||
try:
|
||||
questions = load_demo_scenarios(DEMO_SCENARIOS_FILE)
|
||||
except ScenarioConfigError as exc:
|
||||
st.error(str(exc))
|
||||
return
|
||||
scenario_key = "poc4_mcp_discovery_scenario"
|
||||
qa_questions, qa_history_store, qa_store_error = _load_qa_questions()
|
||||
question_text_key = "poc4_mcp_discovery_question_text"
|
||||
loaded_scenario_key = "poc4_mcp_discovery_loaded_scenario_id"
|
||||
loaded_qa_question_key = "poc4_mcp_discovery_loaded_qa_question_code"
|
||||
conversation_id_key = "poc4_mcp_discovery_conversation_id"
|
||||
chat_page_key = "poc4_mcp_discovery_page"
|
||||
query_progress_notice_key = "poc4_query_progress_notice"
|
||||
mcp_cache_generation_key = "poc4_mcp_tools_cache_generation"
|
||||
selected_scenario_state = st.session_state.get(scenario_key)
|
||||
scenario_ids = {question.question_id for question in questions}
|
||||
if str(getattr(selected_scenario_state, "question_id", "")).strip().upper() not in (
|
||||
"",
|
||||
*scenario_ids,
|
||||
):
|
||||
st.session_state.pop(scenario_key, None)
|
||||
st.session_state.pop(loaded_scenario_key, None)
|
||||
st.session_state[question_text_key] = DEFAULT_QUESTION
|
||||
init_chat_store()
|
||||
if conversation_id_key not in st.session_state:
|
||||
st.session_state[conversation_id_key] = new_conversation_id()
|
||||
@@ -7069,8 +7480,8 @@ def main() -> None:
|
||||
else:
|
||||
selected_token_preset = None
|
||||
if configured_mcp_bearer:
|
||||
# The HMM MCP gateway uses its own server-side credential. VPD user
|
||||
# tokens remain UI context only and must never be forwarded to it.
|
||||
# The Smilegate MCP gateway uses a server-side credential. UI-only
|
||||
# context must never be forwarded as a second bearer token.
|
||||
st.caption("MCP 인증: 서버 관리 토큰 적용")
|
||||
manual_bearer_token = ""
|
||||
elif selected_token_preset is not None:
|
||||
@@ -7243,7 +7654,7 @@ def main() -> None:
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
st.warning("MCP 설정 JSON을 읽지 못했습니다.")
|
||||
st.caption(f"config: {MCP_SERVERS_FILE}")
|
||||
st.caption(f"scenario config: {DEMO_SCENARIOS_FILE}")
|
||||
st.caption(f"QA benchmark: {QA_BENCHMARK_FILE}")
|
||||
st.caption(f"token presets: {VPD_TOKEN_PRESETS_FILE}")
|
||||
st.caption(f"selected LLM model: {selected_query_model_profile}")
|
||||
st.caption(f"default model: {default_query_model_profile}")
|
||||
@@ -7256,42 +7667,43 @@ def main() -> None:
|
||||
'<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs(
|
||||
["아키텍처", "시나리오", "감사로그", "보안관리"]
|
||||
architecture_tab, scenario_tab, few_shot_tab = st.tabs(
|
||||
["아키텍처", "시나리오", "Few-shot 관리"]
|
||||
)
|
||||
with architecture_tab:
|
||||
_render_architecture_tab()
|
||||
|
||||
with scenario_tab:
|
||||
st.markdown(
|
||||
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||
"질문 입력"
|
||||
"</div>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
selected_scenario = st.selectbox(
|
||||
"업무 데모 질의 샘플",
|
||||
options=(None, *questions),
|
||||
format_func=lambda item: (
|
||||
"선택 안 함 · 직접 질문" if item is None else _question_label(item)
|
||||
),
|
||||
key=scenario_key,
|
||||
)
|
||||
selected_scenario_id = (
|
||||
None
|
||||
if selected_scenario is None
|
||||
else str(getattr(selected_scenario, "question_id"))
|
||||
selected_qa_question = _render_qa_benchmark_panel(
|
||||
qa_questions,
|
||||
qa_history_store,
|
||||
qa_store_error,
|
||||
)
|
||||
if question_text_key not in st.session_state:
|
||||
st.session_state[question_text_key] = DEFAULT_QUESTION
|
||||
if st.session_state.get(loaded_scenario_key, "") != str(
|
||||
selected_scenario_id or ""
|
||||
):
|
||||
if selected_scenario is not None:
|
||||
st.session_state[question_text_key] = str(
|
||||
getattr(selected_scenario, "text")
|
||||
)
|
||||
st.session_state[loaded_scenario_key] = str(selected_scenario_id or "")
|
||||
selected_qa_code = (
|
||||
selected_qa_question.question_code if selected_qa_question is not None else ""
|
||||
)
|
||||
if st.session_state.get(loaded_qa_question_key, "") != selected_qa_code:
|
||||
st.session_state[question_text_key] = (
|
||||
selected_qa_question.question_text
|
||||
if selected_qa_question is not None
|
||||
else DEFAULT_QUESTION
|
||||
)
|
||||
st.session_state[loaded_qa_question_key] = selected_qa_code
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||
"질문 실행"
|
||||
"</div>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
if selected_qa_question is not None:
|
||||
st.caption(
|
||||
"선택한 후보 문장을 수정하지 않고 실행하면 자동 판정합니다. 문장을 수정하면 자유 질의로 저장되어 수동 검토 대상이 됩니다."
|
||||
)
|
||||
else:
|
||||
st.caption("자유 질의는 실행 이력으로 남기며, 고객 기준 정답과 자동 비교하지 않습니다.")
|
||||
|
||||
with st.form("poc4_mcp_question_form"):
|
||||
question = st.text_area(
|
||||
@@ -7300,7 +7712,7 @@ def main() -> None:
|
||||
key=question_text_key,
|
||||
height=120,
|
||||
max_chars=1_000,
|
||||
placeholder="업무 질문을 직접 입력하거나 위 질의 샘플을 선택하세요.",
|
||||
placeholder="고객 검증 후보를 선택하거나 업무 질문을 직접 입력하세요.",
|
||||
)
|
||||
submitted = st.form_submit_button(
|
||||
"질문 전송",
|
||||
@@ -7359,11 +7771,12 @@ def main() -> None:
|
||||
height=0,
|
||||
)
|
||||
|
||||
with audit_tab:
|
||||
_render_fga_audit_tab()
|
||||
|
||||
with operations_tab:
|
||||
_render_vpd_operations_tab()
|
||||
with few_shot_tab:
|
||||
_render_few_shot_management_tab(
|
||||
qa_questions,
|
||||
qa_history_store,
|
||||
qa_store_error,
|
||||
)
|
||||
|
||||
if not submitted:
|
||||
return
|
||||
@@ -7383,6 +7796,13 @@ def main() -> None:
|
||||
limit=int(limit),
|
||||
selected_token_preset=selected_token_preset,
|
||||
execution_mode_override=execution_mode_override,
|
||||
benchmark_question=(
|
||||
selected_qa_question
|
||||
if selected_qa_question is not None
|
||||
and question.strip() == selected_qa_question.question_text.strip()
|
||||
else None
|
||||
),
|
||||
qa_history_store=qa_history_store,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"version": 1,
|
||||
"product": {
|
||||
"name": "AI 업무 에이전트",
|
||||
"short_name": "AGENT",
|
||||
"page_title": "AI 업무 에이전트",
|
||||
"name": "SMILEGATE DATA & AI POC",
|
||||
"short_name": "SMILEGATE",
|
||||
"page_title": "SMILEGATE DATA & AI POC",
|
||||
"page_icon": "🤖",
|
||||
"header_title": "AI 업무 에이전트",
|
||||
"header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.",
|
||||
"login_kicker": "DATA & AI DEMO",
|
||||
"login_title": "AI 업무 에이전트",
|
||||
"login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.",
|
||||
"login_footer": "인증된 DEMO 사용자만 접근할 수 있습니다."
|
||||
"header_title": "스마일게이트 게임 데이터 AI 에이전트",
|
||||
"header_description": "게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.",
|
||||
"login_kicker": "SMILEGATE DATA & AI POC",
|
||||
"login_title": "스마일게이트 게임 데이터 AI 에이전트",
|
||||
"login_description": "사용자 인증 후 게임 데이터 AI 질의와 보안 관리 기능을 이용할 수 있습니다.",
|
||||
"login_footer": "승인된 Data & AI PoC 사용자만 접근할 수 있습니다."
|
||||
},
|
||||
"theme": {
|
||||
"primary_color": "#003b70",
|
||||
"text_color": "#172b3a",
|
||||
"muted_color": "#667785",
|
||||
"border_color": "#dfe7ed"
|
||||
"primary_color": "#113F67",
|
||||
"text_color": "#15283B",
|
||||
"muted_color": "#5D6C7C",
|
||||
"border_color": "#D7E0E8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
{
|
||||
"default_server_id": "hmm_hr_mcp",
|
||||
"default_server_id": "smilegate_game_data_mcp",
|
||||
"servers": [
|
||||
{
|
||||
"id": "hmm_hr_mcp",
|
||||
"id": "smilegate_game_data_mcp",
|
||||
"enabled": true,
|
||||
"provider": "hmm_compat_mcp",
|
||||
"provider": "smilegate_select_ai_mcp",
|
||||
"transport": "http",
|
||||
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
|
||||
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||
"endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp",
|
||||
"auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN",
|
||||
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
|
||||
"default_tool": "search_hr_data",
|
||||
"default_tool": "oracle.select_ai.smilegate_fewshot_nl2sql",
|
||||
"router_model_profile": "gpt54_mini_oci",
|
||||
"tool_allowlist": [
|
||||
"search_hr_data",
|
||||
"resolve_hr_term",
|
||||
"search_hr_policy"
|
||||
"oracle.select_ai.smilegate_fewshot_nl2sql",
|
||||
"oracle.select_ai.smilegate_game_text2sql",
|
||||
"oracle.select_ai.qa_vector_search",
|
||||
"oracle.select_ai.qa_vector_store"
|
||||
],
|
||||
"description": "HMM HR knowledge 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,
|
||||
"default": true,
|
||||
"token": "vpd_live_REPLACE_WITH_USER_TOKEN",
|
||||
"user_id": "FC00789",
|
||||
"name": "김설계",
|
||||
"role": "설계사",
|
||||
"channel": "설계사",
|
||||
"scope": "본인 담당 계약 고객"
|
||||
"user_id": "1001",
|
||||
"name": "Data & AI TF 팀장",
|
||||
"role": "DATA_AI_POC_ADMIN",
|
||||
"channel": "DATA_AI_TF",
|
||||
"scope": "SGMP_POC 게임 데이터 전체",
|
||||
"token_env": "SMILEGATE_TEAMLEAD_BEARER_TOKEN"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"user_id": "1002",
|
||||
"name": "Data & AI TF 팀원",
|
||||
"role": "DATA_AI_POC_ADMIN",
|
||||
"channel": "DATA_AI_TF",
|
||||
"scope": "SGMP_POC 게임 데이터 전체",
|
||||
"token_env": "SMILEGATE_TEAMMEMBER_BEARER_TOKEN"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
openpyxl>=3.1,<4
|
||||
oci>=2.180,<3
|
||||
oracledb>=2,<4
|
||||
pandas>=2,<3
|
||||
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 {{
|
||||
background:#fff !important; border:1px solid var(--console-border) !important;
|
||||
border-radius:4px !important; box-shadow:none !important; }}
|
||||
/* Streamlit JSON uses an independently styled code surface. Keep the
|
||||
MCP detail payload readable even when the browser/system prefers a
|
||||
dark code theme. */
|
||||
[data-testid="stJson"], [data-testid="stJson"] > div,
|
||||
[data-testid="stJson"] .react-json-view, [data-testid="stJson"] pre {{
|
||||
background:#f8fafc !important; color:var(--console-text) !important;
|
||||
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||
[data-testid="stJson"] *, [data-testid="stJson"] pre *,
|
||||
[data-testid="stJson"] code {{
|
||||
background:transparent !important; color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
/* Chat responses are rendered in a separate Streamlit surface. Without
|
||||
these rules a dark browser theme can leave the answer card dark while
|
||||
its Markdown keeps the light-theme text color. */
|
||||
div[data-testid="stChatMessage"], div[data-testid="stChatMessageContent"] {{
|
||||
background:#fff !important; color:var(--console-text) !important;
|
||||
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"],
|
||||
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] *,
|
||||
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"],
|
||||
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"] * {{
|
||||
color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
/* Streamlit expanders use a dark summary bar in dark browser themes. */
|
||||
details, details > summary {{
|
||||
background:#fff !important; color:var(--console-text) !important;
|
||||
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||
details > summary *, details > summary::marker {{
|
||||
color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
[data-testid="stExpander"] > details,
|
||||
[data-testid="stExpander"] > details > summary,
|
||||
[data-testid="stExpander"] > details > summary > div,
|
||||
[data-testid="stExpander"] > details > summary > div > div {{
|
||||
background:#fff !important; color:var(--console-text) !important;
|
||||
border-color:var(--console-border) !important; }}
|
||||
[data-testid="stExpander"] > details > summary *,
|
||||
[data-testid="stExpander"] > details > summary svg {{
|
||||
color:var(--console-text) !important; fill:var(--console-text) !important;
|
||||
stroke:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
||||
border-radius:4px !important; box-shadow:none !important; }}
|
||||
background:#fff !important; color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important;
|
||||
border:1px solid var(--console-border) !important;
|
||||
border-radius:4px !important; box-shadow:none !important; color-scheme:light !important; }}
|
||||
div[data-testid="stButton"] > button *, div[data-testid="stFormSubmitButton"] > button * {{
|
||||
color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }}
|
||||
div[data-testid="stButton"] > button[kind="primary"],
|
||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
||||
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
||||
|
||||
@@ -26,7 +26,10 @@ ALLOWED_OCI_SETTINGS = frozenset(
|
||||
"OCI_PROFILE",
|
||||
}
|
||||
)
|
||||
_COMPARTMENT_ID = re.compile(r"^ocid1\.compartment\.[A-Za-z0-9._-]+$")
|
||||
# OCI permits the tenancy OCID when the root compartment is selected.
|
||||
_COMPARTMENT_OR_ROOT_ID = re.compile(
|
||||
r"^ocid1\.(?:compartment|tenancy)\.[A-Za-z0-9._-]+$"
|
||||
)
|
||||
|
||||
|
||||
class CompletionClient(Protocol):
|
||||
@@ -95,7 +98,7 @@ def load_oci_settings() -> OCISettings:
|
||||
raise ValueError("unsupported OCI authentication mode")
|
||||
|
||||
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
|
||||
if not _COMPARTMENT_ID.fullmatch(compartment_id):
|
||||
if not _COMPARTMENT_OR_ROOT_ID.fullmatch(compartment_id):
|
||||
raise ValueError("OCI Generative AI compartment is not configured")
|
||||
return OCISettings(
|
||||
auth_type=auth_type,
|
||||
|
||||
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에는 레벨·접속일·플레이타임 지표가 포함되면 안 됩니다.")
|
||||
697
poc4_active_source_20260714/src/poc4/qa_history_store.py
Normal file
697
poc4_active_source_20260714/src/poc4/qa_history_store.py
Normal file
@@ -0,0 +1,697 @@
|
||||
"""Oracle ADB persistence for the Smilegate customer QA benchmark."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, Mapping
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import oracledb
|
||||
|
||||
from src.poc4.qa_history import QaQuestion, load_benchmark_questions, question_fingerprint, question_from_record
|
||||
|
||||
|
||||
class QaHistoryStoreError(RuntimeError):
|
||||
"""A safe user-facing persistence error."""
|
||||
|
||||
|
||||
QUESTION_TABLE = "SG_AI_QA_QUESTION"
|
||||
ANSWER_TABLE = "SG_AI_QA_ANSWER"
|
||||
VECTOR_EXAMPLE_TABLE = "SG_QA_VECTOR_EXAMPLE"
|
||||
HISTORICAL_RUN_KEY = "HISTORICAL:2026-07-21:term-dict-final-v2"
|
||||
REFERENCE_STATUSES = ("DRAFT", "APPROVED", "RETIRED")
|
||||
REFERENCE_KINDS = ("SQL_TEMPLATE", "NO_TARGET", "OBJECT_UNAVAILABLE", "METADATA_POLICY")
|
||||
TARGET_TYPES = ("NONE", "SINGLE", "MULTI", "ALL", "ANY")
|
||||
INSPECTION_STATUSES = ("PENDING", "REVIEW", "VERIFIED", "RETIRED")
|
||||
|
||||
|
||||
def _env_value(name: str, env_file: Path | None = None) -> str:
|
||||
value = os.environ.get(name, "").strip()
|
||||
if value or env_file is None or not env_file.is_file():
|
||||
return value
|
||||
try:
|
||||
lines = env_file.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeError):
|
||||
return ""
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[7:].lstrip()
|
||||
key, raw = line.split("=", 1)
|
||||
if key.strip() != name:
|
||||
continue
|
||||
raw = raw.strip()
|
||||
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {"'", '"'}:
|
||||
raw = raw[1:-1]
|
||||
return raw.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_oracle_dsn(raw_dsn: str) -> tuple[str, str]:
|
||||
value = str(raw_dsn or "").strip()
|
||||
if value.startswith("jdbc:oracle:thin:@"):
|
||||
value = value[len("jdbc:oracle:thin:@"):]
|
||||
if "?" not in value:
|
||||
return value, ""
|
||||
dsn, query = value.split("?", 1)
|
||||
parsed = parse_qs(query, keep_blank_values=False)
|
||||
wallet_dir = (parsed.get("TNS_ADMIN") or parsed.get("tns_admin") or [""])[0]
|
||||
return dsn.strip(), wallet_dir.strip()
|
||||
|
||||
|
||||
def _read_lob(value: Any) -> Any:
|
||||
return value.read() if hasattr(value, "read") else value
|
||||
|
||||
|
||||
def _record_from_cursor(cursor: Any, row: Any) -> dict[str, Any]:
|
||||
names = [column[0].lower() for column in cursor.description]
|
||||
return {name: _read_lob(value) for name, value in zip(names, row)}
|
||||
|
||||
|
||||
def _to_json(value: Mapping[str, Any] | None) -> str:
|
||||
payload = dict(value or {})
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) <= 120_000:
|
||||
return text
|
||||
return json.dumps(
|
||||
{
|
||||
"truncated": True,
|
||||
"preview": text[:119_800],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _answer_record(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result_json = str(row.get("result_json") or "").strip()
|
||||
try:
|
||||
result = json.loads(result_json) if result_json else {}
|
||||
except ValueError:
|
||||
result = {"raw": result_json}
|
||||
return {
|
||||
"answer_seq": row.get("answer_seq"),
|
||||
"question_id": row.get("question_id"),
|
||||
"answer_kind": str(row.get("answer_kind") or ""),
|
||||
"run_key": str(row.get("run_key") or ""),
|
||||
"conversation_id": str(row.get("conversation_id") or ""),
|
||||
"requested_by": str(row.get("requested_by") or ""),
|
||||
"requested_at": str(row.get("requested_at") or ""),
|
||||
"model_profile": str(row.get("model_profile") or ""),
|
||||
"generated_sql": str(row.get("generated_sql") or ""),
|
||||
"answer_text": str(row.get("answer_text") or ""),
|
||||
"result": result,
|
||||
"execution_output": str(row.get("execution_output") or ""),
|
||||
"execution_status": str(row.get("execution_status") or ""),
|
||||
"judgment_status": str(row.get("judgment_status") or ""),
|
||||
"judgment_reason": str(row.get("judgment_reason") or ""),
|
||||
"duration_ms": row.get("duration_ms"),
|
||||
"created_at": str(row.get("created_at") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _vector_example_record(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"example_id": int(row["example_id"]),
|
||||
"question": str(row.get("question") or ""),
|
||||
"answer_sql": str(row.get("answer_sql") or ""),
|
||||
"answer_text": str(row.get("answer_text") or ""),
|
||||
"reference_status": str(row.get("reference_status") or "DRAFT"),
|
||||
"reference_kind": str(row.get("reference_kind") or "SQL_TEMPLATE"),
|
||||
"target_type": str(row.get("target_type") or "ANY"),
|
||||
"object_role": str(row.get("object_role") or ""),
|
||||
"inspection_status": str(row.get("inspection_status") or "PENDING"),
|
||||
"inspection_note": str(row.get("inspection_note") or ""),
|
||||
"verified_at": str(row.get("verified_at") or ""),
|
||||
"verified_by": str(row.get("verified_by") or ""),
|
||||
"source_case_id": str(row.get("source_case_id") or ""),
|
||||
"source_type": str(row.get("source_type") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _choice(value: object, choices: tuple[str, ...], field: str) -> str:
|
||||
normalized = str(value or "").strip().upper()
|
||||
if normalized not in choices:
|
||||
raise QaHistoryStoreError(f"{field} 값을 확인해 주세요.")
|
||||
return normalized
|
||||
|
||||
|
||||
class QaHistoryStore:
|
||||
def __init__(self, *, env_file: Path | None = None) -> None:
|
||||
self._env_file = env_file
|
||||
self._pool: Any | None = None
|
||||
|
||||
def _config(self) -> dict[str, str]:
|
||||
username = (
|
||||
_env_value("POC4_QA_DB_USERNAME", self._env_file)
|
||||
or _env_value("BACKOFFICE_SELECT_AI_DB_USERNAME", self._env_file)
|
||||
)
|
||||
password = (
|
||||
_env_value("POC4_QA_DB_PASSWORD", self._env_file)
|
||||
or _env_value("BACKOFFICE_SELECT_AI_DB_PASSWORD", self._env_file)
|
||||
)
|
||||
raw_dsn = (
|
||||
_env_value("POC4_QA_DB_DSN", self._env_file)
|
||||
or _env_value("BACKOFFICE_SELECT_AI_DB_URL", self._env_file)
|
||||
)
|
||||
dsn, wallet_from_dsn = _normalize_oracle_dsn(raw_dsn)
|
||||
wallet_dir = (
|
||||
_env_value("POC4_QA_DB_WALLET_DIR", self._env_file)
|
||||
or wallet_from_dsn
|
||||
or _env_value("ORACLE_WALLET_DIR", self._env_file)
|
||||
)
|
||||
if not username or not password or not dsn:
|
||||
raise QaHistoryStoreError("질답 이력 DB 접속 설정을 확인해 주세요.")
|
||||
return {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"dsn": dsn,
|
||||
"wallet_dir": wallet_dir,
|
||||
}
|
||||
|
||||
def _get_pool(self) -> Any:
|
||||
if self._pool is not None:
|
||||
return self._pool
|
||||
config = self._config()
|
||||
kwargs: dict[str, Any] = {
|
||||
"user": config["username"],
|
||||
"password": config["password"],
|
||||
"dsn": config["dsn"],
|
||||
"min": 1,
|
||||
"max": 3,
|
||||
"increment": 1,
|
||||
"getmode": oracledb.POOL_GETMODE_WAIT,
|
||||
}
|
||||
wallet_dir = Path(config["wallet_dir"]).expanduser()
|
||||
if config["wallet_dir"]:
|
||||
if not wallet_dir.is_dir():
|
||||
raise QaHistoryStoreError("질답 이력 DB Wallet 경로를 확인해 주세요.")
|
||||
kwargs["config_dir"] = str(wallet_dir)
|
||||
kwargs["wallet_location"] = str(wallet_dir)
|
||||
try:
|
||||
self._pool = oracledb.create_pool(**kwargs)
|
||||
return self._pool
|
||||
except (oracledb.Error, OSError, ValueError) as exc:
|
||||
raise QaHistoryStoreError("질답 이력 DB에 연결하지 못했습니다.") from exc
|
||||
|
||||
@contextmanager
|
||||
def _connection(self) -> Iterator[Any]:
|
||||
try:
|
||||
with self._get_pool().acquire() as connection:
|
||||
yield connection
|
||||
except QaHistoryStoreError:
|
||||
raise
|
||||
except (oracledb.Error, OSError, ValueError) as exc:
|
||||
raise QaHistoryStoreError("질답 이력 DB 작업에 실패했습니다.") from exc
|
||||
|
||||
def list_questions(self, *, limit: int = 200) -> list[QaQuestion]:
|
||||
sql = f"""
|
||||
SELECT q.question_id, q.question_code, q.category, q.title,
|
||||
q.question_text, q.source_document, q.source_sheet,
|
||||
q.source_row, q.source_scenario, q.sample_sql,
|
||||
q.expected_focus, q.baseline_sql, q.baseline_answer,
|
||||
q.support_level, q.evaluation_rule_json,
|
||||
latest.judgment_status AS last_judgment_status,
|
||||
TO_CHAR(latest.evaluated_at AT TIME ZONE 'Asia/Seoul',
|
||||
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS last_evaluated_at
|
||||
FROM {QUESTION_TABLE} q
|
||||
LEFT JOIN (
|
||||
SELECT question_id, judgment_status, evaluated_at
|
||||
FROM (
|
||||
SELECT question_id, judgment_status, evaluated_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY question_id ORDER BY answer_seq DESC
|
||||
) AS row_no
|
||||
FROM {ANSWER_TABLE}
|
||||
)
|
||||
WHERE row_no = 1
|
||||
) latest ON latest.question_id = q.question_id
|
||||
WHERE q.active_yn = 'Y'
|
||||
ORDER BY q.category, q.question_code
|
||||
FETCH FIRST :row_limit ROWS ONLY
|
||||
"""
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, {"row_limit": int(limit)})
|
||||
rows = [_record_from_cursor(cursor, row) for row in cursor]
|
||||
return [question_from_record(row) for row in rows]
|
||||
|
||||
def get_question(self, question_id: int) -> QaQuestion | None:
|
||||
sql = f"""
|
||||
SELECT question_id, question_code, category, title, question_text,
|
||||
source_document, source_sheet, source_row, source_scenario,
|
||||
sample_sql, expected_focus, baseline_sql, baseline_answer,
|
||||
support_level, evaluation_rule_json
|
||||
FROM {QUESTION_TABLE}
|
||||
WHERE question_id = :question_id AND active_yn = 'Y'
|
||||
"""
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, {"question_id": int(question_id)})
|
||||
row = cursor.fetchone()
|
||||
return question_from_record(_record_from_cursor(cursor, row)) if row else None
|
||||
|
||||
def list_vector_examples_by_case(self, question_code: str) -> list[dict[str, Any]]:
|
||||
"""Return governed few-shot examples linked to one customer QA case."""
|
||||
case_id = str(question_code or "").strip().upper()
|
||||
if not case_id:
|
||||
raise QaHistoryStoreError("질문 코드를 확인해 주세요.")
|
||||
sql = f"""
|
||||
SELECT example_id, question, answer_sql, answer_text,
|
||||
reference_status, reference_kind, target_type, object_role,
|
||||
inspection_status, inspection_note,
|
||||
TO_CHAR(verified_at AT TIME ZONE 'Asia/Seoul',
|
||||
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS verified_at,
|
||||
verified_by, source_case_id, source_type
|
||||
FROM {VECTOR_EXAMPLE_TABLE}
|
||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
||||
AND source_case_id = :source_case_id
|
||||
ORDER BY example_id
|
||||
"""
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, {"source_case_id": case_id})
|
||||
rows = [_record_from_cursor(cursor, row) for row in cursor]
|
||||
return [_vector_example_record(row) for row in rows]
|
||||
|
||||
def update_vector_example(
|
||||
self,
|
||||
*,
|
||||
example_id: int,
|
||||
source_case_id: str,
|
||||
question: str,
|
||||
answer_sql: str,
|
||||
answer_text: str,
|
||||
reference_status: str,
|
||||
reference_kind: str,
|
||||
target_type: str,
|
||||
object_role: str,
|
||||
inspection_status: str,
|
||||
inspection_note: str,
|
||||
verified_by: str,
|
||||
) -> None:
|
||||
"""Update one customer example and rebuild its vector document atomically."""
|
||||
case_id = str(source_case_id or "").strip().upper()
|
||||
normalized_question = str(question or "").strip()
|
||||
normalized_sql = str(answer_sql or "").strip()
|
||||
if not case_id or not normalized_question or not normalized_sql:
|
||||
raise QaHistoryStoreError("질문과 예제 SQL은 비워둘 수 없습니다.")
|
||||
status = _choice(reference_status, REFERENCE_STATUSES, "검색 상태")
|
||||
kind = _choice(reference_kind, REFERENCE_KINDS, "참조 종류")
|
||||
target = _choice(target_type, TARGET_TYPES, "적용 범위")
|
||||
inspection = _choice(inspection_status, INSPECTION_STATUSES, "검토 상태")
|
||||
sql = f"""
|
||||
UPDATE {VECTOR_EXAMPLE_TABLE}
|
||||
SET question = :question,
|
||||
answer_sql = :answer_sql,
|
||||
answer_text = :answer_text,
|
||||
embedding_input = TO_CLOB('Customer QA question: ') || :question
|
||||
|| TO_CLOB(CHR(10) || 'Few-shot SQL template: ') || :answer_sql
|
||||
|| CASE WHEN :answer_text IS NULL THEN NULL
|
||||
ELSE TO_CLOB(CHR(10) || 'Few-shot answer: ') || :answer_text END,
|
||||
embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
TO_CLOB('Customer QA question: ') || :question
|
||||
|| TO_CLOB(CHR(10) || 'Few-shot SQL template: ') || :answer_sql
|
||||
|| CASE WHEN :answer_text IS NULL THEN NULL
|
||||
ELSE TO_CLOB(CHR(10) || 'Few-shot answer: ') || :answer_text END,
|
||||
JSON(sg_qa_vector_params('search_document'))
|
||||
),
|
||||
reference_status = :reference_status,
|
||||
reference_kind = :reference_kind,
|
||||
target_type = :target_type,
|
||||
object_role = :object_role,
|
||||
inspection_status = :inspection_status,
|
||||
inspection_note = :inspection_note,
|
||||
verified_at = CASE WHEN :reference_status = 'APPROVED'
|
||||
THEN SYSTIMESTAMP ELSE NULL END,
|
||||
verified_by = CASE WHEN :reference_status = 'APPROVED'
|
||||
THEN :verified_by ELSE NULL END
|
||||
WHERE example_id = :example_id
|
||||
AND source_type = 'CUSTOMER_QA_BENCHMARK'
|
||||
AND source_case_id = :source_case_id
|
||||
"""
|
||||
binds = {
|
||||
"example_id": int(example_id),
|
||||
"source_case_id": case_id,
|
||||
"question": normalized_question,
|
||||
"answer_sql": normalized_sql,
|
||||
"answer_text": str(answer_text or "").strip() or None,
|
||||
"reference_status": status,
|
||||
"reference_kind": kind,
|
||||
"target_type": target,
|
||||
"object_role": str(object_role or "").strip()[:64] or None,
|
||||
"inspection_status": inspection,
|
||||
"inspection_note": str(inspection_note or "").strip() or None,
|
||||
"verified_by": str(verified_by or "").strip()[:128] or "PORTAL_OPERATOR",
|
||||
}
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, binds)
|
||||
if cursor.rowcount != 1:
|
||||
raise QaHistoryStoreError("수정할 질문별 few-shot 예제를 찾지 못했습니다.")
|
||||
connection.commit()
|
||||
|
||||
def list_answers(self, question_id: int, *, limit: int = 30) -> list[dict[str, Any]]:
|
||||
sql = f"""
|
||||
SELECT answer_seq, question_id, answer_kind, run_key, conversation_id,
|
||||
requested_by,
|
||||
TO_CHAR(requested_at AT TIME ZONE 'Asia/Seoul',
|
||||
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS requested_at,
|
||||
model_profile, generated_sql, answer_text, result_json,
|
||||
execution_output, execution_status, judgment_status,
|
||||
judgment_reason, duration_ms,
|
||||
TO_CHAR(created_at AT TIME ZONE 'Asia/Seoul',
|
||||
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS created_at
|
||||
FROM {ANSWER_TABLE}
|
||||
WHERE question_id = :question_id
|
||||
ORDER BY answer_seq DESC
|
||||
FETCH FIRST :row_limit ROWS ONLY
|
||||
"""
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, {"question_id": int(question_id), "row_limit": int(limit)})
|
||||
rows = [_record_from_cursor(cursor, row) for row in cursor]
|
||||
return [_answer_record(row) for row in rows]
|
||||
|
||||
def find_or_create_free_text_question(self, question_text: str) -> QaQuestion:
|
||||
normalized = str(question_text or "").strip()
|
||||
if not normalized:
|
||||
raise QaHistoryStoreError("자유 질의가 비어 있습니다.")
|
||||
fingerprint = question_fingerprint(normalized)
|
||||
code = f"ADHOC-{fingerprint[:12].upper()}"
|
||||
merge_sql = f"""
|
||||
MERGE INTO {QUESTION_TABLE} target
|
||||
USING (SELECT :question_hash AS question_hash FROM dual) source
|
||||
ON (target.question_hash = source.question_hash)
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
question_code, question_source, question_hash, category, title,
|
||||
question_text, support_level, evaluation_rule_json, active_yn
|
||||
) VALUES (
|
||||
:question_code, 'FREE_TEXT', :question_hash, 'FREE_TEXT',
|
||||
:title, :question_text, 'REVIEW', '{{}}', 'Y'
|
||||
)
|
||||
"""
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
merge_sql,
|
||||
{
|
||||
"question_hash": fingerprint,
|
||||
"question_code": code,
|
||||
"title": normalized[:180],
|
||||
"question_text": normalized,
|
||||
},
|
||||
)
|
||||
connection.commit()
|
||||
cursor.execute(
|
||||
f"""SELECT question_id FROM {QUESTION_TABLE}
|
||||
WHERE question_hash = :question_hash""",
|
||||
{"question_hash": fingerprint},
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise QaHistoryStoreError("자유 질의 마스터를 저장하지 못했습니다.")
|
||||
question = self.get_question(int(row[0]))
|
||||
if question is None:
|
||||
raise QaHistoryStoreError("자유 질의 마스터를 다시 읽지 못했습니다.")
|
||||
return question
|
||||
|
||||
def record_answer(
|
||||
self,
|
||||
*,
|
||||
question_id: int,
|
||||
answer_kind: str,
|
||||
conversation_id: str,
|
||||
requested_by: str,
|
||||
model_profile: str,
|
||||
generated_sql: str,
|
||||
answer_text: str,
|
||||
result: Mapping[str, Any] | None,
|
||||
execution_output: str,
|
||||
execution_status: str,
|
||||
judgment_status: str,
|
||||
judgment_reason: str,
|
||||
duration_ms: int | None,
|
||||
run_key: str = "",
|
||||
) -> None:
|
||||
sql = f"""
|
||||
INSERT INTO {ANSWER_TABLE} (
|
||||
question_id, answer_kind, run_key, conversation_id, requested_by,
|
||||
requested_at, model_profile, generated_sql, answer_text, result_json,
|
||||
execution_output, execution_status, judgment_status,
|
||||
judgment_reason, duration_ms
|
||||
) VALUES (
|
||||
:question_id, :answer_kind, :run_key, :conversation_id,
|
||||
:requested_by, SYSTIMESTAMP, :model_profile, :generated_sql,
|
||||
:answer_text, :result_json, :execution_output, :execution_status,
|
||||
:judgment_status, :judgment_reason, :duration_ms
|
||||
)
|
||||
"""
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
sql,
|
||||
{
|
||||
"question_id": int(question_id),
|
||||
"answer_kind": str(answer_kind)[:20],
|
||||
"run_key": str(run_key)[:100] or None,
|
||||
"conversation_id": str(conversation_id)[:100] or None,
|
||||
"requested_by": str(requested_by)[:100] or None,
|
||||
"model_profile": str(model_profile)[:100] or None,
|
||||
"generated_sql": str(generated_sql or ""),
|
||||
"answer_text": str(answer_text or ""),
|
||||
"result_json": _to_json(result),
|
||||
"execution_output": str(execution_output or ""),
|
||||
"execution_status": str(execution_status)[:40] or None,
|
||||
"judgment_status": str(judgment_status)[:20],
|
||||
"judgment_reason": str(judgment_reason or ""),
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def seed_benchmark(self, benchmark_file: Path) -> tuple[int, int]:
|
||||
questions = load_benchmark_questions(benchmark_file)
|
||||
raw = json.loads(benchmark_file.read_text(encoding="utf-8"))
|
||||
raw_by_code = {
|
||||
str(item.get("case_id") or "").upper(): item
|
||||
for item in raw.get("scenarios", [])
|
||||
if isinstance(item, Mapping)
|
||||
}
|
||||
seeded_questions = 0
|
||||
seeded_answers = 0
|
||||
for question in questions:
|
||||
question_id = self._upsert_benchmark_question(question)
|
||||
seeded_questions += 1
|
||||
raw_item = raw_by_code[question.question_code]
|
||||
history = raw_item.get("historical_answer") if isinstance(raw_item.get("historical_answer"), Mapping) else {}
|
||||
inserted = self._seed_historical_answer(question_id, history, raw)
|
||||
seeded_answers += 1 if inserted else 0
|
||||
return seeded_questions, seeded_answers
|
||||
|
||||
def _upsert_benchmark_question(self, question: QaQuestion) -> int:
|
||||
sql = f"""
|
||||
MERGE INTO {QUESTION_TABLE} target
|
||||
USING (SELECT :question_code AS question_code FROM dual) source
|
||||
ON (target.question_code = source.question_code)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
question_source = 'CUSTOMER_EXCEL',
|
||||
question_hash = :question_hash,
|
||||
category = :category,
|
||||
title = :title,
|
||||
question_text = :question_text,
|
||||
source_document = :source_document,
|
||||
source_sheet = :source_sheet,
|
||||
source_row = :source_row,
|
||||
source_scenario = :source_scenario,
|
||||
sample_sql = :sample_sql,
|
||||
expected_focus = :expected_focus,
|
||||
baseline_sql = :baseline_sql,
|
||||
baseline_answer = :baseline_answer,
|
||||
support_level = :support_level,
|
||||
evaluation_rule_json = :evaluation_rule_json,
|
||||
active_yn = 'Y',
|
||||
updated_at = SYSTIMESTAMP
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
question_code, question_source, question_hash, category, title,
|
||||
question_text, source_document, source_sheet, source_row,
|
||||
source_scenario, sample_sql, expected_focus, baseline_sql,
|
||||
baseline_answer, support_level, evaluation_rule_json, active_yn
|
||||
) VALUES (
|
||||
:question_code, 'CUSTOMER_EXCEL', :question_hash, :category,
|
||||
:title, :question_text, :source_document, :source_sheet,
|
||||
:source_row, :source_scenario, :sample_sql, :expected_focus,
|
||||
:baseline_sql, :baseline_answer, :support_level,
|
||||
:evaluation_rule_json, 'Y'
|
||||
)
|
||||
"""
|
||||
binds = {
|
||||
"question_code": question.question_code,
|
||||
"question_hash": question_fingerprint(question.question_text),
|
||||
"category": question.category[:30],
|
||||
"title": question.title[:200],
|
||||
"question_text": question.question_text,
|
||||
"source_document": question.source_document[:255] or None,
|
||||
"source_sheet": question.source_sheet[:255] or None,
|
||||
"source_row": question.source_row,
|
||||
"source_scenario": question.source_scenario,
|
||||
"sample_sql": question.sample_sql,
|
||||
"expected_focus": question.expected_focus,
|
||||
"baseline_sql": question.baseline_sql,
|
||||
"baseline_answer": question.baseline_answer,
|
||||
"support_level": question.support_level[:20],
|
||||
"evaluation_rule_json": json.dumps(question.evaluation_rule, ensure_ascii=False),
|
||||
}
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, binds)
|
||||
connection.commit()
|
||||
cursor.execute(
|
||||
f"SELECT question_id FROM {QUESTION_TABLE} WHERE question_code = :question_code",
|
||||
{"question_code": question.question_code},
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise QaHistoryStoreError(f"질문 마스터를 적재하지 못했습니다: {question.question_code}")
|
||||
return int(row[0])
|
||||
|
||||
def _seed_historical_answer(
|
||||
self,
|
||||
question_id: int,
|
||||
history: Mapping[str, Any],
|
||||
benchmark: Mapping[str, Any],
|
||||
) -> bool:
|
||||
exists_sql = f"""
|
||||
SELECT COUNT(*) FROM {ANSWER_TABLE}
|
||||
WHERE question_id = :question_id AND run_key = :run_key
|
||||
"""
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(exists_sql, {"question_id": question_id, "run_key": HISTORICAL_RUN_KEY})
|
||||
if int(cursor.fetchone()[0]) > 0:
|
||||
return False
|
||||
result = {
|
||||
"source_report": str(benchmark.get("source_report") or ""),
|
||||
"source_redmine": benchmark.get("source_redmine"),
|
||||
"historical_execution_output": str(history.get("execution_output") or ""),
|
||||
}
|
||||
self.record_answer(
|
||||
question_id=question_id,
|
||||
answer_kind="HISTORICAL",
|
||||
run_key=HISTORICAL_RUN_KEY,
|
||||
conversation_id="",
|
||||
requested_by="customer-excel-baseline",
|
||||
model_profile=str(history.get("profile") or ""),
|
||||
generated_sql=str(history.get("generated_sql") or ""),
|
||||
answer_text=str(history.get("answer_text") or ""),
|
||||
result=result,
|
||||
execution_output=str(history.get("execution_output") or ""),
|
||||
execution_status=str(history.get("execution_status") or ""),
|
||||
judgment_status=str(history.get("judgment_status") or "REVIEW"),
|
||||
judgment_reason=str(history.get("judgment_reason") or ""),
|
||||
duration_ms=int(history.get("duration_ms") or 0),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def schema_statements() -> tuple[str, ...]:
|
||||
return (
|
||||
f"""
|
||||
CREATE TABLE {QUESTION_TABLE} (
|
||||
question_id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY,
|
||||
question_code VARCHAR2(30) UNIQUE,
|
||||
question_source VARCHAR2(30) NOT NULL,
|
||||
question_hash VARCHAR2(64) NOT NULL UNIQUE,
|
||||
category VARCHAR2(30) NOT NULL,
|
||||
title VARCHAR2(200) NOT NULL,
|
||||
question_text CLOB NOT NULL,
|
||||
source_document VARCHAR2(255),
|
||||
source_sheet VARCHAR2(255),
|
||||
source_row NUMBER,
|
||||
source_scenario CLOB,
|
||||
sample_sql CLOB,
|
||||
expected_focus CLOB,
|
||||
baseline_sql CLOB,
|
||||
baseline_answer CLOB,
|
||||
support_level VARCHAR2(20) NOT NULL,
|
||||
evaluation_rule_json CLOB CHECK (evaluation_rule_json IS JSON),
|
||||
active_yn CHAR(1) DEFAULT 'Y' NOT NULL CHECK (active_yn IN ('Y', 'N')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
CONSTRAINT sg_ai_qa_question_source_ck
|
||||
CHECK (question_source IN ('CUSTOMER_EXCEL', 'FREE_TEXT'))
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE TABLE {ANSWER_TABLE} (
|
||||
answer_seq NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
question_id NUMBER NOT NULL,
|
||||
answer_kind VARCHAR2(20) NOT NULL,
|
||||
run_key VARCHAR2(100),
|
||||
conversation_id VARCHAR2(100),
|
||||
requested_by VARCHAR2(100),
|
||||
requested_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
model_profile VARCHAR2(100),
|
||||
generated_sql CLOB,
|
||||
answer_text CLOB,
|
||||
result_json CLOB CHECK (result_json IS JSON),
|
||||
execution_output CLOB,
|
||||
execution_status VARCHAR2(40),
|
||||
judgment_status VARCHAR2(20) NOT NULL,
|
||||
judgment_reason CLOB,
|
||||
duration_ms NUMBER,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
CONSTRAINT sg_ai_qa_answer_question_fk
|
||||
FOREIGN KEY (question_id)
|
||||
REFERENCES {QUESTION_TABLE} (question_id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT sg_ai_qa_answer_kind_ck
|
||||
CHECK (answer_kind IN ('HISTORICAL', 'LIVE')),
|
||||
CONSTRAINT sg_ai_qa_answer_judgment_ck
|
||||
CHECK (judgment_status IN ('PASS', 'WARN', 'FAIL', 'REVIEW'))
|
||||
)
|
||||
""",
|
||||
f"""
|
||||
CREATE INDEX sg_ai_qa_answer_question_ix
|
||||
ON {ANSWER_TABLE} (question_id, answer_seq DESC)
|
||||
""",
|
||||
f"""
|
||||
CREATE UNIQUE INDEX sg_ai_qa_answer_run_uk
|
||||
ON {ANSWER_TABLE} (question_id, run_key)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def ensure_schema(store: QaHistoryStore) -> None:
|
||||
objects = (QUESTION_TABLE, ANSWER_TABLE)
|
||||
with store._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT table_name FROM user_tables WHERE table_name IN (:q, :a)",
|
||||
{"q": objects[0], "a": objects[1]},
|
||||
)
|
||||
existing = {str(row[0]) for row in cursor}
|
||||
statements = schema_statements()
|
||||
if QUESTION_TABLE not in existing:
|
||||
cursor.execute(statements[0])
|
||||
if ANSWER_TABLE not in existing:
|
||||
cursor.execute(statements[1])
|
||||
cursor.execute(
|
||||
"SELECT index_name FROM user_indexes WHERE index_name IN (:ix1, :ix2)",
|
||||
{"ix1": "SG_AI_QA_ANSWER_QUESTION_IX", "ix2": "SG_AI_QA_ANSWER_RUN_UK"},
|
||||
)
|
||||
indexes = {str(row[0]) for row in cursor}
|
||||
if "SG_AI_QA_ANSWER_QUESTION_IX" not in indexes:
|
||||
cursor.execute(statements[2])
|
||||
if "SG_AI_QA_ANSWER_RUN_UK" not in indexes:
|
||||
cursor.execute(statements[3])
|
||||
connection.commit()
|
||||
|
||||
|
||||
def timestamp_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
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()
|
||||
91
poc4_active_source_20260714/tests/test_qa_history.py
Normal file
91
poc4_active_source_20260714/tests/test_qa_history.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from src.poc4.qa_history import evaluate_sql, load_benchmark_questions
|
||||
from src.poc4.qa_history_store import (
|
||||
REFERENCE_STATUSES,
|
||||
QaHistoryStoreError,
|
||||
_choice,
|
||||
_normalize_oracle_dsn,
|
||||
schema_statements,
|
||||
)
|
||||
|
||||
|
||||
class QaHistoryTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
benchmark = Path(__file__).parents[1] / "config" / "smilegate_qa_benchmark.json"
|
||||
cls.questions = {item.question_code: item for item in load_benchmark_questions(benchmark)}
|
||||
|
||||
def test_customer_excel_benchmark_contains_all_47_cases(self) -> None:
|
||||
self.assertEqual(47, len(self.questions))
|
||||
self.assertIn("STD-01", self.questions)
|
||||
self.assertIn("CZN-19", self.questions)
|
||||
|
||||
def test_supported_query_passes_when_required_terms_are_present(self) -> None:
|
||||
judgment = evaluate_sql(
|
||||
self.questions["STD-13"],
|
||||
"SELECT SUM(PAYMT_AMT) FROM COMN_SALES_TXN",
|
||||
execution_succeeded=True,
|
||||
)
|
||||
self.assertEqual("PASS", judgment.status)
|
||||
|
||||
def test_monthly_au_with_au_flag_fails(self) -> None:
|
||||
judgment = evaluate_sql(
|
||||
self.questions["STD-27"],
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM CZN_COMN_USER_MST
|
||||
WHERE AU_FLAG = 1
|
||||
AND BASE_DT = (SELECT MAX(BASE_DT) FROM CZN_COMN_USER_MST)
|
||||
AND LAST_CONN_DT >= ADD_MONTHS(BASE_DT, -1)
|
||||
AND STD_USER_YN = 'Y'
|
||||
AND EXPT_USER_YN = 'N'
|
||||
""",
|
||||
execution_succeeded=True,
|
||||
)
|
||||
self.assertEqual("FAIL", judgment.status)
|
||||
self.assertIn("AU_FLAG", judgment.reason)
|
||||
|
||||
def test_unsupported_game_requires_safe_alias_lookup(self) -> None:
|
||||
safe = evaluate_sql(
|
||||
self.questions["STD-02"],
|
||||
"SELECT GAME_ID FROM COMN_GAME_ALIAS_BAS WHERE GAME_NM LIKE '%버블리즈%'",
|
||||
execution_succeeded=True,
|
||||
)
|
||||
unsafe = evaluate_sql(
|
||||
self.questions["STD-02"],
|
||||
"SELECT COUNT(*) FROM CZN_COMN_USER_MST WHERE GAME_ID = 'STOVE_CHAOSZERO'",
|
||||
execution_succeeded=True,
|
||||
)
|
||||
self.assertEqual("PASS", safe.status)
|
||||
self.assertEqual("FAIL", unsafe.status)
|
||||
|
||||
def test_free_text_is_review_not_automatic_pass(self) -> None:
|
||||
judgment = evaluate_sql(None, "SELECT 1 FROM DUAL", execution_succeeded=True)
|
||||
self.assertEqual("REVIEW", judgment.status)
|
||||
|
||||
def test_jdbc_url_wallet_is_normalized_for_python_driver(self) -> None:
|
||||
self.assertEqual(
|
||||
("sgmpaipoc_medium", "/home/opc/wallet/sgmpaipoc"),
|
||||
_normalize_oracle_dsn(
|
||||
"jdbc:oracle:thin:@sgmpaipoc_medium?TNS_ADMIN=/home/opc/wallet/sgmpaipoc"
|
||||
),
|
||||
)
|
||||
|
||||
def test_schema_defines_two_history_tables_and_indexes(self) -> None:
|
||||
statements = "\n".join(schema_statements())
|
||||
self.assertIn("CREATE TABLE SG_AI_QA_QUESTION", statements)
|
||||
self.assertIn("CREATE TABLE SG_AI_QA_ANSWER", statements)
|
||||
self.assertIn("answer_seq NUMBER GENERATED ALWAYS AS IDENTITY", statements)
|
||||
|
||||
def test_few_shot_status_is_normalized_and_unknown_status_is_rejected(self) -> None:
|
||||
self.assertEqual("APPROVED", _choice(" approved ", REFERENCE_STATUSES, "검색 상태"))
|
||||
with self.assertRaises(QaHistoryStoreError):
|
||||
_choice("LIVE", REFERENCE_STATUSES, "검색 상태")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -16,29 +16,29 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"AGENT_CONSOLE_SHORT_NAME": "HMM",
|
||||
"AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트",
|
||||
"AGENT_CONSOLE_PRIMARY_COLOR": "#003b70",
|
||||
"AGENT_CONSOLE_SHORT_NAME": "SMILEGATE",
|
||||
"AGENT_CONSOLE_PAGE_TITLE": "SMILEGATE DATA & AI POC",
|
||||
"AGENT_CONSOLE_PRIMARY_COLOR": "#113F67",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
profile = load_app_profile(path)
|
||||
|
||||
self.assertEqual(profile.short_name, "HMM")
|
||||
self.assertEqual(profile.page_title, "HMM AI 업무 에이전트")
|
||||
self.assertEqual(profile.primary_color, "#003b70")
|
||||
self.assertEqual(profile.short_name, "SMILEGATE")
|
||||
self.assertEqual(profile.page_title, "SMILEGATE DATA & AI POC")
|
||||
self.assertEqual(profile.primary_color, "#113F67")
|
||||
|
||||
def test_profile_reads_dotenv_values(self) -> None:
|
||||
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
env_file = Path(temp_dir) / ".env"
|
||||
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=HMM\n", encoding="utf-8")
|
||||
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=SMILEGATE\n", encoding="utf-8")
|
||||
profile = load_app_profile(path, env_file)
|
||||
|
||||
self.assertEqual(profile.short_name, "HMM")
|
||||
self.assertEqual(profile.short_name, "SMILEGATE")
|
||||
|
||||
def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
|
||||
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"
|
||||
def test_smilegate_scenarios_are_enabled_and_unique(self) -> None:
|
||||
path = Path(__file__).parents[1] / "config" / "smilegate_demo_scenarios.json"
|
||||
scenarios = load_demo_scenarios(path)
|
||||
|
||||
self.assertGreaterEqual(len(scenarios), 3)
|
||||
|
||||
12
pom.xml
12
pom.xml
@@ -92,18 +92,12 @@
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
</resource>
|
||||
<!-- Keep the reviewed SQL in sql/adb as the sole Git source, and bundle
|
||||
only the curated read-only viewer set into the deployed JAR. -->
|
||||
<!-- Keep reviewed SQL in sql/adb. Runtime exposure remains restricted by
|
||||
BACKOFFICE_SECURITY_SQL_SCRIPTS, never by a request path. -->
|
||||
<resource>
|
||||
<directory>sql/adb</directory>
|
||||
<targetPath>sql/adb</targetPath>
|
||||
<includes>
|
||||
<include>62_kb_aso_masking_backoffice_metadata.sql</include>
|
||||
<include>63_kb_aso_masking_rule_runtime.sql</include>
|
||||
<include>64_kb_aso_masking_default_column_rules.sql</include>
|
||||
<include>65_kb_select_ai_vpd_query_api.sql</include>
|
||||
<include>66_kb_select_ai_vpd_query_ords.sql</include>
|
||||
</includes>
|
||||
<includes><include>**/*.sql</include></includes>
|
||||
</resource>
|
||||
</resources>
|
||||
<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
|
||||
81
sql/adb/112_sgmp_select_ai_oci_llama4scout_profile.sql
Normal file
81
sql/adb/112_sgmp_select_ai_oci_llama4scout_profile.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- Creates a non-operational comparison profile for Smilegate game-scope chat.
|
||||
-- The active GPT profile remains unchanged. Provider credentials and profile
|
||||
-- metadata are copied from it so the only comparison variable is the model.
|
||||
|
||||
DECLARE
|
||||
v_exists PLS_INTEGER;
|
||||
v_credential_name VARCHAR2(128);
|
||||
v_region VARCHAR2(128);
|
||||
v_compartment_id VARCHAR2(4000);
|
||||
v_attributes CLOB;
|
||||
v_attribute_json JSON_OBJECT_T := JSON_OBJECT_T();
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_exists
|
||||
FROM user_cloud_ai_profiles
|
||||
WHERE profile_name = 'SGMP_POC_OCI_LLAMA4SCOUT';
|
||||
|
||||
IF v_exists > 0 THEN
|
||||
DBMS_CLOUD_AI.DROP_PROFILE(
|
||||
profile_name => 'SGMP_POC_OCI_LLAMA4SCOUT',
|
||||
force => TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||
INTO v_credential_name
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'credential_name';
|
||||
|
||||
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||
INTO v_region
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'region';
|
||||
|
||||
SELECT DBMS_LOB.SUBSTR(attribute_value, 4000, 1)
|
||||
INTO v_compartment_id
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'oci_compartment_id';
|
||||
|
||||
v_attribute_json.put('provider', 'oci');
|
||||
v_attribute_json.put('credential_name', v_credential_name);
|
||||
v_attribute_json.put('model', 'meta.llama-4-scout-17b-16e-instruct');
|
||||
v_attribute_json.put('region', v_region);
|
||||
v_attribute_json.put('oci_compartment_id', v_compartment_id);
|
||||
v_attributes := v_attribute_json.to_clob;
|
||||
|
||||
DBMS_CLOUD_AI.CREATE_PROFILE(
|
||||
profile_name => 'SGMP_POC_OCI_LLAMA4SCOUT',
|
||||
attributes => v_attributes,
|
||||
description => 'Non-operational Smilegate game-scope latency comparison'
|
||||
);
|
||||
|
||||
FOR source_attribute IN (
|
||||
SELECT attribute_name, attribute_value
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name NOT IN (
|
||||
'credential_name', 'model', 'provider', 'provider_endpoint',
|
||||
'region', 'oci_compartment_id', 'oci_endpoint_id',
|
||||
'oci_apiformat', 'oci_runtimetype'
|
||||
)
|
||||
) LOOP
|
||||
DBMS_CLOUD_AI.SET_ATTRIBUTE(
|
||||
profile_name => 'SGMP_POC_OCI_LLAMA4SCOUT',
|
||||
attribute_name => source_attribute.attribute_name,
|
||||
attribute_value => source_attribute.attribute_value
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
/
|
||||
|
||||
SELECT attribute_name, attribute_value
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_LLAMA4SCOUT'
|
||||
AND attribute_name IN (
|
||||
'provider', 'model', 'credential_name', 'region', 'oci_compartment_id'
|
||||
)
|
||||
ORDER BY attribute_name;
|
||||
60
sql/adb/113_sgmp_game_scope_chat_profile_benchmark.sql
Normal file
60
sql/adb/113_sgmp_game_scope_chat_profile_benchmark.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Read-only latency and JSON-shape comparison for the game-mention extraction
|
||||
-- stage. Korean input is reconstructed from UTF-8 base64 for SQLcl safety.
|
||||
|
||||
set serveroutput on size unlimited
|
||||
|
||||
DECLARE
|
||||
v_question CLOB := utl_i18n.raw_to_char(
|
||||
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
||||
'66Gc65Oc64KY7J247J20656RIOy5tOygnOuCmOydmCAyMDI264WEIDfsm5QgMTXsnbwgQVXrpbwg6rCB6rCBIOyVjOugpOykmC4='
|
||||
)),
|
||||
'AL32UTF8'
|
||||
);
|
||||
v_prompt CLOB;
|
||||
v_result CLOB;
|
||||
v_json JSON_OBJECT_T;
|
||||
v_started PLS_INTEGER;
|
||||
v_elapsed_seconds NUMBER;
|
||||
|
||||
PROCEDURE run_profile(p_profile_name IN VARCHAR2) IS
|
||||
BEGIN
|
||||
v_started := DBMS_UTILITY.GET_TIME;
|
||||
v_result := DBMS_CLOUD_AI.GENERATE(
|
||||
prompt => v_prompt,
|
||||
profile_name => p_profile_name,
|
||||
action => 'chat'
|
||||
);
|
||||
v_elapsed_seconds := (DBMS_UTILITY.GET_TIME - v_started) / 100;
|
||||
v_json := JSON_OBJECT_T.parse(v_result);
|
||||
DBMS_OUTPUT.PUT_LINE(
|
||||
p_profile_name
|
||||
|| '|elapsed_seconds=' || TO_CHAR(v_elapsed_seconds, 'FM9990D00')
|
||||
|| '|scope_hint=' || NVL(v_json.get_string('scope_hint'), 'NULL')
|
||||
|| '|mention_count=' || v_json.get_array('game_mentions').get_size
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
DBMS_OUTPUT.PUT_LINE(
|
||||
p_profile_name || '|elapsed_seconds='
|
||||
|| TO_CHAR(v_elapsed_seconds, 'FM9990D00')
|
||||
|| '|ERROR|' || SQLCODE || '|' || SUBSTR(SQLERRM, 1, 300)
|
||||
);
|
||||
DBMS_OUTPUT.PUT_LINE(
|
||||
p_profile_name || '|raw_response=' || DBMS_LOB.SUBSTR(v_result, 1000, 1)
|
||||
);
|
||||
END;
|
||||
BEGIN
|
||||
v_prompt := 'Extract only game-name mentions from the user question. '
|
||||
|| 'Metrics, acronyms, dates, filters, and database object or column names are not game names unless they are themselves an explicit game title. '
|
||||
|| 'When a title-like noun directly qualifies a game data request such as user master, character, sales, AU, NRU, server, or game log, preserve that noun as a game-name mention even when it is not in a catalog. '
|
||||
|| 'Do not discard an unknown title merely because it cannot be resolved. General scope words such as common, overall, all, total, or every are not game-name mentions unless they are part of an explicit title. '
|
||||
|| 'Return exactly one JSON object with keys game_mentions (array of strings) '
|
||||
|| 'and scope_hint (GLOBAL, SINGLE_GAME, MULTI_GAME, ALL_GAMES, UNKNOWN). '
|
||||
|| 'Do not resolve names to IDs and do not generate SQL. '
|
||||
|| 'Return raw JSON only: no prose, no Markdown, and no code fence. Question: '
|
||||
|| v_question;
|
||||
|
||||
run_profile('SGMP_POC_OCI_GPT54MINI');
|
||||
run_profile('SGMP_POC_OCI_LLAMA4SCOUT');
|
||||
END;
|
||||
/
|
||||
90
sql/adb/114_sgmp_create_scope_chat_candidate_profiles.sql
Normal file
90
sql/adb/114_sgmp_create_scope_chat_candidate_profiles.sql
Normal file
@@ -0,0 +1,90 @@
|
||||
-- Creates non-operational OCI profiles for game-scope extraction benchmarks.
|
||||
-- Every profile inherits the active GPT profile's OCI credential, region,
|
||||
-- object list, and metadata. Only model is varied.
|
||||
|
||||
DECLARE
|
||||
v_credential_name VARCHAR2(128);
|
||||
v_region VARCHAR2(128);
|
||||
v_compartment_id VARCHAR2(4000);
|
||||
v_attributes CLOB;
|
||||
v_attribute_json JSON_OBJECT_T;
|
||||
v_exists PLS_INTEGER;
|
||||
|
||||
PROCEDURE create_candidate(
|
||||
p_profile_name IN VARCHAR2,
|
||||
p_model IN VARCHAR2
|
||||
) IS
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_exists
|
||||
FROM user_cloud_ai_profiles
|
||||
WHERE profile_name = p_profile_name;
|
||||
|
||||
IF v_exists > 0 THEN
|
||||
DBMS_CLOUD_AI.DROP_PROFILE(profile_name => p_profile_name, force => TRUE);
|
||||
END IF;
|
||||
|
||||
v_attribute_json := JSON_OBJECT_T();
|
||||
v_attribute_json.put('provider', 'oci');
|
||||
v_attribute_json.put('credential_name', v_credential_name);
|
||||
v_attribute_json.put('model', p_model);
|
||||
v_attribute_json.put('region', v_region);
|
||||
v_attribute_json.put('oci_compartment_id', v_compartment_id);
|
||||
v_attributes := v_attribute_json.to_clob;
|
||||
|
||||
DBMS_CLOUD_AI.CREATE_PROFILE(
|
||||
profile_name => p_profile_name,
|
||||
attributes => v_attributes,
|
||||
description => 'Non-operational Smilegate game-scope benchmark profile'
|
||||
);
|
||||
|
||||
FOR source_attribute IN (
|
||||
SELECT attribute_name, attribute_value
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name NOT IN (
|
||||
'credential_name', 'model', 'provider', 'provider_endpoint',
|
||||
'region', 'oci_compartment_id', 'oci_endpoint_id',
|
||||
'oci_apiformat', 'oci_runtimetype'
|
||||
)
|
||||
) LOOP
|
||||
DBMS_CLOUD_AI.SET_ATTRIBUTE(
|
||||
profile_name => p_profile_name,
|
||||
attribute_name => source_attribute.attribute_name,
|
||||
attribute_value => source_attribute.attribute_value
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
BEGIN
|
||||
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||
INTO v_credential_name
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'credential_name';
|
||||
|
||||
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||
INTO v_region
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'region';
|
||||
|
||||
SELECT DBMS_LOB.SUBSTR(attribute_value, 4000, 1)
|
||||
INTO v_compartment_id
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'oci_compartment_id';
|
||||
|
||||
create_candidate('SGMP_SCOPE_COHERE_VISION', 'cohere.command-a-vision');
|
||||
create_candidate('SGMP_SCOPE_COHERE_COMMAND', 'cohere.command-latest');
|
||||
create_candidate('SGMP_SCOPE_COHERE_PLUS', 'cohere.command-plus-latest');
|
||||
create_candidate('SGMP_SCOPE_GEMINI_FLASH', 'google.gemini-2.5-flash-lite');
|
||||
create_candidate('SGMP_SCOPE_LLAMA_MAV', 'meta.llama-4-maverick-17b-128e-instruct-fp8');
|
||||
create_candidate('SGMP_SCOPE_GROK_NONR', 'xai.grok-4.20-non-reasoning');
|
||||
END;
|
||||
/
|
||||
|
||||
SELECT profile_name, attribute_value AS model
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name LIKE 'SGMP_SCOPE_%'
|
||||
AND attribute_name = 'model'
|
||||
ORDER BY profile_name;
|
||||
112
sql/adb/115_sgmp_scope_chat_candidate_benchmark.sql
Normal file
112
sql/adb/115_sgmp_scope_chat_candidate_benchmark.sql
Normal file
@@ -0,0 +1,112 @@
|
||||
-- Read-only benchmark for candidate game-scope extraction profiles.
|
||||
-- Korean test questions use UTF-8 base64 reconstruction for SQLcl safety.
|
||||
|
||||
set serveroutput on size unlimited
|
||||
prompt SG_SCOPE_PROFILE_BENCHMARK_LOADED
|
||||
|
||||
DECLARE
|
||||
TYPE t_case IS RECORD (
|
||||
case_name VARCHAR2(12),
|
||||
question CLOB,
|
||||
expected_scope VARCHAR2(20),
|
||||
expected_mentions PLS_INTEGER
|
||||
);
|
||||
TYPE t_cases IS TABLE OF t_case INDEX BY PLS_INTEGER;
|
||||
v_cases t_cases;
|
||||
v_prompt_prefix CLOB :=
|
||||
'Extract only game-name mentions from the user question. '
|
||||
|| 'Metrics, acronyms, dates, filters, and database object or column names are not game names unless they are themselves an explicit game title. '
|
||||
|| 'When a title-like noun directly qualifies a game data request such as user master, character, sales, AU, NRU, server, or game log, preserve that noun as a game-name mention even when it is not in a catalog. '
|
||||
|| 'Do not discard an unknown title merely because it cannot be resolved. General scope words such as common, overall, all, total, or every are not game-name mentions unless they are part of an explicit title. '
|
||||
|| 'Return exactly one JSON object with keys game_mentions (array of strings) '
|
||||
|| 'and scope_hint (GLOBAL, SINGLE_GAME, MULTI_GAME, ALL_GAMES, UNKNOWN). '
|
||||
|| 'Do not resolve names to IDs and do not generate SQL. '
|
||||
|| 'Return raw JSON only: no prose, no Markdown, and no code fence. Question: ';
|
||||
v_result CLOB;
|
||||
v_json JSON_OBJECT_T;
|
||||
v_started PLS_INTEGER;
|
||||
v_elapsed NUMBER;
|
||||
v_scope VARCHAR2(20);
|
||||
v_mentions PLS_INTEGER;
|
||||
v_raw_json VARCHAR2(5);
|
||||
|
||||
PROCEDURE run_case(
|
||||
p_profile_name IN VARCHAR2,
|
||||
p_case t_case
|
||||
) IS
|
||||
BEGIN
|
||||
v_started := DBMS_UTILITY.GET_TIME;
|
||||
v_result := DBMS_CLOUD_AI.GENERATE(
|
||||
prompt => v_prompt_prefix || p_case.question,
|
||||
profile_name => p_profile_name,
|
||||
action => 'chat'
|
||||
);
|
||||
v_elapsed := (DBMS_UTILITY.GET_TIME - v_started) / 100;
|
||||
v_json := JSON_OBJECT_T.parse(v_result);
|
||||
v_raw_json := 'TRUE';
|
||||
v_scope := v_json.get_string('scope_hint');
|
||||
v_mentions := v_json.get_array('game_mentions').get_size;
|
||||
DBMS_OUTPUT.PUT_LINE(
|
||||
p_profile_name || '|' || p_case.case_name
|
||||
|| '|seconds=' || TO_CHAR(v_elapsed, 'FM9990D00')
|
||||
|| '|raw_json=' || v_raw_json
|
||||
|| '|scope=' || NVL(v_scope, 'NULL')
|
||||
|| '|mentions=' || v_mentions
|
||||
|| '|expected=' || p_case.expected_scope || '/' || p_case.expected_mentions
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
DBMS_OUTPUT.PUT_LINE(
|
||||
p_profile_name || '|' || p_case.case_name
|
||||
|| '|seconds=' || TO_CHAR(v_elapsed, 'FM9990D00')
|
||||
|| '|ERROR=' || SQLCODE || '|' || SUBSTR(SQLERRM, 1, 180)
|
||||
);
|
||||
END;
|
||||
BEGIN
|
||||
DBMS_OUTPUT.PUT_LINE('benchmark_started');
|
||||
v_cases(1).case_name := 'NONE';
|
||||
v_cases(1).question := utl_i18n.raw_to_char(utl_encode.base64_decode(
|
||||
utl_raw.cast_to_raw('7KCE7LK0IOunpOy2nCDslYzroKTspJgu')), 'AL32UTF8');
|
||||
v_cases(1).expected_scope := 'GLOBAL';
|
||||
v_cases(1).expected_mentions := 0;
|
||||
|
||||
v_cases(2).case_name := 'SINGLE';
|
||||
v_cases(2).question := utl_i18n.raw_to_char(utl_encode.base64_decode(
|
||||
utl_raw.cast_to_raw('7Lm07KCc64KYIOy1nOyLoCBBVSDslYzroKTspJgu')), 'AL32UTF8');
|
||||
v_cases(2).expected_scope := 'SINGLE_GAME';
|
||||
v_cases(2).expected_mentions := 1;
|
||||
|
||||
v_cases(3).case_name := 'MULTI';
|
||||
v_cases(3).question := utl_i18n.raw_to_char(utl_encode.base64_decode(
|
||||
utl_raw.cast_to_raw('66Gc65Oc64KY7J247J20656RIOy5tOygnOuCmOydmCAyMDI264WEIDfsm5QgMTXsnbwgQVXrpbwg6rCB6rCBIOyVjOugpOykmC4=')), 'AL32UTF8');
|
||||
v_cases(3).expected_scope := 'MULTI_GAME';
|
||||
v_cases(3).expected_mentions := 2;
|
||||
|
||||
v_cases(4).case_name := 'ALL';
|
||||
v_cases(4).question := utl_i18n.raw_to_char(utl_encode.base64_decode(
|
||||
utl_raw.cast_to_raw('7KCE7LK0IOqyjOyehOydmCDrp6Tstpwg7JWM66Ck7KSYLg==')), 'AL32UTF8');
|
||||
v_cases(4).expected_scope := 'ALL_GAMES';
|
||||
v_cases(4).expected_mentions := 0;
|
||||
|
||||
FOR profile_row IN (
|
||||
SELECT profile_name
|
||||
FROM user_cloud_ai_profiles
|
||||
WHERE profile_name IN (
|
||||
'SGMP_POC_OCI_GPT54MINI',
|
||||
'SGMP_SCOPE_COHERE_VISION',
|
||||
'SGMP_SCOPE_COHERE_COMMAND',
|
||||
'SGMP_SCOPE_COHERE_PLUS',
|
||||
'SGMP_SCOPE_GEMINI_FLASH',
|
||||
'SGMP_SCOPE_LLAMA_MAV',
|
||||
'SGMP_SCOPE_GROK_NONR'
|
||||
)
|
||||
ORDER BY profile_name
|
||||
) LOOP
|
||||
DBMS_OUTPUT.PUT_LINE('profile=' || profile_row.profile_name);
|
||||
FOR i IN 1 .. 4 LOOP
|
||||
run_case(profile_row.profile_name, v_cases(i));
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END;
|
||||
/
|
||||
prompt SG_SCOPE_PROFILE_BENCHMARK_COMPLETED
|
||||
122
sql/adb/116_sg_game_catalog_alias_embeddings.sql
Normal file
122
sql/adb/116_sg_game_catalog_alias_embeddings.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
-- Store all game-name variants as one JSON array per game and embed that JSON
|
||||
-- as the canonical game-search vector. No customer game name is hardcoded.
|
||||
|
||||
DECLARE
|
||||
v_column_count PLS_INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_column_count
|
||||
FROM user_tab_columns
|
||||
WHERE table_name = 'SG_GAME_CATALOG'
|
||||
AND column_name = 'ALIASES_JSON';
|
||||
|
||||
IF v_column_count = 0 THEN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE sg_game_catalog ADD (aliases_json CLOB)';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
UPDATE sg_game_catalog
|
||||
SET aliases_json = '[]'
|
||||
WHERE aliases_json IS NULL;
|
||||
/
|
||||
|
||||
DECLARE
|
||||
v_constraint_count PLS_INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_constraint_count
|
||||
FROM user_constraints
|
||||
WHERE table_name = 'SG_GAME_CATALOG'
|
||||
AND constraint_name = 'SG_GAME_CATALOG_ALIASES_JS_CK';
|
||||
|
||||
IF v_constraint_count = 0 THEN
|
||||
EXECUTE IMMEDIATE
|
||||
'ALTER TABLE sg_game_catalog ADD CONSTRAINT sg_game_catalog_aliases_js_ck '
|
||||
|| 'CHECK (aliases_json IS JSON)';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
MERGE INTO sg_game_catalog c
|
||||
USING (
|
||||
WITH source_alias AS (
|
||||
SELECT game_id AS game_key, game_nm AS alias_name
|
||||
FROM comn_game_alias_bas
|
||||
WHERE use_yn = 'Y' AND game_nm IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT game_id, game_alias_nm
|
||||
FROM comn_game_alias_bas
|
||||
WHERE use_yn = 'Y' AND game_alias_nm IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT game_id, game_id
|
||||
FROM comn_game_alias_bas
|
||||
WHERE use_yn = 'Y' AND game_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT game_id, game_prefix
|
||||
FROM comn_game_alias_bas
|
||||
WHERE use_yn = 'Y' AND game_prefix IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT game_key, display_name
|
||||
FROM sg_game_scope_registry
|
||||
WHERE active_yn = 'Y' AND display_name IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT game_key, game_alias
|
||||
FROM sg_game_scope_registry
|
||||
WHERE active_yn = 'Y' AND game_alias IS NOT NULL
|
||||
),
|
||||
deduplicated_alias AS (
|
||||
SELECT game_key, alias_name
|
||||
FROM source_alias
|
||||
WHERE TRIM(alias_name) IS NOT NULL
|
||||
GROUP BY game_key, alias_name
|
||||
)
|
||||
SELECT game_key,
|
||||
JSON_ARRAYAGG(alias_name ORDER BY alias_name RETURNING CLOB) AS aliases_json
|
||||
FROM deduplicated_alias
|
||||
GROUP BY game_key
|
||||
) s
|
||||
ON (c.game_key = s.game_key)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
c.aliases_json = s.aliases_json,
|
||||
c.updated_at = SYSTIMESTAMP;
|
||||
/
|
||||
|
||||
-- A game has one canonical vector made from its complete JSON alias array.
|
||||
UPDATE sg_game_catalog c
|
||||
SET c.embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
c.aliases_json,
|
||||
JSON(sg_qa_vector_params('search_document'))
|
||||
),
|
||||
c.updated_at = SYSTIMESTAMP
|
||||
WHERE c.active_yn = 'Y';
|
||||
/
|
||||
|
||||
COMMENT ON COLUMN sg_game_catalog.aliases_json IS
|
||||
'Canonical JSON string array of every game-name variant used as the embedding input.';
|
||||
COMMENT ON COLUMN sg_game_catalog.embedding IS
|
||||
'One vector per game, generated from the complete aliases_json array.';
|
||||
/
|
||||
|
||||
CREATE OR REPLACE FUNCTION sg_game_catalog_search(
|
||||
p_question IN CLOB,
|
||||
p_top_k IN PLS_INTEGER DEFAULT 5
|
||||
) RETURN SYS_REFCURSOR AUTHID DEFINER IS
|
||||
v_query VECTOR;
|
||||
v_result SYS_REFCURSOR;
|
||||
BEGIN
|
||||
v_query := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
p_question,
|
||||
JSON(sg_qa_vector_params('search_query'))
|
||||
);
|
||||
OPEN v_result FOR
|
||||
SELECT game_key, game_id, game_prefix, game_nm, game_alias_nm,
|
||||
user_master_object_name,
|
||||
VECTOR_DISTANCE(embedding, v_query, COSINE) AS cosine_distance
|
||||
FROM sg_game_catalog
|
||||
WHERE active_yn = 'Y' AND embedding IS NOT NULL
|
||||
ORDER BY VECTOR_DISTANCE(embedding, v_query, COSINE), priority, game_key
|
||||
FETCH FIRST LEAST(GREATEST(NVL(p_top_k, 5), 1), 20) ROWS ONLY;
|
||||
RETURN v_result;
|
||||
END;
|
||||
/
|
||||
44
sql/adb/117_sg_game_scope_policy.sql
Normal file
44
sql/adb/117_sg_game_scope_policy.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- Customer-managed score policy for vector-only game identity resolution.
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE q'[
|
||||
CREATE TABLE sg_game_scope_policy (
|
||||
policy_key VARCHAR2(128) PRIMARY KEY,
|
||||
number_value NUMBER,
|
||||
text_value VARCHAR2(4000),
|
||||
description VARCHAR2(1000) NOT NULL,
|
||||
active_yn CHAR(1) DEFAULT 'Y' NOT NULL,
|
||||
updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
CONSTRAINT sg_game_scope_policy_active_ck CHECK (active_yn IN ('Y', 'N'))
|
||||
)]';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN RAISE; END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
MERGE INTO sg_game_scope_policy t
|
||||
USING (
|
||||
SELECT 'GAME_ALIAS_MAX_COSINE_DISTANCE' AS policy_key,
|
||||
0.500000 AS number_value,
|
||||
CAST(NULL AS VARCHAR2(4000)) AS text_value,
|
||||
'Maximum cosine distance for accepting the closest independently embedded game alias.' AS description
|
||||
FROM dual
|
||||
) s
|
||||
ON (t.policy_key = s.policy_key)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
t.description = s.description,
|
||||
t.active_yn = 'Y',
|
||||
t.updated_at = SYSTIMESTAMP
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
policy_key, number_value, text_value, description, active_yn
|
||||
) VALUES (
|
||||
s.policy_key, s.number_value, s.text_value, s.description, 'Y'
|
||||
);
|
||||
/
|
||||
|
||||
COMMENT ON TABLE sg_game_scope_policy IS
|
||||
'Customer-managed game scope policy values; changing a value requires no application deployment.';
|
||||
COMMENT ON COLUMN sg_game_scope_policy.number_value IS
|
||||
'Numeric policy value. GAME_ALIAS_MAX_COSINE_DISTANCE applies to the closest alias vector.';
|
||||
/
|
||||
78
sql/adb/118_sgmp_scope_command_profile.sql
Normal file
78
sql/adb/118_sgmp_scope_command_profile.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
-- Operational OCI Cohere profile for short game-name and scope extraction.
|
||||
-- It inherits the active GPT profile's OCI credential, region, and metadata.
|
||||
|
||||
DECLARE
|
||||
v_credential_name VARCHAR2(128);
|
||||
v_region VARCHAR2(128);
|
||||
v_compartment_id VARCHAR2(4000);
|
||||
v_attributes CLOB;
|
||||
v_attribute_json JSON_OBJECT_T;
|
||||
v_exists PLS_INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_exists
|
||||
FROM user_cloud_ai_profiles
|
||||
WHERE profile_name = 'SGMP_POC_OCI_COHERE_COMMAND';
|
||||
|
||||
IF v_exists > 0 THEN
|
||||
DBMS_CLOUD_AI.DROP_PROFILE(
|
||||
profile_name => 'SGMP_POC_OCI_COHERE_COMMAND',
|
||||
force => TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||
INTO v_credential_name
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'credential_name';
|
||||
|
||||
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||
INTO v_region
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'region';
|
||||
|
||||
SELECT DBMS_LOB.SUBSTR(attribute_value, 4000, 1)
|
||||
INTO v_compartment_id
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'oci_compartment_id';
|
||||
|
||||
v_attribute_json := JSON_OBJECT_T();
|
||||
v_attribute_json.put('provider', 'oci');
|
||||
v_attribute_json.put('credential_name', v_credential_name);
|
||||
v_attribute_json.put('model', 'cohere.command-latest');
|
||||
v_attribute_json.put('region', v_region);
|
||||
v_attribute_json.put('oci_compartment_id', v_compartment_id);
|
||||
v_attributes := v_attribute_json.to_clob;
|
||||
|
||||
DBMS_CLOUD_AI.CREATE_PROFILE(
|
||||
profile_name => 'SGMP_POC_OCI_COHERE_COMMAND',
|
||||
attributes => v_attributes,
|
||||
description => 'Smilegate operational OCI Cohere Command profile for game scope extraction'
|
||||
);
|
||||
|
||||
FOR source_attribute IN (
|
||||
SELECT attribute_name, attribute_value
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name NOT IN (
|
||||
'credential_name', 'model', 'provider', 'provider_endpoint',
|
||||
'region', 'oci_compartment_id', 'oci_endpoint_id',
|
||||
'oci_apiformat', 'oci_runtimetype'
|
||||
)
|
||||
) LOOP
|
||||
DBMS_CLOUD_AI.SET_ATTRIBUTE(
|
||||
profile_name => 'SGMP_POC_OCI_COHERE_COMMAND',
|
||||
attribute_name => source_attribute.attribute_name,
|
||||
attribute_value => source_attribute.attribute_value
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
/
|
||||
|
||||
SELECT profile_name, attribute_value AS model
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_COHERE_COMMAND'
|
||||
AND attribute_name = 'model';
|
||||
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;
|
||||
/
|
||||
179
sql/adb/79_sg_game_catalog_vector.sql
Normal file
179
sql/adb/79_sg_game_catalog_vector.sql
Normal file
@@ -0,0 +1,179 @@
|
||||
-- Metadata-driven game catalog for deterministic scope resolution.
|
||||
-- No customer game names or prefixes are embedded in this script.
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE q'[
|
||||
CREATE TABLE sg_game_catalog (
|
||||
game_key VARCHAR2(128) PRIMARY KEY,
|
||||
game_id VARCHAR2(128) NOT NULL,
|
||||
game_prefix VARCHAR2(128),
|
||||
game_nm VARCHAR2(512),
|
||||
game_alias_nm VARCHAR2(512),
|
||||
user_master_object_name VARCHAR2(128),
|
||||
search_text CLOB NOT NULL,
|
||||
embedding VECTOR(1536, FLOAT32),
|
||||
active_yn CHAR(1) DEFAULT 'Y' NOT NULL,
|
||||
priority NUMBER DEFAULT 100 NOT NULL,
|
||||
updated_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP NOT NULL
|
||||
)]';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN RAISE; END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
DECLARE
|
||||
v_count PLS_INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM user_tab_columns
|
||||
WHERE table_name = 'SG_GAME_CATALOG'
|
||||
AND column_name = 'USER_MASTER_OBJECT_NAME';
|
||||
IF v_count = 0 THEN
|
||||
EXECUTE IMMEDIATE
|
||||
'ALTER TABLE sg_game_catalog ADD (user_master_object_name VARCHAR2(128))';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
MERGE INTO sg_game_catalog c
|
||||
USING (
|
||||
SELECT
|
||||
game_id AS game_key,
|
||||
game_id,
|
||||
MAX(game_prefix) AS game_prefix,
|
||||
MAX(game_nm) AS game_nm,
|
||||
LISTAGG(game_alias_nm, ' ') WITHIN GROUP (ORDER BY game_alias_nm) AS game_alias_nm,
|
||||
game_id || ' ' || MAX(NVL(game_nm, '')) || ' '
|
||||
|| LISTAGG(NVL(game_alias_nm, ''), ' ') WITHIN GROUP (ORDER BY game_alias_nm)
|
||||
|| ' ' || MAX(NVL(game_prefix, '')) AS search_text
|
||||
FROM comn_game_alias_bas
|
||||
WHERE use_yn = 'Y'
|
||||
GROUP BY game_id
|
||||
) s
|
||||
ON (c.game_key = s.game_key)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
c.game_id = s.game_id,
|
||||
c.game_prefix = s.game_prefix,
|
||||
c.game_nm = s.game_nm,
|
||||
c.game_alias_nm = s.game_alias_nm,
|
||||
c.search_text = s.search_text,
|
||||
c.active_yn = 'Y',
|
||||
c.updated_at = SYSTIMESTAMP
|
||||
WHEN NOT MATCHED THEN INSERT
|
||||
(game_key, game_id, game_prefix, game_nm, game_alias_nm, search_text)
|
||||
VALUES
|
||||
(s.game_key, s.game_id, s.game_prefix, s.game_nm, s.game_alias_nm, s.search_text);
|
||||
/
|
||||
|
||||
-- Keep operator-maintained games in the same canonical catalog. These rows
|
||||
-- remain observable even when their current approved physical object is null.
|
||||
MERGE INTO sg_game_catalog c
|
||||
USING (
|
||||
SELECT
|
||||
r.game_key,
|
||||
r.game_key AS game_id,
|
||||
MAX(r.game_prefix) AS game_prefix,
|
||||
MAX(r.display_name) AS game_nm,
|
||||
LISTAGG(r.game_alias, ' ') WITHIN GROUP (ORDER BY r.game_alias) AS game_alias_nm,
|
||||
r.game_key || ' ' || MAX(NVL(r.display_name, '')) || ' '
|
||||
|| LISTAGG(NVL(r.game_alias, ''), ' ') WITHIN GROUP (ORDER BY r.game_alias)
|
||||
|| ' ' || MAX(NVL(r.game_prefix, '')) AS search_text
|
||||
FROM sg_game_scope_registry r
|
||||
WHERE r.active_yn = 'Y'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM comn_game_alias_bas a
|
||||
WHERE a.use_yn = 'Y'
|
||||
AND a.game_id = r.game_key
|
||||
)
|
||||
GROUP BY r.game_key
|
||||
) s
|
||||
ON (c.game_key = s.game_key)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
c.game_id = s.game_id,
|
||||
c.game_prefix = s.game_prefix,
|
||||
c.game_nm = s.game_nm,
|
||||
c.game_alias_nm = s.game_alias_nm,
|
||||
c.search_text = s.search_text,
|
||||
c.active_yn = 'Y',
|
||||
c.updated_at = SYSTIMESTAMP
|
||||
WHEN NOT MATCHED THEN INSERT
|
||||
(game_key, game_id, game_prefix, game_nm, game_alias_nm, search_text)
|
||||
VALUES
|
||||
(s.game_key, s.game_id, s.game_prefix, s.game_nm, s.game_alias_nm, s.search_text);
|
||||
/
|
||||
|
||||
-- Resolve the physical user-master object from current valid objects and the
|
||||
-- current Select AI object lists. No game, prefix, or object name is embedded
|
||||
-- in this policy.
|
||||
MERGE INTO sg_game_catalog c
|
||||
USING (
|
||||
WITH approved_objects AS (
|
||||
SELECT DISTINCT UPPER(j.object_name) AS object_name
|
||||
FROM user_cloud_ai_profile_attributes p,
|
||||
JSON_TABLE(
|
||||
p.attribute_value,
|
||||
'$[*]' COLUMNS (object_name VARCHAR2(128) PATH '$.name')
|
||||
) j
|
||||
INNER JOIN user_objects o
|
||||
ON o.object_name = UPPER(j.object_name)
|
||||
AND o.object_type IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW')
|
||||
AND o.status = 'VALID'
|
||||
WHERE p.attribute_name = 'object_list'
|
||||
)
|
||||
SELECT c2.game_key,
|
||||
MIN(a.object_name) AS user_master_object_name
|
||||
FROM sg_game_catalog c2
|
||||
LEFT JOIN approved_objects a
|
||||
ON c2.game_prefix IS NOT NULL
|
||||
AND a.object_name = UPPER(c2.game_prefix || '_COMN_USER_MST')
|
||||
GROUP BY c2.game_key
|
||||
) s
|
||||
ON (c.game_key = s.game_key)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
c.user_master_object_name = s.user_master_object_name,
|
||||
c.updated_at = SYSTIMESTAMP;
|
||||
/
|
||||
|
||||
-- The catalog is small. Recompute embeddings after synchronization so changed
|
||||
-- aliases and registry-only games cannot retain a stale or null vector.
|
||||
UPDATE sg_game_catalog c
|
||||
SET c.embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
c.search_text,
|
||||
JSON(sg_qa_vector_params('search_document'))
|
||||
),
|
||||
c.updated_at = SYSTIMESTAMP
|
||||
WHERE c.active_yn = 'Y';
|
||||
/
|
||||
|
||||
COMMENT ON TABLE sg_game_catalog IS
|
||||
'Customer-owned game catalog used to resolve query scope before NL2SQL.';
|
||||
COMMENT ON COLUMN sg_game_catalog.embedding IS
|
||||
'Vector representation of game names and aliases, generated with the configured vector credential.';
|
||||
COMMENT ON COLUMN sg_game_catalog.user_master_object_name IS
|
||||
'Current valid Select AI-approved physical user-master object for this game; null when unavailable.';
|
||||
/
|
||||
|
||||
CREATE OR REPLACE FUNCTION sg_game_catalog_search(
|
||||
p_question IN CLOB,
|
||||
p_top_k IN PLS_INTEGER DEFAULT 5
|
||||
) RETURN SYS_REFCURSOR AUTHID DEFINER IS
|
||||
v_query VECTOR;
|
||||
v_result SYS_REFCURSOR;
|
||||
BEGIN
|
||||
v_query := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
p_question,
|
||||
JSON(sg_qa_vector_params('search_query'))
|
||||
);
|
||||
OPEN v_result FOR
|
||||
SELECT game_key, game_id, game_prefix, game_nm, game_alias_nm,
|
||||
user_master_object_name,
|
||||
VECTOR_DISTANCE(embedding, v_query, COSINE) AS cosine_distance
|
||||
FROM sg_game_catalog
|
||||
WHERE active_yn = 'Y' AND embedding IS NOT NULL
|
||||
ORDER BY VECTOR_DISTANCE(embedding, v_query, COSINE), priority, game_key
|
||||
FETCH FIRST LEAST(GREATEST(NVL(p_top_k, 5), 1), 20) ROWS ONLY;
|
||||
RETURN v_result;
|
||||
END;
|
||||
/
|
||||
34
sql/adb/80_sg_game_mention_extract.sql
Normal file
34
sql/adb/80_sg_game_mention_extract.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- One short OCI Chat call extracts game-name mentions. Identity resolution is
|
||||
-- performed later by catalog vector score, not by another LLM call.
|
||||
CREATE OR REPLACE FUNCTION sg_game_extract_mentions(p_question IN CLOB)
|
||||
RETURN CLOB AUTHID DEFINER
|
||||
IS
|
||||
v_prompt CLOB;
|
||||
v_result CLOB;
|
||||
v_extract JSON_OBJECT_T;
|
||||
BEGIN
|
||||
v_prompt := 'Extract only game-name mentions from the user question. '
|
||||
|| 'Metrics, acronyms, dates, filters, and database object or column names are not game names unless they are themselves an explicit game title. '
|
||||
|| 'When a title-like noun directly qualifies a game data request such as user master, character, sales, AU, NRU, server, or game log, preserve that noun as a game-name mention even when it is not in a catalog. '
|
||||
|| 'Do not discard an unknown title merely because it cannot be resolved. General scope words such as common, overall, all, total, or every are not game-name mentions unless they are part of an explicit title. '
|
||||
|| 'Return exactly one JSON object with keys game_mentions (array of strings) '
|
||||
|| 'and scope_hint (GLOBAL, SINGLE_GAME, MULTI_GAME, ALL_GAMES, UNKNOWN). '
|
||||
|| 'Do not resolve names to IDs and do not generate SQL. '
|
||||
|| 'Return raw JSON only: no prose, no Markdown, and no code fence. Question: '
|
||||
|| DBMS_LOB.SUBSTR(p_question, 4000, 1);
|
||||
|
||||
v_result := DBMS_CLOUD_AI.GENERATE(
|
||||
prompt => v_prompt,
|
||||
profile_name => 'SGMP_POC_OCI_COHERE_COMMAND',
|
||||
action => 'chat'
|
||||
);
|
||||
v_extract := JSON_OBJECT_T.parse(v_result);
|
||||
IF NOT v_extract.has('game_mentions') OR NOT v_extract.has('scope_hint') THEN
|
||||
RAISE_APPLICATION_ERROR(
|
||||
-20091,
|
||||
'Game mention extraction must return game_mentions and scope_hint JSON keys.'
|
||||
);
|
||||
END IF;
|
||||
RETURN v_result;
|
||||
END;
|
||||
/
|
||||
382
sql/adb/81_sg_game_query_plan.sql
Normal file
382
sql/adb/81_sg_game_query_plan.sql
Normal file
@@ -0,0 +1,382 @@
|
||||
-- Vector-score candidate selection. The closest catalog vector is accepted
|
||||
-- only when it is within the customer-managed policy threshold.
|
||||
CREATE OR REPLACE FUNCTION sg_game_match_candidate(
|
||||
p_mention IN CLOB,
|
||||
p_candidates_json IN CLOB
|
||||
) RETURN CLOB AUTHID DEFINER
|
||||
IS
|
||||
v_candidates JSON_ARRAY_T;
|
||||
v_candidate JSON_OBJECT_T;
|
||||
v_result JSON_OBJECT_T := JSON_OBJECT_T();
|
||||
v_threshold NUMBER;
|
||||
v_distance NUMBER;
|
||||
v_game_key VARCHAR2(128);
|
||||
BEGIN
|
||||
SELECT number_value
|
||||
INTO v_threshold
|
||||
FROM sg_game_scope_policy
|
||||
WHERE policy_key = 'GAME_ALIAS_MAX_COSINE_DISTANCE'
|
||||
AND active_yn = 'Y';
|
||||
|
||||
v_candidates := JSON_ARRAY_T.parse(p_candidates_json);
|
||||
IF v_candidates.get_size = 0 THEN
|
||||
v_result.put('status', 'UNMATCHED');
|
||||
v_result.put_null('game_key');
|
||||
v_result.put('reason', 'No active game catalog vector candidate was returned.');
|
||||
RETURN v_result.to_clob;
|
||||
END IF;
|
||||
|
||||
v_candidate := TREAT(v_candidates.get(0) AS JSON_OBJECT_T);
|
||||
v_distance := v_candidate.get_number('cosineDistance');
|
||||
v_game_key := v_candidate.get_string('gameKey');
|
||||
|
||||
IF v_distance <= v_threshold THEN
|
||||
v_result.put('status', 'MATCHED');
|
||||
v_result.put('game_key', v_game_key);
|
||||
v_result.put(
|
||||
'reason',
|
||||
'Closest catalog vector distance '
|
||||
|| TO_CHAR(v_distance, 'FM0D000000')
|
||||
|| ' is within configured maximum '
|
||||
|| TO_CHAR(v_threshold, 'FM0D000000') || '.'
|
||||
);
|
||||
ELSE
|
||||
v_result.put('status', 'UNMATCHED');
|
||||
v_result.put_null('game_key');
|
||||
v_result.put(
|
||||
'reason',
|
||||
'Closest catalog vector distance '
|
||||
|| TO_CHAR(v_distance, 'FM0D000000')
|
||||
|| ' exceeds configured maximum '
|
||||
|| TO_CHAR(v_threshold, 'FM0D000000') || '.'
|
||||
);
|
||||
END IF;
|
||||
RETURN v_result.to_clob;
|
||||
END;
|
||||
/
|
||||
|
||||
-- A game can be known to the scope registry while not being an active alias
|
||||
-- source for a common fact query. Keep that availability separate from a
|
||||
-- role-specific physical-object availability such as a user-master table.
|
||||
CREATE OR REPLACE FUNCTION sg_game_fact_scope_status(
|
||||
p_game_id IN VARCHAR2
|
||||
) RETURN VARCHAR2 AUTHID DEFINER IS
|
||||
v_exists NUMBER;
|
||||
BEGIN
|
||||
SELECT COUNT(*)
|
||||
INTO v_exists
|
||||
FROM SGMP_POC.comn_game_alias_bas
|
||||
WHERE use_yn = 'Y'
|
||||
AND game_id = p_game_id;
|
||||
RETURN CASE WHEN v_exists > 0 THEN 'ACTIVE_ALIAS' ELSE 'REGISTRY_ONLY' END;
|
||||
END;
|
||||
/
|
||||
|
||||
-- Complete game query planning inside ADB. The MCP server only invokes this
|
||||
-- function and returns its structured result.
|
||||
CREATE OR REPLACE FUNCTION sg_game_query_plan(
|
||||
p_question IN CLOB,
|
||||
p_top_k IN PLS_INTEGER DEFAULT 5
|
||||
) RETURN CLOB AUTHID DEFINER
|
||||
IS
|
||||
TYPE t_seen_game_keys IS TABLE OF BOOLEAN INDEX BY VARCHAR2(128);
|
||||
|
||||
v_extract_raw CLOB;
|
||||
v_extract JSON_OBJECT_T;
|
||||
v_mentions JSON_ARRAY_T;
|
||||
v_scope_type VARCHAR2(30);
|
||||
v_target_type VARCHAR2(20);
|
||||
v_targets JSON_ARRAY_T := JSON_ARRAY_T();
|
||||
v_supported JSON_ARRAY_T := JSON_ARRAY_T();
|
||||
v_data_eligible JSON_ARRAY_T := JSON_ARRAY_T();
|
||||
v_data_ineligible JSON_ARRAY_T := JSON_ARRAY_T();
|
||||
v_unresolved JSON_ARRAY_T := JSON_ARRAY_T();
|
||||
v_unmatched JSON_ARRAY_T := JSON_ARRAY_T();
|
||||
v_mention_results JSON_ARRAY_T := JSON_ARRAY_T();
|
||||
v_seen_game_keys t_seen_game_keys;
|
||||
v_result JSON_OBJECT_T := JSON_OBJECT_T();
|
||||
v_matched_count PLS_INTEGER := 0;
|
||||
v_data_eligible_count PLS_INTEGER := 0;
|
||||
v_unmatched_count PLS_INTEGER := 0;
|
||||
|
||||
v_mention VARCHAR2(1000);
|
||||
v_candidates JSON_ARRAY_T;
|
||||
v_candidate JSON_OBJECT_T;
|
||||
v_decision_raw CLOB;
|
||||
v_decision JSON_OBJECT_T;
|
||||
v_decision_status VARCHAR2(30);
|
||||
v_selected_key VARCHAR2(128);
|
||||
v_reason VARCHAR2(4000);
|
||||
v_matched_candidate JSON_OBJECT_T;
|
||||
v_mention_result JSON_OBJECT_T;
|
||||
v_target JSON_OBJECT_T;
|
||||
v_unmatched_item JSON_OBJECT_T;
|
||||
|
||||
v_cursor SYS_REFCURSOR;
|
||||
v_game_key VARCHAR2(128);
|
||||
v_game_id VARCHAR2(128);
|
||||
v_game_prefix VARCHAR2(128);
|
||||
v_game_nm VARCHAR2(512);
|
||||
v_game_alias_nm VARCHAR2(512);
|
||||
v_user_master_object_name VARCHAR2(128);
|
||||
v_fact_scope_status VARCHAR2(30);
|
||||
v_cosine_distance NUMBER;
|
||||
BEGIN
|
||||
v_extract_raw := sg_game_extract_mentions(p_question);
|
||||
v_extract := JSON_OBJECT_T.parse(v_extract_raw);
|
||||
v_mentions := v_extract.get_array('game_mentions');
|
||||
v_scope_type := NVL(v_extract.get_string('scope_hint'), 'UNKNOWN');
|
||||
|
||||
IF v_scope_type = 'ALL_GAMES' THEN
|
||||
v_target_type := 'ALL';
|
||||
ELSIF v_mentions.get_size = 0 THEN
|
||||
v_target_type := 'NONE';
|
||||
ELSIF v_mentions.get_size = 1 THEN
|
||||
v_target_type := 'SINGLE';
|
||||
ELSE
|
||||
v_target_type := 'MULTI';
|
||||
END IF;
|
||||
|
||||
IF v_target_type = 'ALL' THEN
|
||||
FOR game_row IN (
|
||||
SELECT game_key, game_id, game_prefix, game_nm, game_alias_nm,
|
||||
user_master_object_name
|
||||
FROM SGMP_POC.sg_game_catalog
|
||||
WHERE active_yn = 'Y'
|
||||
ORDER BY priority, game_key
|
||||
) LOOP
|
||||
v_target := JSON_OBJECT_T();
|
||||
v_target.put_null('mention');
|
||||
v_target.put('status', 'CATALOG');
|
||||
v_target.put('matchStatus', 'MATCHED');
|
||||
v_target.put('gameKey', game_row.game_key);
|
||||
v_target.put('gameId', game_row.game_id);
|
||||
v_target.put('gamePrefix', game_row.game_prefix);
|
||||
v_target.put('gameName', game_row.game_nm);
|
||||
v_target.put('aliases', game_row.game_alias_nm);
|
||||
v_target.put(
|
||||
'userMasterObjectName',
|
||||
game_row.user_master_object_name
|
||||
);
|
||||
v_target.put(
|
||||
'objectStatus',
|
||||
CASE WHEN game_row.user_master_object_name IS NULL
|
||||
THEN 'UNAVAILABLE' ELSE 'AVAILABLE' END
|
||||
);
|
||||
v_fact_scope_status := sg_game_fact_scope_status(game_row.game_id);
|
||||
v_target.put('factScopeStatus', v_fact_scope_status);
|
||||
v_target.put(
|
||||
'dataStatus',
|
||||
CASE WHEN v_fact_scope_status = 'ACTIVE_ALIAS'
|
||||
THEN 'AVAILABLE' ELSE 'UNAVAILABLE' END
|
||||
);
|
||||
v_target.put_null('cosineDistance');
|
||||
v_target.put('reasonCode', 'ALL_GAMES_CATALOG');
|
||||
v_target.put('reason', 'Active game returned from the database catalog.');
|
||||
v_targets.append(v_target);
|
||||
v_matched_count := v_matched_count + 1;
|
||||
IF game_row.user_master_object_name IS NOT NULL THEN
|
||||
v_supported.append(v_target);
|
||||
END IF;
|
||||
IF v_fact_scope_status = 'ACTIVE_ALIAS' THEN
|
||||
v_data_eligible.append(v_target);
|
||||
v_data_eligible_count := v_data_eligible_count + 1;
|
||||
ELSE
|
||||
v_data_ineligible.append(v_target);
|
||||
v_unresolved.append(v_target);
|
||||
END IF;
|
||||
END LOOP;
|
||||
ELSIF v_target_type = 'NONE' THEN
|
||||
v_target := JSON_OBJECT_T();
|
||||
v_target.put_null('mention');
|
||||
v_target.put('status', 'NONE');
|
||||
v_target.put('matchStatus', 'NOT_APPLICABLE');
|
||||
v_target.put_null('gameKey');
|
||||
v_target.put_null('gameId');
|
||||
v_target.put_null('gamePrefix');
|
||||
v_target.put_null('gameName');
|
||||
v_target.put_null('aliases');
|
||||
v_target.put_null('userMasterObjectName');
|
||||
v_target.put('objectStatus', 'NOT_APPLICABLE');
|
||||
v_target.put('factScopeStatus', 'NOT_APPLICABLE');
|
||||
v_target.put('dataStatus', 'NOT_APPLICABLE');
|
||||
v_target.put_null('cosineDistance');
|
||||
v_target.put('reasonCode', 'NO_GAME_TARGET');
|
||||
v_target.put(
|
||||
'reason',
|
||||
'The question does not select a particular game.'
|
||||
);
|
||||
v_targets.append(v_target);
|
||||
ELSE
|
||||
FOR i IN 0 .. v_mentions.get_size - 1 LOOP
|
||||
v_mention := v_mentions.get_string(i);
|
||||
v_candidates := JSON_ARRAY_T();
|
||||
v_cursor := SGMP_POC.sg_game_catalog_search(
|
||||
v_mention,
|
||||
LEAST(GREATEST(NVL(p_top_k, 5), 1), 20)
|
||||
);
|
||||
|
||||
LOOP
|
||||
FETCH v_cursor INTO
|
||||
v_game_key,
|
||||
v_game_id,
|
||||
v_game_prefix,
|
||||
v_game_nm,
|
||||
v_game_alias_nm,
|
||||
v_user_master_object_name,
|
||||
v_cosine_distance;
|
||||
EXIT WHEN v_cursor%NOTFOUND;
|
||||
|
||||
v_candidate := JSON_OBJECT_T();
|
||||
v_candidate.put('gameKey', v_game_key);
|
||||
v_candidate.put('gameId', v_game_id);
|
||||
v_candidate.put('gamePrefix', v_game_prefix);
|
||||
v_candidate.put('gameName', v_game_nm);
|
||||
v_candidate.put('aliases', v_game_alias_nm);
|
||||
v_candidate.put(
|
||||
'userMasterObjectName',
|
||||
v_user_master_object_name
|
||||
);
|
||||
v_candidate.put(
|
||||
'objectStatus',
|
||||
CASE WHEN v_user_master_object_name IS NULL
|
||||
THEN 'UNAVAILABLE' ELSE 'AVAILABLE' END
|
||||
);
|
||||
v_candidate.put(
|
||||
'factScopeStatus',
|
||||
sg_game_fact_scope_status(v_game_id)
|
||||
);
|
||||
v_candidate.put('cosineDistance', v_cosine_distance);
|
||||
v_candidates.append(v_candidate);
|
||||
END LOOP;
|
||||
CLOSE v_cursor;
|
||||
|
||||
v_decision_raw := sg_game_match_candidate(
|
||||
v_mention,
|
||||
v_candidates.to_clob
|
||||
);
|
||||
v_decision := JSON_OBJECT_T.parse(v_decision_raw);
|
||||
v_decision_status := UPPER(
|
||||
NVL(v_decision.get_string('status'), 'UNMATCHED')
|
||||
);
|
||||
v_selected_key := v_decision.get_string('game_key');
|
||||
v_reason := v_decision.get_string('reason');
|
||||
v_matched_candidate := NULL;
|
||||
|
||||
IF v_decision_status = 'MATCHED' AND v_selected_key IS NOT NULL THEN
|
||||
FOR j IN 0 .. v_candidates.get_size - 1 LOOP
|
||||
v_candidate := TREAT(v_candidates.get(j) AS JSON_OBJECT_T);
|
||||
IF v_candidate.get_string('gameKey') = v_selected_key THEN
|
||||
v_matched_candidate := v_candidate;
|
||||
EXIT;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
v_mention_result := JSON_OBJECT_T();
|
||||
v_mention_result.put('mention', v_mention);
|
||||
v_mention_result.put('candidateGames', v_candidates);
|
||||
v_mention_result.put('reason', v_reason);
|
||||
|
||||
IF v_matched_candidate IS NOT NULL THEN
|
||||
v_mention_result.put('status', 'MATCHED');
|
||||
v_mention_result.put('reasonCode', 'VECTOR_SCORE_MATCH');
|
||||
v_mention_result.put('matchedGameKey', v_selected_key);
|
||||
|
||||
v_target := JSON_OBJECT_T.parse(v_matched_candidate.to_clob);
|
||||
v_target.put('mention', v_mention);
|
||||
v_target.put('status', 'MATCHED');
|
||||
v_target.put('matchStatus', 'MATCHED');
|
||||
v_target.put('reasonCode', 'VECTOR_SCORE_MATCH');
|
||||
v_target.put('reason', v_reason);
|
||||
v_matched_count := v_matched_count + 1;
|
||||
|
||||
IF v_matched_candidate.get_string('factScopeStatus') = 'ACTIVE_ALIAS' THEN
|
||||
v_target.put('dataStatus', 'AVAILABLE');
|
||||
v_data_eligible.append(v_target);
|
||||
v_data_eligible_count := v_data_eligible_count + 1;
|
||||
ELSE
|
||||
v_target.put('dataStatus', 'UNAVAILABLE');
|
||||
v_data_ineligible.append(v_target);
|
||||
v_unresolved.append(v_target);
|
||||
END IF;
|
||||
v_targets.append(v_target);
|
||||
IF NOT v_seen_game_keys.EXISTS(v_selected_key) THEN
|
||||
v_supported.append(v_target);
|
||||
v_seen_game_keys(v_selected_key) := TRUE;
|
||||
END IF;
|
||||
ELSE
|
||||
v_mention_result.put('status', 'UNMATCHED');
|
||||
v_mention_result.put('reasonCode', 'VECTOR_SCORE_OVER_THRESHOLD');
|
||||
|
||||
v_target := JSON_OBJECT_T();
|
||||
v_target.put('mention', v_mention);
|
||||
v_target.put('status', 'UNMATCHED');
|
||||
v_target.put('matchStatus', 'UNMATCHED');
|
||||
v_target.put_null('gameKey');
|
||||
v_target.put_null('gameId');
|
||||
v_target.put_null('gamePrefix');
|
||||
v_target.put_null('gameName');
|
||||
v_target.put_null('aliases');
|
||||
v_target.put_null('userMasterObjectName');
|
||||
v_target.put('objectStatus', 'UNAVAILABLE');
|
||||
v_target.put('factScopeStatus', 'UNAVAILABLE');
|
||||
v_target.put('dataStatus', 'UNAVAILABLE');
|
||||
v_target.put_null('cosineDistance');
|
||||
v_target.put(
|
||||
'reasonCode',
|
||||
v_mention_result.get_string('reasonCode')
|
||||
);
|
||||
v_target.put('reason', v_reason);
|
||||
v_targets.append(v_target);
|
||||
|
||||
v_unmatched_item := JSON_OBJECT_T();
|
||||
v_unmatched_item.put('mention', v_mention);
|
||||
v_unmatched_item.put(
|
||||
'reasonCode',
|
||||
v_mention_result.get_string('reasonCode')
|
||||
);
|
||||
v_unmatched_item.put('reason', v_reason);
|
||||
v_unmatched.append(v_unmatched_item);
|
||||
v_unresolved.append(v_target);
|
||||
v_unmatched_count := v_unmatched_count + 1;
|
||||
END IF;
|
||||
v_mention_results.append(v_mention_result);
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
v_result.put('contractVersion', '1.0');
|
||||
v_result.put('targetType', v_target_type);
|
||||
v_result.put('scopeType', v_target_type);
|
||||
v_result.put('extractScopeHint', v_scope_type);
|
||||
IF v_target_type = 'NONE' THEN
|
||||
v_result.put('status', 'NO_TARGET');
|
||||
ELSIF v_matched_count = 0 THEN
|
||||
v_result.put('status', 'UNMATCHED');
|
||||
ELSIF v_data_eligible_count = 0 THEN
|
||||
v_result.put('status', 'UNAVAILABLE');
|
||||
ELSIF v_unmatched_count > 0 THEN
|
||||
v_result.put('status', 'PARTIAL');
|
||||
ELSE
|
||||
v_result.put('status', 'SUPPORTED');
|
||||
END IF;
|
||||
v_result.put('targets', v_targets);
|
||||
v_result.put('matchedGames', v_supported);
|
||||
v_result.put('mentionResults', v_mention_results);
|
||||
v_result.put('supportedGames', v_supported);
|
||||
v_result.put('dataEligibleTargets', v_data_eligible);
|
||||
v_result.put('unresolvedTargets', v_unresolved);
|
||||
v_result.put('dataIneligibleTargets', v_data_ineligible);
|
||||
v_result.put('unmatchedGames', v_unmatched);
|
||||
v_result.put('nextAction', 'CALL_FEWSHOT');
|
||||
|
||||
CASE v_target_type
|
||||
WHEN 'NONE' THEN v_result.put('executionMode', 'UNSCOPED');
|
||||
WHEN 'SINGLE' THEN v_result.put('executionMode', 'SINGLE');
|
||||
WHEN 'MULTI' THEN v_result.put('executionMode', 'COMBINED');
|
||||
WHEN 'ALL' THEN v_result.put('executionMode', 'ALL');
|
||||
END CASE;
|
||||
|
||||
RETURN v_result.to_clob;
|
||||
END;
|
||||
/
|
||||
219
sql/adb/82_sgmp_qa_fewshot_reference_governance.sql
Normal file
219
sql/adb/82_sgmp_qa_fewshot_reference_governance.sql
Normal file
@@ -0,0 +1,219 @@
|
||||
-- Governed Few-shot examples for the Smilegate Select AI MCP.
|
||||
-- Existing rows are preserved for audit and only APPROVED rows are retrievable.
|
||||
|
||||
DECLARE
|
||||
PROCEDURE add_column(p_definition IN VARCHAR2) IS
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE sg_qa_vector_example ADD (' || p_definition || ')';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -1430 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
BEGIN
|
||||
add_column('reference_status VARCHAR2(16) DEFAULT ''DRAFT'' NOT NULL');
|
||||
add_column('reference_kind VARCHAR2(32) DEFAULT ''SQL_TEMPLATE'' NOT NULL');
|
||||
add_column('target_type VARCHAR2(16) DEFAULT ''ANY'' NOT NULL');
|
||||
add_column('object_role VARCHAR2(64)');
|
||||
add_column('inspection_status VARCHAR2(16) DEFAULT ''PENDING'' NOT NULL');
|
||||
add_column('inspection_note CLOB');
|
||||
add_column('verified_at TIMESTAMP(6)');
|
||||
add_column('verified_by VARCHAR2(128)');
|
||||
END;
|
||||
/
|
||||
|
||||
UPDATE sg_qa_vector_example
|
||||
SET reference_status = 'RETIRED',
|
||||
reference_kind = 'SQL_TEMPLATE',
|
||||
target_type = 'SINGLE',
|
||||
inspection_status = 'RETIRED',
|
||||
inspection_note = 'Executed successfully but maps BUBBLYZ to the CZN physical user-master object. Conflicts with the approved game-target contract.',
|
||||
verified_at = SYSTIMESTAMP,
|
||||
verified_by = 'SGMP_POC_REVIEW'
|
||||
WHERE example_id = 2;
|
||||
|
||||
UPDATE sg_qa_vector_example
|
||||
SET reference_status = 'RETIRED',
|
||||
reference_kind = 'OBJECT_UNAVAILABLE',
|
||||
target_type = 'SINGLE',
|
||||
object_role = 'GAME_ALIAS_CATALOG',
|
||||
inspection_status = 'RETIRED',
|
||||
inspection_note = 'Executed successfully with no BUBBLYZ alias rows, but the game literal is not a reusable object-unavailable template. The current game query plan is the authoritative boundary source.',
|
||||
verified_at = SYSTIMESTAMP,
|
||||
verified_by = 'SGMP_POC_REVIEW'
|
||||
WHERE example_id = 3;
|
||||
|
||||
UPDATE sg_qa_vector_example
|
||||
SET reference_status = 'APPROVED',
|
||||
reference_kind = 'SQL_TEMPLATE',
|
||||
target_type = 'SINGLE',
|
||||
object_role = 'GAME_USER_MASTER',
|
||||
inspection_status = 'VERIFIED',
|
||||
inspection_note = 'Logical placeholder template. The resolved physical object must come only from the current game query plan.',
|
||||
verified_at = SYSTIMESTAMP,
|
||||
verified_by = 'SGMP_POC_REVIEW'
|
||||
WHERE example_id = 4;
|
||||
|
||||
UPDATE sg_qa_vector_example
|
||||
SET reference_status = 'APPROVED',
|
||||
reference_kind = 'NO_TARGET',
|
||||
target_type = 'NONE',
|
||||
inspection_status = 'VERIFIED',
|
||||
inspection_note = 'Executed successfully with no rows. Canonical boundary for an unscoped game-user-master request.',
|
||||
verified_at = SYSTIMESTAMP,
|
||||
verified_by = 'SGMP_POC_REVIEW'
|
||||
WHERE example_id = 5;
|
||||
|
||||
UPDATE sg_qa_vector_example
|
||||
SET reference_status = 'RETIRED',
|
||||
reference_kind = 'METADATA_POLICY',
|
||||
target_type = 'ANY',
|
||||
object_role = 'GAME_ALIAS_CATALOG',
|
||||
inspection_status = 'RETIRED',
|
||||
inspection_note = 'Not directly executable: requires an unbound GAME_TERM placeholder. Game resolution is now supplied by game_query_plan.',
|
||||
verified_at = SYSTIMESTAMP,
|
||||
verified_by = 'SGMP_POC_REVIEW'
|
||||
WHERE example_id = 6;
|
||||
|
||||
UPDATE sg_qa_vector_example
|
||||
SET reference_status = 'RETIRED',
|
||||
reference_kind = 'SQL_TEMPLATE',
|
||||
target_type = 'NONE',
|
||||
object_role = 'GAME_USER_MASTER',
|
||||
inspection_status = 'RETIRED',
|
||||
inspection_note = 'Executed successfully but chooses CZN_COMN_USER_MST for a game-unscoped question. Conflicts with the NONE target contract.',
|
||||
verified_at = SYSTIMESTAMP,
|
||||
verified_by = 'SGMP_POC_REVIEW'
|
||||
WHERE example_id IN (7, 8);
|
||||
|
||||
CREATE OR REPLACE FUNCTION sg_qa_vector_store(
|
||||
p_question IN CLOB,
|
||||
p_answer_sql IN CLOB,
|
||||
p_answer IN CLOB DEFAULT NULL
|
||||
) RETURN NUMBER
|
||||
AUTHID DEFINER
|
||||
IS
|
||||
PRAGMA AUTONOMOUS_TRANSACTION;
|
||||
v_input CLOB;
|
||||
v_embedding VECTOR;
|
||||
v_example_id NUMBER;
|
||||
BEGIN
|
||||
IF p_question IS NULL OR p_answer_sql IS NULL THEN
|
||||
RAISE_APPLICATION_ERROR(-20002, 'question and answer_sql are required.');
|
||||
END IF;
|
||||
|
||||
v_input := TO_CLOB('Question: ') || p_question
|
||||
|| TO_CLOB(CHR(10) || 'Answer SQL: ') || p_answer_sql
|
||||
|| CASE WHEN p_answer IS NULL THEN NULL ELSE TO_CLOB(CHR(10) || 'Answer: ') || p_answer END;
|
||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
v_input,
|
||||
JSON(sg_qa_vector_params('search_document'))
|
||||
);
|
||||
|
||||
INSERT INTO sg_qa_vector_example (
|
||||
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
|
||||
reference_status, reference_kind, target_type, inspection_status
|
||||
) VALUES (
|
||||
p_question, p_answer_sql, p_answer, v_input, v_embedding, 'cohere.embed-v4.0',
|
||||
'DRAFT', 'SQL_TEMPLATE', 'ANY', 'PENDING'
|
||||
) RETURNING example_id INTO v_example_id;
|
||||
|
||||
COMMIT;
|
||||
RETURN v_example_id;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
ROLLBACK;
|
||||
RAISE;
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
|
||||
p_question IN CLOB,
|
||||
p_top_k IN PLS_INTEGER DEFAULT 3,
|
||||
p_target_type IN VARCHAR2 DEFAULT 'ANY'
|
||||
) RETURN SYS_REFCURSOR
|
||||
AUTHID DEFINER
|
||||
IS
|
||||
v_query_vector VECTOR;
|
||||
v_results SYS_REFCURSOR;
|
||||
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
|
||||
BEGIN
|
||||
IF p_question IS NULL THEN
|
||||
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
|
||||
END IF;
|
||||
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
|
||||
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
|
||||
END IF;
|
||||
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
|
||||
RAISE_APPLICATION_ERROR(-20005, 'target_type must be NONE, SINGLE, MULTI, ALL, or ANY.');
|
||||
END IF;
|
||||
|
||||
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
p_question,
|
||||
JSON(sg_qa_vector_params('search_query'))
|
||||
);
|
||||
|
||||
OPEN v_results FOR
|
||||
SELECT example_id,
|
||||
question,
|
||||
answer_sql,
|
||||
answer_text,
|
||||
embedding_model,
|
||||
reference_kind,
|
||||
target_type,
|
||||
object_role,
|
||||
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
|
||||
FROM sg_qa_vector_example
|
||||
WHERE reference_status = 'APPROVED'
|
||||
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
|
||||
ORDER BY vector_distance(embedding, v_query_vector, COSINE), example_id
|
||||
FETCH FIRST p_top_k ROWS ONLY;
|
||||
RETURN v_results;
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE FUNCTION sg_qa_vector_context(
|
||||
p_question IN CLOB,
|
||||
p_top_k IN PLS_INTEGER DEFAULT 3,
|
||||
p_target_type IN VARCHAR2 DEFAULT 'ANY'
|
||||
) RETURN CLOB
|
||||
AUTHID DEFINER
|
||||
IS
|
||||
v_results SYS_REFCURSOR;
|
||||
v_id NUMBER;
|
||||
v_q CLOB;
|
||||
v_sql CLOB;
|
||||
v_answer CLOB;
|
||||
v_model VARCHAR2(128);
|
||||
v_kind VARCHAR2(32);
|
||||
v_target VARCHAR2(16);
|
||||
v_role VARCHAR2(64);
|
||||
v_dist NUMBER;
|
||||
v_context CLOB := EMPTY_CLOB();
|
||||
BEGIN
|
||||
v_results := sg_qa_vector_search(p_question, p_top_k, p_target_type);
|
||||
LOOP
|
||||
FETCH v_results INTO v_id, v_q, v_sql, v_answer, v_model, v_kind, v_target, v_role, v_dist;
|
||||
EXIT WHEN v_results%NOTFOUND;
|
||||
v_context := v_context
|
||||
|| CASE WHEN DBMS_LOB.GETLENGTH(v_context) = 0 THEN NULL ELSE CHR(10) || CHR(10) END
|
||||
|| '[Example ' || v_id || ', kind=' || v_kind || ', target_type=' || v_target
|
||||
|| ', cosine_distance=' || TO_CHAR(v_dist, 'FM0D000000') || ']' || CHR(10)
|
||||
|| 'Question: ' || v_q || CHR(10)
|
||||
|| 'Answer SQL: ' || v_sql
|
||||
|| CASE WHEN v_answer IS NULL THEN NULL ELSE CHR(10) || 'Answer: ' || v_answer END;
|
||||
END LOOP;
|
||||
CLOSE v_results;
|
||||
RETURN v_context;
|
||||
END;
|
||||
/
|
||||
|
||||
COMMENT ON COLUMN sg_qa_vector_example.reference_status IS
|
||||
'Few-shot retrieval lifecycle: DRAFT, APPROVED, or RETIRED. Only APPROVED is retrievable.';
|
||||
COMMENT ON COLUMN sg_qa_vector_example.reference_kind IS
|
||||
'Few-shot semantic kind: SQL_TEMPLATE, NO_TARGET, OBJECT_UNAVAILABLE, or METADATA_POLICY.';
|
||||
COMMENT ON COLUMN sg_qa_vector_example.target_type IS
|
||||
'Applicable game query-plan target type: NONE, SINGLE, MULTI, ALL, or ANY.';
|
||||
COMMENT ON COLUMN sg_qa_vector_example.object_role IS
|
||||
'Logical object role; physical object names must be sourced from the current game query plan.';
|
||||
304
sql/adb/83_sgmp_qa_benchmark_fewshot_candidates.sql
Normal file
304
sql/adb/83_sgmp_qa_benchmark_fewshot_candidates.sql
Normal file
@@ -0,0 +1,304 @@
|
||||
-- Import the 47 customer QA benchmark rows as governed Few-shot candidates.
|
||||
-- This migration deliberately creates DRAFT records only. Retrieval continues
|
||||
-- to use APPROVED rows only (see 82_sgmp_qa_fewshot_reference_governance.sql).
|
||||
|
||||
DECLARE
|
||||
PROCEDURE add_column(p_definition IN VARCHAR2) IS
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE sg_qa_vector_example ADD (' || p_definition || ')';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -1430 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
BEGIN
|
||||
add_column('source_case_id VARCHAR2(30)');
|
||||
add_column('source_type VARCHAR2(30)');
|
||||
END;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE
|
||||
'CREATE UNIQUE INDEX sg_qa_vector_example_source_uk '
|
||||
|| 'ON sg_qa_vector_example (source_type, source_case_id)';
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF SQLCODE != -955 THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE FUNCTION sg_qa_vector_seed_benchmark
|
||||
RETURN NUMBER
|
||||
AUTHID DEFINER
|
||||
IS
|
||||
PRAGMA AUTONOMOUS_TRANSACTION;
|
||||
v_input CLOB;
|
||||
v_embedding VECTOR;
|
||||
v_answer_sql CLOB;
|
||||
v_answer_text CLOB;
|
||||
v_exists NUMBER;
|
||||
v_inserted NUMBER := 0;
|
||||
BEGIN
|
||||
FOR item IN (
|
||||
SELECT question_code,
|
||||
question_text,
|
||||
baseline_sql,
|
||||
baseline_answer,
|
||||
expected_focus,
|
||||
support_level
|
||||
FROM sg_ai_qa_question
|
||||
WHERE question_source = 'CUSTOMER_EXCEL'
|
||||
AND active_yn = 'Y'
|
||||
ORDER BY question_code
|
||||
) LOOP
|
||||
SELECT CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM sg_qa_vector_example existing
|
||||
WHERE existing.source_type = 'CUSTOMER_QA_BENCHMARK'
|
||||
AND existing.source_case_id = item.question_code
|
||||
) THEN 1 ELSE 0
|
||||
END
|
||||
INTO v_exists
|
||||
FROM dual;
|
||||
|
||||
IF v_exists = 0 THEN
|
||||
v_answer_sql := CASE
|
||||
WHEN item.baseline_sql IS NULL OR DBMS_LOB.GETLENGTH(TRIM(item.baseline_sql)) = 0
|
||||
THEN TO_CLOB('SELECT CAST(NULL AS NUMBER) AS "NO_BASELINE" FROM DUAL WHERE 1 = 0')
|
||||
ELSE item.baseline_sql
|
||||
END;
|
||||
v_answer_text := TO_CLOB('Expected focus: ') || item.expected_focus
|
||||
|| CASE WHEN item.baseline_answer IS NULL THEN NULL
|
||||
ELSE TO_CLOB(CHR(10) || 'Historical answer: ') || item.baseline_answer END;
|
||||
v_input := TO_CLOB('Customer QA case: ') || item.question_code
|
||||
|| TO_CLOB(CHR(10) || 'Question: ') || item.question_text
|
||||
|| TO_CLOB(CHR(10) || 'Expected focus: ') || item.expected_focus
|
||||
|| TO_CLOB(CHR(10) || 'Candidate SQL: ') || v_answer_sql;
|
||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
v_input,
|
||||
JSON(sg_qa_vector_params('search_document'))
|
||||
);
|
||||
|
||||
BEGIN
|
||||
INSERT INTO sg_qa_vector_example (
|
||||
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
|
||||
reference_status, reference_kind, target_type, inspection_status,
|
||||
inspection_note, source_case_id, source_type
|
||||
) VALUES (
|
||||
item.question_text, v_answer_sql, v_answer_text, v_input, v_embedding, 'cohere.embed-v4.0',
|
||||
'DRAFT',
|
||||
CASE WHEN item.support_level = 'UNSUPPORTED' THEN 'OBJECT_UNAVAILABLE'
|
||||
ELSE 'SQL_TEMPLATE' END,
|
||||
'ANY',
|
||||
'PENDING',
|
||||
'Imported from the customer benchmark. A candidate cannot be retrieved until scope, logical object role, and SQL safety are reviewed.',
|
||||
item.question_code,
|
||||
'CUSTOMER_QA_BENCHMARK'
|
||||
);
|
||||
v_inserted := v_inserted + 1;
|
||||
EXCEPTION
|
||||
WHEN DUP_VAL_ON_INDEX THEN
|
||||
NULL;
|
||||
END;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
COMMIT;
|
||||
RETURN v_inserted;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
ROLLBACK;
|
||||
RAISE;
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE FUNCTION sg_qa_vector_approve_verified_benchmark
|
||||
RETURN NUMBER
|
||||
AUTHID DEFINER
|
||||
IS
|
||||
PRAGMA AUTONOMOUS_TRANSACTION;
|
||||
v_target_type VARCHAR2(16);
|
||||
v_input CLOB;
|
||||
v_answer_text CLOB;
|
||||
v_embedding VECTOR;
|
||||
v_approved NUMBER := 0;
|
||||
BEGIN
|
||||
FOR item IN (
|
||||
WITH latest_answer AS (
|
||||
SELECT answer.question_id,
|
||||
answer.generated_sql,
|
||||
answer.answer_text,
|
||||
answer.result_json,
|
||||
answer.judgment_status,
|
||||
answer.execution_status,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY answer.question_id ORDER BY answer.answer_seq DESC
|
||||
) AS row_rank
|
||||
FROM sg_ai_qa_answer answer
|
||||
)
|
||||
SELECT example.example_id,
|
||||
question.question_text,
|
||||
question.expected_focus,
|
||||
question.support_level,
|
||||
latest.generated_sql,
|
||||
latest.answer_text AS live_answer,
|
||||
latest.result_json,
|
||||
latest.judgment_status,
|
||||
latest.execution_status
|
||||
FROM sg_qa_vector_example example
|
||||
JOIN sg_ai_qa_question question
|
||||
ON question.question_code = example.source_case_id
|
||||
LEFT JOIN latest_answer latest
|
||||
ON latest.question_id = question.question_id
|
||||
AND latest.row_rank = 1
|
||||
WHERE example.source_type = 'CUSTOMER_QA_BENCHMARK'
|
||||
AND example.reference_status = 'DRAFT'
|
||||
) LOOP
|
||||
IF item.support_level = 'SUPPORTED'
|
||||
AND item.judgment_status = 'PASS'
|
||||
AND item.execution_status = 'COMPLETED'
|
||||
AND item.generated_sql IS NOT NULL
|
||||
AND DBMS_LOB.GETLENGTH(TRIM(item.generated_sql)) > 0 THEN
|
||||
v_target_type := REGEXP_SUBSTR(
|
||||
DBMS_LOB.SUBSTR(item.result_json, 32767, 1),
|
||||
'"targetType"[[:space:]]*:[[:space:]]*"([A-Z]+)"',
|
||||
1, 1, NULL, 1
|
||||
);
|
||||
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
|
||||
v_target_type := 'ANY';
|
||||
END IF;
|
||||
v_answer_text := TO_CLOB('Expected focus: ') || item.expected_focus
|
||||
|| CASE WHEN item.live_answer IS NULL THEN NULL
|
||||
ELSE TO_CLOB(CHR(10) || 'Verified live answer: ') || item.live_answer END;
|
||||
v_input := TO_CLOB('Customer QA question: ') || item.question_text
|
||||
|| TO_CLOB(CHR(10) || 'Expected focus: ') || item.expected_focus
|
||||
|| TO_CLOB(CHR(10) || 'Verified SQL template: ') || item.generated_sql;
|
||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
v_input,
|
||||
JSON(sg_qa_vector_params('search_document'))
|
||||
);
|
||||
UPDATE sg_qa_vector_example
|
||||
SET answer_sql = item.generated_sql,
|
||||
answer_text = v_answer_text,
|
||||
embedding_input = v_input,
|
||||
embedding = v_embedding,
|
||||
reference_status = 'APPROVED',
|
||||
reference_kind = 'SQL_TEMPLATE',
|
||||
target_type = v_target_type,
|
||||
inspection_status = 'VERIFIED',
|
||||
inspection_note = 'Promoted only after the latest Portal ReAct run completed with PASS for this customer benchmark case.',
|
||||
verified_at = SYSTIMESTAMP,
|
||||
verified_by = 'SGMP_POC_BENCHMARK_REVIEW'
|
||||
WHERE example_id = item.example_id;
|
||||
v_approved := v_approved + 1;
|
||||
ELSE
|
||||
UPDATE sg_qa_vector_example
|
||||
SET inspection_status = 'REVIEW',
|
||||
inspection_note = 'Not retrievable: latest customer benchmark execution is unsupported, incomplete, WARN, FAIL, or lacks executable SQL.'
|
||||
WHERE example_id = item.example_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
COMMIT;
|
||||
RETURN v_approved;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
ROLLBACK;
|
||||
RAISE;
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE FUNCTION sg_qa_vector_search(
|
||||
p_question IN CLOB,
|
||||
p_top_k IN PLS_INTEGER DEFAULT 3,
|
||||
p_target_type IN VARCHAR2 DEFAULT 'ANY'
|
||||
) RETURN SYS_REFCURSOR
|
||||
AUTHID DEFINER
|
||||
IS
|
||||
v_query_vector VECTOR;
|
||||
v_results SYS_REFCURSOR;
|
||||
v_target_type VARCHAR2(16) := UPPER(TRIM(NVL(p_target_type, 'ANY')));
|
||||
BEGIN
|
||||
IF p_question IS NULL THEN
|
||||
RAISE_APPLICATION_ERROR(-20003, 'question is required.');
|
||||
END IF;
|
||||
IF p_top_k IS NULL OR p_top_k < 1 OR p_top_k > 20 THEN
|
||||
RAISE_APPLICATION_ERROR(-20004, 'top_k must be between 1 and 20.');
|
||||
END IF;
|
||||
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
|
||||
RAISE_APPLICATION_ERROR(-20005, 'target_type must be NONE, SINGLE, MULTI, ALL, or ANY.');
|
||||
END IF;
|
||||
|
||||
v_query_vector := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
p_question,
|
||||
JSON(sg_qa_vector_params('search_query'))
|
||||
);
|
||||
|
||||
OPEN v_results FOR
|
||||
SELECT example_id,
|
||||
question,
|
||||
answer_sql,
|
||||
answer_text,
|
||||
embedding_model,
|
||||
reference_kind,
|
||||
target_type,
|
||||
object_role,
|
||||
source_case_id,
|
||||
source_type,
|
||||
vector_distance(embedding, v_query_vector, COSINE) AS cosine_distance
|
||||
FROM sg_qa_vector_example
|
||||
WHERE reference_status = 'APPROVED'
|
||||
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
|
||||
ORDER BY vector_distance(embedding, v_query_vector, COSINE), example_id
|
||||
FETCH FIRST p_top_k ROWS ONLY;
|
||||
RETURN v_results;
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE FUNCTION sg_qa_vector_context(
|
||||
p_question IN CLOB,
|
||||
p_top_k IN PLS_INTEGER DEFAULT 3,
|
||||
p_target_type IN VARCHAR2 DEFAULT 'ANY'
|
||||
) RETURN CLOB
|
||||
AUTHID DEFINER
|
||||
IS
|
||||
v_results SYS_REFCURSOR;
|
||||
v_id NUMBER;
|
||||
v_q CLOB;
|
||||
v_sql CLOB;
|
||||
v_answer CLOB;
|
||||
v_model VARCHAR2(128);
|
||||
v_kind VARCHAR2(32);
|
||||
v_target VARCHAR2(16);
|
||||
v_role VARCHAR2(64);
|
||||
v_case_id VARCHAR2(30);
|
||||
v_source VARCHAR2(30);
|
||||
v_dist NUMBER;
|
||||
v_context CLOB := EMPTY_CLOB();
|
||||
BEGIN
|
||||
v_results := sg_qa_vector_search(p_question, p_top_k, p_target_type);
|
||||
LOOP
|
||||
FETCH v_results INTO v_id, v_q, v_sql, v_answer, v_model, v_kind, v_target, v_role,
|
||||
v_case_id, v_source, v_dist;
|
||||
EXIT WHEN v_results%NOTFOUND;
|
||||
v_context := v_context
|
||||
|| CASE WHEN DBMS_LOB.GETLENGTH(v_context) = 0 THEN NULL ELSE CHR(10) || CHR(10) END
|
||||
|| '[Example ' || v_id || ', kind=' || v_kind || ', target_type=' || v_target
|
||||
|| CASE WHEN v_case_id IS NULL THEN NULL ELSE ', source_case_id=' || v_case_id END
|
||||
|| ', cosine_distance=' || TO_CHAR(v_dist, 'FM0D000000') || ']' || CHR(10)
|
||||
|| 'Question: ' || v_q || CHR(10)
|
||||
|| 'Answer SQL: ' || v_sql
|
||||
|| CASE WHEN v_answer IS NULL THEN NULL ELSE CHR(10) || 'Answer: ' || v_answer END;
|
||||
END LOOP;
|
||||
CLOSE v_results;
|
||||
RETURN v_context;
|
||||
END;
|
||||
/
|
||||
|
||||
COMMENT ON COLUMN sg_qa_vector_example.source_case_id IS
|
||||
'Customer QA benchmark case identifier, such as STD-01 or CZN-01.';
|
||||
COMMENT ON COLUMN sg_qa_vector_example.source_type IS
|
||||
'Candidate provenance. CUSTOMER_QA_BENCHMARK rows remain DRAFT until reviewed.';
|
||||
31
sql/adb/84_sgmp_none_scope_common_object_guidance.sql
Normal file
31
sql/adb/84_sgmp_none_scope_common_object_guidance.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- NONE means "no selected game", not "no executable query".
|
||||
-- Keep the boundary example scoped to its logical object role so it cannot
|
||||
-- be generalized to approved common-object questions.
|
||||
|
||||
UPDATE sg_qa_vector_example
|
||||
SET object_role = 'GAME_USER_MASTER',
|
||||
answer_text = 'This boundary applies only to a game-scoped user-master operation. '
|
||||
|| 'When no game identifier is resolved, do not select a prefix-specific user-master object. '
|
||||
|| 'This does not prohibit an approved common-object query.',
|
||||
inspection_note = 'Canonical boundary for an unscoped game-user-master request. '
|
||||
|| 'It applies only to GAME_USER_MASTER and must not suppress common-object queries.'
|
||||
WHERE example_id = 5
|
||||
AND reference_kind = 'NO_TARGET';
|
||||
|
||||
-- Populate logical roles from verified SQL. This is prompt metadata only;
|
||||
-- runtime physical-object selection remains governed by the query plan.
|
||||
UPDATE sg_qa_vector_example
|
||||
SET object_role = CASE
|
||||
WHEN REGEXP_LIKE(answer_sql, 'COMN_SALES_TXN', 'i') THEN 'SALES_TRANSACTION'
|
||||
WHEN REGEXP_LIKE(answer_sql, 'COMN_REFUND_TXN', 'i') THEN 'REFUND_TRANSACTION'
|
||||
WHEN REGEXP_LIKE(answer_sql, 'COMN_CHARACTER_MST', 'i') THEN 'GAME_CHARACTER_MASTER'
|
||||
WHEN REGEXP_LIKE(answer_sql, 'COMN_USER_MST', 'i') THEN 'GAME_USER_MASTER'
|
||||
ELSE object_role
|
||||
END
|
||||
WHERE reference_status = 'APPROVED'
|
||||
AND object_role IS NULL;
|
||||
|
||||
COMMENT ON COLUMN sg_qa_vector_example.object_role IS
|
||||
'Logical business object role used to bound Few-shot interpretation. It is not a runtime physical-object selector.';
|
||||
|
||||
COMMIT;
|
||||
39
sql/adb/85_sgmp_no_target_null_value_template.sql
Normal file
39
sql/adb/85_sgmp_no_target_null_value_template.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- A no-target aggregate is a successful empty-value result, not a no-row
|
||||
-- execution failure. Keep the output alias from the verified logical metric.
|
||||
|
||||
DECLARE
|
||||
v_input CLOB;
|
||||
v_embedding VECTOR;
|
||||
BEGIN
|
||||
SELECT TO_CLOB('Question: ') || question
|
||||
|| TO_CLOB(CHR(10) || 'Answer SQL: SELECT CAST(NULL AS NUMBER) AS "USER_COUNT" FROM DUAL')
|
||||
|| TO_CLOB(CHR(10) || 'Answer: This boundary applies only to a game-scoped user-master operation. '
|
||||
|| 'When no game identifier is resolved, do not select a prefix-specific user-master object. '
|
||||
|| 'Return USER_COUNT as NULL. This does not prohibit an approved common-object query.')
|
||||
INTO v_input
|
||||
FROM sg_qa_vector_example
|
||||
WHERE example_id = 5;
|
||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||
v_input,
|
||||
JSON(sg_qa_vector_params('search_document'))
|
||||
);
|
||||
|
||||
UPDATE sg_qa_vector_example
|
||||
SET answer_sql = 'SELECT CAST(NULL AS NUMBER) AS "USER_COUNT" FROM DUAL',
|
||||
answer_text = 'This boundary applies only to a game-scoped user-master operation. '
|
||||
|| 'When no game identifier is resolved, do not select a prefix-specific user-master object. '
|
||||
|| 'Return USER_COUNT as NULL. This does not prohibit an approved common-object query.',
|
||||
embedding_input = v_input,
|
||||
embedding = v_embedding,
|
||||
embedding_model = 'cohere.embed-v4.0',
|
||||
inspection_note = 'Canonical GAME_USER_MASTER boundary: no game target returns a NULL metric value, '
|
||||
|| 'not an execution error and not a default game selection.',
|
||||
verified_at = SYSTIMESTAMP,
|
||||
verified_by = 'SGMP_POC_REVIEW'
|
||||
WHERE example_id = 5
|
||||
AND reference_status = 'APPROVED'
|
||||
AND reference_kind = 'NO_TARGET'
|
||||
AND object_role = 'GAME_USER_MASTER';
|
||||
COMMIT;
|
||||
END;
|
||||
/
|
||||
18
sql/adb/86_sgmp_game_scope_profile_guidance.sql
Normal file
18
sql/adb/86_sgmp_game_scope_profile_guidance.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- General target-contract guidance belongs to the Select AI profile, not application branches.
|
||||
-- It contains no current game, prefix, ID, or physical object name.
|
||||
|
||||
BEGIN
|
||||
DBMS_CLOUD_AI.SET_ATTRIBUTE(
|
||||
profile_name => 'SGMP_POC_OCI_GPT54MINI',
|
||||
attribute_name => 'additional_instructions',
|
||||
attribute_value => q'~Generate Oracle SQL only for the listed approved objects. Do not reference external tables. Use English aliases only. Use database comments and annotations as the source of business rules.
|
||||
|
||||
When an authoritative game query plan is supplied in the user request, use its targets and statuses as the only game-scope source; do not independently rematch a game, infer a default game, or substitute one target's object for another. A plan with no selected game does not by itself prohibit a query: use an approved common object when it can answer the operation. If an operation inherently requires a game-scoped logical object and the relevant plan target is unresolved or its physical object is unavailable, return an appropriate zero-row result. A zero-row result is not a synthetic row containing a NULL value and is not an execution failure. For multiple or all resolved targets, use the supplied target identifiers to group a common object when applicable, or combine only the supplied available objects. When filtering an approved common object by game, derive the allowed game identifiers through the active approved game-alias catalog lookup; do not put a game identifier or prefix directly as a SQL literal predicate. Preserve an alias lookup with no matching data as an empty or aggregate-null result, never as a substituted game. Never invent identifiers, prefixes, or physical object names.~'
|
||||
);
|
||||
END;
|
||||
/
|
||||
|
||||
SELECT attribute_name, attribute_value
|
||||
FROM user_cloud_ai_profile_attributes
|
||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||
AND attribute_name = 'additional_instructions';
|
||||
23
sql/adb/87_sgmp_std09_catalog_fact_boundary.sql
Normal file
23
sql/adb/87_sgmp_std09_catalog_fact_boundary.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Benchmark correction: game-catalog resolution and fact-row availability are distinct.
|
||||
-- Korean text is reconstructed from UTF-8 base64 so SQLcl cannot corrupt it.
|
||||
|
||||
UPDATE sg_ai_qa_question
|
||||
SET expected_focus = utl_i18n.raw_to_char(
|
||||
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
||||
'6rKM7J6EIOuzhOy5rSDsubTtg4jroZzqt7jsl5DshJwg7ZmV7J2465CcIOqyjOyehOydgCDqs7XthrUg66ek7LacIOqwneyytOydmCBHQU1FX0lE66W8IOuzhOy5rSDsubTtg4jroZzqt7ggRVhJU1RTL+yEnOu4jOy/vOumrOuhnCDsl7DqsrDtlZzri6QuIEdBTUVfSUTrgpggcHJlZml466W8IOyngeygkSDrpqzthLDrn7TroZwg6rOg7KCV7ZWY7KeAIOyViuuKlOuLpC4g7Lm07YOI66Gc6re4IOunpOy5reqzvCDtlbTri7kg66ek7LacIOyCrOyLpCDrjbDsnbTthLAg7KG07J6sIOyXrOu2gOuKlCDrs4TqsJzsnbTrqbAsIOunpOy5reuQmOyXiOyngOunjCDtlbTri7kg7J287J6QIOunpOy2nCDtlonsnbQg7JeG7Jy866m0IOynkeqzhCBOVUxMIOuYkOuKlCAw6rG07J2AIOygleyDgSDqsrDqs7zri6QuIOyehOydmOydmCDri6Trpbgg6rKM7J6E7Jy866GcIOuMgOyytO2VmOyngCDslYrripTri6Qu'
|
||||
)),
|
||||
'AL32UTF8'
|
||||
)
|
||||
WHERE question_code = 'STD-09';
|
||||
|
||||
UPDATE sg_qa_vector_example
|
||||
SET inspection_status = 'REVIEW',
|
||||
inspection_note = 'Customer benchmark criterion updated: catalog resolution and common-fact availability are evaluated separately.'
|
||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
||||
AND source_case_id = 'STD-09';
|
||||
|
||||
COMMIT;
|
||||
|
||||
SELECT question_code, expected_focus
|
||||
FROM sg_ai_qa_question
|
||||
WHERE question_code = 'STD-09';
|
||||
@@ -6,7 +6,15 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(BackofficeProperties.class)
|
||||
@EnableConfigurationProperties({
|
||||
BackofficeProperties.class,
|
||||
CatalogProperties.class,
|
||||
GameScopeProperties.class,
|
||||
MaskingProperties.class,
|
||||
McpProperties.class,
|
||||
ProductProperties.class,
|
||||
SecuritySqlScriptProperties.class
|
||||
})
|
||||
public class AppConfig {
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -8,7 +8,8 @@ public record BackofficeProperties(
|
||||
Security security,
|
||||
Token token,
|
||||
Ords ords,
|
||||
Ai ai
|
||||
Ai ai,
|
||||
SelectAi selectAi
|
||||
) {
|
||||
|
||||
public record Security(
|
||||
@@ -72,4 +73,22 @@ public record BackofficeProperties(
|
||||
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();
|
||||
permissionService.findRoles();
|
||||
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; }
|
||||
}
|
||||
@@ -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******** (예시)"),
|
||||
RRN_PARTIAL(
|
||||
"RRN_PARTIAL",
|
||||
"주민등록번호 부분 마스킹",
|
||||
"앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.",
|
||||
"식별번호 부분 마스킹",
|
||||
"앞 6자리만 표시하고 나머지를 가리는 사전 정의 식별번호 규칙입니다.",
|
||||
"DBMS_REDACT.REGEXP",
|
||||
"900101-******* (예시)");
|
||||
"123456-******* (예시)");
|
||||
|
||||
private final String code;
|
||||
private final String label;
|
||||
|
||||
@@ -50,16 +50,16 @@ public record PermissionView(
|
||||
return "토큰으로 식별된 이해관계자 본인 행";
|
||||
}
|
||||
if (upper.contains(" OWN_CONTRACT")) {
|
||||
return "담당 설계사 본인 계약";
|
||||
return "담당 게임 서비스 범위";
|
||||
}
|
||||
if (upper.contains(" CHANNEL_CONTRACT")) {
|
||||
return "토큰 사용자의 채널 계약";
|
||||
return "토큰 채널 게임 서비스 범위";
|
||||
}
|
||||
if (upper.contains(" OWN_CUSTOMER")) {
|
||||
return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유";
|
||||
return "담당 게임 사용자·거래 데이터 범위";
|
||||
}
|
||||
if (upper.contains(" CHANNEL_CUSTOMER")) {
|
||||
return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유";
|
||||
return "토큰 채널 게임 사용자·거래 데이터 범위";
|
||||
}
|
||||
if (upper.contains(" STATIC_SQL ")) {
|
||||
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
|
||||
|
||||
@@ -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(
|
||||
String key,
|
||||
String tableName,
|
||||
String objectType,
|
||||
String businessName,
|
||||
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.MaskingPolicyStatus;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
|
||||
import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
@@ -28,7 +29,10 @@ public interface MaskingRuleMapper {
|
||||
|
||||
List<ColumnMaskingRule> findColumnRules();
|
||||
|
||||
List<MaskingPolicyStatus> findPolicyStatuses();
|
||||
List<MaskingPolicyStatus> findPolicyStatuses(
|
||||
@Param("owner") String owner,
|
||||
@Param("targets") List<MaskingPolicyTarget> targets
|
||||
);
|
||||
|
||||
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으로 반환하는 전체 마스킹 방식"),
|
||||
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
|
||||
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
|
||||
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
|
||||
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
|
||||
new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
|
||||
"앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
|
||||
);
|
||||
|
||||
private static final String MASKING_RULE_SEED_SQL = """
|
||||
@@ -272,10 +272,16 @@ public class BackofficeSchemaService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
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.properties = properties;
|
||||
this.dataCatalog = dataCatalog;
|
||||
}
|
||||
|
||||
public SchemaPreflightView preflight() {
|
||||
@@ -705,7 +711,7 @@ public class BackofficeSchemaService {
|
||||
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
|
||||
@sql/adb/25_agent_ords_security_backoffice_support.sql
|
||||
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
|
||||
@sql/adb/62_kb_aso_masking_backoffice_metadata.sql
|
||||
-- 4. 배포 환경에서 선택한 사용자·권한 초기화 SQL을 별도로 실행
|
||||
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
|
||||
|
||||
-- 2. ORDS parsing schema로 접속
|
||||
@@ -717,10 +723,9 @@ public class BackofficeSchemaService {
|
||||
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
||||
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
|
||||
|
||||
-- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행
|
||||
@sql/adb/64_kb_aso_masking_default_column_rules.sql
|
||||
@sql/adb/63_kb_aso_masking_rule_runtime.sql
|
||||
""".formatted(owner.toLowerCase());
|
||||
-- 5. 마스킹 규칙을 UI에서 등록된 업무 데이터 컬럼에 연결
|
||||
-- DBMS_REDACT 정책은 백오피스가 %s 대상에 자동 동기화합니다.
|
||||
""".formatted(owner.toLowerCase(), dataCatalog.owner());
|
||||
}
|
||||
|
||||
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,144 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Clob;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* MCP database gateway for the ADB-owned game query planning functions.
|
||||
*
|
||||
* <p>Game mention extraction, JSON-alias vector retrieval, and score-based
|
||||
* scope resolution run inside {@code SG_GAME_QUERY_PLAN}. Java validates
|
||||
* authentication and transports the structured JSON result without applying
|
||||
* game-name matching rules.</p>
|
||||
*/
|
||||
@Service
|
||||
public class GameCatalogVectorService {
|
||||
|
||||
private final BackofficeProperties properties;
|
||||
private final BearerTokenService bearerTokenService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public GameCatalogVectorService(
|
||||
BackofficeProperties properties,
|
||||
BearerTokenService bearerTokenService,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.bearerTokenService = bearerTokenService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public ObjectNode queryPlan(
|
||||
String token, String question, int topK
|
||||
) {
|
||||
requireActiveToken(token);
|
||||
BackofficeProperties.SelectAi database = requiredDatabase();
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
database.dbUrl(), database.dbUsername(), database.dbPassword());
|
||||
CallableStatement statement = connection.prepareCall(
|
||||
"{ ? = call sg_game_query_plan(?, ?) }")) {
|
||||
statement.registerOutParameter(1, Types.CLOB);
|
||||
statement.setString(2, question);
|
||||
statement.setInt(3, boundedTopK(topK));
|
||||
statement.execute();
|
||||
Clob value = (Clob) statement.getObject(1);
|
||||
String raw = value == null ? "" :
|
||||
value.getSubString(1, (int) value.length());
|
||||
JsonNode parsed = objectMapper.readTree(raw);
|
||||
if (!(parsed instanceof ObjectNode plan)
|
||||
|| !plan.hasNonNull("status")
|
||||
|| !plan.hasNonNull("targetType")
|
||||
|| !plan.path("targets").isArray()
|
||||
|| !plan.path("supportedGames").isArray()
|
||||
|| !plan.path("unmatchedGames").isArray()) {
|
||||
throw new AppException("ADB 게임 질의 계획 응답 형식이 올바르지 않습니다.");
|
||||
}
|
||||
return plan;
|
||||
} catch (SQLException exception) {
|
||||
throw new AppException(
|
||||
"ADB 게임 질의 계획 실패: " + exception.getMessage());
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AppException(
|
||||
"ADB 게임 질의 계획 JSON 파싱 실패: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<GameCandidate> search(
|
||||
String token, String question, int topK
|
||||
) {
|
||||
requireActiveToken(token);
|
||||
BackofficeProperties.SelectAi database = requiredDatabase();
|
||||
List<GameCandidate> result = new ArrayList<>();
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
database.dbUrl(), database.dbUsername(), database.dbPassword());
|
||||
CallableStatement statement = connection.prepareCall(
|
||||
"{ ? = call sg_game_catalog_search(?, ?) }")) {
|
||||
statement.registerOutParameter(1, Types.REF_CURSOR);
|
||||
statement.setString(2, question);
|
||||
statement.setInt(3, boundedTopK(topK));
|
||||
statement.execute();
|
||||
try (ResultSet rows = (ResultSet) statement.getObject(1)) {
|
||||
while (rows.next()) {
|
||||
result.add(new GameCandidate(
|
||||
rows.getString("GAME_KEY"),
|
||||
rows.getString("GAME_ID"),
|
||||
rows.getString("GAME_PREFIX"),
|
||||
rows.getString("GAME_NM"),
|
||||
rows.getString("GAME_ALIAS_NM"),
|
||||
rows.getString("USER_MASTER_OBJECT_NAME"),
|
||||
rows.getDouble("COSINE_DISTANCE")
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (SQLException exception) {
|
||||
throw new AppException(
|
||||
"게임 카탈로그 벡터 검색 실패: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
static int boundedTopK(int topK) {
|
||||
return Math.min(Math.max(topK, 1), 20);
|
||||
}
|
||||
|
||||
private BackofficeProperties.SelectAi requiredDatabase() {
|
||||
BackofficeProperties.SelectAi database =
|
||||
properties == null ? null : properties.selectAi();
|
||||
if (database == null || !database.configured()) {
|
||||
throw new AppException("게임 카탈로그 DB 설정이 필요합니다.");
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
private void requireActiveToken(String token) {
|
||||
if (token == null || token.isBlank()
|
||||
|| bearerTokenService == null
|
||||
|| bearerTokenService.findByPlainToken(token.trim()) == null) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
public record GameCandidate(
|
||||
String gameKey,
|
||||
String gameId,
|
||||
String gamePrefix,
|
||||
String gameName,
|
||||
String aliases,
|
||||
String userMasterObjectName,
|
||||
double similarity
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.GameScopeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Resolves only DB-declared game scopes before an agent invokes a SQL worker.
|
||||
*
|
||||
* <p>The service never infers a game, table, prefix, or support status. It reads
|
||||
* the configured customer view and returns only aliases physically present in the
|
||||
* question. This makes unavailable games observable when they are retained in
|
||||
* the customer's game master, while keeping product-specific data out of code.</p>
|
||||
*/
|
||||
@Service
|
||||
public class GameScopeService {
|
||||
|
||||
private static final int MAX_QUESTION_LENGTH = 4_000;
|
||||
private static final Pattern VIEW_NAME = Pattern.compile(
|
||||
"^[A-Za-z][A-Za-z0-9_$#]*(?:\\.[A-Za-z][A-Za-z0-9_$#]*)?$");
|
||||
|
||||
private final BackofficeProperties properties;
|
||||
private final GameScopeProperties gameScopeProperties;
|
||||
private final BearerTokenService bearerTokenService;
|
||||
private final Clock clock;
|
||||
|
||||
public GameScopeService(
|
||||
BackofficeProperties properties,
|
||||
GameScopeProperties gameScopeProperties,
|
||||
BearerTokenService bearerTokenService,
|
||||
Clock clock
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.gameScopeProperties = gameScopeProperties;
|
||||
this.bearerTokenService = bearerTokenService;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public GameScopeResult resolve(String bearerToken, String question) {
|
||||
requireActiveToken(bearerToken);
|
||||
String normalizedQuestion = requiredQuestion(question);
|
||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||
String viewName = requiredViewName();
|
||||
String comparableQuestion = comparable(normalizedQuestion);
|
||||
|
||||
List<GameScope> matches = new ArrayList<>();
|
||||
String sql = "SELECT game_key, display_name, game_alias, query_allowed_yn, reason_code, "
|
||||
+ "alias_priority, scope_version FROM " + viewName
|
||||
+ " WHERE profile_name = ?"
|
||||
+ " AND INSTR(?, REGEXP_REPLACE(UPPER(game_alias), '[[:space:][:punct:]]', '')) > 0"
|
||||
+ " ORDER BY alias_priority DESC, LENGTH(game_alias) DESC, game_key";
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, selectAi.profile());
|
||||
statement.setString(2, comparableQuestion);
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
while (rows.next()) {
|
||||
matches.add(new GameScope(
|
||||
rows.getString("GAME_KEY"),
|
||||
rows.getString("DISPLAY_NAME"),
|
||||
rows.getString("GAME_ALIAS"),
|
||||
"Y".equalsIgnoreCase(rows.getString("QUERY_ALLOWED_YN")) ? "SUPPORTED" : "UNSUPPORTED",
|
||||
rows.getString("REASON_CODE"),
|
||||
rows.getInt("ALIAS_PRIORITY"),
|
||||
rows.getString("SCOPE_VERSION")
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("게임 범위 DB 조회 실패: " + exception.getMessage());
|
||||
}
|
||||
|
||||
Map<String, GameScope> uniqueGames = new LinkedHashMap<>();
|
||||
for (GameScope match : matches) {
|
||||
uniqueGames.putIfAbsent(match.gameKey(), match);
|
||||
}
|
||||
List<GameScope> scopes = uniqueGames.values().stream()
|
||||
.sorted(Comparator.comparing(GameScope::aliasPriority).reversed()
|
||||
.thenComparing(GameScope::gameKey))
|
||||
.limit(gameScopeProperties.resolvedMaxScopes())
|
||||
.toList();
|
||||
String status = scopes.isEmpty() ? "NO_MATCH" : "RESOLVED";
|
||||
return new GameScopeResult(normalizedQuestion, status, scopes);
|
||||
}
|
||||
|
||||
public boolean configured() {
|
||||
return gameScopeProperties != null && gameScopeProperties.configured();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a generated SQL references a DB-declared game-prefix
|
||||
* object. Prefixes are read from the configured scope view; no game or table
|
||||
* name is embedded in application code.
|
||||
*/
|
||||
public boolean referencesGameScopedObject(String bearerToken, String sql) {
|
||||
return referencesGameScopedObjectOutsidePrefixes(bearerToken, sql, Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when SQL references a DB-declared game-prefix object whose
|
||||
* prefix is not present in the authoritative query plan.
|
||||
*/
|
||||
public boolean referencesGameScopedObjectOutsidePrefixes(
|
||||
String bearerToken, String sql, Set<String> allowedPrefixes
|
||||
) {
|
||||
requireActiveToken(bearerToken);
|
||||
if (!gameScopeProperties.configured() || sql == null || sql.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||
String viewName = requiredViewName();
|
||||
String prefixSql = "SELECT DISTINCT game_prefix FROM " + viewName
|
||||
+ " WHERE profile_name = ? AND active_yn = 'Y'"
|
||||
+ " AND query_allowed_yn = 'Y' AND game_prefix IS NOT NULL";
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||
PreparedStatement statement = connection.prepareStatement(prefixSql)) {
|
||||
statement.setString(1, selectAi.profile());
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
String normalizedSql = sql.toUpperCase(Locale.ROOT);
|
||||
Set<String> normalizedAllowed = allowedPrefixes == null ? Set.of()
|
||||
: allowedPrefixes.stream()
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.map(value -> value.trim().toUpperCase(Locale.ROOT))
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
while (rows.next()) {
|
||||
String prefix = rows.getString("GAME_PREFIX");
|
||||
if (prefix != null && Pattern.compile(
|
||||
"(?<![A-Z0-9_$#])" + Pattern.quote(prefix.toUpperCase(Locale.ROOT))
|
||||
+ "_[A-Z0-9_$#]+(?![A-Z0-9_$#])").matcher(normalizedSql).find()
|
||||
&& !normalizedAllowed.contains(prefix.toUpperCase(Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("게임 범위 prefix 검증 실패: " + exception.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String requiredViewName() {
|
||||
String value = gameScopeProperties == null ? "" : gameScopeProperties.viewName();
|
||||
if (gameScopeProperties == null || !gameScopeProperties.configured() || !VIEW_NAME.matcher(value.trim()).matches()) {
|
||||
throw new AppException("게임 범위 view 설정이 필요합니다. BACKOFFICE_GAME_SCOPE_VIEW를 확인하세요.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private BackofficeProperties.SelectAi requiredSelectAi() {
|
||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || !selectAi.configured()) {
|
||||
throw new AppException("게임 범위 DB 연결 설정이 필요합니다.");
|
||||
}
|
||||
return selectAi;
|
||||
}
|
||||
|
||||
private void requireActiveToken(String bearerToken) {
|
||||
if (bearerToken == null || bearerToken.isBlank()) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||
if (token == null || !token.active(now)) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
private String requiredQuestion(String question) {
|
||||
String value = question == null ? "" : question.trim();
|
||||
if (value.isEmpty()) {
|
||||
throw new AppException("question은 필수입니다.");
|
||||
}
|
||||
if (value.length() > MAX_QUESTION_LENGTH) {
|
||||
throw new AppException("question은 " + MAX_QUESTION_LENGTH + "자 이하여야 합니다.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String comparable(String value) {
|
||||
return value.toUpperCase(Locale.ROOT).replaceAll("[\\s\\p{Punct}]", "");
|
||||
}
|
||||
|
||||
public record GameScope(
|
||||
String gameKey,
|
||||
String displayName,
|
||||
String matchedAlias,
|
||||
String status,
|
||||
String reasonCode,
|
||||
int aliasPriority,
|
||||
String scopeVersion
|
||||
) {}
|
||||
|
||||
public record GameScopeResult(String question, String status, List<GameScope> scopes) {}
|
||||
}
|
||||
@@ -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.mapper.MaskingRuleMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -24,37 +23,31 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
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 Map<String, String> MANAGED_POLICIES = managedPolicyMap();
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
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.mapper = mapper;
|
||||
}
|
||||
|
||||
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);
|
||||
this.dataCatalog = dataCatalog;
|
||||
this.policyCatalog = policyCatalog;
|
||||
}
|
||||
|
||||
public Set<String> managedObjectNames() {
|
||||
return MANAGED_POLICIES.keySet();
|
||||
return policyCatalog.objectNames();
|
||||
}
|
||||
|
||||
public boolean isManagedObject(String objectName) {
|
||||
return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
static String managedPolicyName(String objectName) {
|
||||
return MANAGED_POLICIES.get(objectName);
|
||||
return policyCatalog.containsObject(objectName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,9 +60,9 @@ public class MaskingPolicySynchronizer {
|
||||
public MaskingPolicySyncResult synchronize() {
|
||||
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
||||
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
||||
if (OWNER.equalsIgnoreCase(rule.owner())
|
||||
if (dataCatalog.owner().equalsIgnoreCase(rule.owner())
|
||||
&& rule.ruleEnabled()
|
||||
&& MANAGED_POLICIES.containsKey(rule.objectName())) {
|
||||
&& policyCatalog.containsObject(rule.objectName())) {
|
||||
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
||||
}
|
||||
}
|
||||
@@ -79,9 +72,9 @@ public class MaskingPolicySynchronizer {
|
||||
int addedColumns = 0;
|
||||
int modifiedColumns = 0;
|
||||
int droppedColumns = 0;
|
||||
for (Map.Entry<String, String> policy : MANAGED_POLICIES.entrySet()) {
|
||||
String objectName = policy.getKey();
|
||||
String policyName = policy.getValue();
|
||||
for (MaskingPolicyTarget policy : policyCatalog.targets()) {
|
||||
String objectName = policy.objectName();
|
||||
String policyName = policy.policyName();
|
||||
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
|
||||
String enableStatus = policyEnableStatus(objectName, policyName);
|
||||
if (desired.isEmpty()) {
|
||||
@@ -141,7 +134,7 @@ public class MaskingPolicySynchronizer {
|
||||
SELECT enable
|
||||
FROM redaction_policies
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -150,7 +143,7 @@ public class MaskingPolicySynchronizer {
|
||||
SELECT column_name
|
||||
FROM redaction_columns
|
||||
WHERE object_owner = ? AND object_name = ?
|
||||
""", String.class, OWNER, objectName).stream()
|
||||
""", String.class, dataCatalog.owner(), objectName).stream()
|
||||
.map(this::requiredColumnName)
|
||||
.toList();
|
||||
}
|
||||
@@ -160,7 +153,7 @@ public class MaskingPolicySynchronizer {
|
||||
BEGIN
|
||||
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||
END;
|
||||
""", OWNER, objectName, policyName);
|
||||
""", dataCatalog.owner(), objectName, policyName);
|
||||
}
|
||||
|
||||
private void enablePolicy(String objectName, String policyName) {
|
||||
@@ -168,7 +161,7 @@ public class MaskingPolicySynchronizer {
|
||||
BEGIN
|
||||
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||
END;
|
||||
""", OWNER, objectName, policyName);
|
||||
""", dataCatalog.owner(), objectName, policyName);
|
||||
}
|
||||
|
||||
private void dropColumn(String objectName, String policyName, String columnName) {
|
||||
@@ -179,7 +172,7 @@ public class MaskingPolicySynchronizer {
|
||||
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
||||
);
|
||||
END;
|
||||
""", OWNER, objectName, policyName, columnName);
|
||||
""", dataCatalog.owner(), objectName, policyName, columnName);
|
||||
}
|
||||
|
||||
private void addPolicy(
|
||||
@@ -251,9 +244,9 @@ public class MaskingPolicySynchronizer {
|
||||
END;
|
||||
""".formatted(functionConstant);
|
||||
if (regexPattern == null) {
|
||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||
} else {
|
||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -276,9 +269,9 @@ public class MaskingPolicySynchronizer {
|
||||
END;
|
||||
""".formatted(actionConstant, functionConstant);
|
||||
if (regexPattern == null) {
|
||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||
} 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 => ?
|
||||
);
|
||||
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 AuditService auditService;
|
||||
private final MaskingPolicySynchronizer maskingPolicySynchronizer;
|
||||
private final DataCatalog dataCatalog;
|
||||
private final MaskingPolicyCatalog maskingPolicyCatalog;
|
||||
|
||||
public MaskingRuleService(
|
||||
MaskingRuleMapper mapper,
|
||||
UserMapper userMapper,
|
||||
ProtectedObjectService protectedObjectService,
|
||||
AuditService auditService,
|
||||
MaskingPolicySynchronizer maskingPolicySynchronizer
|
||||
MaskingPolicySynchronizer maskingPolicySynchronizer,
|
||||
DataCatalog dataCatalog,
|
||||
MaskingPolicyCatalog maskingPolicyCatalog
|
||||
) {
|
||||
this.mapper = mapper;
|
||||
this.userMapper = userMapper;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
this.auditService = auditService;
|
||||
this.maskingPolicySynchronizer = maskingPolicySynchronizer;
|
||||
this.dataCatalog = dataCatalog;
|
||||
this.maskingPolicyCatalog = maskingPolicyCatalog;
|
||||
}
|
||||
|
||||
public List<MaskingRule> findAllRules() {
|
||||
@@ -55,9 +61,12 @@ public class MaskingRuleService {
|
||||
return mapper.findColumnRules();
|
||||
}
|
||||
|
||||
/** Reads the actual Oracle Data Redaction state for the three managed KB objects. */
|
||||
/** Reads the actual Oracle Data Redaction state for configured managed objects. */
|
||||
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() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -8,31 +10,38 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */
|
||||
/** MCP boundary exposing Select AI query and read-only prompt diagnostic tools. */
|
||||
@Service
|
||||
public class McpSseService {
|
||||
|
||||
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd";
|
||||
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
|
||||
private static final String SELECT_AI_VPD_QUERY_PROFILE =
|
||||
"KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
|
||||
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
|
||||
SELECT_AI_VPD_QUERY_TOOL,
|
||||
"GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.",
|
||||
-1L,
|
||||
"KB Select AI 행 접근 자연어 조회",
|
||||
SELECT_AI_VPD_QUERY_PATH
|
||||
);
|
||||
private static final String SELECT_AI_TOOL_PATH = "/mcp (tools/call)";
|
||||
private static final String GAME_CATALOG_TOOL = "oracle.select_ai.game_catalog_resolve";
|
||||
private static final String GAME_QUERY_PLAN_TOOL = "oracle.select_ai.game_query_plan";
|
||||
|
||||
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
|
||||
private final SelectAiService selectAiService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final BackofficeProperties properties;
|
||||
private final McpProperties mcpProperties;
|
||||
private final QaVectorService qaVectorService;
|
||||
private final GameScopeService gameScopeService;
|
||||
private final GameCatalogVectorService gameCatalogVectorService;
|
||||
|
||||
public McpSseService(
|
||||
SelectAiAgentOrdsService selectAiAgentOrdsService,
|
||||
ObjectMapper objectMapper
|
||||
SelectAiService selectAiService,
|
||||
ObjectMapper objectMapper,
|
||||
BackofficeProperties properties,
|
||||
McpProperties mcpProperties,
|
||||
QaVectorService qaVectorService,
|
||||
GameScopeService gameScopeService,
|
||||
GameCatalogVectorService gameCatalogVectorService
|
||||
) {
|
||||
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
|
||||
this.selectAiService = selectAiService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
this.mcpProperties = mcpProperties;
|
||||
this.qaVectorService = qaVectorService;
|
||||
this.gameScopeService = gameScopeService;
|
||||
this.gameCatalogVectorService = gameCatalogVectorService;
|
||||
}
|
||||
|
||||
public ObjectNode handle(String contextPath, JsonNode request) {
|
||||
@@ -69,9 +78,11 @@ public class McpSseService {
|
||||
return response;
|
||||
}
|
||||
|
||||
/** The only tool registered by this MCP server. */
|
||||
/** Tools registered by this MCP server. */
|
||||
public List<McpToolView> registeredTools() {
|
||||
return List.of(SELECT_AI_VPD_QUERY_VIEW);
|
||||
return List.of(
|
||||
selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(),
|
||||
qaVectorStoreView(), fewShotNl2SqlView(), gameScopeView(), gameCatalogView(), gameQueryPlanView());
|
||||
}
|
||||
|
||||
private ObjectNode initializeResult(String contextPath) {
|
||||
@@ -90,46 +101,85 @@ public class McpSseService {
|
||||
private ObjectNode toolsListResult() {
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ArrayNode tools = objectMapper.createArrayNode();
|
||||
tools.add(selectAiVpdQueryTool());
|
||||
tools.add(toolDefinition(selectAiQueryView()));
|
||||
tools.add(toolDefinition(selectAiShowpromptView()));
|
||||
tools.add(toolDefinition(qaVectorSearchView()));
|
||||
tools.add(toolDefinition(qaVectorStoreView()));
|
||||
tools.add(toolDefinition(fewShotNl2SqlView()));
|
||||
tools.add(toolDefinition(gameScopeView()));
|
||||
tools.add(toolDefinition(gameCatalogView()));
|
||||
tools.add(toolDefinition(gameQueryPlanView()));
|
||||
result.set("tools", tools);
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode selectAiVpdQueryTool() {
|
||||
private ObjectNode toolDefinition(McpToolView toolView) {
|
||||
ObjectNode item = objectMapper.createObjectNode();
|
||||
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
|
||||
item.put("description", SELECT_AI_VPD_QUERY_VIEW.description());
|
||||
item.put("name", toolView.name());
|
||||
item.put("description", toolView.description());
|
||||
|
||||
ObjectNode schema = objectMapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
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();
|
||||
required.add("prompt");
|
||||
if (qaVectorSearchToolName().equals(toolView.name()) || gameScopeToolName().equals(toolView.name())
|
||||
|| GAME_CATALOG_TOOL.equals(toolView.name()) || GAME_QUERY_PLAN_TOOL.equals(toolView.name())) {
|
||||
addStringProperty(properties, "question", "few-shot 예제 SQL을 찾을 현재 질문입니다.", 4000);
|
||||
ObjectNode topK = properties.putObject("topK");
|
||||
topK.put("type", "integer");
|
||||
topK.put("description", "반환할 유사 예제 수입니다. 기본값은 3입니다.");
|
||||
topK.put("minimum", 1);
|
||||
topK.put("maximum", 20);
|
||||
topK.put("default", 3);
|
||||
required.add("question");
|
||||
} else if (qaVectorStoreToolName().equals(toolView.name())) {
|
||||
addStringProperty(properties, "question", "검토된 Select AI 예제가 답한 업무 질문입니다.", 4000);
|
||||
addStringProperty(properties, "answerSql", "검토된 단일 읽기 전용 SELECT/WITH SQL입니다.", 20000);
|
||||
addStringProperty(properties, "answer", "선택 사항인 답변 또는 검토 메모입니다.", 20000);
|
||||
required.add("question");
|
||||
required.add("answerSql");
|
||||
} else {
|
||||
addStringProperty(properties, "prompt", promptDescription(), 4000);
|
||||
if (fewShotNl2SqlToolName().equals(toolView.name())) {
|
||||
addStringProperty(properties, "scopeGameKey",
|
||||
"하위 호환용 선택 값입니다. 새 흐름에서는 queryPlan 전체를 전달하고 비워 둡니다.", 128);
|
||||
ObjectNode plan = properties.putObject("queryPlan");
|
||||
plan.put("type", "object");
|
||||
plan.put("description",
|
||||
"바로 앞 game_query_plan의 전체 결과입니다. targetType이 NONE, SINGLE, MULTI, ALL 중 "
|
||||
+ "어느 값이어도 원 질문과 함께 그대로 전달합니다.");
|
||||
plan.put("additionalProperties", true);
|
||||
}
|
||||
required.add("prompt");
|
||||
}
|
||||
schema.set("properties", properties);
|
||||
schema.set("required", required);
|
||||
schema.put("additionalProperties", false);
|
||||
item.set("inputSchema", schema);
|
||||
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) {
|
||||
String toolName = params.path("name").asText("");
|
||||
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
|
||||
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
|
||||
String calledToolName = params.path("name").asText("");
|
||||
boolean queryTool = toolName().equals(calledToolName);
|
||||
boolean showpromptTool = showpromptToolName().equals(calledToolName);
|
||||
boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName);
|
||||
boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName);
|
||||
boolean fewShotNl2SqlTool = fewShotNl2SqlToolName().equals(calledToolName);
|
||||
boolean gameScopeTool = gameScopeToolName().equals(calledToolName);
|
||||
boolean gameCatalogTool = GAME_CATALOG_TOOL.equals(calledToolName);
|
||||
boolean gameQueryPlanTool = GAME_QUERY_PLAN_TOOL.equals(calledToolName);
|
||||
if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool && !fewShotNl2SqlTool
|
||||
&& !gameScopeTool && !gameCatalogTool && !gameQueryPlanTool) {
|
||||
throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName);
|
||||
}
|
||||
|
||||
JsonNode arguments = params.path("arguments");
|
||||
@@ -139,19 +189,43 @@ public class McpSseService {
|
||||
}
|
||||
JsonNode response;
|
||||
try {
|
||||
response = selectAiAgentOrdsService.run(
|
||||
token,
|
||||
arguments.path("prompt").asText(""),
|
||||
normalizeLimit(arguments.path("limit").asInt(50))
|
||||
);
|
||||
if (qaVectorSearchTool) {
|
||||
response = qaVectorSearchResponse(
|
||||
qaVectorService.search(token, arguments.path("question").asText(""), arguments.path("topK").asInt(3)));
|
||||
} 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 if (gameCatalogTool || gameQueryPlanTool) {
|
||||
response = gameCatalogVectorService.queryPlan(
|
||||
token,
|
||||
arguments.path("question").asText(""),
|
||||
arguments.path("topK").asInt(5)
|
||||
);
|
||||
} else {
|
||||
String prompt = arguments.path("prompt").asText("");
|
||||
response = queryTool
|
||||
? selectAiService.generateAndExecute(token, prompt)
|
||||
: fewShotNl2SqlTool
|
||||
? selectAiService.generateAndExecute(token, prompt,
|
||||
arguments.path("scopeGameKey").asText(""),
|
||||
arguments.path("queryPlan"))
|
||||
: selectAiService.generatePrompt(token, prompt);
|
||||
}
|
||||
} catch (VpdTokenAccessDeniedException ignored) {
|
||||
return tokenAccessDeniedResult();
|
||||
}
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
|
||||
payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE);
|
||||
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
|
||||
payload.put("toolName", calledToolName);
|
||||
payload.put("profile", selectAiProfile());
|
||||
payload.put("ordsPath", SELECT_AI_TOOL_PATH);
|
||||
payload.set("response", response);
|
||||
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
@@ -165,6 +239,72 @@ public class McpSseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode qaVectorSearchResponse(QaVectorService.VectorSearchResult result) {
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("status", "QA_VECTOR_SEARCH");
|
||||
response.put("instruction", "Select AI SQL 생성 전에 few-shot 예제 SQL 후보를 확인합니다.");
|
||||
response.put("question", result.question());
|
||||
response.put("topK", result.topK());
|
||||
ArrayNode examples = response.putArray("examples");
|
||||
for (QaVectorService.VectorExample example : result.examples()) {
|
||||
ObjectNode item = examples.addObject();
|
||||
item.put("exampleId", example.exampleId());
|
||||
item.put("question", example.question());
|
||||
item.put("answerSql", example.answerSql());
|
||||
if (example.answer() != null) {
|
||||
item.put("answer", example.answer());
|
||||
}
|
||||
item.put("embeddingModel", example.embeddingModel());
|
||||
item.put("referenceKind", example.referenceKind());
|
||||
item.put("targetType", example.targetType());
|
||||
if (example.objectRole() != null) {
|
||||
item.put("objectRole", example.objectRole());
|
||||
}
|
||||
if (example.sourceCaseId() != null) {
|
||||
item.put("sourceCaseId", example.sourceCaseId());
|
||||
}
|
||||
if (example.sourceType() != null) {
|
||||
item.put("sourceType", example.sourceType());
|
||||
}
|
||||
item.put("cosineDistance", example.cosineDistance());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) {
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("status", "QA_VECTOR_STORED");
|
||||
response.put("instruction", "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장했습니다.");
|
||||
response.put("exampleId", stored.exampleId());
|
||||
response.put("question", stored.question());
|
||||
response.put("embeddingModel", stored.embeddingModel());
|
||||
response.put("referenceStatus", stored.referenceStatus());
|
||||
response.put("nextStep", "DRAFT 예제입니다. 실행·정책 검토 후 APPROVED로 전환해야 Few-shot 검색에 사용됩니다.");
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Converts DB-derived scope facts to a tool response; this tool never executes query SQL. */
|
||||
private ObjectNode gameScopeResponse(GameScopeService.GameScopeResult result) {
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("status", result.status());
|
||||
response.put("instruction", "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.");
|
||||
response.put("question", result.question());
|
||||
ArrayNode scopes = response.putArray("scopes");
|
||||
for (GameScopeService.GameScope scope : result.scopes()) {
|
||||
ObjectNode item = scopes.addObject();
|
||||
item.put("gameKey", scope.gameKey());
|
||||
item.put("displayName", scope.displayName());
|
||||
item.put("matchedAlias", scope.matchedAlias());
|
||||
item.put("status", scope.status());
|
||||
item.put("reasonCode", scope.reasonCode());
|
||||
item.put("aliasPriority", scope.aliasPriority());
|
||||
item.put("scopeVersion", scope.scopeVersion());
|
||||
item.put("nextAction", "SUPPORTED".equals(scope.status())
|
||||
? "CALL_FEW_SHOT_NL2SQL" : "REPORT_UNSUPPORTED");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private ObjectNode tokenAccessDeniedResult() {
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("status", "VPD_TOKEN_DENIED");
|
||||
@@ -181,11 +321,178 @@ public class McpSseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private int normalizeLimit(int limit) {
|
||||
if (limit < 1) {
|
||||
return 50;
|
||||
private McpToolView selectAiQueryView() {
|
||||
String profile = selectAiProfile();
|
||||
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()
|
||||
+ " game_query_plan의 targetType이 NONE, SINGLE, MULTI, ALL 중 어느 값이어도 "
|
||||
+ "원 질문과 전체 queryPlan을 한 번 받아 단일 읽기 전용 SQL을 생성·실행합니다. "
|
||||
+ "queryPlan에 없는 게임, prefix, 물리 객체를 추측하지 않습니다.",
|
||||
-1L,
|
||||
fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH);
|
||||
}
|
||||
|
||||
private McpToolView gameScopeView() {
|
||||
return new McpToolView(
|
||||
gameScopeToolName(), gameScopeToolDescription(), -1L,
|
||||
gameScopeToolLabel(), SELECT_AI_TOOL_PATH);
|
||||
}
|
||||
|
||||
private McpToolView gameCatalogView() {
|
||||
return new McpToolView(
|
||||
GAME_CATALOG_TOOL,
|
||||
"게임 카탈로그에서 질문의 게임 범위를 확인합니다. SQL은 실행하지 않습니다.",
|
||||
-1L, "게임 카탈로그 범위 확인", SELECT_AI_TOOL_PATH);
|
||||
}
|
||||
|
||||
private McpToolView gameQueryPlanView() {
|
||||
return new McpToolView(GAME_QUERY_PLAN_TOOL,
|
||||
"항상 먼저 호출해 질문의 게임 대상을 NONE, SINGLE, MULTI, ALL로 판정합니다. "
|
||||
+ "targets에는 DB 카탈로그의 게임 식별자와 승인된 사용자 마스터 물리 객체명이 포함됩니다. "
|
||||
+ "어떤 targetType도 종료 조건이 아닙니다. 원 질문과 이 도구의 전체 결과를 "
|
||||
+ "smilegate_fewshot_nl2sql의 prompt와 queryPlan에 한 번 전달하세요. SQL은 실행하지 않습니다.",
|
||||
-1L, "게임 질의 계획", SELECT_AI_TOOL_PATH);
|
||||
}
|
||||
|
||||
private String selectAiProfile() {
|
||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return 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) {
|
||||
|
||||
@@ -643,7 +643,7 @@ public class OrdsProbeService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// APEX_JSON omits a ref-cursor column whose value is NULL. The KB
|
||||
// APEX_JSON omits a ref-cursor column whose value is NULL. The
|
||||
// object handlers always select every registered protected column, so
|
||||
// a sensitive column missing from every JSON row is also a NULL result
|
||||
// and must be reported as masked to the MCP/UI caller.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Calls the SGMP_POC QA-vector database API through the schema-owned Select AI connection. */
|
||||
@Service
|
||||
public class QaVectorService {
|
||||
|
||||
private static final int MAX_QUESTION_LENGTH = 4_000;
|
||||
private static final int MAX_ANSWER_SQL_LENGTH = 20_000;
|
||||
private static final int MAX_ANSWER_LENGTH = 20_000;
|
||||
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|dbms_[a-z0-9_]*|"
|
||||
+ "utl_[a-z0-9_]*|sys\\s*\\.)\\b"
|
||||
);
|
||||
|
||||
private final BackofficeProperties properties;
|
||||
private final BearerTokenService bearerTokenService;
|
||||
private final Clock clock;
|
||||
|
||||
public QaVectorService(
|
||||
BackofficeProperties properties,
|
||||
BearerTokenService bearerTokenService,
|
||||
Clock clock
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.bearerTokenService = bearerTokenService;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public VectorSearchResult search(String bearerToken, String question, int topK) {
|
||||
return search(bearerToken, question, topK, "ANY");
|
||||
}
|
||||
|
||||
/** Searches only approved examples compatible with the current game target contract. */
|
||||
public VectorSearchResult search(
|
||||
String bearerToken, String question, int topK, String targetType) {
|
||||
requireActiveToken(bearerToken);
|
||||
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
|
||||
if (topK < 1 || topK > 20) {
|
||||
throw new AppException("topK는 1에서 20 사이여야 합니다.");
|
||||
}
|
||||
String normalizedTargetType = requiredTargetType(targetType);
|
||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||
|
||||
List<VectorExample> examples = new ArrayList<>();
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||
CallableStatement statement = connection.prepareCall("{ ? = call sg_qa_vector_search(?, ?, ?) }")) {
|
||||
statement.registerOutParameter(1, Types.REF_CURSOR);
|
||||
statement.setString(2, normalizedQuestion);
|
||||
statement.setInt(3, topK);
|
||||
statement.setString(4, normalizedTargetType);
|
||||
statement.execute();
|
||||
try (ResultSet resultSet = (ResultSet) statement.getObject(1)) {
|
||||
while (resultSet.next()) {
|
||||
examples.add(new VectorExample(
|
||||
resultSet.getLong("EXAMPLE_ID"),
|
||||
resultSet.getString("QUESTION"),
|
||||
resultSet.getString("ANSWER_SQL"),
|
||||
resultSet.getString("ANSWER_TEXT"),
|
||||
resultSet.getString("EMBEDDING_MODEL"),
|
||||
resultSet.getString("REFERENCE_KIND"),
|
||||
resultSet.getString("TARGET_TYPE"),
|
||||
resultSet.getString("OBJECT_ROLE"),
|
||||
resultSet.getString("SOURCE_CASE_ID"),
|
||||
resultSet.getString("SOURCE_TYPE"),
|
||||
resultSet.getDouble("COSINE_DISTANCE")
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("QA 벡터 예제 SQL 조회 실패: " + exception.getMessage());
|
||||
}
|
||||
return new VectorSearchResult(normalizedQuestion, topK, examples);
|
||||
}
|
||||
|
||||
public VectorStoreResult store(String bearerToken, String question, String answerSql, String answer) {
|
||||
requireActiveToken(bearerToken);
|
||||
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
|
||||
String normalizedAnswerSql = requiredReadOnlySql(answerSql);
|
||||
String normalizedAnswer = optionalText(answer, "answer", MAX_ANSWER_LENGTH);
|
||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT sg_qa_vector_store(?, ?, ?) AS example_id FROM dual")) {
|
||||
statement.setString(1, normalizedQuestion);
|
||||
statement.setString(2, normalizedAnswerSql);
|
||||
statement.setString(3, normalizedAnswer);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
throw new AppException("QA 벡터 예제 SQL 저장 결과가 없습니다.");
|
||||
}
|
||||
return new VectorStoreResult(
|
||||
resultSet.getLong("EXAMPLE_ID"), normalizedQuestion, "cohere.embed-v4.0", "DRAFT");
|
||||
}
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("QA 벡터 예제 SQL 저장 실패: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private BackofficeProperties.SelectAi requiredSelectAi() {
|
||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || !selectAi.configured()) {
|
||||
throw new AppException("QA 벡터 연결 설정이 필요합니다. "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
||||
}
|
||||
return selectAi;
|
||||
}
|
||||
|
||||
private void requireActiveToken(String bearerToken) {
|
||||
if (bearerToken == null || bearerToken.isBlank()) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||
if (token == null || !token.active(now)) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
private String requiredReadOnlySql(String answerSql) {
|
||||
String normalized = requiredText(answerSql, "answerSql", MAX_ANSWER_SQL_LENGTH)
|
||||
.replaceFirst(";\\s*$", "").trim();
|
||||
if (!normalized.matches("(?is)^(select|with)\\b.*") || normalized.contains(";")
|
||||
|| normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|
||||
|| UNSAFE_SQL.matcher(normalized).find()) {
|
||||
throw new AppException("answerSql은 단일 읽기 전용 SELECT/WITH SQL이어야 합니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String requiredText(String value, String fieldName, int maximumLength) {
|
||||
String normalized = optionalText(value, fieldName, maximumLength);
|
||||
if (normalized == null) {
|
||||
throw new AppException(fieldName + "는 필수입니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String requiredTargetType(String value) {
|
||||
String normalized = value == null ? "ANY" : value.trim().toUpperCase();
|
||||
if (!List.of("NONE", "SINGLE", "MULTI", "ALL", "ANY").contains(normalized)) {
|
||||
throw new AppException("targetType은 NONE, SINGLE, MULTI, ALL, ANY 중 하나여야 합니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String optionalText(String value, String fieldName, int maximumLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.length() > maximumLength) {
|
||||
throw new AppException(fieldName + "는 " + maximumLength + "자 이하여야 합니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public record VectorExample(
|
||||
long exampleId,
|
||||
String question,
|
||||
String answerSql,
|
||||
String answer,
|
||||
String embeddingModel,
|
||||
String referenceKind,
|
||||
String targetType,
|
||||
String objectRole,
|
||||
String sourceCaseId,
|
||||
String sourceType,
|
||||
double cosineDistance
|
||||
) {
|
||||
}
|
||||
|
||||
public record VectorSearchResult(String question, int topK, List<VectorExample> examples) {
|
||||
}
|
||||
|
||||
public record VectorStoreResult(
|
||||
long exampleId, String question, String embeddingModel, String referenceStatus) {
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,43 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
|
||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
|
||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Screen-level orchestration for schema metadata. Database reads and DDL are
|
||||
* intentionally delegated only to {@link SchemaMetadataMapper}.
|
||||
*/
|
||||
@Service
|
||||
public class SchemaMetadataService {
|
||||
|
||||
private static final String OWNER = "SGMP_POC";
|
||||
private static final int MAX_COMMENT_LENGTH = 4000;
|
||||
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
|
||||
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SchemaMetadataMapper mapper;
|
||||
private final StructuredDataService structuredDataService;
|
||||
private final DataCatalog catalog;
|
||||
|
||||
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
public SchemaMetadataService(
|
||||
SchemaMetadataMapper mapper,
|
||||
StructuredDataService structuredDataService, DataCatalog catalog
|
||||
) {
|
||||
this.mapper = mapper;
|
||||
this.structuredDataService = structuredDataService;
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
public List<StructuredDataTable> tables() {
|
||||
@@ -41,13 +50,12 @@ public class SchemaMetadataService {
|
||||
|
||||
public SchemaMetadataView find(String tableKey) {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
String tableName = table.tableName();
|
||||
String tableComment = tableComment(tableName);
|
||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
|
||||
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
||||
return new SchemaMetadataView(
|
||||
table,
|
||||
nullToEmpty(tableComment),
|
||||
nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)),
|
||||
annotations.getOrDefault(tableTargetKey(), List.of()),
|
||||
columns
|
||||
);
|
||||
@@ -56,24 +64,24 @@ public class SchemaMetadataService {
|
||||
@Transactional
|
||||
public void updateTableComment(String tableKey, String comment) {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
||||
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName())
|
||||
+ " IS " + quoteLiteral(normalizedComment));
|
||||
mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateColumnComment(String tableKey, String columnName, String comment) {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
String column = requireColumn(table.tableName(), columnName);
|
||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||
String column = requireColumn(tableName, columnName);
|
||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
||||
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "."
|
||||
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
|
||||
mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
|
||||
updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -84,8 +92,9 @@ public class SchemaMetadataService {
|
||||
String annotationValue
|
||||
) {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
String column = requireColumn(table.tableName(), columnName);
|
||||
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
|
||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||
String column = requireColumn(tableName, columnName);
|
||||
updateAnnotation(tableName, column, annotationName, annotationValue);
|
||||
}
|
||||
|
||||
private void updateAnnotation(
|
||||
@@ -97,75 +106,55 @@ public class SchemaMetadataService {
|
||||
String key = requireSimpleName(annotationName, "annotation name");
|
||||
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
||||
if (annotationExists(tableName, columnName, key)) {
|
||||
jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key)));
|
||||
if (columnName == null) {
|
||||
mapper.dropTableAnnotation(catalog.owner(), tableName, key);
|
||||
} else {
|
||||
mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key);
|
||||
}
|
||||
}
|
||||
if (!value.isBlank()) {
|
||||
jdbcTemplate.execute(annotationSql(tableName, columnName,
|
||||
"ADD " + quoteName(key) + " " + quoteLiteral(value)));
|
||||
if (columnName == null) {
|
||||
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(
|
||||
String tableName,
|
||||
Map<String, List<SchemaAnnotation>> annotations
|
||||
) {
|
||||
return jdbcTemplate.query("""
|
||||
SELECT c.column_name,
|
||||
CASE
|
||||
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
|
||||
THEN c.data_type || '(' || c.char_length || ')'
|
||||
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
|
||||
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
|
||||
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
|
||||
THEN c.data_type || '(' || c.data_precision || ')'
|
||||
ELSE c.data_type
|
||||
END AS display_type,
|
||||
c.nullable,
|
||||
cc.comments
|
||||
FROM all_tab_columns c
|
||||
LEFT JOIN all_col_comments cc
|
||||
ON cc.owner = c.owner
|
||||
AND cc.table_name = c.table_name
|
||||
AND cc.column_name = c.column_name
|
||||
WHERE c.owner = ?
|
||||
AND c.table_name = ?
|
||||
ORDER BY c.column_id
|
||||
""", (rs, rowNum) -> new SchemaMetadataColumn(
|
||||
rs.getString("column_name"),
|
||||
rs.getString("display_type"),
|
||||
"Y".equalsIgnoreCase(rs.getString("nullable")),
|
||||
nullToEmpty(rs.getString("comments")),
|
||||
annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of())
|
||||
), OWNER, tableName);
|
||||
return mapper.findColumns(catalog.owner(), tableName).stream()
|
||||
.map(row -> toColumn(row, annotations))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private SchemaMetadataColumn toColumn(
|
||||
SchemaMetadataColumnRow row,
|
||||
Map<String, List<SchemaAnnotation>> annotations
|
||||
) {
|
||||
String columnName = requireSimpleName(row.columnName(), "column name");
|
||||
return new SchemaMetadataColumn(
|
||||
columnName,
|
||||
row.dataType(),
|
||||
"Y".equalsIgnoreCase(row.nullable()),
|
||||
nullToEmpty(row.comment()),
|
||||
annotations.getOrDefault(columnTargetKey(columnName), List.of())
|
||||
);
|
||||
}
|
||||
|
||||
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
|
||||
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
|
||||
jdbcTemplate.query("""
|
||||
SELECT column_name, annotation_name, annotation_value
|
||||
FROM all_annotations_usage
|
||||
WHERE object_name = ?
|
||||
AND object_type = 'TABLE'
|
||||
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
|
||||
""", rs -> {
|
||||
String target = rs.getString("column_name") == null
|
||||
? tableTargetKey()
|
||||
: columnTargetKey(rs.getString("column_name"));
|
||||
grouped
|
||||
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
|
||||
.computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>())
|
||||
.add(nullToEmpty(rs.getString("annotation_value")));
|
||||
}, tableName);
|
||||
for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(tableName)) {
|
||||
String target = row.columnName() == null
|
||||
? tableTargetKey()
|
||||
: columnTargetKey(row.columnName());
|
||||
grouped
|
||||
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
|
||||
.computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>())
|
||||
.add(nullToEmpty(row.annotationValue()));
|
||||
}
|
||||
|
||||
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
|
||||
grouped.forEach((target, valuesByName) -> {
|
||||
@@ -180,44 +169,14 @@ public class SchemaMetadataService {
|
||||
}
|
||||
|
||||
private boolean annotationExists(String tableName, String columnName, String annotationName) {
|
||||
Integer count = columnName == null
|
||||
? jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM all_annotations_usage
|
||||
WHERE object_name = ?
|
||||
AND object_type = 'TABLE'
|
||||
AND annotation_name = ?
|
||||
AND column_name IS NULL
|
||||
""", Integer.class, tableName, annotationName)
|
||||
: jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM all_annotations_usage
|
||||
WHERE object_name = ?
|
||||
AND object_type = 'TABLE'
|
||||
AND annotation_name = ?
|
||||
AND column_name = ?
|
||||
""", Integer.class, tableName, annotationName, columnName);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private String annotationSql(String tableName, String columnName, String operation) {
|
||||
if (columnName == null) {
|
||||
return "ALTER TABLE " + qualifiedTable(tableName) + " ANNOTATIONS (" + operation + ")";
|
||||
}
|
||||
return "ALTER TABLE " + qualifiedTable(tableName) + " MODIFY " + quoteName(columnName)
|
||||
+ " ANNOTATIONS (" + operation + ")";
|
||||
return columnName == null
|
||||
? mapper.countTableAnnotation(tableName, annotationName) > 0
|
||||
: mapper.countColumnAnnotation(tableName, columnName, annotationName) > 0;
|
||||
}
|
||||
|
||||
private String requireColumn(String tableName, String columnName) {
|
||||
String column = requireSimpleName(columnName, "column name");
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM all_tab_columns
|
||||
WHERE owner = ?
|
||||
AND table_name = ?
|
||||
AND column_name = ?
|
||||
""", Integer.class, OWNER, tableName, column);
|
||||
if (count == null || count == 0) {
|
||||
if (mapper.countColumn(catalog.owner(), tableName, column) == 0) {
|
||||
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
||||
}
|
||||
return column;
|
||||
@@ -242,14 +201,6 @@ public class SchemaMetadataService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String qualifiedTable(String tableName) {
|
||||
return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name"));
|
||||
}
|
||||
|
||||
private String quoteName(String value) {
|
||||
return "\"" + value.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
private String quoteLiteral(String value) {
|
||||
return "'" + value.replace("'", "''") + "'";
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
|
||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -17,46 +21,18 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class SecuritySqlScriptService {
|
||||
|
||||
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
|
||||
new ScriptDefinition(
|
||||
"aso-masking-metadata",
|
||||
"ASO / 마스킹",
|
||||
"62_kb_aso_masking_backoffice_metadata.sql",
|
||||
"컬럼 마스킹 규칙 메타데이터",
|
||||
"ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
|
||||
),
|
||||
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 static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}");
|
||||
private static final Pattern RESOURCE_PATH = Pattern.compile(
|
||||
"(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql"
|
||||
);
|
||||
private final List<ScriptDefinition> scripts;
|
||||
|
||||
public SecuritySqlScriptService(SecuritySqlScriptProperties properties, ObjectMapper objectMapper) {
|
||||
scripts = parse(properties.scripts(), objectMapper);
|
||||
}
|
||||
|
||||
public List<SecuritySqlScriptSummary> list() {
|
||||
return CURATED_SCRIPTS.stream()
|
||||
return scripts.stream()
|
||||
.map(definition -> new SecuritySqlScriptSummary(
|
||||
definition.scriptId(),
|
||||
definition.category(),
|
||||
@@ -68,7 +44,7 @@ public class SecuritySqlScriptService {
|
||||
}
|
||||
|
||||
public SecuritySqlScript find(String scriptId) {
|
||||
ScriptDefinition definition = CURATED_SCRIPTS.stream()
|
||||
ScriptDefinition definition = scripts.stream()
|
||||
.filter(candidate -> candidate.scriptId().equals(scriptId))
|
||||
.findFirst()
|
||||
.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 category,
|
||||
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,649 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile.
|
||||
*/
|
||||
@Service
|
||||
public class SelectAiService {
|
||||
|
||||
private static final int MAX_PROMPT_LENGTH = 4_000;
|
||||
private static final int MAX_RESULT_ROWS = 100;
|
||||
private static final int QUERY_TIMEOUT_SECONDS = 30;
|
||||
private static final int MAX_FEW_SHOT_EXAMPLES = 3;
|
||||
private static final int MAX_FEW_SHOT_SQL_CHARS = 4_000;
|
||||
private static final int MAX_ENRICHED_PROMPT_LENGTH = 16_000;
|
||||
private static final String POLICY_PREFIX =
|
||||
"Answer the original user question using the current approved object list and profile policy.\n\n";
|
||||
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
||||
+ "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b"
|
||||
);
|
||||
|
||||
private final BackofficeProperties properties;
|
||||
private final BearerTokenService bearerTokenService;
|
||||
private final Clock clock;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final QaVectorService qaVectorService;
|
||||
private final GameScopeService gameScopeService;
|
||||
private final GameCatalogVectorService gameCatalogVectorService;
|
||||
|
||||
public SelectAiService(
|
||||
BackofficeProperties properties,
|
||||
BearerTokenService bearerTokenService,
|
||||
Clock clock,
|
||||
ObjectMapper objectMapper,
|
||||
QaVectorService qaVectorService,
|
||||
GameScopeService gameScopeService,
|
||||
GameCatalogVectorService gameCatalogVectorService
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.bearerTokenService = bearerTokenService;
|
||||
this.clock = clock;
|
||||
this.objectMapper = objectMapper;
|
||||
this.qaVectorService = qaVectorService;
|
||||
this.gameScopeService = gameScopeService;
|
||||
this.gameCatalogVectorService = gameCatalogVectorService;
|
||||
}
|
||||
|
||||
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
||||
return generateAndExecute(bearerToken, prompt, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes one DB-resolved game scope. The optional key is revalidated for
|
||||
* the original question; it is never a caller-provided table or prefix.
|
||||
*/
|
||||
public JsonNode generateAndExecute(String bearerToken, String prompt, String scopeGameKey) {
|
||||
requireActiveToken(bearerToken);
|
||||
String normalizedPrompt = requiredPrompt(prompt);
|
||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||
ResolvedExecutionScope scope = resolveExecutionScope(bearerToken, normalizedPrompt, scopeGameKey);
|
||||
|
||||
GameContext gameContext = resolveGameContext(bearerToken, normalizedPrompt);
|
||||
return generateAndExecutePrepared(
|
||||
bearerToken,
|
||||
normalizedPrompt,
|
||||
selectAi,
|
||||
gameContext.prompt(scope.prompt()),
|
||||
gameContext.scopeType(),
|
||||
gameContext.status(),
|
||||
gameContext.candidates().size(),
|
||||
scope,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public JsonNode generateAndExecute(
|
||||
String bearerToken, String prompt, String scopeGameKey, JsonNode priorToolContext) {
|
||||
if (priorToolContext == null || priorToolContext.isMissingNode()
|
||||
|| priorToolContext.isNull()) {
|
||||
return generateAndExecute(bearerToken, prompt, scopeGameKey);
|
||||
}
|
||||
requireActiveToken(bearerToken);
|
||||
String normalizedPrompt = requiredPrompt(prompt);
|
||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||
QueryPlanContext queryPlan = requiredQueryPlan(priorToolContext);
|
||||
ResolvedExecutionScope scope = queryPlan.scope(scopeGameKey);
|
||||
return generateAndExecutePrepared(
|
||||
bearerToken,
|
||||
normalizedPrompt,
|
||||
selectAi,
|
||||
queryPlan.prompt(normalizedPrompt),
|
||||
queryPlan.targetType(),
|
||||
queryPlan.status(),
|
||||
queryPlan.targetCount(),
|
||||
scope,
|
||||
queryPlan.allowedPrefixes(),
|
||||
queryPlan
|
||||
);
|
||||
}
|
||||
|
||||
private JsonNode generateAndExecutePrepared(
|
||||
String bearerToken,
|
||||
String originalPrompt,
|
||||
BackofficeProperties.SelectAi selectAi,
|
||||
String executionPrompt,
|
||||
String gameScopeType,
|
||||
String gameScopeStatus,
|
||||
int gameCandidateCount,
|
||||
ResolvedExecutionScope scope,
|
||||
Set<String> allowedPrefixes,
|
||||
QueryPlanContext queryPlan
|
||||
) {
|
||||
EnrichedPrompt enrichedPrompt = enrichWithFewShot(
|
||||
bearerToken, selectAi, executionPrompt, queryPlan == null ? "ANY" : queryPlan.targetType());
|
||||
String generatedSql = generate(selectAi, enrichedPrompt.prompt(), "showsql");
|
||||
String normalizedSql = validateReadOnlySql(generatedSql);
|
||||
if (allowedPrefixes != null && gameScopeService != null
|
||||
&& gameScopeService.configured()
|
||||
&& gameScopeService.referencesGameScopedObjectOutsidePrefixes(
|
||||
bearerToken, normalizedSql, allowedPrefixes)) {
|
||||
// This is a model-output validation failure, not an answer fallback.
|
||||
// Give Select AI its own violated-plan feedback once and validate the
|
||||
// regenerated SQL under exactly the same read-only and scope rules.
|
||||
generatedSql = generate(selectAi, scopeCorrectionPrompt(enrichedPrompt.prompt()), "showsql");
|
||||
normalizedSql = validateReadOnlySql(generatedSql);
|
||||
if (gameScopeService.referencesGameScopedObjectOutsidePrefixes(
|
||||
bearerToken, normalizedSql, allowedPrefixes)) {
|
||||
throw new AppException(
|
||||
"Select AI 생성 SQL이 게임 질의 계획에 없는 prefix 전용 객체를 참조했습니다.");
|
||||
}
|
||||
}
|
||||
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||
response.put("profile", selectAi.profile());
|
||||
response.put("originalPrompt", originalPrompt);
|
||||
response.put("gameScopeType", gameScopeType);
|
||||
response.put("gameScopeStatus", gameScopeStatus);
|
||||
response.put("gameCandidateCount", gameCandidateCount);
|
||||
if (queryPlan != null) {
|
||||
response.put("queryPlanTargetType", queryPlan.targetType());
|
||||
response.put("queryPlanStatus", queryPlan.status());
|
||||
response.put("queryPlanTargetCount", queryPlan.targetCount());
|
||||
}
|
||||
if (scope.gameKey() != null) {
|
||||
response.put("scopeGameKey", scope.gameKey());
|
||||
response.put("scopeDisplayName", scope.displayName());
|
||||
response.put("scopeStatus", queryPlan == null
|
||||
? "DB_REVALIDATED" : "QUERY_PLAN_VALIDATED");
|
||||
}
|
||||
response.put("fewShotStatus", enrichedPrompt.status());
|
||||
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
|
||||
addFewShotExamples(response, enrichedPrompt.examples());
|
||||
response.put("generatedSql", normalizedSql);
|
||||
response.put("execution", "READ_ONLY_EXECUTED");
|
||||
response.put("rowCount", execution.items().size());
|
||||
response.put("truncated", execution.truncated());
|
||||
response.set("items", execution.items());
|
||||
response.put("nextStep", execution.truncated()
|
||||
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다."
|
||||
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
|
||||
return response;
|
||||
}
|
||||
|
||||
private QueryPlanContext requiredQueryPlan(JsonNode plan) {
|
||||
JsonNode normalizedPlan = unwrapMcpToolResult(plan);
|
||||
String targetType = normalizedPlan.path("targetType")
|
||||
.asText("").trim().toUpperCase(Locale.ROOT);
|
||||
if (!Set.of("NONE", "SINGLE", "MULTI", "ALL").contains(targetType)
|
||||
|| !normalizedPlan.path("targets").isArray()) {
|
||||
throw new AppException(
|
||||
"queryPlan은 targetType(NONE/SINGLE/MULTI/ALL)과 targets 배열이 필요합니다.");
|
||||
}
|
||||
Set<String> allowedPrefixes = new LinkedHashSet<>();
|
||||
for (JsonNode target : normalizedPlan.path("targets")) {
|
||||
String prefix = target.path("gamePrefix").asText("").trim();
|
||||
if (!prefix.isEmpty()) {
|
||||
allowedPrefixes.add(prefix.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return new QueryPlanContext(
|
||||
targetType,
|
||||
normalizedPlan.path("status").asText(""),
|
||||
normalizedPlan.path("targets").size(),
|
||||
Set.copyOf(allowedPrefixes),
|
||||
normalizedPlan.deepCopy()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the direct game-query-plan contract as well as the standard MCP
|
||||
* tools/call envelope the ReAct model can copy from a preceding observation.
|
||||
* This is transport normalization only; the target contract itself remains
|
||||
* the authoritative data-driven policy.
|
||||
*/
|
||||
JsonNode unwrapMcpToolResult(JsonNode candidate) {
|
||||
JsonNode current = candidate;
|
||||
for (int depth = 0; depth < 6 && current != null; depth++) {
|
||||
if (current.isTextual()) {
|
||||
try {
|
||||
current = objectMapper.readTree(current.asText());
|
||||
continue;
|
||||
} catch (JsonProcessingException ignored) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
// LangChain can forward an MCP TextContent item verbatim as
|
||||
// {"type":"text","text":"{...tools/call response...}"}. Unwrap the
|
||||
// transport envelope before looking for the response payload.
|
||||
if (current.isObject() && "text".equals(current.path("type").asText())
|
||||
&& current.path("text").isTextual()) {
|
||||
current = current.path("text");
|
||||
continue;
|
||||
}
|
||||
JsonNode response = current.path("response");
|
||||
if (response.isObject() || response.isTextual()) {
|
||||
current = response;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return current == null ? objectMapper.createObjectNode() : current;
|
||||
}
|
||||
|
||||
private GameContext resolveGameContext(String bearerToken, String question) {
|
||||
try {
|
||||
List<GameCatalogVectorService.GameCandidate> candidates =
|
||||
gameCatalogVectorService.search(bearerToken, question, 5);
|
||||
String status = candidates.isEmpty() ? "NO_MATCH" : "RESOLVED";
|
||||
String scopeType = candidates.isEmpty() ? "UNKNOWN" : "SINGLE_GAME";
|
||||
return new GameContext(scopeType, status, candidates);
|
||||
} catch (Exception ignored) {
|
||||
return new GameContext("UNKNOWN", "UNAVAILABLE", List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private ResolvedExecutionScope resolveExecutionScope(
|
||||
String bearerToken, String originalPrompt, String scopeGameKey
|
||||
) {
|
||||
String requestedKey = scopeGameKey == null ? "" : scopeGameKey.trim();
|
||||
if (requestedKey.isEmpty()) {
|
||||
return new ResolvedExecutionScope(originalPrompt, null, null);
|
||||
}
|
||||
if (gameScopeService == null) {
|
||||
throw new AppException("게임 범위 검증 서비스를 사용할 수 없습니다.");
|
||||
}
|
||||
GameScopeService.GameScope scope = gameScopeService.resolve(bearerToken, originalPrompt).scopes().stream()
|
||||
.filter(item -> requestedKey.equals(item.gameKey()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException("요청한 게임 범위가 DB 조회 결과에 없습니다."));
|
||||
if (!"SUPPORTED".equals(scope.status())) {
|
||||
throw new AppException("DB 게임 범위가 조회 실행을 허용하지 않습니다: " + scope.reasonCode());
|
||||
}
|
||||
String scopedPrompt = "Use only the DB-resolved game scope below. Do not generate SQL for any other "
|
||||
+ "game mentioned in the original question. The scope was validated through current game alias "
|
||||
+ "metadata and approved object availability.\n"
|
||||
+ "Resolved game key: " + scope.gameKey() + "\n"
|
||||
+ "Resolved display name: " + scope.displayName() + "\n"
|
||||
+ "Matched alias: " + scope.matchedAlias() + "\n\n"
|
||||
+ "Original user question:\n" + originalPrompt;
|
||||
return new ResolvedExecutionScope(scopedPrompt, scope.gameKey(), scope.displayName());
|
||||
}
|
||||
|
||||
private void addFewShotExamples(ObjectNode response, List<QaVectorService.VectorExample> examples) {
|
||||
ArrayNode items = response.putArray("fewShotExamples");
|
||||
for (QaVectorService.VectorExample example : examples) {
|
||||
ObjectNode item = items.addObject();
|
||||
item.put("exampleId", example.exampleId());
|
||||
item.put("question", example.question());
|
||||
item.put("answerSql", example.answerSql());
|
||||
if (example.answer() != null) {
|
||||
item.put("answer", example.answer());
|
||||
}
|
||||
item.put("embeddingModel", example.embeddingModel());
|
||||
item.put("referenceKind", example.referenceKind());
|
||||
item.put("targetType", example.targetType());
|
||||
if (example.objectRole() != null) {
|
||||
item.put("objectRole", example.objectRole());
|
||||
}
|
||||
if (example.sourceCaseId() != null) {
|
||||
item.put("sourceCaseId", example.sourceCaseId());
|
||||
}
|
||||
if (example.sourceType() != null) {
|
||||
item.put("sourceType", example.sourceType());
|
||||
}
|
||||
item.put("cosineDistance", example.cosineDistance());
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the prompt Select AI assembled for SQL generation without executing generated SQL. */
|
||||
public JsonNode generatePrompt(String bearerToken, String prompt) {
|
||||
requireActiveToken(bearerToken);
|
||||
String normalizedPrompt = requiredPrompt(prompt);
|
||||
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
|
||||
EnrichedPrompt enrichedPrompt = enrichWithFewShot(bearerToken, selectAi, normalizedPrompt, "ANY");
|
||||
String selectAiPrompt = generate(selectAi, enrichedPrompt.prompt(), "showprompt");
|
||||
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("status", "SHOWPROMPT");
|
||||
response.put("profile", selectAi.profile());
|
||||
response.put("originalPrompt", normalizedPrompt);
|
||||
response.put("fewShotStatus", enrichedPrompt.status());
|
||||
response.put("fewShotExampleCount", enrichedPrompt.exampleCount());
|
||||
response.put("selectAiPrompt", selectAiPrompt);
|
||||
return response;
|
||||
}
|
||||
|
||||
private BackofficeProperties.SelectAi requiredSelectAi() {
|
||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || !selectAi.configured()) {
|
||||
throw new AppException("Select AI 연결 설정이 필요합니다. "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
||||
}
|
||||
return selectAi;
|
||||
}
|
||||
|
||||
private void requireActiveToken(String bearerToken) {
|
||||
if (bearerToken == null || bearerToken.isBlank()) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||
if (token == null || !token.active(now)) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
private String requiredPrompt(String prompt) {
|
||||
String normalized = prompt == null ? "" : prompt.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new AppException("prompt는 필수입니다.");
|
||||
}
|
||||
if (normalized.length() > MAX_PROMPT_LENGTH) {
|
||||
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String generate(
|
||||
BackofficeProperties.SelectAi selectAi,
|
||||
String prompt,
|
||||
String action
|
||||
) {
|
||||
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, ?) FROM dual";
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, prompt);
|
||||
statement.setString(2, selectAi.profile());
|
||||
statement.setString(3, action);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next() || resultSet.getString(1) == null) {
|
||||
throw new AppException("Select AI가 " + action.toUpperCase() + " 결과를 반환하지 않았습니다.");
|
||||
}
|
||||
return resultSet.getString(1);
|
||||
}
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("Select AI " + action.toUpperCase() + " 생성 실패: "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private EnrichedPrompt enrichWithFewShot(
|
||||
String bearerToken,
|
||||
BackofficeProperties.SelectAi selectAi,
|
||||
String prompt,
|
||||
String targetType
|
||||
) {
|
||||
if (!fewShotEnabled(selectAi) || qaVectorService == null) {
|
||||
return new EnrichedPrompt(prompt, "DISABLED", 0, List.of());
|
||||
}
|
||||
try {
|
||||
List<QaVectorService.VectorExample> examples = qaVectorService
|
||||
.search(bearerToken, prompt, fewShotTopK(selectAi), targetType)
|
||||
.examples();
|
||||
if (examples.isEmpty()) {
|
||||
return new EnrichedPrompt(composePolicyPrompt(prompt), "NO_MATCH", 0, List.of());
|
||||
}
|
||||
return new EnrichedPrompt(
|
||||
composeFewShotPrompt(prompt, examples), "APPLIED", Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES),
|
||||
examples.subList(0, Math.min(examples.size(), MAX_FEW_SHOT_EXAMPLES)));
|
||||
} catch (Exception ignored) {
|
||||
// Vector retrieval is an optional prompt aid; preserve the normal Text2SQL path on failure.
|
||||
return new EnrichedPrompt(composePolicyPrompt(prompt), "UNAVAILABLE", 0, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
static String composePolicyPrompt(String prompt) {
|
||||
return POLICY_PREFIX
|
||||
+ "Resolve business terms and game names from the approved game-alias metadata before selecting a "
|
||||
+ "game-scoped object. A generic term such as common user means no particular game. If no game alias "
|
||||
+ "is resolved, do not substitute an arbitrary game-scoped object. Keep common-object questions "
|
||||
+ "game-neutral and preserve the resolver status for the answer layer.\n"
|
||||
+ "Original user question:\n" + prompt;
|
||||
}
|
||||
|
||||
static String scopeCorrectionPrompt(String originalPrompt) {
|
||||
return originalPrompt
|
||||
+ "\n\n[SQL VALIDATION FEEDBACK]\n"
|
||||
+ "The previous SQL selected a game-scoped object outside the authoritative query plan. "
|
||||
+ "Regenerate one read-only SQL statement using the same plan. Do not substitute any "
|
||||
+ "game-scoped object. Apply the active profile instructions and do not report a SQL failure.\n";
|
||||
}
|
||||
|
||||
static String composeFewShotPrompt(String prompt, List<QaVectorService.VectorExample> examples) {
|
||||
StringBuilder enriched = new StringBuilder(POLICY_PREFIX
|
||||
+ "The verified examples below are guidance only: use only relevant SQL patterns, do not invent "
|
||||
+ "identifiers, and do not override current metadata or game-alias resolution policy. "
|
||||
+ "When examples use a game-specific object, reuse that pattern only after the current question "
|
||||
+ "resolves the same game alias; otherwise keep the query unscoped or request clarification.\n\n"
|
||||
+ "Verified few-shot examples:\n");
|
||||
int included = 0;
|
||||
for (QaVectorService.VectorExample example : examples) {
|
||||
if (included >= MAX_FEW_SHOT_EXAMPLES) {
|
||||
break;
|
||||
}
|
||||
String candidate = referenceCandidate(included + 1, example);
|
||||
if (candidate.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (enriched.length() + candidate.length() + prompt.length() > MAX_ENRICHED_PROMPT_LENGTH) {
|
||||
break;
|
||||
}
|
||||
enriched.append(candidate);
|
||||
included++;
|
||||
}
|
||||
if (included == 0) {
|
||||
return composePolicyPrompt(prompt);
|
||||
}
|
||||
return enriched.append("Original user question:\n").append(prompt).toString();
|
||||
}
|
||||
|
||||
private static String referenceCandidate(int index, QaVectorService.VectorExample example) {
|
||||
String prefix = "Example " + index + " question:\n" + example.question()
|
||||
+ "\nExample " + index + " target type: " + example.targetType()
|
||||
+ "\nExample " + index + " reference kind: " + example.referenceKind()
|
||||
+ "\nExample " + index + " logical object role: "
|
||||
+ (example.objectRole() == null || example.objectRole().isBlank()
|
||||
? "UNSPECIFIED" : example.objectRole()) + "\n";
|
||||
if ("NO_TARGET".equals(example.referenceKind())
|
||||
|| "OBJECT_UNAVAILABLE".equals(example.referenceKind())) {
|
||||
String boundary = truncate(example.answer(), MAX_FEW_SHOT_SQL_CHARS);
|
||||
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
|
||||
if (boundary.isBlank() || answerSql.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return prefix + "Boundary rule (when the current plan is NONE and this logical object role "
|
||||
+ "matches the requested operation, follow this boundary SQL template instead of "
|
||||
+ "substituting a game-scoped object; do not apply it to an approved common-object operation):\n"
|
||||
+ boundary
|
||||
+ "\nVerified boundary SQL template:\n" + answerSql + "\n\n";
|
||||
}
|
||||
String answerSql = truncate(example.answerSql(), MAX_FEW_SHOT_SQL_CHARS);
|
||||
return answerSql.isBlank() ? "" : prefix + "Verified SQL template:\n" + answerSql + "\n\n";
|
||||
}
|
||||
|
||||
private static String truncate(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private boolean fewShotEnabled(BackofficeProperties.SelectAi selectAi) {
|
||||
return selectAi.fewShotEnabled() == null || selectAi.fewShotEnabled();
|
||||
}
|
||||
|
||||
private int fewShotTopK(BackofficeProperties.SelectAi selectAi) {
|
||||
Integer configured = selectAi.fewShotTopK();
|
||||
if (configured == null) {
|
||||
return MAX_FEW_SHOT_EXAMPLES;
|
||||
}
|
||||
return Math.max(1, Math.min(configured, MAX_FEW_SHOT_EXAMPLES));
|
||||
}
|
||||
|
||||
private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) {
|
||||
ArrayNode items = objectMapper.createArrayNode();
|
||||
boolean truncated = false;
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||
Statement transaction = connection.createStatement()) {
|
||||
connection.setAutoCommit(false);
|
||||
connection.setReadOnly(true);
|
||||
transaction.execute("SET TRANSACTION READ ONLY");
|
||||
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
statement.setFetchSize(MAX_RESULT_ROWS + 1);
|
||||
statement.setMaxRows(MAX_RESULT_ROWS + 1);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
ResultSetMetaData metadata = resultSet.getMetaData();
|
||||
while (resultSet.next()) {
|
||||
if (items.size() >= MAX_RESULT_ROWS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
ObjectNode row = items.addObject();
|
||||
for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) {
|
||||
String column = metadata.getColumnLabel(columnIndex);
|
||||
if (column == null || column.isBlank()) {
|
||||
column = metadata.getColumnName(columnIndex);
|
||||
}
|
||||
putResultValue(row, column, resultSet.getObject(columnIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connection.rollback();
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage());
|
||||
}
|
||||
return new QueryExecution(items, truncated);
|
||||
}
|
||||
|
||||
private void putResultValue(ObjectNode row, String column, Object value) {
|
||||
if (value == null) {
|
||||
row.putNull(column);
|
||||
} else if (value instanceof BigDecimal number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof BigInteger number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Integer number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Long number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Short number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Float number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Double number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Boolean bool) {
|
||||
row.put(column, bool);
|
||||
} else {
|
||||
row.put(column, String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
private String validateReadOnlySql(String generatedSql) {
|
||||
String normalized = generatedSql == null ? "" : generatedSql.trim();
|
||||
if (normalized.startsWith("```")) {
|
||||
int firstLineEnd = normalized.indexOf('\n');
|
||||
int closingFence = normalized.lastIndexOf("```");
|
||||
if (firstLineEnd >= 0 && closingFence > firstLineEnd) {
|
||||
normalized = normalized.substring(firstLineEnd + 1, closingFence).trim();
|
||||
}
|
||||
}
|
||||
normalized = normalized.replaceFirst(";\\s*$", "").trim();
|
||||
if (!normalized.matches("(?is)^(select|with)\\b.*")) {
|
||||
throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
|
||||
}
|
||||
if (normalized.contains(";")) {
|
||||
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
|
||||
}
|
||||
if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|
||||
|| UNSAFE_SQL.matcher(normalized).find()) {
|
||||
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private record QueryExecution(ArrayNode items, boolean truncated) {}
|
||||
|
||||
private record EnrichedPrompt(
|
||||
String prompt, String status, int exampleCount, List<QaVectorService.VectorExample> examples) {}
|
||||
|
||||
private record ResolvedExecutionScope(String prompt, String gameKey, String displayName) {}
|
||||
|
||||
private record QueryPlanContext(
|
||||
String targetType,
|
||||
String status,
|
||||
int targetCount,
|
||||
Set<String> allowedPrefixes,
|
||||
JsonNode plan
|
||||
) {
|
||||
String prompt(String originalQuestion) {
|
||||
return "[AUTHORITATIVE GAME QUERY PLAN]\n" + plan
|
||||
+ "\n[ORIGINAL USER QUESTION]\n" + originalQuestion;
|
||||
}
|
||||
|
||||
ResolvedExecutionScope scope(String requestedGameKey) {
|
||||
String requested = requestedGameKey == null ? "" : requestedGameKey.trim();
|
||||
if (requested.isEmpty()) {
|
||||
return new ResolvedExecutionScope("", null, null);
|
||||
}
|
||||
for (JsonNode target : plan.path("targets")) {
|
||||
if (requested.equals(target.path("gameKey").asText(""))) {
|
||||
return new ResolvedExecutionScope(
|
||||
"", requested, target.path("gameName").asText(requested));
|
||||
}
|
||||
}
|
||||
throw new AppException("scopeGameKey가 queryPlan targets에 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private record GameContext(
|
||||
String scopeType, String status, List<GameCatalogVectorService.GameCandidate> candidates) {
|
||||
String prompt(String original) {
|
||||
StringBuilder context = new StringBuilder();
|
||||
context.append("[GAME CATALOG MATCH]\n")
|
||||
.append("scope_type: ").append(scopeType).append('\n')
|
||||
.append("status: ").append(status).append('\n');
|
||||
for (GameCatalogVectorService.GameCandidate candidate : candidates) {
|
||||
context.append("game_key: ").append(candidate.gameKey()).append('\n')
|
||||
.append("game_id: ").append(candidate.gameId()).append('\n')
|
||||
.append("game_prefix: ").append(candidate.gamePrefix()).append('\n')
|
||||
.append("game_name: ").append(candidate.gameName()).append('\n')
|
||||
.append("matched_aliases: ").append(candidate.aliases()).append('\n')
|
||||
.append("user_master_object_name: ")
|
||||
.append(candidate.userMasterObjectName()).append('\n')
|
||||
.append("cosine_distance: ").append(candidate.similarity()).append('\n');
|
||||
}
|
||||
context.append("[GAME SCOPE METADATA]\n")
|
||||
.append("The following resolver facts are authoritative metadata. ")
|
||||
.append("Apply the table and column annotations associated with these facts; ")
|
||||
.append("do not invent identifiers or scope rules.\n\n")
|
||||
.append(original);
|
||||
return context.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,36 +11,25 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class StructuredDataService {
|
||||
|
||||
private static final String OWNER = "SGMP_POC";
|
||||
private static final int ROW_LIMIT = 50;
|
||||
private static final List<StructuredDataTable> TABLES = List.of(
|
||||
new StructuredDataTable("game-users", "CZN_COMN_USER_MST", "게임 사용자", "카제나 게임 사용자 마스터"),
|
||||
new StructuredDataTable("characters", "CZN_COMN_CHARACTER_MST", "캐릭터", "카제나 캐릭터 마스터"),
|
||||
new StructuredDataTable("sales", "COMN_SALES_TXN", "판매 거래", "게임 상품 판매 거래"),
|
||||
new StructuredDataTable("refunds", "COMN_REFUND_TXN", "환불 거래", "게임 상품 환불 거래"),
|
||||
new StructuredDataTable("products", "COMN_SALES_PRODUCT_DISP_BAS", "상품", "판매 상품 전시 기준"),
|
||||
new StructuredDataTable("game-servers", "COMN_GAME_SERVER_BAS", "게임 서버", "게임 서버 기준 정보"),
|
||||
new StructuredDataTable("game-aliases", "COMN_GAME_ALIAS_BAS", "게임 별칭", "게임명·별칭·prefix 매핑"));
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final DataCatalog catalog;
|
||||
|
||||
public StructuredDataService(JdbcTemplate jdbcTemplate) {
|
||||
public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
public List<StructuredDataTable> tables() {
|
||||
return TABLES;
|
||||
return catalog.objects();
|
||||
}
|
||||
|
||||
public String defaultKey() {
|
||||
return TABLES.getFirst().key();
|
||||
return catalog.objects().getFirst().key();
|
||||
}
|
||||
|
||||
public StructuredDataTable requireTable(String key) {
|
||||
return TABLES.stream()
|
||||
.filter(table -> table.key().equals(key))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다."));
|
||||
return catalog.require(key);
|
||||
}
|
||||
|
||||
public StructuredDataPreview preview(String key) {
|
||||
@@ -54,7 +43,7 @@ public class StructuredDataService {
|
||||
AND table_name = ?
|
||||
ORDER BY column_id
|
||||
""",
|
||||
(resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName());
|
||||
(resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName());
|
||||
if (columns.isEmpty()) {
|
||||
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
||||
}
|
||||
@@ -63,24 +52,11 @@ public class StructuredDataService {
|
||||
previewSql(table), ROW_LIMIT);
|
||||
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
||||
} catch (DataAccessException exception) {
|
||||
throw new AppException("게임 데이터를 조회할 수 없습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요.");
|
||||
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) {
|
||||
return switch (table.key()) {
|
||||
case "game-users" -> "SELECT * FROM SGMP_POC.CZN_COMN_USER_MST WHERE ROWNUM <= ?";
|
||||
case "characters" -> "SELECT * FROM SGMP_POC.CZN_COMN_CHARACTER_MST WHERE ROWNUM <= ?";
|
||||
case "sales" -> "SELECT * FROM SGMP_POC.COMN_SALES_TXN WHERE ROWNUM <= ?";
|
||||
case "refunds" -> "SELECT * FROM SGMP_POC.COMN_REFUND_TXN WHERE ROWNUM <= ?";
|
||||
case "products" -> "SELECT * FROM SGMP_POC.COMN_SALES_PRODUCT_DISP_BAS WHERE ROWNUM <= ?";
|
||||
case "game-servers" -> "SELECT * FROM SGMP_POC.COMN_GAME_SERVER_BAS WHERE ROWNUM <= ?";
|
||||
case "game-aliases" -> "SELECT * FROM SGMP_POC.COMN_GAME_ALIAS_BAS WHERE ROWNUM <= ?";
|
||||
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
|
||||
};
|
||||
return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public class DashboardController {
|
||||
|
||||
@GetMapping("/")
|
||||
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.
|
||||
model.addAttribute("users", userService.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.SchemaMetadataService;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -13,6 +15,8 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
@Controller
|
||||
public class SchemaMetadataController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SchemaMetadataController.class);
|
||||
|
||||
private final SchemaMetadataService schemaMetadataService;
|
||||
|
||||
public SchemaMetadataController(SchemaMetadataService schemaMetadataService) {
|
||||
@@ -27,7 +31,8 @@ public class SchemaMetadataController {
|
||||
try {
|
||||
model.addAttribute("metadata", schemaMetadataService.find(selectedKey));
|
||||
} catch (AppException | DataAccessException exception) {
|
||||
model.addAttribute("errorMessage", safeMessage(exception));
|
||||
log.warn("Schema metadata lookup failed for table key={}", selectedKey, exception);
|
||||
model.addAttribute("errorMessage", readMessage(exception));
|
||||
}
|
||||
return "schema-metadata";
|
||||
}
|
||||
@@ -105,4 +110,10 @@ public class SchemaMetadataController {
|
||||
? exception.getMessage()
|
||||
: "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요.";
|
||||
}
|
||||
|
||||
private String readMessage(Exception exception) {
|
||||
return exception instanceof AppException
|
||||
? exception.getMessage()
|
||||
: "DB 메타데이터를 조회하지 못했습니다. 카탈로그 소유자 조회 권한과 대상 객체 상태를 확인하세요.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,3 +68,49 @@ backoffice:
|
||||
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
|
||||
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
||||
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
|
||||
select-ai:
|
||||
# Cloud AI profiles are schema-owned. This connection must use 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">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
|
||||
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
|
||||
INSERT INTO hmm_access_audit (
|
||||
event_type, key_id, object_id, status, row_count, error_code, message, created_at
|
||||
INSERT INTO sg_audit_event (
|
||||
audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
|
||||
) VALUES (
|
||||
sg_audit_event_seq.NEXTVAL,
|
||||
#{eventType,jdbcType=VARCHAR},
|
||||
#{keyId,jdbcType=NUMERIC},
|
||||
#{objectId,jdbcType=NUMERIC},
|
||||
|
||||
@@ -68,10 +68,9 @@
|
||||
-->
|
||||
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
|
||||
WITH managed_policy AS (
|
||||
SELECT 'KB_CUSTOMERS' AS object_name, 'KB_CUSTOMER_PII_REDACT' AS policy_name FROM dual
|
||||
UNION ALL SELECT 'KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT' FROM dual
|
||||
UNION ALL SELECT 'KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT' FROM dual
|
||||
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT' FROM dual
|
||||
<foreach collection="targets" item="target" separator=" UNION ALL ">
|
||||
SELECT #{target.objectName} AS object_name, #{target.policyName} AS policy_name FROM dual
|
||||
</foreach>
|
||||
),
|
||||
configured AS (
|
||||
SELECT protected_object.object_name,
|
||||
@@ -80,7 +79,7 @@
|
||||
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||
WHERE protected_object.owner = 'POC_2'
|
||||
WHERE protected_object.owner = #{owner}
|
||||
AND rule.enabled_yn = 'Y'
|
||||
GROUP BY protected_object.object_name
|
||||
),
|
||||
@@ -96,7 +95,7 @@
|
||||
LEFT JOIN redaction_columns policy_column
|
||||
ON policy_column.object_owner = policy.object_owner
|
||||
AND policy_column.object_name = policy.object_name
|
||||
WHERE policy.object_owner = 'POC_2'
|
||||
WHERE policy.object_owner = #{owner}
|
||||
GROUP BY policy.object_name, policy.policy_name, policy.enable
|
||||
),
|
||||
missing_columns AS (
|
||||
@@ -110,7 +109,7 @@
|
||||
ON policy_column.object_owner = protected_object.owner
|
||||
AND policy_column.object_name = protected_object.object_name
|
||||
AND policy_column.column_name = protected_column.column_name
|
||||
WHERE protected_object.owner = 'POC_2'
|
||||
WHERE protected_object.owner = #{owner}
|
||||
AND rule.enabled_yn = 'Y'
|
||||
AND policy_column.column_name IS NULL
|
||||
GROUP BY protected_object.object_name
|
||||
@@ -120,14 +119,14 @@
|
||||
COUNT(*) AS extra_column_count
|
||||
FROM redaction_columns policy_column
|
||||
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
|
||||
WHERE policy_column.object_owner = 'POC_2'
|
||||
WHERE policy_column.object_owner = #{owner}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sg_column_masking_rule link
|
||||
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||
WHERE protected_object.owner = 'POC_2'
|
||||
WHERE protected_object.owner = #{owner}
|
||||
AND protected_object.object_name = policy_column.object_name
|
||||
AND protected_column.column_name = policy_column.column_name
|
||||
AND rule.enabled_yn = 'Y'
|
||||
@@ -138,18 +137,14 @@
|
||||
SELECT policy.object_name,
|
||||
COUNT(*) AS legacy_vpd_column_policy_count
|
||||
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.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
|
||||
)
|
||||
SELECT 'POC_2' AS owner,
|
||||
SELECT #{owner} AS owner,
|
||||
managed.object_name,
|
||||
managed.policy_name,
|
||||
database_policy.enable AS enabled,
|
||||
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user