Compare commits
66 Commits
smilegate
...
8b04fb4e89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -43,8 +43,7 @@ export BACKOFFICE_SESSION_COOKIE_SECURE="false"
|
|||||||
export BACKOFFICE_REMEMBER_ME_ENABLED="false"
|
export BACKOFFICE_REMEMBER_ME_ENABLED="false"
|
||||||
export BACKOFFICE_REMEMBER_ME_KEY=""
|
export BACKOFFICE_REMEMBER_ME_KEY=""
|
||||||
export BACKOFFICE_REMEMBER_ME_DAYS="14"
|
export BACKOFFICE_REMEMBER_ME_DAYS="14"
|
||||||
# HMM HR 질의는 HMM MCP/DBMS_CLOUD_AI_AGENT를 사용합니다. 기존 ORDS 운영 기능이 필요할 때만 설정합니다.
|
export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
|
||||||
export BACKOFFICE_ORDS_BASE_URL=""
|
|
||||||
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
|
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
|
||||||
# ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로
|
# ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로
|
||||||
# ADMIN으로 실행 중이면 Handler 생성은 막히고 소스 보기만 사용합니다.
|
# ADMIN으로 실행 중이면 Handler 생성은 막히고 소스 보기만 사용합니다.
|
||||||
@@ -113,7 +112,7 @@ export BACKOFFICE_AI_OCI_COMPARTMENT_ID=""
|
|||||||
export VPDTEST1_API_KEY=""
|
export VPDTEST1_API_KEY=""
|
||||||
export VPDTEST2_API_KEY=""
|
export VPDTEST2_API_KEY=""
|
||||||
|
|
||||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (database/adb/07_end_users.sql 에서 사용) ---
|
# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) ---
|
||||||
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
|
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
|
||||||
# 4명의 데모 유저:
|
# 4명의 데모 유저:
|
||||||
# vpduser_my → MySQL view 만 SELECT 가능
|
# vpduser_my → MySQL view 만 SELECT 가능
|
||||||
@@ -126,7 +125,7 @@ export VPDUSER_BOTH_PASSWORD="RowFilter#Both26"
|
|||||||
export VPDUSER_NONE_PASSWORD="RowFilter#None26"
|
export VPDUSER_NONE_PASSWORD="RowFilter#None26"
|
||||||
|
|
||||||
# --- (3b) OPTIONAL — DDS 변형 데모용 엔드유저 비밀번호 ---
|
# --- (3b) OPTIONAL — DDS 변형 데모용 엔드유저 비밀번호 ---
|
||||||
# database/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용.
|
# sql/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용.
|
||||||
# 동일한 4-user 매트릭스를 declarative Data Grants 로 재현. VPD 데모와 공존.
|
# 동일한 4-user 매트릭스를 declarative Data Grants 로 재현. VPD 데모와 공존.
|
||||||
export DDSUSER_MY_PASSWORD="DdsGrant#My2026"
|
export DDSUSER_MY_PASSWORD="DdsGrant#My2026"
|
||||||
export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026"
|
export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026"
|
||||||
@@ -147,7 +146,7 @@ export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
|
|||||||
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
|
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
|
||||||
|
|
||||||
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
||||||
# database/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
||||||
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.
|
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.
|
||||||
export PG_HOST="" # 예: vpd-poc.xxxxx.ap-northeast-2.rds.amazonaws.com
|
export PG_HOST="" # 예: vpd-poc.xxxxx.ap-northeast-2.rds.amazonaws.com
|
||||||
export PG_PORT="5432"
|
export PG_PORT="5432"
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -32,7 +32,7 @@ End-to-End 데모입니다.
|
|||||||
행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만
|
행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만
|
||||||
`email` 컬럼은 항상 `NULL` 로 반환.
|
`email` 컬럼은 항상 `NULL` 로 반환.
|
||||||
|
|
||||||
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`database/adb/06a_redaction.sql`).
|
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`sql/adb/06a_redaction.sql`).
|
||||||
* DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수.
|
* DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수.
|
||||||
|
|
||||||
### 3. 두 가지 구현 — VPD 와 DDS
|
### 3. 두 가지 구현 — VPD 와 DDS
|
||||||
@@ -59,21 +59,19 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
|
|||||||
|
|
||||||
## 소스 위치와 Git 저장 기준
|
## 소스 위치와 Git 저장 기준
|
||||||
|
|
||||||
현재 이 저장소에는 서로 독립적으로 빌드·실행되는 세 애플리케이션이 있다. 각 제품은
|
현재 이 저장소에는 **VPD 관리 백오피스**와 **PoC4 MCP AI Console 외피**가 함께 들어 있다.
|
||||||
최상위 폴더를 하나씩 사용하며 저장소 루트에 프레임워크 `src`를 두지 않는다.
|
두 소스는 별도 Git repository가 아니라 같은 repository의 서로 다른 경로로 관리한다.
|
||||||
|
|
||||||
| 구분 | 저장 위치 | 역할 |
|
| 구분 | 저장 위치 | 역할 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| VPD/ASO/FGA 관리 백오피스 | `vpd-backoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
| VPD/ASO/FGA 관리 백오피스 | `src/main/java/com/cloudhandson/vpdbackoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
||||||
| VPD 백오피스 화면 | `vpd-backoffice/src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
| VPD 백오피스 화면 | `src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
||||||
| VPD 백오피스 CSS/JS | `vpd-backoffice/src/main/resources/static/` | 화면 동작과 스타일 |
|
| VPD 백오피스 CSS/JS | `src/main/resources/static/` | 화면 동작과 스타일 |
|
||||||
| VPD/ASO/FGA/ORDS/Select AI SQL | `database/adb/` | ADB 적용·검증 SQL 스크립트 |
|
| VPD/ASO/FGA/ORDS/Select AI SQL | `sql/adb/` | ADB 적용·검증 SQL 스크립트 |
|
||||||
| 원격 원장 seed/검증 SQL | `database/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 |
|
|
||||||
| DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 |
|
| DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 |
|
||||||
| AI Web Agent Console | `ai-web-agent-console/` | Streamlit 기반 MCP 업무 에이전트 콘솔 |
|
| Streamlit 외피 / PoC4 MCP AI Console | `poc4_active_source_20260714/` | ReAct-style로 MCP tool을 호출하는 PoC4 데모 소스 snapshot |
|
||||||
| AI Web Agent Console 진입점 | `ai-web-agent-console/app.py` | MCP discovery, 도구 호출, 답변 합성, 감사 증적 표시 |
|
| Streamlit 외피 메인 화면 | `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py` | MCP discovery, tool routing, ReAct-style agent loop, FGA evidence 표시 |
|
||||||
| AI Web Agent Console 패키지 | `ai-web-agent-console/ai_web_agent_console/` | 인증, 화면, MCP, 모델과 질의 계약 모듈 |
|
| Streamlit 외피 MCP 설정 | `poc4_active_source_20260714/config/mcp_servers.json` | `kb_mcp`, `kb_vector_mcp` endpoint와 allowlist |
|
||||||
| AI Web Agent Console 설정 | `ai-web-agent-console/config/` | 제품, MCP, 모델, 데모 사용자와 질문 JSON |
|
|
||||||
|
|
||||||
Git 기준은 다음과 같다.
|
Git 기준은 다음과 같다.
|
||||||
|
|
||||||
@@ -82,12 +80,14 @@ Git 기준은 다음과 같다.
|
|||||||
| 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` |
|
| 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` |
|
||||||
| 운영 기준 remote | `gitea` |
|
| 운영 기준 remote | `gitea` |
|
||||||
| 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` |
|
| 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` |
|
||||||
| HMM 기준 branch | `hmm-backoffice` |
|
| 기준 branch | `main` |
|
||||||
|
| 보조 remote | `origin` = `https://github.com/devmrko/vpd-permission-poc.git` |
|
||||||
|
|
||||||
주의:
|
주의:
|
||||||
|
|
||||||
- HMM 운영·현행화 기준은 Gitea의 `hmm-backoffice` 브랜치다.
|
- 현재 운영·현행화 기준은 `gitea/main`이다.
|
||||||
- 날짜 스냅샷과 PoC 번호는 현재 소스 경로로 사용하지 않는다.
|
- `origin/main`은 보조 remote이며, 이 작업공간의 `main`이 `origin/main`보다 앞서 있을 수 있다.
|
||||||
|
- `poc4_active_source_20260714/`는 배포 VM의 PoC4 활성 소스를 가져온 snapshot이다. 원본은 배포 VM의 `/home/opc/poc_4/` 계열 경로에서 운영되었고, 저장소에는 비밀정보 없이 소스와 설정 sample만 보관한다.
|
||||||
- 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다.
|
- 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -97,7 +97,6 @@ Git 기준은 다음과 같다.
|
|||||||
| 계층 | 객체 | 역할 |
|
| 계층 | 객체 | 역할 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 원격 | `public.customers` (PG), `ecommerce_poc.customers` (MySQL) | 원본 데이터 |
|
| 원격 | `public.customers` (PG), `ecommerce_poc.customers` (MySQL) | 원본 데이터 |
|
||||||
| 원격 HMM 데모 | `hmm_demo.carriers`, `hmm_demo.carrier_monthly_performance` (PG) | 가상 선사 기준정보와 최근 18개월 월간 KPI |
|
|
||||||
| ADB - 연결 | `RDS_POSTGRES_LINK`, `RDS_LINK` | `DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 로 만든 heterogeneous DB Link |
|
| ADB - 연결 | `RDS_POSTGRES_LINK`, `RDS_LINK` | `DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 로 만든 heterogeneous DB Link |
|
||||||
| ADB - 매핑 | `app_customer`, `app_user`, `app_group`, `user_group`, `db_source`, `permission` | 누가 어느 소스의 어느 region 을 볼 수 있는지 |
|
| ADB - 매핑 | `app_customer`, `app_user`, `app_group`, `user_group`, `db_source`, `permission` | 누가 어느 소스의 어느 region 을 볼 수 있는지 |
|
||||||
| ADB - 컨텍스트 | `vpd_ctx` (Secure Application Context) + `ctx_pkg` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
|
| ADB - 컨텍스트 | `vpd_ctx` (Secure Application Context) + `ctx_pkg` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
|
||||||
@@ -110,7 +109,7 @@ Git 기준은 다음과 같다.
|
|||||||
## 빠른 시작 (One-Click)
|
## 빠른 시작 (One-Click)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --branch hmm-backoffice https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git
|
git clone https://github.com/<you>/vpd-permission-poc.git
|
||||||
cd vpd-permission-poc
|
cd vpd-permission-poc
|
||||||
|
|
||||||
# 1) 환경값 채우기
|
# 1) 환경값 채우기
|
||||||
@@ -168,7 +167,7 @@ $EDITOR .env
|
|||||||
|
|
||||||
## 데모 시나리오 — 2×2 source access matrix
|
## 데모 시나리오 — 2×2 source access matrix
|
||||||
|
|
||||||
`database/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
`sql/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
||||||
|
|
||||||
| DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 |
|
| DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
@@ -186,7 +185,7 @@ $EDITOR .env
|
|||||||
(`1=0` predicate) — **deny by default**.
|
(`1=0` predicate) — **deny by default**.
|
||||||
* 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음.
|
* 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음.
|
||||||
|
|
||||||
`database/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트
|
`sql/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트
|
||||||
스푸핑, DBMS_RLS 변경, 매핑 테이블 SELECT) 를 시도하고 모두 ORA-xxxxx 로 실패하는 것을
|
스푸핑, DBMS_RLS 변경, 매핑 테이블 SELECT) 를 시도하고 모두 ORA-xxxxx 로 실패하는 것을
|
||||||
보여줍니다. 09/10/11 은 각 유저의 expected 행 수를 가볍게 확인합니다.
|
보여줍니다. 09/10/11 은 각 유저의 expected 행 수를 가볍게 확인합니다.
|
||||||
|
|
||||||
@@ -198,7 +197,7 @@ $EDITOR .env
|
|||||||
|
|
||||||
## DDS 설정 핵심
|
## DDS 설정 핵심
|
||||||
|
|
||||||
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이
|
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이
|
||||||
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
|
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
|
||||||
|
|
||||||
### 사전 조건
|
### 사전 조건
|
||||||
@@ -258,23 +257,10 @@ CREATE DATA GRANT admin.alice_apac_grant
|
|||||||
.
|
.
|
||||||
├── run.sh # 원클릭 엔트리포인트
|
├── run.sh # 원클릭 엔트리포인트
|
||||||
├── .env.example
|
├── .env.example
|
||||||
├── vpd-backoffice/ # Spring Boot VPD 관리 백오피스
|
|
||||||
│ ├── pom.xml
|
|
||||||
│ └── src/
|
|
||||||
├── ai-web-agent-console/ # Streamlit MCP 업무 에이전트
|
|
||||||
│ ├── app.py
|
|
||||||
│ ├── ai_web_agent_console/
|
|
||||||
│ ├── config/
|
|
||||||
│ └── tests/
|
|
||||||
├── dds-backoffice/ # DDS 전용 Spring Boot 백오피스
|
|
||||||
├── deploy/ # 애플리케이션별 배포 설정
|
|
||||||
├── tests/ # 저장소 폴더 경계와 경로 규칙 검증
|
|
||||||
├── scripts/lib/common.sh # log/ok/warn/die + env 검증 헬퍼
|
├── scripts/lib/common.sh # log/ok/warn/die + env 검증 헬퍼
|
||||||
├── database/
|
├── sql/
|
||||||
│ ├── source/
|
│ ├── source/
|
||||||
│ │ ├── postgres_setup.sql # 원격 PG: customers + 12 rows
|
│ │ ├── postgres_setup.sql # 원격 PG: customers + 12 rows
|
||||||
│ │ ├── postgres_hmm_carrier_performance.sql # 원격 PG: HMM 가상 선사 8개 + 월간 KPI 144 rows
|
|
||||||
│ │ ├── postgres_hmm_carrier_performance_verify.sql # HMM 선사 실적 건수·패턴·제약·TLS·권한 검증
|
|
||||||
│ │ └── mysql_setup.sql # 원격 MySQL: customers + 12 rows
|
│ │ └── mysql_setup.sql # 원격 MySQL: customers + 12 rows
|
||||||
│ └── adb/
|
│ └── adb/
|
||||||
│ ├── 00_cleanup.sql # 멱등 teardown
|
│ ├── 00_cleanup.sql # 멱등 teardown
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# AI Web Agent Console local/VM configuration example.
|
|
||||||
# Copy to .env and keep real credentials out of Git.
|
|
||||||
|
|
||||||
# MCP
|
|
||||||
HMM_MCP_BEARER_TOKEN=
|
|
||||||
AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS=45
|
|
||||||
|
|
||||||
# OCI Generative AI SDK
|
|
||||||
OCI_AUTH_TYPE=config_file
|
|
||||||
OCI_CONFIG_FILE=~/.oci/config
|
|
||||||
OCI_PROFILE=DEFAULT
|
|
||||||
OCI_GENAI_COMPARTMENT_ID=
|
|
||||||
AI_WEB_AGENT_CONSOLE_MODEL_PROFILE=gpt54_mini_oci
|
|
||||||
AI_WEB_AGENT_CONSOLE_COMPLEX_REASONING_MODEL_PROFILE=gpt54_mini_oci
|
|
||||||
|
|
||||||
# Optional model route overrides
|
|
||||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini
|
|
||||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION=us-chicago-1
|
|
||||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
|
||||||
|
|
||||||
# Login gateway. Generate PBKDF2 hash and random cookie secret outside Git.
|
|
||||||
AI_WEB_AGENT_CONSOLE_LOGIN_USER=demo-admin
|
|
||||||
AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2=
|
|
||||||
AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET=
|
|
||||||
PORTAL_AUTH_BIND_ADDRESS=127.0.0.1
|
|
||||||
PORTAL_AUTH_PORT=8621
|
|
||||||
PORTAL_AUTH_SESSION_SECONDS=43200
|
|
||||||
PORTAL_AUTH_REMEMBER_SECONDS=604800
|
|
||||||
|
|
||||||
# Product display
|
|
||||||
AGENT_CONSOLE_NAME=HMM AI 업무 에이전트
|
|
||||||
AGENT_CONSOLE_SHORT_NAME=HMM
|
|
||||||
AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트
|
|
||||||
AGENT_CONSOLE_PRIMARY_COLOR=#004b87
|
|
||||||
|
|
||||||
# Local conversation store
|
|
||||||
AI_WEB_AGENT_CONSOLE_CHAT_DB_PATH=data/ai_web_agent_console_chat.sqlite3
|
|
||||||
|
|
||||||
# Optional audit DB. Password and Wallet remain outside Git.
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE=.env
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_USER=ADMIN
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_PASSWORD=
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_DSN=
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_DIR=
|
|
||||||
AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD=
|
|
||||||
|
|
||||||
# Optional query contract override
|
|
||||||
AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH=config/hmm_hr_query_contracts.json
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# AI Web Agent Console
|
|
||||||
|
|
||||||
HMM 업무 데이터를 MCP 도구로 조회하고 OCI Generative AI로 답변을 구성하는 Streamlit
|
|
||||||
애플리케이션이다. 과거 PoC 번호나 소스 추출 날짜는 애플리케이션 이름과 패키지 경계로
|
|
||||||
사용하지 않는다.
|
|
||||||
|
|
||||||
## 구조
|
|
||||||
|
|
||||||
```text
|
|
||||||
ai-web-agent-console/
|
|
||||||
├── app.py Streamlit 실행 진입점
|
|
||||||
├── ai_web_agent_console/ 인증, 화면, MCP, 모델, 질의 계약 모듈
|
|
||||||
├── config/ 제품·모델·MCP·시나리오 JSON
|
|
||||||
├── tests/ Python 회귀 테스트
|
|
||||||
├── .env.example 환경변수 예시
|
|
||||||
└── requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ai-web-agent-console
|
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip install -r requirements.txt
|
|
||||||
.venv/bin/streamlit run app.py --server.address 0.0.0.0 --server.port 8622
|
|
||||||
```
|
|
||||||
|
|
||||||
인증 게이트웨이는 같은 디렉토리에서 다음과 같이 실행한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.venv/bin/python -m ai_web_agent_console.auth_gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
## 설정
|
|
||||||
|
|
||||||
- `config/app_profile.json`: 제품명, 설명, 아이콘, 색상
|
|
||||||
- `config/mcp_servers.json`: MCP endpoint와 허용 도구
|
|
||||||
- `config/model_profiles.json`: OCI Generative AI 모델 profile
|
|
||||||
- `config/vpd_token_presets.json`: 데모 사용자와 토큰 환경변수 매핑
|
|
||||||
- `config/hmm_demo_scenarios.json`: 화면의 예제 질문
|
|
||||||
- `config/hmm_hr_query_contracts.json`: 질의별 필수 근거와 계산·시간 규칙
|
|
||||||
|
|
||||||
실제 토큰, DB 비밀번호, Wallet, OCI private key는 Git에 넣지 않는다. 데모 사용자 JSON은
|
|
||||||
`HMM_MCP_BEARER_TOKEN` 같은 환경변수 이름만 참조한다.
|
|
||||||
|
|
||||||
## 검증
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m compileall -q app.py ai_web_agent_console tests
|
|
||||||
python3 -m unittest discover -s tests -p 'test_*.py'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 운영 호환성
|
|
||||||
|
|
||||||
운영 서버의 물리 경로 `/opt/hmm-poc4`와 기존 SQLite 테이블명은 대화 이력과 서비스 전환
|
|
||||||
호환을 위해 유지할 수 있다. Git 폴더, Python 패키지와 실행 명령은 이 문서의 새 이름만
|
|
||||||
사용한다.
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"""Reusable audit-tab renderer with data loaders supplied by the application."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
|
|
||||||
AuditInventoryLoader = Callable[[], list[dict[str, Any]]]
|
|
||||||
AuditEventsLoader = Callable[[int, int, str, str], list[dict[str, Any]]]
|
|
||||||
|
|
||||||
|
|
||||||
def render_hmm_audit_tab(
|
|
||||||
st: Any,
|
|
||||||
inventory_loader: AuditInventoryLoader,
|
|
||||||
events_loader: AuditEventsLoader,
|
|
||||||
error_type: type[Exception],
|
|
||||||
) -> None:
|
|
||||||
"""Render HMM access audit data without owning DB connection details."""
|
|
||||||
|
|
||||||
st.markdown(
|
|
||||||
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
|
||||||
'감사로그 ( <strong>HMM 접근 관리</strong> )'
|
|
||||||
'</div>',
|
|
||||||
unsafe_allow_html=True,
|
|
||||||
)
|
|
||||||
st.markdown(
|
|
||||||
'<div class="kb-audit-lead">'
|
|
||||||
'HMM 백오피스의 사용자·그룹·역할·토큰·접근 정책 변경 이력을 시간순으로 확인합니다. '
|
|
||||||
'이벤트 유형과 처리 상태로 필터링해 운영 변경의 성공·실패를 추적할 수 있습니다.'
|
|
||||||
'</div>',
|
|
||||||
unsafe_allow_html=True,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
inventory = inventory_loader()
|
|
||||||
except error_type as exc:
|
|
||||||
st.error(str(exc))
|
|
||||||
return
|
|
||||||
|
|
||||||
event_types = tuple(
|
|
||||||
str(item.get("event_type") or "").strip()
|
|
||||||
for item in inventory
|
|
||||||
if str(item.get("event_type") or "").strip()
|
|
||||||
)
|
|
||||||
st.markdown('<div class="kb-audit-heading">조회 조건</div>', unsafe_allow_html=True)
|
|
||||||
with st.container(key="poc4_hmm_audit_filters"):
|
|
||||||
event_column, status_column = st.columns(2)
|
|
||||||
with event_column:
|
|
||||||
selected_event_type = st.selectbox(
|
|
||||||
"이벤트 유형",
|
|
||||||
options=("", *event_types),
|
|
||||||
format_func=lambda value: "전체 이벤트" if not value else value,
|
|
||||||
key="poc4_hmm_audit_event_filter",
|
|
||||||
)
|
|
||||||
with status_column:
|
|
||||||
selected_status = st.selectbox(
|
|
||||||
"처리 상태",
|
|
||||||
options=("", "SUCCESS", "FAILURE", "DENIED"),
|
|
||||||
format_func=lambda value: "전체 상태" if not value else value,
|
|
||||||
key="poc4_hmm_audit_status_filter",
|
|
||||||
)
|
|
||||||
days_column, limit_column, refresh_column = st.columns([1.5, 1, 0.8])
|
|
||||||
with days_column:
|
|
||||||
days = st.slider(
|
|
||||||
"조회 기간",
|
|
||||||
min_value=1,
|
|
||||||
max_value=90,
|
|
||||||
value=7,
|
|
||||||
format="%d일",
|
|
||||||
key="poc4_hmm_audit_days",
|
|
||||||
)
|
|
||||||
with limit_column:
|
|
||||||
row_limit = st.number_input(
|
|
||||||
"최대 건수",
|
|
||||||
min_value=10,
|
|
||||||
max_value=500,
|
|
||||||
value=100,
|
|
||||||
step=10,
|
|
||||||
key="poc4_hmm_audit_row_limit",
|
|
||||||
)
|
|
||||||
with refresh_column:
|
|
||||||
st.markdown('<div style="height: 28px"></div>', unsafe_allow_html=True)
|
|
||||||
if st.button(
|
|
||||||
"새로고침",
|
|
||||||
icon=":material/refresh:",
|
|
||||||
width="stretch",
|
|
||||||
key="poc4_hmm_audit_refresh",
|
|
||||||
):
|
|
||||||
inventory_loader.clear()
|
|
||||||
events_loader.clear()
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
try:
|
|
||||||
events = events_loader(
|
|
||||||
int(days), int(row_limit), selected_event_type, selected_status
|
|
||||||
)
|
|
||||||
except error_type as exc:
|
|
||||||
st.error(str(exc))
|
|
||||||
return
|
|
||||||
|
|
||||||
success_count = sum(
|
|
||||||
1 for item in events if str(item.get("status") or "").upper() == "SUCCESS"
|
|
||||||
)
|
|
||||||
with st.container(key="poc4_hmm_audit_metrics"):
|
|
||||||
type_metric, event_metric, success_metric, failure_metric = st.columns(4)
|
|
||||||
type_metric.metric("이벤트 유형", len(inventory))
|
|
||||||
event_metric.metric("조회 이벤트", len(events))
|
|
||||||
success_metric.metric("성공", success_count)
|
|
||||||
failure_metric.metric("실패·거부", len(events) - success_count)
|
|
||||||
|
|
||||||
st.markdown(
|
|
||||||
'<div class="kb-audit-heading">이벤트 유형 현황</div>'
|
|
||||||
f'<div class="kb-audit-caption">현재 기록된 이벤트 유형 {len(inventory)}개 · '
|
|
||||||
'유형별 누적 건수와 최근 발생 시각을 확인합니다.</div>',
|
|
||||||
unsafe_allow_html=True,
|
|
||||||
)
|
|
||||||
with st.container(key="poc4_hmm_audit_inventory_panel"):
|
|
||||||
with st.expander("감사 이벤트 유형", expanded=True):
|
|
||||||
if inventory:
|
|
||||||
st.dataframe(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"이벤트 유형": str(item.get("event_type") or ""),
|
|
||||||
"누적 건수": int(item.get("event_count") or 0),
|
|
||||||
"최근 발생(KST)": str(item.get("latest_event_time") or ""),
|
|
||||||
}
|
|
||||||
for item in inventory
|
|
||||||
],
|
|
||||||
column_config={
|
|
||||||
"이벤트 유형": st.column_config.TextColumn(width="large"),
|
|
||||||
"누적 건수": st.column_config.NumberColumn(width="small"),
|
|
||||||
"최근 발생(KST)": st.column_config.TextColumn(width="medium"),
|
|
||||||
},
|
|
||||||
hide_index=True,
|
|
||||||
width="stretch",
|
|
||||||
height=min(360, 72 + 36 * len(inventory)),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
st.caption("아직 기록된 HMM 접근 관리 이벤트가 없습니다.")
|
|
||||||
|
|
||||||
st.markdown(
|
|
||||||
'<div class="kb-audit-heading">감사 이벤트</div>'
|
|
||||||
'<div class="kb-audit-caption">최신 이벤트부터 표시합니다. '
|
|
||||||
'처리 상태와 대상 식별자, 오류 메시지를 먼저 확인하세요.</div>',
|
|
||||||
unsafe_allow_html=True,
|
|
||||||
)
|
|
||||||
show_details = st.toggle(
|
|
||||||
"상세 메시지 표시",
|
|
||||||
value=True,
|
|
||||||
key="poc4_hmm_audit_show_details",
|
|
||||||
)
|
|
||||||
if not events:
|
|
||||||
st.info("선택한 조건에 해당하는 HMM 접근 관리 이벤트가 없습니다.")
|
|
||||||
return
|
|
||||||
|
|
||||||
display_rows: list[dict[str, Any]] = []
|
|
||||||
for event in events:
|
|
||||||
row: dict[str, Any] = {
|
|
||||||
"감사 ID": int(event.get("audit_id") or 0),
|
|
||||||
"발생시각(KST)": str(event.get("event_time") or ""),
|
|
||||||
"이벤트 유형": str(event.get("event_type") or ""),
|
|
||||||
"상태": str(event.get("status") or ""),
|
|
||||||
"토큰 Key ID": event.get("key_id"),
|
|
||||||
"대상 Object ID": event.get("object_id"),
|
|
||||||
"처리 행": event.get("row_count"),
|
|
||||||
"오류 코드": str(event.get("error_code") or ""),
|
|
||||||
}
|
|
||||||
if show_details:
|
|
||||||
row["메시지"] = str(event.get("message") or "")
|
|
||||||
display_rows.append(row)
|
|
||||||
|
|
||||||
column_config: dict[str, Any] = {
|
|
||||||
"감사 ID": st.column_config.NumberColumn(width="small"),
|
|
||||||
"발생시각(KST)": st.column_config.TextColumn(width="medium"),
|
|
||||||
"이벤트 유형": st.column_config.TextColumn(width="large"),
|
|
||||||
"상태": st.column_config.TextColumn(width="small"),
|
|
||||||
"토큰 Key ID": st.column_config.NumberColumn(width="small"),
|
|
||||||
"대상 Object ID": st.column_config.NumberColumn(width="small"),
|
|
||||||
"처리 행": st.column_config.NumberColumn(width="small"),
|
|
||||||
"오류 코드": st.column_config.TextColumn(width="medium"),
|
|
||||||
}
|
|
||||||
if show_details:
|
|
||||||
column_config["메시지"] = st.column_config.TextColumn(width="large")
|
|
||||||
with st.container(key="poc4_hmm_audit_event_panel"):
|
|
||||||
st.dataframe(
|
|
||||||
display_rows,
|
|
||||||
column_config=column_config,
|
|
||||||
hide_index=True,
|
|
||||||
width="stretch",
|
|
||||||
height=min(640, 104 + 38 * len(display_rows)),
|
|
||||||
)
|
|
||||||
@@ -1,563 +0,0 @@
|
|||||||
"""Small localhost authentication service for the HMM Streamlit portal.
|
|
||||||
|
|
||||||
Nginx owns the public security boundary. This module validates the existing
|
|
||||||
PBKDF2 login, issues a signed HttpOnly cookie, and answers Nginx auth_request
|
|
||||||
subrequests. Authentication values are never accepted from a URL.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
from collections import defaultdict, deque
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from http import HTTPStatus
|
|
||||||
from http.cookies import SimpleCookie
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Deque
|
|
||||||
from urllib.parse import parse_qs, urlsplit
|
|
||||||
|
|
||||||
|
|
||||||
def _environment_value(*names: str, default: str = "") -> str:
|
|
||||||
for name in names:
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is not None and value.strip():
|
|
||||||
return value.strip()
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger("hmm_portal_auth")
|
|
||||||
SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION"
|
|
||||||
CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF"
|
|
||||||
SESSION_TOKEN_VERSION = 2
|
|
||||||
MAX_REQUEST_BYTES = 8_192
|
|
||||||
MAX_FIELD_CHARS = 200
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AuthConfig:
|
|
||||||
username: str
|
|
||||||
password_pbkdf2: str
|
|
||||||
cookie_secret: str
|
|
||||||
bind_address: str = "127.0.0.1"
|
|
||||||
port: int = 8621
|
|
||||||
session_seconds: int = 12 * 60 * 60
|
|
||||||
remember_seconds: int = 7 * 24 * 60 * 60
|
|
||||||
product_name: str = "HMM AI 업무 에이전트"
|
|
||||||
login_title: str = "HMM AI 업무 에이전트"
|
|
||||||
login_description: str = "사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다."
|
|
||||||
login_footer: str = "승인된 사용자만 접속할 수 있습니다."
|
|
||||||
primary_color: str = "#004b87"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_environment(cls) -> "AuthConfig":
|
|
||||||
config = cls(
|
|
||||||
username=_environment_value(
|
|
||||||
"AI_WEB_AGENT_CONSOLE_LOGIN_USER", "POC4_LOGIN_USER"
|
|
||||||
),
|
|
||||||
password_pbkdf2=_environment_value(
|
|
||||||
"AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2",
|
|
||||||
"POC4_LOGIN_PASSWORD_PBKDF2",
|
|
||||||
),
|
|
||||||
cookie_secret=_environment_value(
|
|
||||||
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET",
|
|
||||||
"POC4_LOGIN_COOKIE_SECRET",
|
|
||||||
),
|
|
||||||
bind_address=os.environ.get(
|
|
||||||
"PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1"
|
|
||||||
).strip(),
|
|
||||||
port=int(os.environ.get("PORTAL_AUTH_PORT", "8621")),
|
|
||||||
session_seconds=int(
|
|
||||||
os.environ.get("PORTAL_AUTH_SESSION_SECONDS", str(12 * 60 * 60))
|
|
||||||
),
|
|
||||||
remember_seconds=int(
|
|
||||||
os.environ.get(
|
|
||||||
"PORTAL_AUTH_REMEMBER_SECONDS", str(7 * 24 * 60 * 60)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
product_name=os.environ.get(
|
|
||||||
"AGENT_CONSOLE_NAME", "HMM AI 업무 에이전트"
|
|
||||||
).strip(),
|
|
||||||
login_title=os.environ.get(
|
|
||||||
"AGENT_CONSOLE_LOGIN_TITLE", "HMM AI 업무 에이전트"
|
|
||||||
).strip(),
|
|
||||||
login_description=os.environ.get(
|
|
||||||
"AGENT_CONSOLE_LOGIN_DESCRIPTION",
|
|
||||||
"사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다.",
|
|
||||||
).strip(),
|
|
||||||
login_footer=os.environ.get(
|
|
||||||
"AGENT_CONSOLE_LOGIN_FOOTER",
|
|
||||||
"승인된 사용자만 접속할 수 있습니다.",
|
|
||||||
).strip(),
|
|
||||||
primary_color=os.environ.get(
|
|
||||||
"AGENT_CONSOLE_PRIMARY_COLOR", "#004b87"
|
|
||||||
).strip(),
|
|
||||||
)
|
|
||||||
config.validate()
|
|
||||||
return config
|
|
||||||
|
|
||||||
def validate(self) -> None:
|
|
||||||
if not self.username or not self.password_pbkdf2:
|
|
||||||
raise ValueError("AI web agent console login credentials are not configured")
|
|
||||||
if len(self.cookie_secret.encode("utf-8")) < 32:
|
|
||||||
raise ValueError(
|
|
||||||
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET must be at least 32 bytes"
|
|
||||||
)
|
|
||||||
if self.bind_address not in {"127.0.0.1", "::1"}:
|
|
||||||
raise ValueError("Portal authentication service must bind to loopback")
|
|
||||||
if not 1 <= self.port <= 65535:
|
|
||||||
raise ValueError("PORTAL_AUTH_PORT is invalid")
|
|
||||||
if not 300 <= self.session_seconds <= 24 * 60 * 60:
|
|
||||||
raise ValueError("PORTAL_AUTH_SESSION_SECONDS is outside the safe range")
|
|
||||||
if not self.session_seconds <= self.remember_seconds <= 30 * 24 * 60 * 60:
|
|
||||||
raise ValueError("PORTAL_AUTH_REMEMBER_SECONDS is outside the safe range")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AuthenticatedSession:
|
|
||||||
username: str
|
|
||||||
expires_at: int
|
|
||||||
|
|
||||||
|
|
||||||
class SessionTokenCodec:
|
|
||||||
def __init__(self, secret: str):
|
|
||||||
self._secret = secret.encode("utf-8")
|
|
||||||
|
|
||||||
def issue(self, username: str, lifetime_seconds: int, now: int | None = None) -> str:
|
|
||||||
issued_at = int(time.time()) if now is None else now
|
|
||||||
payload = {
|
|
||||||
"v": SESSION_TOKEN_VERSION,
|
|
||||||
"u": username,
|
|
||||||
"i": issued_at,
|
|
||||||
"e": issued_at + lifetime_seconds,
|
|
||||||
"n": secrets.token_urlsafe(18),
|
|
||||||
}
|
|
||||||
encoded = _base64url_encode(
|
|
||||||
json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
||||||
)
|
|
||||||
signature = hmac.new(
|
|
||||||
self._secret, encoded.encode("ascii"), hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
return f"{encoded}.{signature}"
|
|
||||||
|
|
||||||
def verify(self, token: str, expected_username: str, now: int | None = None) -> AuthenticatedSession | None:
|
|
||||||
if not token or len(token) > 2048:
|
|
||||||
return None
|
|
||||||
current_time = int(time.time()) if now is None else now
|
|
||||||
try:
|
|
||||||
encoded, supplied_signature = token.split(".", 1)
|
|
||||||
expected_signature = hmac.new(
|
|
||||||
self._secret, encoded.encode("ascii"), hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
if not hmac.compare_digest(supplied_signature, expected_signature):
|
|
||||||
return None
|
|
||||||
payload = json.loads(_base64url_decode(encoded).decode("utf-8"))
|
|
||||||
version = int(payload["v"])
|
|
||||||
username = str(payload["u"])
|
|
||||||
issued_at = int(payload["i"])
|
|
||||||
expires_at = int(payload["e"])
|
|
||||||
except (
|
|
||||||
binascii.Error,
|
|
||||||
KeyError,
|
|
||||||
TypeError,
|
|
||||||
ValueError,
|
|
||||||
UnicodeDecodeError,
|
|
||||||
json.JSONDecodeError,
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
if version != SESSION_TOKEN_VERSION:
|
|
||||||
return None
|
|
||||||
if issued_at > current_time + 30 or expires_at <= current_time:
|
|
||||||
return None
|
|
||||||
if expires_at - issued_at > 30 * 24 * 60 * 60:
|
|
||||||
return None
|
|
||||||
if not hmac.compare_digest(username, expected_username):
|
|
||||||
return None
|
|
||||||
return AuthenticatedSession(username=username, expires_at=expires_at)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginAttemptLimiter:
|
|
||||||
def __init__(self, maximum_failures: int = 5, window_seconds: int = 300):
|
|
||||||
self._maximum_failures = maximum_failures
|
|
||||||
self._window_seconds = window_seconds
|
|
||||||
self._failures: dict[str, Deque[float]] = defaultdict(deque)
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def blocked(self, key: str, now: float | None = None) -> bool:
|
|
||||||
current_time = time.monotonic() if now is None else now
|
|
||||||
with self._lock:
|
|
||||||
failures = self._failures[key]
|
|
||||||
self._prune(failures, current_time)
|
|
||||||
return len(failures) >= self._maximum_failures
|
|
||||||
|
|
||||||
def record_failure(self, key: str, now: float | None = None) -> None:
|
|
||||||
current_time = time.monotonic() if now is None else now
|
|
||||||
with self._lock:
|
|
||||||
failures = self._failures[key]
|
|
||||||
self._prune(failures, current_time)
|
|
||||||
failures.append(current_time)
|
|
||||||
|
|
||||||
def reset(self, key: str) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._failures.pop(key, None)
|
|
||||||
|
|
||||||
def _prune(self, failures: Deque[float], now: float) -> None:
|
|
||||||
cutoff = now - self._window_seconds
|
|
||||||
while failures and failures[0] < cutoff:
|
|
||||||
failures.popleft()
|
|
||||||
|
|
||||||
|
|
||||||
def password_matches(password: str, encoded_password: str) -> bool:
|
|
||||||
try:
|
|
||||||
scheme, iterations_text, salt_hex, expected_hex = encoded_password.split(
|
|
||||||
"$", 3
|
|
||||||
)
|
|
||||||
iterations = int(iterations_text)
|
|
||||||
salt = bytes.fromhex(salt_hex)
|
|
||||||
expected = bytes.fromhex(expected_hex)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000:
|
|
||||||
return False
|
|
||||||
candidate = hashlib.pbkdf2_hmac(
|
|
||||||
"sha256", password.encode("utf-8"), salt, iterations
|
|
||||||
)
|
|
||||||
return hmac.compare_digest(candidate, expected)
|
|
||||||
|
|
||||||
|
|
||||||
def session_cookie_header(token: str, max_age: int | None) -> str:
|
|
||||||
attributes = [
|
|
||||||
f"{SESSION_COOKIE_NAME}={token}",
|
|
||||||
"Path=/",
|
|
||||||
"Secure",
|
|
||||||
"HttpOnly",
|
|
||||||
"SameSite=Lax",
|
|
||||||
]
|
|
||||||
if max_age is not None:
|
|
||||||
attributes.append(f"Max-Age={max_age}")
|
|
||||||
return "; ".join(attributes)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_session_cookie_header() -> str:
|
|
||||||
return (
|
|
||||||
f"{SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; "
|
|
||||||
"Secure; HttpOnly; SameSite=Lax"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_cookie_header(value: str, max_age: int = 600) -> str:
|
|
||||||
return (
|
|
||||||
f"{CSRF_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; "
|
|
||||||
"Secure; HttpOnly; SameSite=Strict"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_csrf_cookie_header() -> str:
|
|
||||||
return (
|
|
||||||
f"{CSRF_COOKIE_NAME}=; Path=/; Max-Age=0; "
|
|
||||||
"Secure; HttpOnly; SameSite=Strict"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _base64url_encode(value: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
|
||||||
|
|
||||||
|
|
||||||
def _base64url_decode(value: str) -> bytes:
|
|
||||||
padded = value + "=" * (-len(value) % 4)
|
|
||||||
return base64.urlsafe_b64decode(padded)
|
|
||||||
|
|
||||||
|
|
||||||
def _cookie_value(cookie_header: str, name: str) -> str:
|
|
||||||
try:
|
|
||||||
cookies = SimpleCookie()
|
|
||||||
cookies.load(cookie_header)
|
|
||||||
morsel = cookies.get(name)
|
|
||||||
return morsel.value if morsel is not None else ""
|
|
||||||
except (KeyError, TypeError):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _login_page(config: AuthConfig, csrf_value: str, error: str = "") -> bytes:
|
|
||||||
error_html = (
|
|
||||||
f'<div class="error" role="alert">{html.escape(error)}</div>'
|
|
||||||
if error
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
return f"""<!doctype html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>{html.escape(config.product_name)}</title>
|
|
||||||
<style>
|
|
||||||
:root {{ --primary:{html.escape(config.primary_color)}; --text:#17232d;
|
|
||||||
--muted:#60717f; --border:#d9e0e5; }}
|
|
||||||
* {{ box-sizing:border-box; }}
|
|
||||||
body {{ margin:0; background:#fff; color:var(--text);
|
|
||||||
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
|
|
||||||
main {{ width:min(420px,calc(100% - 40px)); margin:12vh auto 0; }}
|
|
||||||
.wordmark {{ color:var(--primary); font-size:1.25rem; font-weight:800;
|
|
||||||
letter-spacing:.08em; }}
|
|
||||||
h1 {{ margin:16px 0 8px; font-size:1.75rem; }}
|
|
||||||
.description,.footer {{ color:var(--muted); line-height:1.55; }}
|
|
||||||
form {{ margin-top:28px; }}
|
|
||||||
label {{ display:block; margin:0 0 18px; font-weight:700; }}
|
|
||||||
input[type="text"],input[type="password"] {{ width:100%; margin-top:8px;
|
|
||||||
padding:12px 13px; border:1px solid var(--border); border-radius:5px;
|
|
||||||
font:inherit; color:var(--text); background:#fff; }}
|
|
||||||
.remember {{ display:flex; align-items:center; gap:8px; font-weight:500; }}
|
|
||||||
.remember input {{ width:17px; height:17px; }}
|
|
||||||
button {{ width:100%; padding:12px; border:1px solid var(--primary);
|
|
||||||
border-radius:5px; background:var(--primary); color:#fff; font:inherit;
|
|
||||||
font-weight:800; cursor:pointer; }}
|
|
||||||
.error {{ margin:18px 0 0; padding:11px 12px; border:1px solid #d99898;
|
|
||||||
border-radius:5px; color:#8a2222; background:#fff7f7; }}
|
|
||||||
.footer {{ margin-top:22px; font-size:.9rem; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<div class="wordmark">HMM</div>
|
|
||||||
<h1>{html.escape(config.login_title)}</h1>
|
|
||||||
<p class="description">{html.escape(config.login_description)}</p>
|
|
||||||
{error_html}
|
|
||||||
<form action="/auth/login" method="post" autocomplete="on">
|
|
||||||
<input type="hidden" name="csrf" value="{html.escape(csrf_value)}">
|
|
||||||
<label>사용자 ID
|
|
||||||
<input name="username" type="text" maxlength="80" autocomplete="username"
|
|
||||||
required autofocus>
|
|
||||||
</label>
|
|
||||||
<label>비밀번호
|
|
||||||
<input name="password" type="password" maxlength="200"
|
|
||||||
autocomplete="current-password" required>
|
|
||||||
</label>
|
|
||||||
<label class="remember">
|
|
||||||
<input name="remember" type="checkbox" value="yes"> 로그인 유지 (7일)
|
|
||||||
</label>
|
|
||||||
<button type="submit">로그인</button>
|
|
||||||
</form>
|
|
||||||
<p class="footer">{html.escape(config.login_footer)}</p>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>""".encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def build_handler(config: AuthConfig) -> type[BaseHTTPRequestHandler]:
|
|
||||||
codec = SessionTokenCodec(config.cookie_secret)
|
|
||||||
limiter = LoginAttemptLimiter()
|
|
||||||
|
|
||||||
class PortalAuthHandler(BaseHTTPRequestHandler):
|
|
||||||
server_version = "HMMPortalAuth/1.0"
|
|
||||||
sys_version = ""
|
|
||||||
|
|
||||||
def do_HEAD(self) -> None:
|
|
||||||
self._route(send_body=False)
|
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
self._route(send_body=True)
|
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
path = urlsplit(self.path).path
|
|
||||||
if path == "/auth/login":
|
|
||||||
self._login()
|
|
||||||
elif path == "/auth/logout":
|
|
||||||
self._logout()
|
|
||||||
else:
|
|
||||||
self._send_text(HTTPStatus.NOT_FOUND, "Not found")
|
|
||||||
|
|
||||||
def _route(self, send_body: bool) -> None:
|
|
||||||
path = urlsplit(self.path).path
|
|
||||||
if path == "/auth/check":
|
|
||||||
self._check()
|
|
||||||
elif path == "/auth/login":
|
|
||||||
self._show_login(send_body=send_body)
|
|
||||||
elif path == "/auth/logout":
|
|
||||||
self._logout()
|
|
||||||
elif path == "/auth/healthz":
|
|
||||||
self._send_text(HTTPStatus.OK, "ok", send_body=send_body)
|
|
||||||
else:
|
|
||||||
self._send_text(HTTPStatus.NOT_FOUND, "Not found", send_body=send_body)
|
|
||||||
|
|
||||||
def _check(self) -> None:
|
|
||||||
session = self._session()
|
|
||||||
if session is None:
|
|
||||||
self._send_empty(HTTPStatus.UNAUTHORIZED)
|
|
||||||
return
|
|
||||||
self.send_response(HTTPStatus.NO_CONTENT)
|
|
||||||
self._security_headers()
|
|
||||||
self.send_header("X-Auth-User", session.username)
|
|
||||||
self.send_header("X-Auth-Expires", str(session.expires_at))
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def _show_login(self, send_body: bool = True, error: str = "") -> None:
|
|
||||||
if self._session() is not None and not error:
|
|
||||||
self._redirect("/")
|
|
||||||
return
|
|
||||||
csrf_value = secrets.token_urlsafe(32)
|
|
||||||
body = _login_page(config, csrf_value, error)
|
|
||||||
self.send_response(HTTPStatus.OK)
|
|
||||||
self._security_headers()
|
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.send_header("Set-Cookie", csrf_cookie_header(csrf_value))
|
|
||||||
self.end_headers()
|
|
||||||
if send_body:
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def _login(self) -> None:
|
|
||||||
client_key = self._client_key()
|
|
||||||
if limiter.blocked(client_key):
|
|
||||||
self._show_login(error="로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해 주세요.")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
content_length = int(self.headers.get("Content-Length", "0"))
|
|
||||||
except ValueError:
|
|
||||||
content_length = 0
|
|
||||||
if not 1 <= content_length <= MAX_REQUEST_BYTES:
|
|
||||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
|
||||||
return
|
|
||||||
raw_body = self.rfile.read(content_length)
|
|
||||||
try:
|
|
||||||
form = parse_qs(
|
|
||||||
raw_body.decode("utf-8"),
|
|
||||||
keep_blank_values=True,
|
|
||||||
strict_parsing=False,
|
|
||||||
max_num_fields=8,
|
|
||||||
)
|
|
||||||
except (UnicodeDecodeError, ValueError):
|
|
||||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
|
||||||
return
|
|
||||||
username = _form_value(form, "username")
|
|
||||||
password = _form_value(form, "password")
|
|
||||||
csrf_form = _form_value(form, "csrf")
|
|
||||||
csrf_cookie = _cookie_value(
|
|
||||||
self.headers.get("Cookie", ""), CSRF_COOKIE_NAME
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
not csrf_form
|
|
||||||
or not csrf_cookie
|
|
||||||
or not hmac.compare_digest(csrf_form, csrf_cookie)
|
|
||||||
):
|
|
||||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
|
||||||
return
|
|
||||||
valid_credentials = (
|
|
||||||
len(username) <= 80
|
|
||||||
and len(password) <= MAX_FIELD_CHARS
|
|
||||||
and hmac.compare_digest(username.strip(), config.username)
|
|
||||||
and password_matches(password, config.password_pbkdf2)
|
|
||||||
)
|
|
||||||
if not valid_credentials:
|
|
||||||
limiter.record_failure(client_key)
|
|
||||||
self._show_login(error="사용자 ID 또는 비밀번호를 확인해 주세요.")
|
|
||||||
return
|
|
||||||
limiter.reset(client_key)
|
|
||||||
remember = _form_value(form, "remember") == "yes"
|
|
||||||
lifetime = (
|
|
||||||
config.remember_seconds if remember else config.session_seconds
|
|
||||||
)
|
|
||||||
token = codec.issue(config.username, lifetime)
|
|
||||||
self.send_response(HTTPStatus.SEE_OTHER)
|
|
||||||
self._security_headers()
|
|
||||||
self.send_header("Location", "/")
|
|
||||||
self.send_header(
|
|
||||||
"Set-Cookie",
|
|
||||||
session_cookie_header(token, lifetime if remember else None),
|
|
||||||
)
|
|
||||||
self.send_header("Set-Cookie", clear_csrf_cookie_header())
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def _logout(self) -> None:
|
|
||||||
self.send_response(HTTPStatus.SEE_OTHER)
|
|
||||||
self._security_headers()
|
|
||||||
self.send_header("Location", "/auth/login")
|
|
||||||
self.send_header("Set-Cookie", clear_session_cookie_header())
|
|
||||||
self.send_header("Set-Cookie", clear_csrf_cookie_header())
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def _session(self) -> AuthenticatedSession | None:
|
|
||||||
token = _cookie_value(
|
|
||||||
self.headers.get("Cookie", ""), SESSION_COOKIE_NAME
|
|
||||||
)
|
|
||||||
return codec.verify(token, config.username)
|
|
||||||
|
|
||||||
def _client_key(self) -> str:
|
|
||||||
forwarded = self.headers.get("X-Real-IP", "").strip()
|
|
||||||
return forwarded or self.client_address[0]
|
|
||||||
|
|
||||||
def _redirect(self, location: str) -> None:
|
|
||||||
self.send_response(HTTPStatus.SEE_OTHER)
|
|
||||||
self._security_headers()
|
|
||||||
self.send_header("Location", location)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def _send_empty(self, status: HTTPStatus) -> None:
|
|
||||||
self.send_response(status)
|
|
||||||
self._security_headers()
|
|
||||||
self.send_header("Content-Length", "0")
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def _send_text(
|
|
||||||
self,
|
|
||||||
status: HTTPStatus,
|
|
||||||
message: str,
|
|
||||||
send_body: bool = True,
|
|
||||||
) -> None:
|
|
||||||
body = message.encode("utf-8")
|
|
||||||
self.send_response(status)
|
|
||||||
self._security_headers()
|
|
||||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
if send_body:
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def _security_headers(self) -> None:
|
|
||||||
self.send_header("Cache-Control", "no-store")
|
|
||||||
self.send_header("Pragma", "no-cache")
|
|
||||||
self.send_header("X-Content-Type-Options", "nosniff")
|
|
||||||
self.send_header("X-Frame-Options", "DENY")
|
|
||||||
self.send_header("Referrer-Policy", "no-referrer")
|
|
||||||
self.send_header(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
"default-src 'none'; style-src 'unsafe-inline'; "
|
|
||||||
"form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
||||||
)
|
|
||||||
|
|
||||||
def log_message(self, _format: str, *args: object) -> None:
|
|
||||||
# Do not log query strings, cookies, form bodies, or tokens.
|
|
||||||
LOG.info("%s %s", self.command, urlsplit(self.path).path)
|
|
||||||
|
|
||||||
return PortalAuthHandler
|
|
||||||
|
|
||||||
|
|
||||||
def _form_value(form: dict[str, list[str]], name: str) -> str:
|
|
||||||
values = form.get(name)
|
|
||||||
return values[0] if values else ""
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
logging.basicConfig(
|
|
||||||
level=os.environ.get("PORTAL_AUTH_LOG_LEVEL", "INFO"),
|
|
||||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
||||||
)
|
|
||||||
config = AuthConfig.from_environment()
|
|
||||||
server = ThreadingHTTPServer(
|
|
||||||
(config.bind_address, config.port), build_handler(config)
|
|
||||||
)
|
|
||||||
LOG.info("HMM portal authentication service listening on loopback port %s", config.port)
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""Pure helpers for MCP result envelopes used by the Streamlit console."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def response_payload(value: Any) -> Mapping[str, Any]:
|
|
||||||
"""Return the business payload from a direct or nested MCP response."""
|
|
||||||
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
return {}
|
|
||||||
nested = value.get("response")
|
|
||||||
return nested if isinstance(nested, Mapping) else value
|
|
||||||
|
|
||||||
|
|
||||||
def text_result(value: Any) -> str:
|
|
||||||
"""Return a textual `result` field without stringifying other structures."""
|
|
||||||
|
|
||||||
result = response_payload(value).get("result")
|
|
||||||
return result.strip() if isinstance(result, str) else ""
|
|
||||||
|
|
||||||
|
|
||||||
def status_result_summary(value: Any, *, excerpt_chars: int = 900) -> dict[str, Any]:
|
|
||||||
"""Build a safe UI summary for status/result-style compatibility tools."""
|
|
||||||
|
|
||||||
payload = response_payload(value)
|
|
||||||
summary: dict[str, Any] = {}
|
|
||||||
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
|
||||||
item = payload.get(key)
|
|
||||||
if item not in (None, "", []):
|
|
||||||
summary[key] = item
|
|
||||||
result = text_result(value)
|
|
||||||
if result:
|
|
||||||
summary["result_chars"] = len(result)
|
|
||||||
summary["result_excerpt"] = result[:excerpt_chars] + (
|
|
||||||
"..." if len(result) > excerpt_chars else ""
|
|
||||||
)
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
def status_result_evidence(value: Any, *, max_chars: int = 7000) -> dict[str, Any]:
|
|
||||||
"""Preserve bounded textual policy/data evidence for final answer synthesis."""
|
|
||||||
|
|
||||||
payload = response_payload(value)
|
|
||||||
evidence: dict[str, Any] = {}
|
|
||||||
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
|
||||||
item = payload.get(key)
|
|
||||||
if item not in (None, "", []):
|
|
||||||
evidence[key] = item
|
|
||||||
result = text_result(value)
|
|
||||||
if result:
|
|
||||||
evidence["result"] = result[:max_chars] + (
|
|
||||||
"..." if len(result) > max_chars else ""
|
|
||||||
)
|
|
||||||
evidence["result_chars"] = len(result)
|
|
||||||
return evidence
|
|
||||||
|
|
||||||
|
|
||||||
def has_actionable_text_result(value: Any) -> bool:
|
|
||||||
"""Return whether a textual result contains evidence worth stopping on."""
|
|
||||||
|
|
||||||
result = text_result(value)
|
|
||||||
if not result:
|
|
||||||
return False
|
|
||||||
normalized = " ".join(result.casefold().split())
|
|
||||||
return not any(
|
|
||||||
marker in normalized
|
|
||||||
for marker in ("no data found", "no evidence found", "error:")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"has_actionable_text_result",
|
|
||||||
"response_payload",
|
|
||||||
"status_result_evidence",
|
|
||||||
"status_result_summary",
|
|
||||||
"text_result",
|
|
||||||
]
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
"""Configuration-driven query and answer evidence contracts."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ast
|
|
||||||
from decimal import Decimal, InvalidOperation
|
|
||||||
from functools import lru_cache
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
DEFAULT_CONTRACT_FILE = ROOT / "config" / "hmm_hr_query_contracts.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _contract_file() -> Path:
|
|
||||||
configured = str(
|
|
||||||
os.getenv("AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH")
|
|
||||||
or os.getenv("POC4_QUERY_CONTRACTS_PATH")
|
|
||||||
or ""
|
|
||||||
).strip()
|
|
||||||
if not configured:
|
|
||||||
return DEFAULT_CONTRACT_FILE
|
|
||||||
path = Path(configured).expanduser()
|
|
||||||
return path if path.is_absolute() else ROOT / path
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=4)
|
|
||||||
def _load_contract_file(path_text: str) -> tuple[Mapping[str, Any], ...]:
|
|
||||||
path = Path(path_text)
|
|
||||||
if not path.exists():
|
|
||||||
return ()
|
|
||||||
value = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
contracts = value.get("contracts") if isinstance(value, Mapping) else None
|
|
||||||
if not isinstance(contracts, list):
|
|
||||||
raise ValueError("query contracts must contain a contracts array")
|
|
||||||
return tuple(item for item in contracts if isinstance(item, Mapping))
|
|
||||||
|
|
||||||
|
|
||||||
def load_query_contracts() -> tuple[Mapping[str, Any], ...]:
|
|
||||||
return _load_contract_file(str(_contract_file()))
|
|
||||||
|
|
||||||
|
|
||||||
def matching_query_contracts(
|
|
||||||
question: str,
|
|
||||||
tool_name: str,
|
|
||||||
) -> tuple[Mapping[str, Any], ...]:
|
|
||||||
normalized_question = " ".join(str(question or "").casefold().split())
|
|
||||||
normalized_tool = str(tool_name or "").strip()
|
|
||||||
selected: list[Mapping[str, Any]] = []
|
|
||||||
for contract in load_query_contracts():
|
|
||||||
tools = contract.get("applies_to_tools")
|
|
||||||
if isinstance(tools, list) and normalized_tool not in {
|
|
||||||
str(item) for item in tools
|
|
||||||
}:
|
|
||||||
continue
|
|
||||||
intent = contract.get("intent_match")
|
|
||||||
if not isinstance(intent, Mapping):
|
|
||||||
continue
|
|
||||||
subject_terms = [
|
|
||||||
str(item).casefold()
|
|
||||||
for item in intent.get("subject_terms_any", [])
|
|
||||||
if str(item).strip()
|
|
||||||
]
|
|
||||||
action_terms = [
|
|
||||||
str(item).casefold()
|
|
||||||
for item in intent.get("action_terms_any", [])
|
|
||||||
if str(item).strip()
|
|
||||||
]
|
|
||||||
if subject_terms and not any(
|
|
||||||
term in normalized_question for term in subject_terms
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if action_terms and not any(
|
|
||||||
term in normalized_question for term in action_terms
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
selected.append(contract)
|
|
||||||
return tuple(selected)
|
|
||||||
|
|
||||||
|
|
||||||
def query_contract_guidance(question: str, tool_name: str) -> str:
|
|
||||||
contracts = matching_query_contracts(question, tool_name)
|
|
||||||
if not contracts:
|
|
||||||
return ""
|
|
||||||
return json.dumps(
|
|
||||||
{"query_contracts": contracts},
|
|
||||||
ensure_ascii=False,
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def append_query_contract_guidance(
|
|
||||||
tool_query: str,
|
|
||||||
*,
|
|
||||||
original_question: str,
|
|
||||||
tool_name: str,
|
|
||||||
) -> str:
|
|
||||||
guidance = query_contract_guidance(original_question, tool_name)
|
|
||||||
normalized = str(tool_query or "").strip()
|
|
||||||
if not guidance:
|
|
||||||
return normalized
|
|
||||||
return (
|
|
||||||
f"{normalized}\n"
|
|
||||||
"다음 질의 계약의 필드·계산·시간 기준을 반드시 지켜 결과를 반환하세요. "
|
|
||||||
f"계약: {guidance}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_data_result(value: str) -> Any:
|
|
||||||
marker = "DATA_RESULT"
|
|
||||||
position = value.find(marker)
|
|
||||||
if position < 0:
|
|
||||||
return None
|
|
||||||
remainder = value[position + len(marker) :]
|
|
||||||
object_position = remainder.find("{")
|
|
||||||
array_position = remainder.find("[")
|
|
||||||
positions = [
|
|
||||||
candidate
|
|
||||||
for candidate in (object_position, array_position)
|
|
||||||
if candidate >= 0
|
|
||||||
]
|
|
||||||
if not positions:
|
|
||||||
return None
|
|
||||||
candidate = remainder[min(positions) :]
|
|
||||||
try:
|
|
||||||
parsed, _ = json.JSONDecoder().raw_decode(candidate)
|
|
||||||
except (TypeError, ValueError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _evidence_rows(value: Any) -> list[Mapping[str, Any]]:
|
|
||||||
rows: list[Mapping[str, Any]] = []
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
rows.append(value)
|
|
||||||
for item in value.values():
|
|
||||||
rows.extend(_evidence_rows(item))
|
|
||||||
elif isinstance(value, list):
|
|
||||||
for item in value:
|
|
||||||
rows.extend(_evidence_rows(item))
|
|
||||||
elif isinstance(value, str):
|
|
||||||
parsed = _json_data_result(value)
|
|
||||||
if parsed is not None:
|
|
||||||
rows.extend(_evidence_rows(parsed))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _decimal(value: Any) -> Decimal:
|
|
||||||
if isinstance(value, bool) or value is None:
|
|
||||||
raise InvalidOperation
|
|
||||||
return Decimal(str(value))
|
|
||||||
|
|
||||||
|
|
||||||
def _evaluate_contract_expression(
|
|
||||||
expression: str,
|
|
||||||
row: Mapping[str, Any],
|
|
||||||
) -> Decimal:
|
|
||||||
tree = ast.parse(expression, mode="eval")
|
|
||||||
|
|
||||||
def evaluate(node: ast.AST) -> Decimal:
|
|
||||||
if isinstance(node, ast.Expression):
|
|
||||||
return evaluate(node.body)
|
|
||||||
if isinstance(node, ast.Name):
|
|
||||||
key = node.id.casefold()
|
|
||||||
if key not in row:
|
|
||||||
raise InvalidOperation
|
|
||||||
return _decimal(row[key])
|
|
||||||
if isinstance(node, ast.Constant):
|
|
||||||
return _decimal(node.value)
|
|
||||||
if isinstance(node, ast.UnaryOp) and isinstance(
|
|
||||||
node.op, (ast.UAdd, ast.USub)
|
|
||||||
):
|
|
||||||
value = evaluate(node.operand)
|
|
||||||
return value if isinstance(node.op, ast.UAdd) else -value
|
|
||||||
if isinstance(node, ast.BinOp) and isinstance(
|
|
||||||
node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)
|
|
||||||
):
|
|
||||||
left = evaluate(node.left)
|
|
||||||
right = evaluate(node.right)
|
|
||||||
if isinstance(node.op, ast.Add):
|
|
||||||
return left + right
|
|
||||||
if isinstance(node.op, ast.Sub):
|
|
||||||
return left - right
|
|
||||||
if isinstance(node.op, ast.Mult):
|
|
||||||
return left * right
|
|
||||||
return left / right
|
|
||||||
raise ValueError("unsupported contract expression")
|
|
||||||
|
|
||||||
return evaluate(tree)
|
|
||||||
|
|
||||||
|
|
||||||
def evidence_contract_report(
|
|
||||||
contracts: tuple[Mapping[str, Any], ...],
|
|
||||||
evidence: Any,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
rows = [
|
|
||||||
{str(key).strip().casefold(): value for key, value in row.items()}
|
|
||||||
for row in _evidence_rows(evidence)
|
|
||||||
]
|
|
||||||
reports: list[dict[str, Any]] = []
|
|
||||||
for contract in contracts:
|
|
||||||
data_contract = contract.get("data_contract")
|
|
||||||
if not isinstance(data_contract, Mapping):
|
|
||||||
continue
|
|
||||||
required = [
|
|
||||||
str(item).strip().casefold()
|
|
||||||
for item in data_contract.get("required_fields", [])
|
|
||||||
if str(item).strip()
|
|
||||||
]
|
|
||||||
matching_row = next(
|
|
||||||
(row for row in rows if required and all(key in row for key in required)),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
observed = sorted(
|
|
||||||
{
|
|
||||||
key
|
|
||||||
for row in rows
|
|
||||||
for key in row
|
|
||||||
if not required or key in required
|
|
||||||
}
|
|
||||||
)
|
|
||||||
missing = [
|
|
||||||
key.upper()
|
|
||||||
for key in required
|
|
||||||
if matching_row is None or key not in matching_row
|
|
||||||
]
|
|
||||||
computed_checks: list[dict[str, Any]] = []
|
|
||||||
computed_fields = data_contract.get("computed_fields")
|
|
||||||
if matching_row is not None and isinstance(computed_fields, Mapping):
|
|
||||||
for field, definition in computed_fields.items():
|
|
||||||
normalized_field = str(field).strip().casefold()
|
|
||||||
expression = (
|
|
||||||
str(definition.get("expression") or "").strip()
|
|
||||||
if isinstance(definition, Mapping)
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
if not normalized_field or not expression:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
actual = _decimal(matching_row.get(normalized_field))
|
|
||||||
expected = _evaluate_contract_expression(
|
|
||||||
expression,
|
|
||||||
matching_row,
|
|
||||||
)
|
|
||||||
matches = actual == expected
|
|
||||||
computed_checks.append(
|
|
||||||
{
|
|
||||||
"field": normalized_field.upper(),
|
|
||||||
"expression": expression,
|
|
||||||
"actual": str(actual),
|
|
||||||
"expected": str(expected),
|
|
||||||
"satisfied": matches,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except (InvalidOperation, ValueError, ZeroDivisionError):
|
|
||||||
computed_checks.append(
|
|
||||||
{
|
|
||||||
"field": normalized_field.upper(),
|
|
||||||
"expression": expression,
|
|
||||||
"satisfied": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
calculations_satisfied = all(
|
|
||||||
bool(check.get("satisfied")) for check in computed_checks
|
|
||||||
)
|
|
||||||
temporal_checks: list[dict[str, Any]] = []
|
|
||||||
temporal_contract = contract.get("temporal_contract")
|
|
||||||
if matching_row is not None and isinstance(temporal_contract, Mapping):
|
|
||||||
status_fields = [
|
|
||||||
str(item).strip().casefold()
|
|
||||||
for item in temporal_contract.get("period_status_fields", [])
|
|
||||||
if str(item).strip()
|
|
||||||
]
|
|
||||||
past_status = str(
|
|
||||||
temporal_contract.get("past_period_status") or ""
|
|
||||||
).strip()
|
|
||||||
observed_status = next(
|
|
||||||
(
|
|
||||||
str(matching_row.get(field) or "").strip()
|
|
||||||
for field in status_fields
|
|
||||||
if str(matching_row.get(field) or "").strip()
|
|
||||||
),
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
if observed_status == past_status and past_status:
|
|
||||||
decision_field = str(
|
|
||||||
temporal_contract.get("past_period_decision_field") or ""
|
|
||||||
).strip().casefold()
|
|
||||||
decision_value = str(
|
|
||||||
temporal_contract.get("past_period_decision_value") or ""
|
|
||||||
).strip()
|
|
||||||
actual_decision = str(
|
|
||||||
matching_row.get(decision_field) or ""
|
|
||||||
).strip()
|
|
||||||
temporal_checks.append(
|
|
||||||
{
|
|
||||||
"check": "past_period_decision",
|
|
||||||
"field": decision_field.upper(),
|
|
||||||
"expected": decision_value,
|
|
||||||
"actual": actual_decision,
|
|
||||||
"satisfied": bool(
|
|
||||||
decision_field
|
|
||||||
and decision_value
|
|
||||||
and actual_decision == decision_value
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
forbidden_patterns = [
|
|
||||||
str(item).strip()
|
|
||||||
for item in temporal_contract.get(
|
|
||||||
"forbidden_past_period_decision_field_patterns", []
|
|
||||||
)
|
|
||||||
if str(item).strip()
|
|
||||||
]
|
|
||||||
forbidden_fields = sorted(
|
|
||||||
key.upper()
|
|
||||||
for key in matching_row
|
|
||||||
if any(
|
|
||||||
re.search(pattern, key, flags=re.IGNORECASE)
|
|
||||||
for pattern in forbidden_patterns
|
|
||||||
)
|
|
||||||
)
|
|
||||||
temporal_checks.append(
|
|
||||||
{
|
|
||||||
"check": "no_past_period_yes_no_decision",
|
|
||||||
"forbidden_fields": forbidden_fields,
|
|
||||||
"satisfied": not forbidden_fields,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
required_counts = [
|
|
||||||
str(item).strip().casefold()
|
|
||||||
for item in temporal_contract.get(
|
|
||||||
"validate_stated_days_against", []
|
|
||||||
)
|
|
||||||
if str(item).strip()
|
|
||||||
]
|
|
||||||
temporal_checks.append(
|
|
||||||
{
|
|
||||||
"check": "date_range_counts",
|
|
||||||
"required_fields": [
|
|
||||||
field.upper() for field in required_counts
|
|
||||||
],
|
|
||||||
"missing_fields": [
|
|
||||||
field.upper()
|
|
||||||
for field in required_counts
|
|
||||||
if field not in matching_row
|
|
||||||
],
|
|
||||||
"satisfied": all(
|
|
||||||
field in matching_row for field in required_counts
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
temporal_satisfied = all(
|
|
||||||
bool(check.get("satisfied")) for check in temporal_checks
|
|
||||||
)
|
|
||||||
reports.append(
|
|
||||||
{
|
|
||||||
"contract_id": str(contract.get("id") or ""),
|
|
||||||
"satisfied": bool(
|
|
||||||
matching_row is not None
|
|
||||||
and not missing
|
|
||||||
and calculations_satisfied
|
|
||||||
and temporal_satisfied
|
|
||||||
),
|
|
||||||
"required_fields": [key.upper() for key in required],
|
|
||||||
"observed_required_fields": [key.upper() for key in observed],
|
|
||||||
"missing_fields": missing,
|
|
||||||
"computed_field_checks": computed_checks,
|
|
||||||
"temporal_contract_checks": temporal_checks,
|
|
||||||
"validated_record": (
|
|
||||||
{
|
|
||||||
key.upper(): matching_row.get(key)
|
|
||||||
for key in required
|
|
||||||
}
|
|
||||||
if matching_row is not None
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return reports
|
|
||||||
|
|
||||||
|
|
||||||
def missing_evidence_message(
|
|
||||||
contracts: tuple[Mapping[str, Any], ...],
|
|
||||||
) -> str:
|
|
||||||
for contract in contracts:
|
|
||||||
answer_contract = contract.get("answer_contract")
|
|
||||||
if not isinstance(answer_contract, Mapping):
|
|
||||||
continue
|
|
||||||
message = str(answer_contract.get("missing_evidence_message") or "").strip()
|
|
||||||
if message:
|
|
||||||
return message
|
|
||||||
return "조회 결과가 답변 계약의 필수 근거를 충족하지 않아 결론을 제공할 수 없습니다."
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"append_query_contract_guidance",
|
|
||||||
"evidence_contract_report",
|
|
||||||
"load_query_contracts",
|
|
||||||
"matching_query_contracts",
|
|
||||||
"missing_evidence_message",
|
|
||||||
"query_contract_guidance",
|
|
||||||
]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Smilegate demo modules.
|
|
||||||
|
|
||||||
The portal is assembled from small modules so each feature can be reviewed and
|
|
||||||
released independently.
|
|
||||||
"""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Presentation modules for the Smilegate demo."""
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Blank presentation shell.
|
|
||||||
|
|
||||||
No authentication, data access, MCP call, persistence, or customer text belongs
|
|
||||||
in this module. It exists only to prove the minimal Streamlit runtime path.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def render_blank_shell(st: Any) -> None:
|
|
||||||
"""Render the intentionally empty first review screen."""
|
|
||||||
st.set_page_config(page_title="Smilegate Demo", layout="wide")
|
|
||||||
st.markdown(
|
|
||||||
"""
|
|
||||||
<style>
|
|
||||||
[data-testid="stHeader"],
|
|
||||||
[data-testid="stToolbar"],
|
|
||||||
#MainMenu,
|
|
||||||
footer { display: none; }
|
|
||||||
[data-testid="stAppViewContainer"],
|
|
||||||
.stApp { background: #ffffff; }
|
|
||||||
.block-container { padding: 0; max-width: none; }
|
|
||||||
</style>
|
|
||||||
""",
|
|
||||||
unsafe_allow_html=True,
|
|
||||||
)
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"contracts": [
|
|
||||||
{
|
|
||||||
"id": "leave_balance_eligibility",
|
|
||||||
"description": "연차·휴가 잔여와 신청 가능 여부를 원장 연도 및 구성값으로 검증한다.",
|
|
||||||
"applies_to_tools": [
|
|
||||||
"search_hr_data"
|
|
||||||
],
|
|
||||||
"intent_match": {
|
|
||||||
"subject_terms_any": [
|
|
||||||
"연차",
|
|
||||||
"휴가"
|
|
||||||
],
|
|
||||||
"action_terms_any": [
|
|
||||||
"잔여",
|
|
||||||
"가능",
|
|
||||||
"승인",
|
|
||||||
"신청",
|
|
||||||
"사용할 수"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"data_contract": {
|
|
||||||
"source_object": "HMM_LEAVE_BALANCES",
|
|
||||||
"identity_fields": [
|
|
||||||
"EMPLOYEE_CODE"
|
|
||||||
],
|
|
||||||
"required_fields": [
|
|
||||||
"LEAVE_YEAR",
|
|
||||||
"ANNUAL_ENTITLEMENT",
|
|
||||||
"CARRYOVER_DAYS",
|
|
||||||
"USED_DAYS",
|
|
||||||
"PENDING_DAYS",
|
|
||||||
"AVAILABLE_REMAINING_DAYS"
|
|
||||||
],
|
|
||||||
"computed_fields": {
|
|
||||||
"AVAILABLE_REMAINING_DAYS": {
|
|
||||||
"expression": "ANNUAL_ENTITLEMENT + CARRYOVER_DAYS - USED_DAYS - PENDING_DAYS",
|
|
||||||
"authoritative_source": "DATABASE_VIRTUAL_COLUMN"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"missing_record_semantics": "NO_BALANCE_DATA_FOR_YEAR",
|
|
||||||
"forbidden_missing_record_fallbacks": [
|
|
||||||
"ZERO",
|
|
||||||
"NVL",
|
|
||||||
"COALESCE",
|
|
||||||
"OUTER_JOIN_DEFAULT",
|
|
||||||
"LITERAL_VALUE"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"temporal_contract": {
|
|
||||||
"current_balance_year": "LATEST_AVAILABLE_LEAVE_YEAR",
|
|
||||||
"requested_period_and_current_balance_are_separate": true,
|
|
||||||
"period_status_fields": [
|
|
||||||
"REQUEST_PERIOD_LABEL",
|
|
||||||
"PERIOD_STATUS"
|
|
||||||
],
|
|
||||||
"past_period_status": "HISTORICAL_REQUEST_PERIOD",
|
|
||||||
"past_period_decision": "DO_NOT_DECIDE_NEW_APPLICATION_FROM_CURRENT_BALANCE",
|
|
||||||
"past_period_decision_field": "APPLICATION_DECISION_STATUS",
|
|
||||||
"past_period_decision_value": "NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",
|
|
||||||
"forbidden_past_period_decision_field_patterns": [
|
|
||||||
"^CAN_",
|
|
||||||
"^APPROVAL_(ALLOWED|DENIED)$"
|
|
||||||
],
|
|
||||||
"validate_stated_days_against": [
|
|
||||||
"INCLUSIVE_CALENDAR_DAYS",
|
|
||||||
"MONDAY_TO_FRIDAY_DAYS"
|
|
||||||
],
|
|
||||||
"day_count_mismatch_status": "DATE_COUNT_REQUIRES_VALIDATION"
|
|
||||||
},
|
|
||||||
"answer_contract": {
|
|
||||||
"separate_data_facts_and_policy_requirements": true,
|
|
||||||
"deny_conclusions_without_required_fields": [
|
|
||||||
"ZERO_REMAINING_DAYS",
|
|
||||||
"LEAVE_APPROVAL_ALLOWED",
|
|
||||||
"LEAVE_APPROVAL_DENIED"
|
|
||||||
],
|
|
||||||
"missing_evidence_response": "STATE_INSUFFICIENT_EVIDENCE_WITHOUT_INVENTING_VALUES",
|
|
||||||
"missing_evidence_message": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"default_server_id": "smilegate_game_data_mcp",
|
|
||||||
"servers": [
|
|
||||||
{
|
|
||||||
"id": "smilegate_game_data_mcp",
|
|
||||||
"enabled": true,
|
|
||||||
"provider": "smilegate_select_ai_mcp",
|
|
||||||
"transport": "http",
|
|
||||||
"endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp",
|
|
||||||
"auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN",
|
|
||||||
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
|
|
||||||
"default_tool": "oracle.select_ai.smilegate_fewshot_nl2sql",
|
|
||||||
"router_model_profile": "gpt54_mini_oci",
|
|
||||||
"tool_allowlist": [
|
|
||||||
"oracle.select_ai.fewshot_preflight",
|
|
||||||
"oracle.select_ai.game_query_plan",
|
|
||||||
"oracle.select_ai.game_daily_au_lookup",
|
|
||||||
"oracle.select_ai.smilegate_fewshot_nl2sql",
|
|
||||||
"oracle.select_ai.smilegate_game_text2sql",
|
|
||||||
"oracle.select_ai.qa_vector_search",
|
|
||||||
"oracle.select_ai.qa_vector_store"
|
|
||||||
],
|
|
||||||
"tool_workflow": [
|
|
||||||
{
|
|
||||||
"tool": "oracle.select_ai.fewshot_preflight",
|
|
||||||
"prelude": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tool": "oracle.select_ai.game_query_plan",
|
|
||||||
"prelude": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tool": "oracle.select_ai.smilegate_fewshot_nl2sql",
|
|
||||||
"arguments_from": [
|
|
||||||
{
|
|
||||||
"argument": "fewShotPreflight",
|
|
||||||
"tool": "oracle.select_ai.fewshot_preflight"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"argument": "queryPlan",
|
|
||||||
"tool": "oracle.select_ai.game_query_plan"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tool": "oracle.select_ai.game_daily_au_lookup",
|
|
||||||
"arguments_from": [
|
|
||||||
{
|
|
||||||
"argument": "queryPlan",
|
|
||||||
"tool": "oracle.select_ai.game_query_plan"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Smilegate game-data Text2SQL MCP server"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
"""Small same-origin authentication gateway for the Smilegate Streamlit portal.
|
|
||||||
|
|
||||||
The gateway issues a signed HttpOnly cookie after validating the configured
|
|
||||||
PBKDF2 password. The Streamlit application verifies the signature and expiry
|
|
||||||
from the incoming request, so browser refreshes and WebSocket reconnects do not
|
|
||||||
require a new login.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from http import HTTPStatus
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from urllib.parse import parse_qs
|
|
||||||
|
|
||||||
|
|
||||||
COOKIE_NAME = "poc4_portal_auth"
|
|
||||||
MAX_BODY_BYTES = 8_192
|
|
||||||
COOKIE_TTL_SECONDS = int(os.environ.get("POC4_LOGIN_COOKIE_TTL_SECONDS", "43200"))
|
|
||||||
|
|
||||||
|
|
||||||
def _password_matches(password: str, encoded_password: str) -> bool:
|
|
||||||
try:
|
|
||||||
scheme, iterations_text, salt_hex, expected_hex = encoded_password.split("$", 3)
|
|
||||||
iterations = int(iterations_text)
|
|
||||||
salt = bytes.fromhex(salt_hex)
|
|
||||||
expected = bytes.fromhex(expected_hex)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000:
|
|
||||||
return False
|
|
||||||
candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
|
||||||
return hmac.compare_digest(candidate, expected)
|
|
||||||
|
|
||||||
|
|
||||||
def _cookie_value(username: str) -> str:
|
|
||||||
secret = os.environ["POC4_LOGIN_REMEMBER_SECRET"]
|
|
||||||
claims = {"v": 1, "u": username, "e": int(time.time()) + COOKIE_TTL_SECONDS}
|
|
||||||
encoded = base64.urlsafe_b64encode(
|
|
||||||
json.dumps(claims, 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 _set_cookie(handler: BaseHTTPRequestHandler, value: str, max_age: int) -> None:
|
|
||||||
attributes = [
|
|
||||||
f"{COOKIE_NAME}={value}",
|
|
||||||
"Path=/",
|
|
||||||
f"Max-Age={max_age}",
|
|
||||||
"HttpOnly",
|
|
||||||
"Secure",
|
|
||||||
"SameSite=Lax",
|
|
||||||
]
|
|
||||||
handler.send_header("Set-Cookie", "; ".join(attributes))
|
|
||||||
|
|
||||||
|
|
||||||
class PortalAuthHandler(BaseHTTPRequestHandler):
|
|
||||||
server_version = "SmilegatePortalAuth/1.0"
|
|
||||||
|
|
||||||
def log_message(self, _format: str, *_args: object) -> None:
|
|
||||||
# Do not log form data or authentication details.
|
|
||||||
return
|
|
||||||
|
|
||||||
def _redirect(self, location: str, cookie_value: str | None = None, max_age: int = 0) -> None:
|
|
||||||
self.send_response(HTTPStatus.SEE_OTHER)
|
|
||||||
if cookie_value is not None:
|
|
||||||
_set_cookie(self, cookie_value, max_age)
|
|
||||||
self.send_header("Location", location)
|
|
||||||
self.send_header("Cache-Control", "no-store")
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
|
||||||
if self.path == "/health":
|
|
||||||
self.send_response(HTTPStatus.OK)
|
|
||||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
self.send_header("Cache-Control", "no-store")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b"ok\n")
|
|
||||||
return
|
|
||||||
if self.path == "/logout":
|
|
||||||
self._redirect("/", "", 0)
|
|
||||||
return
|
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802
|
|
||||||
if self.path != "/login":
|
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
content_length = int(self.headers.get("Content-Length", "0"))
|
|
||||||
except ValueError:
|
|
||||||
content_length = 0
|
|
||||||
if content_length <= 0 or content_length > MAX_BODY_BYTES:
|
|
||||||
self._redirect("/?login=failed")
|
|
||||||
return
|
|
||||||
form = parse_qs(self.rfile.read(content_length).decode("utf-8"), keep_blank_values=True)
|
|
||||||
username = form.get("username", [""])[0].strip()
|
|
||||||
password = form.get("password", [""])[0]
|
|
||||||
expected_username = os.environ.get("POC4_LOGIN_USER", "").strip()
|
|
||||||
encoded_password = os.environ.get("POC4_LOGIN_PASSWORD_PBKDF2", "").strip()
|
|
||||||
if (
|
|
||||||
expected_username
|
|
||||||
and hmac.compare_digest(username, expected_username)
|
|
||||||
and _password_matches(password, encoded_password)
|
|
||||||
):
|
|
||||||
self._redirect("/", _cookie_value(username), COOKIE_TTL_SECONDS)
|
|
||||||
return
|
|
||||||
self._redirect("/?login=failed")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
address = os.environ.get("POC4_AUTH_BIND", "127.0.0.1")
|
|
||||||
port = int(os.environ.get("POC4_AUTH_PORT", "8623"))
|
|
||||||
required = ("POC4_LOGIN_USER", "POC4_LOGIN_PASSWORD_PBKDF2", "POC4_LOGIN_REMEMBER_SECRET")
|
|
||||||
missing = [name for name in required if not os.environ.get(name, "").strip()]
|
|
||||||
if missing:
|
|
||||||
raise RuntimeError("missing required portal auth configuration")
|
|
||||||
ThreadingHTTPServer((address, port), PortalAuthHandler).serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"""Minimal Smilegate Streamlit demo entrypoint.
|
|
||||||
|
|
||||||
This entrypoint intentionally wires only the blank presentation shell. Feature
|
|
||||||
modules such as authentication, MCP querying, and history are added separately
|
|
||||||
after each review.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import streamlit as st
|
|
||||||
|
|
||||||
from ai_web_agent_console.smilegate_demo.ui.shell import render_blank_shell
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
render_blank_shell(st)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import http.client
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(ROOT))
|
|
||||||
|
|
||||||
from ai_web_agent_console.auth_gateway import ( # noqa: E402
|
|
||||||
AuthConfig,
|
|
||||||
LoginAttemptLimiter,
|
|
||||||
SESSION_COOKIE_NAME,
|
|
||||||
SessionTokenCodec,
|
|
||||||
build_handler,
|
|
||||||
clear_session_cookie_header,
|
|
||||||
password_matches,
|
|
||||||
session_cookie_header,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthGatewayTest(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.secret = "s" * 48
|
|
||||||
self.codec = SessionTokenCodec(self.secret)
|
|
||||||
|
|
||||||
def test_session_token_round_trip_and_tamper_rejection(self) -> None:
|
|
||||||
token = self.codec.issue("demo-admin", 3600, now=1_000)
|
|
||||||
|
|
||||||
session = self.codec.verify(token, "demo-admin", now=1_001)
|
|
||||||
|
|
||||||
self.assertIsNotNone(session)
|
|
||||||
self.assertEqual("demo-admin", session.username)
|
|
||||||
self.assertEqual(4_600, session.expires_at)
|
|
||||||
self.assertIsNone(self.codec.verify(token + "x", "demo-admin", now=1_001))
|
|
||||||
self.assertIsNone(self.codec.verify(token, "other-user", now=1_001))
|
|
||||||
|
|
||||||
def test_expired_session_token_is_rejected(self) -> None:
|
|
||||||
token = self.codec.issue("demo-admin", 300, now=1_000)
|
|
||||||
|
|
||||||
self.assertIsNone(self.codec.verify(token, "demo-admin", now=1_300))
|
|
||||||
|
|
||||||
def test_remember_cookie_has_required_security_attributes(self) -> None:
|
|
||||||
header = session_cookie_header("signed-value", 604_800)
|
|
||||||
|
|
||||||
self.assertIn(f"{SESSION_COOKIE_NAME}=signed-value", header)
|
|
||||||
self.assertIn("Path=/", header)
|
|
||||||
self.assertIn("Secure", header)
|
|
||||||
self.assertIn("HttpOnly", header)
|
|
||||||
self.assertIn("SameSite=Lax", header)
|
|
||||||
self.assertIn("Max-Age=604800", header)
|
|
||||||
self.assertNotIn("Domain=", header)
|
|
||||||
|
|
||||||
def test_session_cookie_omits_persistent_max_age(self) -> None:
|
|
||||||
header = session_cookie_header("signed-value", None)
|
|
||||||
|
|
||||||
self.assertNotIn("Max-Age", header)
|
|
||||||
self.assertIn("HttpOnly", header)
|
|
||||||
|
|
||||||
def test_logout_cookie_expires_immediately(self) -> None:
|
|
||||||
header = clear_session_cookie_header()
|
|
||||||
|
|
||||||
self.assertIn("Max-Age=0", header)
|
|
||||||
self.assertIn("Secure", header)
|
|
||||||
self.assertIn("HttpOnly", header)
|
|
||||||
|
|
||||||
def test_pbkdf2_password_verification(self) -> None:
|
|
||||||
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
|
|
||||||
expected = hashlib.pbkdf2_hmac(
|
|
||||||
"sha256", b"correct-password", salt, 200_000
|
|
||||||
).hex()
|
|
||||||
encoded = f"pbkdf2_sha256$200000${salt.hex()}${expected}"
|
|
||||||
|
|
||||||
self.assertTrue(password_matches("correct-password", encoded))
|
|
||||||
self.assertFalse(password_matches("wrong-password", encoded))
|
|
||||||
|
|
||||||
def test_rate_limiter_blocks_only_after_threshold(self) -> None:
|
|
||||||
limiter = LoginAttemptLimiter(maximum_failures=2, window_seconds=10)
|
|
||||||
|
|
||||||
limiter.record_failure("client", now=1)
|
|
||||||
self.assertFalse(limiter.blocked("client", now=2))
|
|
||||||
limiter.record_failure("client", now=3)
|
|
||||||
self.assertTrue(limiter.blocked("client", now=4))
|
|
||||||
self.assertFalse(limiter.blocked("client", now=20))
|
|
||||||
|
|
||||||
def test_environment_config_requires_new_cookie_secret(self) -> None:
|
|
||||||
previous = dict(os.environ)
|
|
||||||
try:
|
|
||||||
os.environ["POC4_LOGIN_USER"] = "demo-admin"
|
|
||||||
os.environ["POC4_LOGIN_PASSWORD_PBKDF2"] = "encoded"
|
|
||||||
os.environ.pop("POC4_LOGIN_COOKIE_SECRET", None)
|
|
||||||
with self.assertRaisesRegex(ValueError, "COOKIE_SECRET"):
|
|
||||||
AuthConfig.from_environment()
|
|
||||||
finally:
|
|
||||||
os.environ.clear()
|
|
||||||
os.environ.update(previous)
|
|
||||||
|
|
||||||
def test_http_login_check_and_logout_flow_never_uses_url_token(self) -> None:
|
|
||||||
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
|
|
||||||
expected = hashlib.pbkdf2_hmac(
|
|
||||||
"sha256", b"correct-password", salt, 200_000
|
|
||||||
).hex()
|
|
||||||
config = AuthConfig(
|
|
||||||
username="demo-admin",
|
|
||||||
password_pbkdf2=(
|
|
||||||
f"pbkdf2_sha256$200000${salt.hex()}${expected}"
|
|
||||||
),
|
|
||||||
cookie_secret=self.secret,
|
|
||||||
port=8621,
|
|
||||||
)
|
|
||||||
from http.server import ThreadingHTTPServer
|
|
||||||
|
|
||||||
server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(config))
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
connection = http.client.HTTPConnection(
|
|
||||||
"127.0.0.1", server.server_address[1], timeout=3
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
connection.request("GET", "/auth/login")
|
|
||||||
login_page = connection.getresponse()
|
|
||||||
body = login_page.read().decode("utf-8")
|
|
||||||
self.assertEqual(200, login_page.status)
|
|
||||||
csrf_header = next(
|
|
||||||
value
|
|
||||||
for name, value in login_page.getheaders()
|
|
||||||
if name.lower() == "set-cookie"
|
|
||||||
and value.startswith("__Host-HMM_LOGIN_CSRF=")
|
|
||||||
)
|
|
||||||
csrf_value = csrf_header.split("=", 1)[1].split(";", 1)[0]
|
|
||||||
self.assertIn(
|
|
||||||
f'name="csrf" value="{csrf_value}"',
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = urlencode(
|
|
||||||
{
|
|
||||||
"csrf": csrf_value,
|
|
||||||
"username": "demo-admin",
|
|
||||||
"password": "correct-password",
|
|
||||||
"remember": "yes",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
connection.request(
|
|
||||||
"POST",
|
|
||||||
"/auth/login",
|
|
||||||
body=payload,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Cookie": f"__Host-HMM_LOGIN_CSRF={csrf_value}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logged_in = connection.getresponse()
|
|
||||||
logged_in.read()
|
|
||||||
self.assertEqual(303, logged_in.status)
|
|
||||||
self.assertEqual("/", logged_in.getheader("Location"))
|
|
||||||
self.assertNotRegex(logged_in.getheader("Location"), r"token|remember")
|
|
||||||
session_header = next(
|
|
||||||
value
|
|
||||||
for name, value in logged_in.getheaders()
|
|
||||||
if name.lower() == "set-cookie"
|
|
||||||
and value.startswith(f"{SESSION_COOKIE_NAME}=")
|
|
||||||
)
|
|
||||||
session_value = session_header.split("=", 1)[1].split(";", 1)[0]
|
|
||||||
self.assertIn("Secure", session_header)
|
|
||||||
self.assertIn("HttpOnly", session_header)
|
|
||||||
self.assertIn("SameSite=Lax", session_header)
|
|
||||||
|
|
||||||
connection.request(
|
|
||||||
"GET",
|
|
||||||
"/auth/check",
|
|
||||||
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
|
|
||||||
)
|
|
||||||
check = connection.getresponse()
|
|
||||||
check.read()
|
|
||||||
self.assertEqual(204, check.status)
|
|
||||||
self.assertEqual("demo-admin", check.getheader("X-Auth-User"))
|
|
||||||
|
|
||||||
connection.request(
|
|
||||||
"GET",
|
|
||||||
"/auth/check",
|
|
||||||
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}x"},
|
|
||||||
)
|
|
||||||
tampered = connection.getresponse()
|
|
||||||
tampered.read()
|
|
||||||
self.assertEqual(401, tampered.status)
|
|
||||||
|
|
||||||
connection.request(
|
|
||||||
"GET",
|
|
||||||
"/auth/logout",
|
|
||||||
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
|
|
||||||
)
|
|
||||||
logout = connection.getresponse()
|
|
||||||
logout.read()
|
|
||||||
self.assertEqual(303, logout.status)
|
|
||||||
self.assertEqual("/auth/login", logout.getheader("Location"))
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
name.lower() == "set-cookie" and "Max-Age=0" in value
|
|
||||||
for name, value in logout.getheaders()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
thread.join(timeout=3)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import unittest
|
|
||||||
|
|
||||||
from ai_web_agent_console.query_contracts import (
|
|
||||||
append_query_contract_guidance,
|
|
||||||
evidence_contract_report,
|
|
||||||
matching_query_contracts,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_leave_eligibility_question_selects_external_contract():
|
|
||||||
contracts = matching_query_contracts(
|
|
||||||
"직원 E9999의 연차 잔여와 신청 가능 여부를 알려줘",
|
|
||||||
"search_hr_data",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [contract["id"] for contract in contracts] == [
|
|
||||||
"leave_balance_eligibility"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_unrelated_tool_does_not_receive_leave_contract():
|
|
||||||
contracts = matching_query_contracts(
|
|
||||||
"직원 E9999의 연차 잔여를 알려줘",
|
|
||||||
"search_carrier_performance",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert contracts == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_incomplete_result_cannot_be_interpreted_as_zero_balance():
|
|
||||||
contracts = matching_query_contracts(
|
|
||||||
"직원 E9999의 연차 사용 가능 여부를 알려줘",
|
|
||||||
"search_hr_data",
|
|
||||||
)
|
|
||||||
evidence = {
|
|
||||||
"result": (
|
|
||||||
'DATA_RESULT: {"items":[{"EMPLOYEE_CODE":"E9999",'
|
|
||||||
'"REMAINING_DAYS":0}]}'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
report = evidence_contract_report(contracts, evidence)
|
|
||||||
|
|
||||||
assert report[0]["satisfied"] is False
|
|
||||||
assert "AVAILABLE_REMAINING_DAYS" in report[0]["missing_fields"]
|
|
||||||
assert "LEAVE_YEAR" in report[0]["missing_fields"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_database_row_satisfies_contract_without_fixture_values():
|
|
||||||
contracts = matching_query_contracts(
|
|
||||||
"직원 E9999의 연차 신청 가능 여부를 알려줘",
|
|
||||||
"search_hr_data",
|
|
||||||
)
|
|
||||||
evidence = {
|
|
||||||
"result": (
|
|
||||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
|
||||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
|
||||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
|
||||||
'"AVAILABLE_REMAINING_DAYS":14}]}'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
report = evidence_contract_report(contracts, evidence)
|
|
||||||
|
|
||||||
assert report[0]["satisfied"] is True
|
|
||||||
assert report[0]["validated_record"]["LEAVE_YEAR"] == 2031
|
|
||||||
assert report[0]["validated_record"]["AVAILABLE_REMAINING_DAYS"] == 14
|
|
||||||
|
|
||||||
|
|
||||||
def test_conflicting_computed_value_fails_contract():
|
|
||||||
contracts = matching_query_contracts(
|
|
||||||
"직원 E9999의 연차 신청 가능 여부를 알려줘",
|
|
||||||
"search_hr_data",
|
|
||||||
)
|
|
||||||
evidence = {
|
|
||||||
"result": (
|
|
||||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
|
||||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
|
||||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
|
||||||
'"AVAILABLE_REMAINING_DAYS":0}]}'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
report = evidence_contract_report(contracts, evidence)
|
|
||||||
|
|
||||||
assert report[0]["satisfied"] is False
|
|
||||||
assert report[0]["computed_field_checks"][0]["expected"] == "14"
|
|
||||||
assert report[0]["computed_field_checks"][0]["actual"] == "0"
|
|
||||||
|
|
||||||
|
|
||||||
def test_historical_period_rejects_yes_no_decision_and_missing_counts():
|
|
||||||
contracts = matching_query_contracts(
|
|
||||||
"직원 E9999의 과거 연차 신청 가능 여부를 알려줘",
|
|
||||||
"search_hr_data",
|
|
||||||
)
|
|
||||||
evidence = {
|
|
||||||
"result": (
|
|
||||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
|
||||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
|
||||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
|
||||||
'"AVAILABLE_REMAINING_DAYS":14,'
|
|
||||||
'"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",'
|
|
||||||
'"CAN_APPLY":"YES","INCLUSIVE_CALENDAR_DAYS":11}]}'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
report = evidence_contract_report(contracts, evidence)
|
|
||||||
|
|
||||||
assert report[0]["satisfied"] is False
|
|
||||||
failed = {
|
|
||||||
check["check"]
|
|
||||||
for check in report[0]["temporal_contract_checks"]
|
|
||||||
if not check["satisfied"]
|
|
||||||
}
|
|
||||||
assert failed == {
|
|
||||||
"past_period_decision",
|
|
||||||
"no_past_period_yes_no_decision",
|
|
||||||
"date_range_counts",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_historical_period_contract_accepts_non_decision_status():
|
|
||||||
contracts = matching_query_contracts(
|
|
||||||
"직원 E9999의 과거 연차 신청 가능 여부를 알려줘",
|
|
||||||
"search_hr_data",
|
|
||||||
)
|
|
||||||
evidence = {
|
|
||||||
"result": (
|
|
||||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
|
||||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
|
||||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
|
||||||
'"AVAILABLE_REMAINING_DAYS":14,'
|
|
||||||
'"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",'
|
|
||||||
'"APPLICATION_DECISION_STATUS":'
|
|
||||||
'"NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",'
|
|
||||||
'"INCLUSIVE_CALENDAR_DAYS":11,'
|
|
||||||
'"MONDAY_TO_FRIDAY_DAYS":7}]}'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
report = evidence_contract_report(contracts, evidence)
|
|
||||||
|
|
||||||
assert report[0]["satisfied"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_guidance_is_appended_from_json_contract():
|
|
||||||
guided = append_query_contract_guidance(
|
|
||||||
"직원 E9999의 휴가 잔여를 조회해줘",
|
|
||||||
original_question="직원 E9999의 휴가 잔여를 조회해줘",
|
|
||||||
tool_name="search_hr_data",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert '"id":"leave_balance_eligibility"' in guided
|
|
||||||
assert "AVAILABLE_REMAINING_DAYS" in guided
|
|
||||||
|
|
||||||
|
|
||||||
class QueryContractTest(unittest.TestCase):
|
|
||||||
test_leave_eligibility_question_selects_external_contract = staticmethod(
|
|
||||||
test_leave_eligibility_question_selects_external_contract
|
|
||||||
)
|
|
||||||
test_unrelated_tool_does_not_receive_leave_contract = staticmethod(
|
|
||||||
test_unrelated_tool_does_not_receive_leave_contract
|
|
||||||
)
|
|
||||||
test_incomplete_result_cannot_be_interpreted_as_zero_balance = staticmethod(
|
|
||||||
test_incomplete_result_cannot_be_interpreted_as_zero_balance
|
|
||||||
)
|
|
||||||
test_complete_database_row_satisfies_contract_without_fixture_values = (
|
|
||||||
staticmethod(test_complete_database_row_satisfies_contract_without_fixture_values)
|
|
||||||
)
|
|
||||||
test_conflicting_computed_value_fails_contract = staticmethod(
|
|
||||||
test_conflicting_computed_value_fails_contract
|
|
||||||
)
|
|
||||||
test_historical_period_rejects_yes_no_decision_and_missing_counts = (
|
|
||||||
staticmethod(test_historical_period_rejects_yes_no_decision_and_missing_counts)
|
|
||||||
)
|
|
||||||
test_historical_period_contract_accepts_non_decision_status = staticmethod(
|
|
||||||
test_historical_period_contract_accepts_non_decision_status
|
|
||||||
)
|
|
||||||
test_guidance_is_appended_from_json_contract = staticmethod(
|
|
||||||
test_guidance_is_appended_from_json_contract
|
|
||||||
)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- Approve the reviewed customer QA example for a grouped business-AU query.
|
|
||||||
-- Empty result sets remain valid executed query results.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
answer_text = 'Expected focus: aggregate business AU by the user-master country attribute. '
|
|
||||||
|| 'Join CZN_CUSTOM_BIZ_USER_TXN to CZN_COMN_USER_MST by GUID and BASE_DT; filter BIZ_AU_FLAG=1 and EXPT_USER_YN=''N'', then group by LAST_CONN_COUNTRY_CD. '
|
|
||||||
|| 'A successfully executed query with no country rows is a valid result, not a SQL failure. '
|
|
||||||
|| 'Historical answer: no result rows.',
|
|
||||||
inspection_note = 'Customer QA verified: country business-AU is a grouped join; an empty result is a valid query outcome.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-05';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT example_id, reference_status, inspection_status, source_case_id, answer_text
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-05';
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
-- Approve the reviewed customer QA example for a grouped standard-AU query.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
answer_text = 'Expected focus: aggregate standard AU by user-master country, joining COMN_COUNTRY_BAS only for the country display name. '
|
|
||||||
|| 'Use CZN_COMN_USER_MST with AU_FLAG=1 and EXPT_USER_YN=''N'', grouped by LAST_CONN_COUNTRY_CD and COUNTRY_KR_NM. '
|
|
||||||
|| 'The label standard AU does not imply STD_USER_YN. A successfully executed empty result is valid. '
|
|
||||||
|| 'Historical answer: no result rows.',
|
|
||||||
inspection_note = 'Customer QA verified: country standard-AU is grouped AU_FLAG aggregation; empty output is valid.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-06';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- Approve the reviewed customer QA example for daily in-game currency holdings.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
answer_text = 'Expected focus: daily crystal holdings use CZN_CUSTOM_GOODS_HAVE_TXN joined to CZN_COMN_USER_MST and CZN_COMN_SVC_DIM_BAS. '
|
|
||||||
|| 'Filter the goods dimension to crystal, nonzero HAVE_CNT, eligible returning-user population, and the requested date range; group by BASE_DT. '
|
|
||||||
|| 'A successfully executed empty result is valid. Historical answer: no result rows.',
|
|
||||||
inspection_note = 'Customer QA verified: daily crystal holdings are a date-grouped goods/user/dimension join; empty output is valid.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-07';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- Approve the exact customer QA for standard-AU crystal holdings per user.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
answer_text = 'Use the verified customer SQL template for crystal holdings among standard AU. '
|
|
||||||
|| 'The standard-AU population uses AU_FLAG=1 and EXPT_USER_YN=''N''; do not add STD_USER_YN unless explicitly requested. '
|
|
||||||
|| 'Use the template population denominator for the per-user average. Null aggregate values are valid when the qualifying set is empty.',
|
|
||||||
inspection_note = 'Customer QA verified: retain the approved standard-AU population and average denominator semantics.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-08';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- Customer-provided CZN benchmark examples are the approved reference corpus
|
|
||||||
-- for exact-question Few-shot retrieval. Their SQL and expected-answer text
|
|
||||||
-- remain the source of metric semantics; no runtime game/table branching is added.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Customer QA benchmark approved for exact-question Few-shot retrieval.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id LIKE 'CZN-%'
|
|
||||||
AND reference_status <> 'APPROVED';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT source_case_id, reference_status, inspection_status
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id LIKE 'CZN-%'
|
|
||||||
ORDER BY source_case_id;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- Preserve customer QA output semantics for empty numeric aggregates.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET answer_text = NVL(answer_text, '') || ' For this approved metric, normalize an empty numeric aggregate to 0 in the returned result. Preserve the template join from CZN_CUSTOM_GOODS_CHANGE_TXN to CZN_COMN_USER_MST, apply u.EXPT_USER_YN=''N'', and count distinct u.GUID.',
|
|
||||||
inspection_note = 'Customer QA verified: empty total Ether usage is reported as numeric zero with the template user-master join, excluded-user filter, and user population.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-13';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- Approve the remaining customer-provided standard QA references for exact-question Few-shot retrieval.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Customer QA benchmark approved for exact-question Few-shot retrieval.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id LIKE 'STD-%'
|
|
||||||
AND reference_status <> 'APPROVED';
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET answer_text = NVL(answer_text, '') || ' This unavailable-object case must not fabricate a DUAL/NULL result row. Return no result rows and explain that no approved physical object is available for the resolved game.',
|
|
||||||
inspection_note = 'Customer QA verified: unavailable game objects return no result rows; no synthetic DUAL result.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'STD-01';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
-- Customer QA is evaluation data, never production Few-shot context.
|
|
||||||
-- Preserve it for SG_AI_QA_* baseline/history audit while retiring its vector copies.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'RETIRED',
|
|
||||||
inspection_note = 'Evaluation-only customer QA. Excluded from production Few-shot retrieval.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_EVALUATION_SEPARATION'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
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 (source_type IS NULL OR source_type <> 'CUSTOMER_QA_BENCHMARK')
|
|
||||||
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;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT source_type, reference_status, COUNT(*) AS example_count
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
GROUP BY source_type, reference_status
|
|
||||||
ORDER BY source_type, reference_status;
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
-- Build one generalized runtime Few-shot pattern for every customer QA case.
|
|
||||||
-- The source benchmark remains evaluation-only; this derived record contains
|
|
||||||
-- no customer game name, date literal, expected result, or physical CZN object.
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_pattern_question CLOB;
|
|
||||||
v_pattern_sql CLOB;
|
|
||||||
v_embedding_input CLOB;
|
|
||||||
v_embedding VECTOR;
|
|
||||||
v_object_role VARCHAR2(64);
|
|
||||||
v_exists NUMBER;
|
|
||||||
|
|
||||||
FUNCTION generalized_question(p_question CLOB) RETURN CLOB IS
|
|
||||||
v_value CLOB := p_question;
|
|
||||||
BEGIN
|
|
||||||
-- Resolved names/aliases become a semantic game placeholder.
|
|
||||||
FOR token IN (
|
|
||||||
SELECT column_value AS value
|
|
||||||
FROM TABLE(sys.odcivarchar2list(
|
|
||||||
'카오스 제로 나이트메어', '카오스제로나이트메어', 'Chaos Zero Nightmare',
|
|
||||||
'STOVE_CHAOSZERO', '카제나', 'CZN', 'Bubblyz', '버블리즈',
|
|
||||||
'로드나인', '로나', '테스트게임', 'BUBBLYZ', 'LORDNINE'
|
|
||||||
))
|
|
||||||
) LOOP
|
|
||||||
v_value := REPLACE(v_value, token.value, '<게임>');
|
|
||||||
END LOOP;
|
|
||||||
v_value := REGEXP_REPLACE(v_value, '[0-9]{4}년[[:space:]]*[0-9]{1,2}월[[:space:]]*[0-9]{1,2}일', '<기준일>');
|
|
||||||
v_value := REGEXP_REPLACE(v_value, '[0-9]{4}-[0-9]{2}-[0-9]{2}', '<기준일>');
|
|
||||||
RETURN v_value;
|
|
||||||
END;
|
|
||||||
|
|
||||||
FUNCTION generalized_sql(p_sql CLOB) RETURN CLOB IS
|
|
||||||
v_value CLOB := p_sql;
|
|
||||||
BEGIN
|
|
||||||
-- Physical game objects become logical roles. Common dimensions remain
|
|
||||||
-- logical as well so the current metadata/plan selects real objects.
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_COMN_USER_MST"', '<RESOLVED_GAME_USER_MASTER>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_COMN_CHARACTER_MST"', '<RESOLVED_GAME_CHARACTER_MASTER>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_CUSTOM_GOODS_HAVE_TXN"', '<RESOLVED_GAME_GOODS_HOLDINGS>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_CUSTOM_GOODS_CHANGE_TXN"', '<RESOLVED_GAME_GOODS_CHANGE>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_CUSTOM_BIZ_USER_TXN"', '<RESOLVED_GAME_BUSINESS_USER>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_CUSTOM_USER_GOODS_TXN"', '<RESOLVED_GAME_USER_GOODS>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."COMN_SALES_TXN"', '<APPROVED_SALES_TRANSACTION>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."COMN_REFUND_TXN"', '<APPROVED_REFUND_TRANSACTION>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."COMN_GAME_ALIAS_BAS"', '<GAME_ALIAS_CATALOG>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."COMN_COUNTRY_BAS"', '<COUNTRY_DIMENSION>');
|
|
||||||
v_value := REPLACE(v_value, '"SGMP_POC"."CZN_COMN_SVC_DIM_BAS"', '<RESOLVED_GAME_SERVICE_DIMENSION>');
|
|
||||||
v_value := REPLACE(v_value, 'STOVE_CHAOSZERO', '<RESOLVED_GAME_ID>');
|
|
||||||
v_value := REPLACE(v_value, '''카제나''', '<RESOLVED_GAME_NAME>');
|
|
||||||
v_value := REPLACE(v_value, '''CZN''', '<RESOLVED_GAME_PREFIX>');
|
|
||||||
v_value := REGEXP_REPLACE(v_value, 'CZN_[A-Z0-9_]+', '<RESOLVED_GAME_OBJECT>');
|
|
||||||
v_value := REPLACE(v_value, '카제나', '<RESOLVED_GAME_NAME>');
|
|
||||||
v_value := REPLACE(v_value, '카오스 제로 나이트메어', '<RESOLVED_GAME_NAME>');
|
|
||||||
v_value := REPLACE(v_value, '카오스제로나이트메어', '<RESOLVED_GAME_NAME>');
|
|
||||||
v_value := REPLACE(v_value, 'CZN', '<RESOLVED_GAME_PREFIX>');
|
|
||||||
v_value := REPLACE(v_value, 'BUBBLYZ', '<RESOLVED_GAME_ID>');
|
|
||||||
v_value := REPLACE(v_value, 'Bubblyz', '<RESOLVED_GAME_NAME>');
|
|
||||||
v_value := REPLACE(v_value, '버블리즈', '<RESOLVED_GAME_NAME>');
|
|
||||||
v_value := REPLACE(v_value, 'LORDNINE', '<RESOLVED_GAME_ID>');
|
|
||||||
v_value := REPLACE(v_value, '로드나인', '<RESOLVED_GAME_NAME>');
|
|
||||||
v_value := REPLACE(v_value, '테스트게임', '<RESOLVED_GAME_NAME>');
|
|
||||||
v_value := REGEXP_REPLACE(v_value, 'TO_DATE\(''[0-9]{4}-[0-9]{2}-[0-9]{2}'', ''YYYY-MM-DD''\)', '<BUSINESS_DATE>');
|
|
||||||
v_value := REGEXP_REPLACE(v_value, 'TO_DATE\(''[0-9]{8}'', ''YYYYMMDD''\)', '<BUSINESS_DATE>');
|
|
||||||
v_value := REGEXP_REPLACE(v_value, 'DATE ''[0-9]{4}-[0-9]{2}-[0-9]{2}''', '<BUSINESS_DATE>');
|
|
||||||
RETURN v_value;
|
|
||||||
END;
|
|
||||||
|
|
||||||
FUNCTION role_of(p_sql CLOB) RETURN VARCHAR2 IS
|
|
||||||
BEGIN
|
|
||||||
IF DBMS_LOB.INSTR(p_sql, 'CZN_COMN_CHARACTER_MST') > 0 THEN
|
|
||||||
RETURN 'GAME_CHARACTER_MASTER';
|
|
||||||
ELSIF DBMS_LOB.INSTR(p_sql, 'CZN_CUSTOM_GOODS_HAVE_TXN') > 0 THEN
|
|
||||||
RETURN 'GAME_GOODS_HOLDINGS';
|
|
||||||
ELSIF DBMS_LOB.INSTR(p_sql, 'CZN_CUSTOM_GOODS_CHANGE_TXN') > 0 THEN
|
|
||||||
RETURN 'GAME_GOODS_CHANGE';
|
|
||||||
ELSIF DBMS_LOB.INSTR(p_sql, 'CZN_CUSTOM_BIZ_USER_TXN') > 0 THEN
|
|
||||||
RETURN 'GAME_BUSINESS_USER';
|
|
||||||
ELSIF DBMS_LOB.INSTR(p_sql, 'COMN_SALES_TXN') > 0 THEN
|
|
||||||
RETURN 'SALES_TRANSACTION';
|
|
||||||
ELSIF DBMS_LOB.INSTR(p_sql, 'COMN_REFUND_TXN') > 0 THEN
|
|
||||||
RETURN 'REFUND_TRANSACTION';
|
|
||||||
ELSIF DBMS_LOB.INSTR(p_sql, 'CZN_COMN_USER_MST') > 0 THEN
|
|
||||||
RETURN 'GAME_USER_MASTER';
|
|
||||||
END IF;
|
|
||||||
RETURN 'METADATA_OR_OPERATION';
|
|
||||||
END;
|
|
||||||
BEGIN
|
|
||||||
FOR source_row IN (
|
|
||||||
SELECT example_id, source_case_id, question, answer_sql
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
ORDER BY source_case_id
|
|
||||||
) LOOP
|
|
||||||
v_pattern_question := generalized_question(source_row.question);
|
|
||||||
v_pattern_sql := generalized_sql(source_row.answer_sql);
|
|
||||||
v_object_role := role_of(source_row.answer_sql);
|
|
||||||
v_embedding_input := TO_CLOB('Generalized question pattern: ') || v_pattern_question
|
|
||||||
|| CHR(10) || 'Logical object role: ' || v_object_role
|
|
||||||
|| CHR(10) || 'Structural SQL template: ' || v_pattern_sql
|
|
||||||
|| CHR(10) || 'Use only current game scope metadata and replace placeholders from the current request.';
|
|
||||||
|
|
||||||
-- A generalized runtime pattern must not contain known customer answer
|
|
||||||
-- identifiers or fixed business-date literals.
|
|
||||||
IF REGEXP_LIKE(v_pattern_question,
|
|
||||||
'카제나|버블리즈|Bubblyz|로드나인|테스트게임|[0-9]{4}년|[0-9]{4}-[0-9]{2}-[0-9]{2}', 'i')
|
|
||||||
OR REGEXP_LIKE(v_pattern_sql,
|
|
||||||
'CZN_|STOVE_CHAOSZERO|카제나|버블리즈|Bubblyz|[0-9]{4}-[0-9]{2}-[0-9]{2}', 'i') THEN
|
|
||||||
RAISE_APPLICATION_ERROR(-20061, 'Generalization leak in ' || source_row.source_case_id);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
v_embedding_input,
|
|
||||||
JSON(sg_qa_vector_params('search_document'))
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_exists
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'GENERALIZED_QUESTION_PATTERN'
|
|
||||||
AND source_case_id = 'PAT-' || source_row.source_case_id;
|
|
||||||
|
|
||||||
IF v_exists = 0 THEN
|
|
||||||
INSERT INTO sg_qa_vector_example (
|
|
||||||
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
|
|
||||||
reference_status, reference_kind, target_type, object_role,
|
|
||||||
inspection_status, inspection_note, verified_at, verified_by,
|
|
||||||
source_case_id, source_type
|
|
||||||
) VALUES (
|
|
||||||
v_pattern_question,
|
|
||||||
v_pattern_sql,
|
|
||||||
'Question-specific generalized Few-shot. Structural only: it contains no customer game, date, result, or executable answer. First decide whether this pattern is applicable; then apply the authoritative NONE/SINGLE/MULTI/ALL game plan and replace placeholders from current metadata.',
|
|
||||||
v_embedding_input,
|
|
||||||
v_embedding,
|
|
||||||
'cohere.embed-v4.0',
|
|
||||||
'APPROVED', 'SQL_TEMPLATE', 'ANY', v_object_role,
|
|
||||||
'VERIFIED',
|
|
||||||
'Derived from a customer QA structure after game/date/result/object leakage validation; runtime uses this generalized pattern only.',
|
|
||||||
SYSTIMESTAMP, 'SGMP_POC_PATTERN_REVIEW',
|
|
||||||
'PAT-' || source_row.source_case_id, 'GENERALIZED_QUESTION_PATTERN'
|
|
||||||
);
|
|
||||||
ELSE
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET question = v_pattern_question,
|
|
||||||
answer_sql = v_pattern_sql,
|
|
||||||
answer_text = 'Question-specific generalized Few-shot. Structural only: it contains no customer game, date, result, or executable answer. First decide whether this pattern is applicable; then apply the authoritative NONE/SINGLE/MULTI/ALL game plan and replace placeholders from current metadata.',
|
|
||||||
embedding_input = v_embedding_input,
|
|
||||||
embedding = v_embedding,
|
|
||||||
object_role = v_object_role,
|
|
||||||
reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Derived from a customer QA structure after game/date/result/object leakage validation; runtime uses this generalized pattern only.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_PATTERN_REVIEW'
|
|
||||||
WHERE source_type = 'GENERALIZED_QUESTION_PATTERN'
|
|
||||||
AND source_case_id = 'PAT-' || source_row.source_case_id;
|
|
||||||
END IF;
|
|
||||||
END LOOP;
|
|
||||||
COMMIT;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT source_type, reference_status, COUNT(*) AS example_count
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
GROUP BY source_type, reference_status
|
|
||||||
ORDER BY source_type, reference_status;
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
-- Generate one reusable, question-specific Few-shot pattern per customer QA
|
|
||||||
-- benchmark without promoting the benchmark answer itself. Game identity is
|
|
||||||
-- deliberately not inferred here: sg_game_query_plan owns that through OCI
|
|
||||||
-- GenAI chat + the current game catalog.
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION sg_qa_genai_generalize_pattern(
|
|
||||||
p_question IN CLOB,
|
|
||||||
p_answer_sql IN CLOB,
|
|
||||||
p_target_type IN VARCHAR2
|
|
||||||
) RETURN CLOB AUTHID DEFINER
|
|
||||||
IS
|
|
||||||
v_prompt CLOB;
|
|
||||||
v_result CLOB;
|
|
||||||
BEGIN
|
|
||||||
v_prompt :=
|
|
||||||
'Create one reusable, question-specific Few-shot SQL pattern from the source example. '
|
|
||||||
|| 'This is training guidance, never an answer key. Return exactly these tagged sections and nothing else: '
|
|
||||||
|| '[[PATTERN_QUESTION]], [[STRUCTURAL_SQL_PATTERN]], [[OBJECT_ROLE]], [[TARGET_TYPE]], '
|
|
||||||
|| '[[APPLICABILITY]], [[END]]. '
|
|
||||||
|| 'Preserve only the query intent and structural operations such as aggregation, joins, '
|
|
||||||
|| 'grouping, ordering, date semantics, and filters. Replace every game name, alias, game ID, '
|
|
||||||
|| 'schema name, physical object name, column name, literal date, literal number, user ID, '
|
|
||||||
|| 'currency amount, and expected output with semantic placeholders such as <GAME_SCOPE>, '
|
|
||||||
|| '<LOGICAL_FACT>, <LOGICAL_DIMENSION>, <METRIC>, <AS_OF_DATE>, <FILTER>, and <GROUPING>. '
|
|
||||||
|| 'In STRUCTURAL_SQL_PATTERN, every non-SQL identifier must be an angle-bracket placeholder: '
|
|
||||||
|| 'do not retain any source column, alias, table, schema, literal, code, or business value. '
|
|
||||||
|| 'Do not include executable SQL. Do not include a game name or a customer answer. '
|
|
||||||
|| 'The current game scope is supplied separately at runtime by a database OCI GenAI chat '
|
|
||||||
|| 'resolver, therefore never choose or imply a game. The TARGET_TYPE section must be one of NONE, '
|
|
||||||
|| 'SINGLE, MULTI, ALL, ANY and must describe applicability, not a game identity. '
|
|
||||||
|| 'Source target type from the current resolver: ' || NVL(p_target_type, 'ANY') || CHR(10)
|
|
||||||
|| 'Source question:' || CHR(10) || DBMS_LOB.SUBSTR(p_question, 4000, 1) || CHR(10)
|
|
||||||
|| 'Source SQL (structure only; do not copy identifiers or values):' || CHR(10)
|
|
||||||
|| DBMS_LOB.SUBSTR(p_answer_sql, 12000, 1);
|
|
||||||
v_result := DBMS_CLOUD_AI.GENERATE(
|
|
||||||
prompt => v_prompt,
|
|
||||||
profile_name => 'SGMP_POC_OCI_GPT54MINI',
|
|
||||||
action => 'chat'
|
|
||||||
);
|
|
||||||
RETURN v_result;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION sg_qa_genai_validate_pattern(
|
|
||||||
p_pattern_json IN CLOB
|
|
||||||
) RETURN CLOB AUTHID DEFINER
|
|
||||||
IS
|
|
||||||
v_prompt CLOB;
|
|
||||||
v_result CLOB;
|
|
||||||
BEGIN
|
|
||||||
v_prompt :=
|
|
||||||
'Inspect only concrete-answer leakage in this reusable Few-shot pattern. Return exactly '
|
|
||||||
|| '[[CONCRETE_LEAKAGE]] YES or NO, then [[REASON]] and a short reason, then [[END]]. '
|
|
||||||
|| 'Return YES only when a customer answer, concrete game identity, physical schema/table/column '
|
|
||||||
|| 'identifier, literal date, literal business result, or executable SQL against a real object remains. '
|
|
||||||
|| 'Return NO when all such references are semantic angle-bracket placeholders. A pseudo-SQL pattern '
|
|
||||||
|| 'using SELECT/FROM/JOIN/GROUP BY, generic game-scope checks, EXISTS, UNION, or equality with '
|
|
||||||
|| 'angle-bracket placeholders is not concrete leakage and must return NO. Do not judge usefulness or '
|
|
||||||
|| 'completeness; classify leakage only. '
|
|
||||||
|| 'Candidate:' || CHR(10) || DBMS_LOB.SUBSTR(p_pattern_json, 16000, 1);
|
|
||||||
v_result := DBMS_CLOUD_AI.GENERATE(
|
|
||||||
prompt => v_prompt,
|
|
||||||
profile_name => 'SGMP_POC_OCI_GPT54MINI',
|
|
||||||
action => 'chat'
|
|
||||||
);
|
|
||||||
RETURN v_result;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION sg_qa_generate_generalized_patterns
|
|
||||||
RETURN NUMBER AUTHID DEFINER
|
|
||||||
IS
|
|
||||||
PRAGMA AUTONOMOUS_TRANSACTION;
|
|
||||||
v_plan_raw CLOB;
|
|
||||||
v_plan JSON_OBJECT_T;
|
|
||||||
v_target_type VARCHAR2(16);
|
|
||||||
v_pattern_raw CLOB;
|
|
||||||
v_validation_raw CLOB;
|
|
||||||
v_status VARCHAR2(16);
|
|
||||||
v_validation_note CLOB;
|
|
||||||
v_question CLOB;
|
|
||||||
v_sql_pattern CLOB;
|
|
||||||
v_answer_text CLOB;
|
|
||||||
v_object_role VARCHAR2(64);
|
|
||||||
v_embedding_input CLOB;
|
|
||||||
v_embedding VECTOR;
|
|
||||||
v_count NUMBER := 0;
|
|
||||||
|
|
||||||
FUNCTION parse_json_result(p_value CLOB) RETURN JSON_OBJECT_T IS
|
|
||||||
v_text CLOB := TRIM(p_value);
|
|
||||||
BEGIN
|
|
||||||
IF DBMS_LOB.SUBSTR(v_text, 7, 1) = '```json' THEN
|
|
||||||
v_text := REGEXP_REPLACE(v_text, '^```json[[:space:]]*', '');
|
|
||||||
v_text := REGEXP_REPLACE(v_text, '[[:space:]]*```[[:space:]]*$', '');
|
|
||||||
ELSIF DBMS_LOB.SUBSTR(v_text, 3, 1) = '```' THEN
|
|
||||||
v_text := REGEXP_REPLACE(v_text, '^```[[:space:]]*', '');
|
|
||||||
v_text := REGEXP_REPLACE(v_text, '[[:space:]]*```[[:space:]]*$', '');
|
|
||||||
END IF;
|
|
||||||
RETURN JSON_OBJECT_T.parse(v_text);
|
|
||||||
END;
|
|
||||||
|
|
||||||
FUNCTION section_value(
|
|
||||||
p_raw IN CLOB, p_start_tag IN VARCHAR2, p_end_tag IN VARCHAR2
|
|
||||||
) RETURN CLOB IS
|
|
||||||
v_start PLS_INTEGER;
|
|
||||||
v_end PLS_INTEGER;
|
|
||||||
BEGIN
|
|
||||||
v_start := DBMS_LOB.INSTR(p_raw, p_start_tag, 1, 1);
|
|
||||||
IF v_start = 0 THEN
|
|
||||||
RAISE_APPLICATION_ERROR(-20071, 'OCI GenAI response is missing ' || p_start_tag);
|
|
||||||
END IF;
|
|
||||||
v_start := v_start + LENGTH(p_start_tag);
|
|
||||||
v_end := DBMS_LOB.INSTR(p_raw, p_end_tag, v_start, 1);
|
|
||||||
IF v_end = 0 OR v_end <= v_start THEN
|
|
||||||
RAISE_APPLICATION_ERROR(-20072, 'OCI GenAI response is missing ' || p_end_tag);
|
|
||||||
END IF;
|
|
||||||
RETURN TRIM(DBMS_LOB.SUBSTR(p_raw, LEAST(v_end - v_start, 32767), v_start));
|
|
||||||
END;
|
|
||||||
|
|
||||||
PROCEDURE upsert_pattern(
|
|
||||||
p_case_id IN VARCHAR2,
|
|
||||||
p_status IN VARCHAR2,
|
|
||||||
p_note IN CLOB
|
|
||||||
) IS
|
|
||||||
BEGIN
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET question = v_question,
|
|
||||||
answer_sql = v_sql_pattern,
|
|
||||||
answer_text = v_answer_text,
|
|
||||||
embedding_input = v_embedding_input,
|
|
||||||
embedding = v_embedding,
|
|
||||||
embedding_model = 'cohere.embed-v4.0',
|
|
||||||
reference_status = p_status,
|
|
||||||
reference_kind = 'SQL_PATTERN',
|
|
||||||
target_type = v_target_type,
|
|
||||||
object_role = v_object_role,
|
|
||||||
inspection_status = CASE WHEN p_status = 'APPROVED' THEN 'GENAI_VERIFIED' ELSE 'GENAI_REJECTED' END,
|
|
||||||
inspection_note = p_note,
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_OCI_GENAI_PATTERN'
|
|
||||||
WHERE source_type = 'GENERALIZED_QUESTION_PATTERN'
|
|
||||||
AND source_case_id = 'PAT-' || p_case_id;
|
|
||||||
|
|
||||||
IF SQL%ROWCOUNT = 0 THEN
|
|
||||||
INSERT INTO sg_qa_vector_example (
|
|
||||||
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
|
|
||||||
reference_status, reference_kind, target_type, object_role, inspection_status,
|
|
||||||
inspection_note, verified_at, verified_by, source_case_id, source_type
|
|
||||||
) VALUES (
|
|
||||||
v_question, v_sql_pattern, v_answer_text, v_embedding_input, v_embedding, 'cohere.embed-v4.0',
|
|
||||||
p_status, 'SQL_PATTERN', v_target_type, v_object_role,
|
|
||||||
CASE WHEN p_status = 'APPROVED' THEN 'GENAI_VERIFIED' ELSE 'GENAI_REJECTED' END,
|
|
||||||
p_note, SYSTIMESTAMP, 'SGMP_POC_OCI_GENAI_PATTERN',
|
|
||||||
'PAT-' || p_case_id, 'GENERALIZED_QUESTION_PATTERN'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
BEGIN
|
|
||||||
FOR source_row IN (
|
|
||||||
SELECT source.source_case_id, source.question, source.answer_sql
|
|
||||||
FROM sg_qa_vector_example source
|
|
||||||
WHERE source.source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM sg_qa_vector_example pattern
|
|
||||||
WHERE pattern.source_type = 'GENERALIZED_QUESTION_PATTERN'
|
|
||||||
AND pattern.source_case_id = 'PAT-' || source.source_case_id
|
|
||||||
AND pattern.reference_status = 'APPROVED'
|
|
||||||
)
|
|
||||||
ORDER BY source_case_id
|
|
||||||
) LOOP
|
|
||||||
BEGIN
|
|
||||||
-- The target category comes from the existing OCI GenAI game resolver;
|
|
||||||
-- no alias, prefix, table, or name is transformed in this migration.
|
|
||||||
v_plan_raw := sg_game_query_plan(source_row.question, 5);
|
|
||||||
v_plan := parse_json_result(v_plan_raw);
|
|
||||||
v_target_type := UPPER(NVL(v_plan.get_string('targetType'), 'ANY'));
|
|
||||||
IF v_target_type NOT IN ('NONE', 'SINGLE', 'MULTI', 'ALL') THEN
|
|
||||||
v_target_type := 'ANY';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
v_pattern_raw := sg_qa_genai_generalize_pattern(
|
|
||||||
source_row.question, source_row.answer_sql, v_target_type
|
|
||||||
);
|
|
||||||
v_question := section_value(v_pattern_raw, '[[PATTERN_QUESTION]]', '[[STRUCTURAL_SQL_PATTERN]]');
|
|
||||||
v_sql_pattern := section_value(v_pattern_raw, '[[STRUCTURAL_SQL_PATTERN]]', '[[OBJECT_ROLE]]');
|
|
||||||
v_object_role := SUBSTR(section_value(v_pattern_raw, '[[OBJECT_ROLE]]', '[[TARGET_TYPE]]'), 1, 64);
|
|
||||||
IF section_value(v_pattern_raw, '[[TARGET_TYPE]]', '[[APPLICABILITY]]')
|
|
||||||
IN ('NONE', 'SINGLE', 'MULTI', 'ALL', 'ANY') THEN
|
|
||||||
v_target_type := section_value(v_pattern_raw, '[[TARGET_TYPE]]', '[[APPLICABILITY]]');
|
|
||||||
END IF;
|
|
||||||
v_answer_text := TO_CLOB('Generalized, question-specific structural pattern. '
|
|
||||||
|| 'Current game scope must be supplied only by sg_game_query_plan. Applicability: ')
|
|
||||||
|| section_value(v_pattern_raw, '[[APPLICABILITY]]', '[[END]]');
|
|
||||||
v_embedding_input := TO_CLOB('Question-specific generalized Few-shot pattern:' || CHR(10))
|
|
||||||
|| v_question || CHR(10) || 'Logical role: ' || v_object_role || CHR(10)
|
|
||||||
|| 'Structural SQL pattern:' || CHR(10) || v_sql_pattern;
|
|
||||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
v_embedding_input, JSON(sg_qa_vector_params('search_document'))
|
|
||||||
);
|
|
||||||
v_validation_raw := sg_qa_genai_validate_pattern(v_pattern_raw);
|
|
||||||
v_status := CASE
|
|
||||||
WHEN REGEXP_SUBSTR(
|
|
||||||
UPPER(section_value(v_validation_raw, '[[CONCRETE_LEAKAGE]]', '[[REASON]]')),
|
|
||||||
'[A-Z]+'
|
|
||||||
) = 'NO'
|
|
||||||
THEN 'APPROVE'
|
|
||||||
ELSE 'REJECT'
|
|
||||||
END;
|
|
||||||
v_validation_note := section_value(v_validation_raw, '[[REASON]]', '[[END]]');
|
|
||||||
|
|
||||||
IF v_status = 'APPROVE' THEN
|
|
||||||
upsert_pattern(source_row.source_case_id, 'APPROVED',
|
|
||||||
'ADB OCI GenAI generated and independently validated a generalized pattern. '
|
|
||||||
|| 'The original customer QA remains evaluation-only. ' || v_validation_note);
|
|
||||||
v_count := v_count + 1;
|
|
||||||
ELSE
|
|
||||||
upsert_pattern(source_row.source_case_id, 'DRAFT',
|
|
||||||
'ADB OCI GenAI rejected the generalized pattern: ' || v_validation_note);
|
|
||||||
END IF;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
-- Persist an auditable non-runtime draft and continue with the other
|
|
||||||
-- customer questions; one malformed LLM response must not block all 47.
|
|
||||||
v_question := source_row.question;
|
|
||||||
v_sql_pattern := TO_CLOB('<PATTERN_GENERATION_FAILED>');
|
|
||||||
v_answer_text := TO_CLOB('No runtime Few-shot pattern: OCI GenAI generalization failed.');
|
|
||||||
v_object_role := 'UNSPECIFIED';
|
|
||||||
v_target_type := 'ANY';
|
|
||||||
v_embedding_input := TO_CLOB('Failed generalized pattern: ') || source_row.question;
|
|
||||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
v_embedding_input, JSON(sg_qa_vector_params('search_document'))
|
|
||||||
);
|
|
||||||
upsert_pattern(source_row.source_case_id, 'DRAFT',
|
|
||||||
'OCI GenAI pattern generation error: ' || SQLERRM);
|
|
||||||
END;
|
|
||||||
END LOOP;
|
|
||||||
COMMIT;
|
|
||||||
RETURN v_count;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
ROLLBACK;
|
|
||||||
RAISE;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
-- Re-run only the independent OCI Chat safety review after its policy changes.
|
|
||||||
-- It never reads a customer benchmark and never changes the generated pattern.
|
|
||||||
CREATE OR REPLACE FUNCTION sg_qa_revalidate_generalized_patterns
|
|
||||||
RETURN NUMBER AUTHID DEFINER
|
|
||||||
IS
|
|
||||||
PRAGMA AUTONOMOUS_TRANSACTION;
|
|
||||||
v_raw CLOB;
|
|
||||||
v_status VARCHAR2(16);
|
|
||||||
v_reason CLOB;
|
|
||||||
v_start PLS_INTEGER;
|
|
||||||
v_end PLS_INTEGER;
|
|
||||||
v_count NUMBER := 0;
|
|
||||||
|
|
||||||
FUNCTION section_value(
|
|
||||||
p_raw IN CLOB, p_start_tag IN VARCHAR2, p_end_tag IN VARCHAR2
|
|
||||||
) RETURN CLOB IS
|
|
||||||
v_from PLS_INTEGER;
|
|
||||||
v_to PLS_INTEGER;
|
|
||||||
BEGIN
|
|
||||||
v_from := DBMS_LOB.INSTR(p_raw, p_start_tag, 1, 1);
|
|
||||||
IF v_from = 0 THEN RAISE_APPLICATION_ERROR(-20073, 'Missing ' || p_start_tag); END IF;
|
|
||||||
v_from := v_from + LENGTH(p_start_tag);
|
|
||||||
v_to := DBMS_LOB.INSTR(p_raw, p_end_tag, v_from, 1);
|
|
||||||
IF v_to = 0 OR v_to <= v_from THEN RAISE_APPLICATION_ERROR(-20074, 'Missing ' || p_end_tag); END IF;
|
|
||||||
RETURN TRIM(DBMS_LOB.SUBSTR(p_raw, LEAST(v_to - v_from, 32767), v_from));
|
|
||||||
END;
|
|
||||||
BEGIN
|
|
||||||
FOR item IN (
|
|
||||||
SELECT example_id, question, answer_sql, answer_text
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'GENERALIZED_QUESTION_PATTERN'
|
|
||||||
ORDER BY source_case_id
|
|
||||||
) LOOP
|
|
||||||
BEGIN
|
|
||||||
v_raw := sg_qa_genai_validate_pattern(
|
|
||||||
TO_CLOB('[[PATTERN_QUESTION]]') || item.question
|
|
||||||
|| TO_CLOB(CHR(10) || '[[STRUCTURAL_SQL_PATTERN]]') || item.answer_sql
|
|
||||||
|| TO_CLOB(CHR(10) || '[[APPLICABILITY]]') || item.answer_text || CHR(10) || '[[END]]'
|
|
||||||
);
|
|
||||||
v_status := CASE
|
|
||||||
WHEN REGEXP_SUBSTR(
|
|
||||||
UPPER(section_value(v_raw, '[[CONCRETE_LEAKAGE]]', '[[REASON]]')),
|
|
||||||
'[A-Z]+'
|
|
||||||
) = 'NO'
|
|
||||||
THEN 'APPROVE'
|
|
||||||
ELSE 'REJECT'
|
|
||||||
END;
|
|
||||||
v_reason := section_value(v_raw, '[[REASON]]', '[[END]]');
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = CASE WHEN v_status = 'APPROVE' THEN 'APPROVED' ELSE 'DRAFT' END,
|
|
||||||
inspection_status = CASE WHEN v_status = 'APPROVE' THEN 'GENAI_VERIFIED' ELSE 'GENAI_REJECTED' END,
|
|
||||||
inspection_note = 'ADB OCI GenAI independent revalidation: ' || v_reason,
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_OCI_GENAI_PATTERN'
|
|
||||||
WHERE example_id = item.example_id;
|
|
||||||
IF v_status = 'APPROVE' THEN v_count := v_count + 1; END IF;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
v_reason := TO_CLOB('OCI GenAI revalidation error: ' || SQLERRM);
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'DRAFT',
|
|
||||||
inspection_status = 'GENAI_REJECTED',
|
|
||||||
inspection_note = v_reason,
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_OCI_GENAI_PATTERN'
|
|
||||||
WHERE example_id = item.example_id;
|
|
||||||
END;
|
|
||||||
END LOOP;
|
|
||||||
COMMIT;
|
|
||||||
RETURN v_count;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
ROLLBACK;
|
|
||||||
RAISE;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
-- Production retrieval accepts only independently generalized patterns or
|
|
||||||
-- policy templates. Customer QA benchmarks remain evaluation-only forever.
|
|
||||||
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, 'invalid target type.');
|
|
||||||
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 source_type IN ('GENERALIZED_QUESTION_PATTERN', 'POLICY_TEMPLATE')
|
|
||||||
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;
|
|
||||||
/
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- The MCP DB account owns the OCI GenAI planning functions while the game
|
|
||||||
-- catalog is owned by the data schema. Definer-rights PL/SQL needs direct
|
|
||||||
-- object grants; role grants are not sufficient at compile time.
|
|
||||||
BEGIN
|
|
||||||
EXECUTE IMMEDIATE 'GRANT SELECT ON SGMP_POC.SG_GAME_CATALOG TO ADMIN';
|
|
||||||
EXECUTE IMMEDIATE 'GRANT SELECT ON SGMP_POC.COMN_GAME_ALIAS_BAS TO ADMIN';
|
|
||||||
EXECUTE IMMEDIATE 'GRANT EXECUTE ON SGMP_POC.SG_GAME_CATALOG_SEARCH TO ADMIN';
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
-- Deterministic daily-AU lookup for an already resolved game query plan.
|
|
||||||
-- Physical user-master objects are selected only from SG_GAME_CATALOG.
|
|
||||||
-- No game name, alias, prefix, or object name is embedded in this function.
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION sg_game_daily_au_lookup(
|
|
||||||
p_query_plan IN CLOB,
|
|
||||||
p_base_date IN DATE DEFAULT NULL
|
|
||||||
) RETURN CLOB AUTHID DEFINER IS
|
|
||||||
v_plan JSON_OBJECT_T;
|
|
||||||
v_targets JSON_ARRAY_T;
|
|
||||||
v_target JSON_OBJECT_T;
|
|
||||||
v_result JSON_OBJECT_T := JSON_OBJECT_T();
|
|
||||||
v_items JSON_ARRAY_T := JSON_ARRAY_T();
|
|
||||||
v_item JSON_OBJECT_T;
|
|
||||||
v_game_key VARCHAR2(128);
|
|
||||||
v_game_id VARCHAR2(128);
|
|
||||||
v_game_name VARCHAR2(512);
|
|
||||||
v_object_name VARCHAR2(128);
|
|
||||||
v_safe_object_name VARCHAR2(128);
|
|
||||||
v_effective_date DATE;
|
|
||||||
v_au_count NUMBER;
|
|
||||||
v_column_count PLS_INTEGER;
|
|
||||||
v_object_count PLS_INTEGER;
|
|
||||||
v_seen SYS.ODCIVARCHAR2LIST := SYS.ODCIVARCHAR2LIST();
|
|
||||||
v_target_count PLS_INTEGER := 0;
|
|
||||||
|
|
||||||
FUNCTION is_seen(p_game_key IN VARCHAR2) RETURN BOOLEAN IS
|
|
||||||
BEGIN
|
|
||||||
FOR i IN 1 .. v_seen.COUNT LOOP
|
|
||||||
IF v_seen(i) = p_game_key THEN
|
|
||||||
RETURN TRUE;
|
|
||||||
END IF;
|
|
||||||
END LOOP;
|
|
||||||
RETURN FALSE;
|
|
||||||
END;
|
|
||||||
|
|
||||||
PROCEDURE add_status(
|
|
||||||
p_game_key IN VARCHAR2,
|
|
||||||
p_status IN VARCHAR2,
|
|
||||||
p_reason IN VARCHAR2
|
|
||||||
) IS
|
|
||||||
BEGIN
|
|
||||||
v_item := JSON_OBJECT_T();
|
|
||||||
v_item.put('gameKey', p_game_key);
|
|
||||||
v_item.put('status', p_status);
|
|
||||||
v_item.put('reason', p_reason);
|
|
||||||
v_items.append(v_item);
|
|
||||||
END;
|
|
||||||
BEGIN
|
|
||||||
IF p_query_plan IS NULL THEN
|
|
||||||
RAISE_APPLICATION_ERROR(-20001, 'queryPlan is required');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
v_plan := JSON_OBJECT_T.parse(p_query_plan);
|
|
||||||
v_targets := v_plan.get_array('dataEligibleTargets');
|
|
||||||
IF v_targets IS NULL THEN
|
|
||||||
v_targets := v_plan.get_array('targets');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_targets IS NOT NULL AND v_targets.get_size > 0 THEN
|
|
||||||
FOR i IN 0 .. v_targets.get_size - 1 LOOP
|
|
||||||
v_target := TREAT(v_targets.get(i) AS JSON_OBJECT_T);
|
|
||||||
IF v_target IS NULL OR NOT v_target.has('gameKey') THEN
|
|
||||||
CONTINUE;
|
|
||||||
END IF;
|
|
||||||
v_game_key := v_target.get_string('gameKey');
|
|
||||||
IF v_game_key IS NULL OR is_seen(v_game_key) THEN
|
|
||||||
CONTINUE;
|
|
||||||
END IF;
|
|
||||||
v_seen.EXTEND;
|
|
||||||
v_seen(v_seen.COUNT) := v_game_key;
|
|
||||||
v_target_count := v_target_count + 1;
|
|
||||||
|
|
||||||
BEGIN
|
|
||||||
SELECT game_id, game_nm, user_master_object_name
|
|
||||||
INTO v_game_id, v_game_name, v_object_name
|
|
||||||
FROM sg_game_catalog
|
|
||||||
WHERE game_key = v_game_key
|
|
||||||
AND active_yn = 'Y';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN NO_DATA_FOUND THEN
|
|
||||||
add_status(v_game_key, 'UNAVAILABLE', 'Catalog target is not active.');
|
|
||||||
CONTINUE;
|
|
||||||
END;
|
|
||||||
|
|
||||||
IF v_object_name IS NULL THEN
|
|
||||||
add_status(v_game_key, 'UNAVAILABLE', 'No approved user-master object is registered.');
|
|
||||||
CONTINUE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
v_safe_object_name := DBMS_ASSERT.SIMPLE_SQL_NAME(UPPER(v_object_name));
|
|
||||||
SELECT COUNT(*) INTO v_object_count
|
|
||||||
FROM user_objects
|
|
||||||
WHERE object_name = v_safe_object_name
|
|
||||||
AND object_type IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW')
|
|
||||||
AND status = 'VALID';
|
|
||||||
SELECT COUNT(*) INTO v_column_count
|
|
||||||
FROM user_tab_columns
|
|
||||||
WHERE table_name = v_safe_object_name
|
|
||||||
AND column_name IN ('GUID', 'BASE_DT', 'AU_FLAG', 'EXPT_USER_YN');
|
|
||||||
IF v_object_count = 0 OR v_column_count <> 4 THEN
|
|
||||||
add_status(v_game_key, 'UNAVAILABLE', 'Approved user-master object is not query-ready.');
|
|
||||||
CONTINUE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF p_base_date IS NULL THEN
|
|
||||||
EXECUTE IMMEDIATE 'SELECT MAX(BASE_DT) FROM ' || v_safe_object_name
|
|
||||||
INTO v_effective_date;
|
|
||||||
ELSE
|
|
||||||
v_effective_date := TRUNC(p_base_date);
|
|
||||||
END IF;
|
|
||||||
IF v_effective_date IS NULL THEN
|
|
||||||
add_status(v_game_key, 'NO_DATA', 'No available base date in the selected object.');
|
|
||||||
CONTINUE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
EXECUTE IMMEDIATE
|
|
||||||
'SELECT COUNT(DISTINCT GUID) FROM ' || v_safe_object_name
|
|
||||||
|| ' WHERE BASE_DT = :1 AND AU_FLAG = 1 AND EXPT_USER_YN = ''N'''
|
|
||||||
INTO v_au_count USING v_effective_date;
|
|
||||||
|
|
||||||
v_item := JSON_OBJECT_T();
|
|
||||||
v_item.put('gameKey', v_game_key);
|
|
||||||
v_item.put('gameId', v_game_id);
|
|
||||||
v_item.put('gameName', v_game_name);
|
|
||||||
v_item.put('objectName', v_safe_object_name);
|
|
||||||
v_item.put('baseDate', TO_CHAR(v_effective_date, 'YYYY-MM-DD'));
|
|
||||||
v_item.put('auCount', v_au_count);
|
|
||||||
v_item.put('status', 'READY');
|
|
||||||
v_item.put('sqlTemplate',
|
|
||||||
'SELECT COUNT(DISTINCT GUID) AS AU_COUNT FROM <catalog_user_master_object> '
|
|
||||||
|| 'WHERE BASE_DT = :baseDate AND AU_FLAG = 1 AND EXPT_USER_YN = ''N''');
|
|
||||||
v_items.append(v_item);
|
|
||||||
END LOOP;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
v_result.put('status', CASE WHEN v_target_count = 0 THEN 'NO_GAME_TARGET' ELSE 'GAME_AU_LOOKUP' END);
|
|
||||||
v_result.put('targetType', NVL(v_plan.get_string('targetType'), 'NONE'));
|
|
||||||
IF p_base_date IS NULL THEN
|
|
||||||
v_result.put_null('requestedBaseDate');
|
|
||||||
ELSE
|
|
||||||
v_result.put('requestedBaseDate', TO_CHAR(TRUNC(p_base_date), 'YYYY-MM-DD'));
|
|
||||||
END IF;
|
|
||||||
v_result.put('targetCount', v_target_count);
|
|
||||||
v_result.put('items', v_items);
|
|
||||||
RETURN v_result.to_clob;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
-- 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
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
/
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
-- 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
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'SCOPE_GUIDANCE_NONE', NULL,
|
|
||||||
'{"mode":"GAME_UNSPECIFIED","allowGameScopedObjects":false,"targetExecution":"COMMON_OBJECTS_OR_ZERO_ROW","instruction":"No game was selected. Do not use a game-scoped object. Use only a game-neutral common object when it answers the question; otherwise return a zero-row result."}',
|
|
||||||
'Prompt guidance for a question without a selected game.'
|
|
||||||
FROM dual
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'SCOPE_GUIDANCE_SINGLE', NULL,
|
|
||||||
'{"mode":"EXACT_TARGETS","allowGameScopedObjects":true,"targetExecution":"ONLY_RESOLVED_TARGETS","instruction":"Use only the resolved target in targets. Do not select another game-scoped object."}',
|
|
||||||
'Prompt guidance for exactly one resolved game target.'
|
|
||||||
FROM dual
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'SCOPE_GUIDANCE_MULTI', NULL,
|
|
||||||
'{"mode":"MULTIPLE_TARGETS","allowGameScopedObjects":true,"targetExecution":"ALL_RESOLVED_TARGETS","instruction":"Return results for all resolved available targets. Preserve unresolved targets as unavailable; do not replace them with another game."}',
|
|
||||||
'Prompt guidance for multiple game targets.'
|
|
||||||
FROM dual
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'SCOPE_GUIDANCE_ALL', NULL,
|
|
||||||
'{"mode":"ALL_CATALOG_TARGETS","allowGameScopedObjects":true,"targetExecution":"ALL_AVAILABLE_CATALOG_TARGETS","instruction":"Use all available catalog targets. Do not invent games or game-scoped objects outside the catalog."}',
|
|
||||||
'Prompt guidance for every catalog game.'
|
|
||||||
FROM dual
|
|
||||||
) s
|
|
||||||
ON (t.policy_key = s.policy_key)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
t.text_value = CASE
|
|
||||||
WHEN s.policy_key LIKE 'SCOPE_GUIDANCE_%' THEN s.text_value
|
|
||||||
ELSE t.text_value
|
|
||||||
END,
|
|
||||||
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.';
|
|
||||||
/
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
-- Customer-managed quality floor for runtime Few-shot retrieval.
|
|
||||||
-- Lower cosine distance is more similar. The value is data, not application code.
|
|
||||||
MERGE INTO sg_game_scope_policy t
|
|
||||||
USING (
|
|
||||||
SELECT 'QA_VECTOR_MAX_COSINE_DISTANCE' AS policy_key,
|
|
||||||
0.650000 AS number_value,
|
|
||||||
'Maximum cosine distance accepted for a runtime approved Few-shot example.' AS description
|
|
||||||
FROM dual
|
|
||||||
) s
|
|
||||||
ON (t.policy_key = s.policy_key)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
t.number_value = s.number_value,
|
|
||||||
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, NULL, s.description, 'Y'
|
|
||||||
);
|
|
||||||
/
|
|
||||||
|
|
||||||
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')));
|
|
||||||
v_max_cosine_distance NUMBER;
|
|
||||||
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, 'invalid target type.');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT number_value
|
|
||||||
INTO v_max_cosine_distance
|
|
||||||
FROM sg_game_scope_policy
|
|
||||||
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE'
|
|
||||||
AND active_yn = 'Y'
|
|
||||||
AND number_value IS NOT NULL;
|
|
||||||
|
|
||||||
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,
|
|
||||||
cosine_distance
|
|
||||||
FROM (
|
|
||||||
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'
|
|
||||||
-- A generated structural pattern is review material, not a runtime
|
|
||||||
-- Few-shot. Runtime examples must have human verification and an
|
|
||||||
-- executable SQL body rather than unresolved logical placeholders.
|
|
||||||
AND inspection_status = 'VERIFIED'
|
|
||||||
AND answer_sql IS NOT NULL
|
|
||||||
AND NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i')
|
|
||||||
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
|
|
||||||
)
|
|
||||||
WHERE cosine_distance <= v_max_cosine_distance
|
|
||||||
ORDER BY cosine_distance, example_id
|
|
||||||
FETCH FIRST p_top_k ROWS ONLY;
|
|
||||||
RETURN v_results;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMENT ON TABLE sg_game_scope_policy IS
|
|
||||||
'Customer-managed game scope and runtime retrieval policy values; changing a value requires no application deployment.';
|
|
||||||
/
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
-- STD-12 is a multi-target orchestration case, not a reusable SQL few-shot.
|
|
||||||
-- Preserve SG_AI_QA_QUESTION as the customer benchmark; remove only its
|
|
||||||
-- invalid vector-example row so it cannot be managed as a few-shot.
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_count PLS_INTEGER;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_count
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE example_id = 51
|
|
||||||
AND source_case_id = 'STD-12'
|
|
||||||
AND source_type = 'CUSTOMER_QA_BENCHMARK';
|
|
||||||
|
|
||||||
IF v_count <> 1 THEN
|
|
||||||
RAISE_APPLICATION_ERROR(-20051, 'Expected exactly one invalid STD-12 few-shot row.');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
DELETE FROM sg_qa_vector_example
|
|
||||||
WHERE example_id = 51
|
|
||||||
AND source_case_id = 'STD-12'
|
|
||||||
AND source_type = 'CUSTOMER_QA_BENCHMARK';
|
|
||||||
|
|
||||||
IF SQL%ROWCOUNT <> 1 THEN
|
|
||||||
RAISE_APPLICATION_ERROR(-20052, 'Invalid STD-12 few-shot row was not deleted.');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT COUNT(*) AS remaining_fewshot_rows
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE example_id = 51;
|
|
||||||
|
|
||||||
SELECT COUNT(*) AS preserved_question_rows
|
|
||||||
FROM sg_ai_qa_question
|
|
||||||
WHERE question_code = 'STD-12';
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
-- One DB-owned JSON identity document per game. No game value is hardcoded.
|
|
||||||
-- The relational alias child makes aliases a nested Duality View array.
|
|
||||||
|
|
||||||
BEGIN
|
|
||||||
EXECUTE IMMEDIATE q'[
|
|
||||||
CREATE TABLE sg_game_catalog_identity_alias (
|
|
||||||
game_key VARCHAR2(128) NOT NULL,
|
|
||||||
alias_value VARCHAR2(512) NOT NULL,
|
|
||||||
CONSTRAINT sg_game_catalog_identity_alias_pk PRIMARY KEY (game_key, alias_value),
|
|
||||||
CONSTRAINT sg_game_catalog_identity_alias_fk FOREIGN KEY (game_key)
|
|
||||||
REFERENCES sg_game_catalog (game_key)
|
|
||||||
)]';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
IF SQLCODE != -955 THEN RAISE; END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
MERGE INTO sg_game_catalog_identity_alias target
|
|
||||||
USING (
|
|
||||||
SELECT c.game_key, aliases.alias_value
|
|
||||||
FROM sg_game_catalog c,
|
|
||||||
JSON_TABLE(
|
|
||||||
c.aliases_json,
|
|
||||||
'$[*]' COLUMNS (alias_value VARCHAR2(512) PATH '$')
|
|
||||||
) aliases
|
|
||||||
WHERE c.active_yn = 'Y'
|
|
||||||
) source
|
|
||||||
ON (target.game_key = source.game_key AND target.alias_value = source.alias_value)
|
|
||||||
WHEN NOT MATCHED THEN INSERT (game_key, alias_value)
|
|
||||||
VALUES (source.game_key, source.alias_value);
|
|
||||||
/
|
|
||||||
|
|
||||||
DELETE FROM sg_game_catalog_identity_alias target
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM sg_game_catalog c,
|
|
||||||
JSON_TABLE(
|
|
||||||
c.aliases_json,
|
|
||||||
'$[*]' COLUMNS (alias_value VARCHAR2(512) PATH '$')
|
|
||||||
) aliases
|
|
||||||
WHERE c.game_key = target.game_key
|
|
||||||
AND c.active_yn = 'Y'
|
|
||||||
AND aliases.alias_value = target.alias_value
|
|
||||||
);
|
|
||||||
/
|
|
||||||
|
|
||||||
CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW sg_game_catalog_identity_dv AS
|
|
||||||
SELECT JSON {
|
|
||||||
'_id' : c.game_key,
|
|
||||||
'gameId' : c.game_id,
|
|
||||||
'gamePrefix' : c.game_prefix,
|
|
||||||
'gameName' : c.game_nm,
|
|
||||||
'gameAliases' : [
|
|
||||||
SELECT JSON {
|
|
||||||
'_id' : { 'gameKey' : a.game_key, 'value' : a.alias_value }
|
|
||||||
}
|
|
||||||
FROM sg_game_catalog_identity_alias a
|
|
||||||
WHERE a.game_key = c.game_key
|
|
||||||
]
|
|
||||||
}
|
|
||||||
FROM sg_game_catalog c
|
|
||||||
WHERE c.active_yn = 'Y'
|
|
||||||
WITH CHECK OPTION;
|
|
||||||
/
|
|
||||||
|
|
||||||
-- Serialize the DB JSON document itself before embedding. GAME_ID, GAME_PREFIX,
|
|
||||||
-- names and every alias therefore share one vector search document.
|
|
||||||
UPDATE sg_game_catalog c
|
|
||||||
SET c.embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
(
|
|
||||||
SELECT JSON_SERIALIZE(d.data RETURNING CLOB)
|
|
||||||
FROM sg_game_catalog_identity_dv d
|
|
||||||
WHERE JSON_VALUE(d.data, '$._id') = c.game_key
|
|
||||||
),
|
|
||||||
JSON(sg_qa_vector_params('search_document'))
|
|
||||||
),
|
|
||||||
c.updated_at = SYSTIMESTAMP
|
|
||||||
WHERE c.active_yn = 'Y';
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMENT ON TABLE sg_game_catalog_identity_dv IS
|
|
||||||
'DB JSON identity document for each active game; the canonical embedding source for game-name, alias, GAME_ID and GAME_PREFIX resolution.';
|
|
||||||
/
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
-- STD-18 asks for an aggregate over qualifying orders, not an individual
|
|
||||||
-- transaction list. Remove the invalid customer-derived references and keep
|
|
||||||
-- one reusable, data-neutral aggregate pattern for runtime retrieval.
|
|
||||||
|
|
||||||
DELETE FROM sg_qa_vector_example
|
|
||||||
WHERE source_case_id IN ('STD-18', 'PAT-STD-18')
|
|
||||||
AND source_type IN ('CUSTOMER_QA_BENCHMARK', 'GENERALIZED_QUESTION_PATTERN');
|
|
||||||
/
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_input CLOB;
|
|
||||||
v_embedding VECTOR;
|
|
||||||
v_exists NUMBER;
|
|
||||||
BEGIN
|
|
||||||
v_input := TO_CLOB('질문 패턴: 전체 매출에서 금액 조건을 만족하는 주문을 집계해줘.')
|
|
||||||
|| CHR(10) || 'Question pattern: summarize whole-scope sales after a payment amount filter.'
|
|
||||||
|| CHR(10) || 'Logical object role: SALES_TRANSACTION'
|
|
||||||
|| CHR(10) || 'Required result grain: one aggregate row with total sales amount, distinct buyer count, and order count.'
|
|
||||||
|| CHR(10) || 'A reference to orders does not by itself request individual order detail.';
|
|
||||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
v_input,
|
|
||||||
JSON(sg_qa_vector_params('search_document'))
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_exists
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'FILTERED_SALES_AGGREGATE';
|
|
||||||
|
|
||||||
IF v_exists = 0 THEN
|
|
||||||
INSERT INTO sg_qa_vector_example (
|
|
||||||
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
|
|
||||||
reference_status, reference_kind, target_type, object_role,
|
|
||||||
inspection_status, inspection_note, verified_at, verified_by,
|
|
||||||
source_case_id, source_type
|
|
||||||
) VALUES (
|
|
||||||
'전체 매출에서 금액 조건을 만족하는 주문을 집계해줘.',
|
|
||||||
TO_CLOB('SELECT SUM(CAST(s."PAYMT_AMT" AS NUMBER)) AS "TOTAL_SALES_AMOUNT",' || CHR(10)
|
|
||||||
|| ' COUNT(DISTINCT s."GUID") AS "BUYER_COUNT",' || CHR(10)
|
|
||||||
|| ' COUNT(*) AS "ORDER_COUNT"' || CHR(10)
|
|
||||||
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" s' || CHR(10)
|
|
||||||
|| 'WHERE s."PAYMT_DTM" >= <BUSINESS_DATE_START>' || CHR(10)
|
|
||||||
|| ' AND s."PAYMT_DTM" < <BUSINESS_DATE_END>' || CHR(10)
|
|
||||||
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) <AMOUNT_CONDITION>' || CHR(10)
|
|
||||||
|| ' AND s."EXPT_USER_YN" = ''N'''),
|
|
||||||
'Structural Few-shot: return one aggregate row containing total sales amount, distinct buyer count, and order count after the requested payment-amount filter. Do not return individual orders unless the user explicitly asks for a list or detail rows.',
|
|
||||||
v_input,
|
|
||||||
v_embedding,
|
|
||||||
'cohere.embed-v4.0',
|
|
||||||
'APPROVED', 'SQL_TEMPLATE', 'NONE', 'SALES_TRANSACTION',
|
|
||||||
'VERIFIED',
|
|
||||||
'Reusable whole-scope filtered-sales aggregate. No customer date, amount, game, or expected result is stored.',
|
|
||||||
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW',
|
|
||||||
'FILTERED_SALES_AGGREGATE', 'POLICY_TEMPLATE'
|
|
||||||
);
|
|
||||||
ELSE
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET question = '전체 매출에서 금액 조건을 만족하는 주문을 집계해줘.',
|
|
||||||
answer_sql = TO_CLOB('SELECT SUM(CAST(s."PAYMT_AMT" AS NUMBER)) AS "TOTAL_SALES_AMOUNT",' || CHR(10)
|
|
||||||
|| ' COUNT(DISTINCT s."GUID") AS "BUYER_COUNT",' || CHR(10)
|
|
||||||
|| ' COUNT(*) AS "ORDER_COUNT"' || CHR(10)
|
|
||||||
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" s' || CHR(10)
|
|
||||||
|| 'WHERE s."PAYMT_DTM" >= <BUSINESS_DATE_START>' || CHR(10)
|
|
||||||
|| ' AND s."PAYMT_DTM" < <BUSINESS_DATE_END>' || CHR(10)
|
|
||||||
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) <AMOUNT_CONDITION>' || CHR(10)
|
|
||||||
|| ' AND s."EXPT_USER_YN" = ''N'''),
|
|
||||||
answer_text = 'Structural Few-shot: return one aggregate row containing total sales amount, distinct buyer count, and order count after the requested payment-amount filter. Do not return individual orders unless the user explicitly asks for a list or detail rows.',
|
|
||||||
embedding_input = v_input,
|
|
||||||
embedding = v_embedding,
|
|
||||||
reference_status = 'APPROVED',
|
|
||||||
reference_kind = 'SQL_TEMPLATE',
|
|
||||||
target_type = 'NONE',
|
|
||||||
object_role = 'SALES_TRANSACTION',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Reusable whole-scope filtered-sales aggregate. No customer date, amount, game, or expected result is stored.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'FILTERED_SALES_AGGREGATE';
|
|
||||||
END IF;
|
|
||||||
COMMIT;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
-- A SQL template is prompt context, never an executable statement. Permit
|
|
||||||
-- reviewed policy templates to retain logical placeholders while continuing
|
|
||||||
-- to require executable SQL for automatically generalized patterns.
|
|
||||||
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')));
|
|
||||||
v_max_cosine_distance NUMBER;
|
|
||||||
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, 'invalid target type.');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT number_value INTO v_max_cosine_distance
|
|
||||||
FROM sg_game_scope_policy
|
|
||||||
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE'
|
|
||||||
AND active_yn = 'Y'
|
|
||||||
AND number_value IS NOT NULL;
|
|
||||||
|
|
||||||
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,
|
|
||||||
cosine_distance
|
|
||||||
FROM (
|
|
||||||
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 inspection_status = 'VERIFIED'
|
|
||||||
AND answer_sql IS NOT NULL
|
|
||||||
AND (source_type = 'POLICY_TEMPLATE'
|
|
||||||
OR NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i'))
|
|
||||||
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
|
|
||||||
)
|
|
||||||
WHERE cosine_distance <= v_max_cosine_distance
|
|
||||||
ORDER BY cosine_distance, example_id
|
|
||||||
FETCH FIRST p_top_k ROWS ONLY;
|
|
||||||
RETURN v_results;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT example_id, source_case_id, source_type, reference_status, inspection_status
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_case_id IN ('STD-18', 'PAT-STD-18', 'FILTERED_SALES_AGGREGATE')
|
|
||||||
ORDER BY example_id;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
-- This customer question is game-unscoped. Keep the existing NONE-compatible
|
|
||||||
-- Few-shot path; game_query_plan remains responsible for scope resolution.
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET question = '매출에서 금액 조건을 만족하는 주문의 총액, 구매자 수, 주문 수를 집계해줘.',
|
|
||||||
answer_sql = TO_CLOB('SELECT' || CHR(10)
|
|
||||||
|| ' SUM(CAST(s."PAYMT_AMT" AS NUMBER)) AS "TOTAL_SALES_AMOUNT",' || CHR(10)
|
|
||||||
|| ' COUNT(DISTINCT s."GUID") AS "BUYER_COUNT",' || CHR(10)
|
|
||||||
|| ' COUNT(*) AS "ORDER_COUNT"' || CHR(10)
|
|
||||||
|| ' FROM <COMMON_SALES_TRANSACTION> s' || CHR(10)
|
|
||||||
|| ' WHERE s."PAYMT_DTM" >= <BUSINESS_DATE_START>' || CHR(10)
|
|
||||||
|| ' AND s."PAYMT_DTM" < <BUSINESS_DATE_END>' || CHR(10)
|
|
||||||
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) <AMOUNT_CONDITION>' || CHR(10)
|
|
||||||
|| ' AND s."EXPT_USER_YN" = ''N'''),
|
|
||||||
answer_text = 'Aggregate result-shape reference: return one row with total sales amount, distinct buyer count, and order count. The game plan separately supplies any game scope; use this pattern only when the question is semantically similar.',
|
|
||||||
embedding_input = TO_CLOB('Question pattern: summarize sales after a payment amount condition.' || CHR(10)
|
|
||||||
|| 'Logical object role: SALES_TRANSACTION' || CHR(10)
|
|
||||||
|| 'Result grain: one aggregate row with total sales amount, distinct buyer count, and order count.'),
|
|
||||||
embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
TO_CLOB('Question pattern: summarize sales after a payment amount condition.' || CHR(10)
|
|
||||||
|| 'Logical object role: SALES_TRANSACTION' || CHR(10)
|
|
||||||
|| 'Result grain: one aggregate row with total sales amount, distinct buyer count, and order count.'),
|
|
||||||
JSON(sg_qa_vector_params('search_document'))),
|
|
||||||
target_type = 'NONE',
|
|
||||||
source_case_id = 'PORTAL-STD-18',
|
|
||||||
inspection_note = 'Generalized aggregate pattern for the current game-unscoped question; game_query_plan controls scope separately.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE example_id = 141
|
|
||||||
AND source_type = 'POLICY_TEMPLATE';
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
/
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- The reviewed customer benchmark answer is part of this Few-shot guidance.
|
|
||||||
-- It clarifies that the requested result is one aggregate row, not detail rows.
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET answer_text = TO_CLOB('Expected answer shape: return exactly one aggregate row, not individual order rows.' || CHR(10)
|
|
||||||
|| 'Expected answer:' || CHR(10)
|
|
||||||
|| 'TOTAL_SALES_AMOUNT BUYER_COUNT ORDER_COUNT' || CHR(10)
|
|
||||||
|| '------------------ ----------- -----------' || CHR(10)
|
|
||||||
|| ' 204720 6 6'),
|
|
||||||
embedding_input = TO_CLOB('Question pattern: summarize sales after a payment amount condition.' || CHR(10)
|
|
||||||
|| 'Expected output: TOTAL_SALES_AMOUNT, BUYER_COUNT, ORDER_COUNT as one aggregate row.' || CHR(10)
|
|
||||||
|| 'Expected result example: 204720, 6, 6.'),
|
|
||||||
embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
TO_CLOB('Question pattern: summarize sales after a payment amount condition.' || CHR(10)
|
|
||||||
|| 'Expected output: TOTAL_SALES_AMOUNT, BUYER_COUNT, ORDER_COUNT as one aggregate row.' || CHR(10)
|
|
||||||
|| 'Expected result example: 204720, 6, 6.'),
|
|
||||||
JSON(sg_qa_vector_params('search_document'))),
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE example_id = 141
|
|
||||||
AND source_type = 'POLICY_TEMPLATE';
|
|
||||||
/
|
|
||||||
COMMIT;
|
|
||||||
/
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
-- STD-25 is a period AU metric, not the daily AU_FLAG metric.
|
|
||||||
-- Keep the evaluation evidence explicit so the LLM judge accepts the valid
|
|
||||||
-- weekly result shape produced by the NL2SQL tool.
|
|
||||||
|
|
||||||
UPDATE sg_ai_qa_question
|
|
||||||
SET expected_focus = 'Weekly AU: use one as-of snapshot (BASE_DT=2026-07-15), '
|
|
||||||
|| 'count DISTINCT GUID whose LAST_CONN_DT is in the inclusive seven-day window '
|
|
||||||
|| '(2026-07-09 through 2026-07-15), with STD_USER_YN=''Y'' and EXPT_USER_YN=''N''. '
|
|
||||||
|| 'This is one aggregate result, not daily rows. Do not substitute daily AU_FLAG=1 for the period definition.',
|
|
||||||
baseline_sql = TO_CLOB('SELECT COUNT(DISTINCT u."GUID") AS "RECENT_7DAY_AU"' || CHR(10)
|
|
||||||
|| 'FROM "SGMP_POC"."CZN_COMN_USER_MST" u' || CHR(10)
|
|
||||||
|| 'WHERE u."BASE_DT" = DATE ''2026-07-15''' || CHR(10)
|
|
||||||
|| ' AND u."LAST_CONN_DT" BETWEEN DATE ''2026-07-09'' AND DATE ''2026-07-15''' || CHR(10)
|
|
||||||
|| ' AND u."STD_USER_YN" = ''Y''' || CHR(10)
|
|
||||||
|| ' AND u."EXPT_USER_YN" = ''N'''),
|
|
||||||
baseline_answer = 'RECENT_7DAY_AU=0',
|
|
||||||
evaluation_rule_json = '{"required_sql_terms":["CZN_COMN_USER_MST","LAST_CONN_DT","STD_USER_YN","EXPT_USER_YN","COUNT"],"recommended_sql_terms":["BASE_DT"],"forbidden_sql_terms":["AU_FLAG"],"required_result_shape":"SINGLE_AGGREGATE"}',
|
|
||||||
updated_at = SYSTIMESTAMP
|
|
||||||
WHERE question_code = 'STD-25';
|
|
||||||
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT question_code, expected_focus, baseline_sql, baseline_answer, evaluation_rule_json
|
|
||||||
FROM sg_ai_qa_question
|
|
||||||
WHERE question_code = 'STD-25';
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
-- CZN-02 customer sample marks STD_USER_YN='Y' as optional for daily
|
|
||||||
-- standard-AU reporting. It must not turn an otherwise correct AU query into
|
|
||||||
-- a failure merely because the condition is present.
|
|
||||||
|
|
||||||
UPDATE sg_ai_qa_question
|
|
||||||
SET expected_focus = 'Daily standard AU: COUNT(DISTINCT GUID) from CZN_COMN_USER_MST '
|
|
||||||
|| 'for BASE_DT=2026-07-15 with AU_FLAG=1 and EXPT_USER_YN=''N''. '
|
|
||||||
|| 'STD_USER_YN=''Y'' is an allowed optional cohort filter in the customer sample; '
|
|
||||||
|| 'its presence or absence is not a contradiction to this baseline.',
|
|
||||||
evaluation_rule_json = '{"required_sql_terms":["CZN_COMN_USER_MST","AU_FLAG","EXPT_USER_YN","COUNT"],"recommended_sql_terms":["BASE_DT","STD_USER_YN"],"optional_sql_terms":["STD_USER_YN"]}',
|
|
||||||
updated_at = SYSTIMESTAMP
|
|
||||||
WHERE question_code = 'CZN-02';
|
|
||||||
|
|
||||||
/
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET answer_text = 'Expected focus: daily standard AU uses CZN_COMN_USER_MST, BASE_DT=2026-07-15, '
|
|
||||||
|| 'AU_FLAG=1 and EXPT_USER_YN=''N''. The customer sample permits STD_USER_YN=''Y'' '
|
|
||||||
|| 'as an optional standard-user cohort filter; do not treat its presence as a conflicting condition. '
|
|
||||||
|| 'Historical answer: STD_AU_COUNT=0',
|
|
||||||
inspection_note = 'Customer sample permits optional STD_USER_YN filtering for daily standard AU; AU_FLAG and excluded-user filtering remain mandatory.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-02'
|
|
||||||
AND reference_status = 'APPROVED';
|
|
||||||
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT q.question_code, q.expected_focus, q.evaluation_rule_json,
|
|
||||||
e.example_id, e.answer_text
|
|
||||||
FROM sg_ai_qa_question q
|
|
||||||
LEFT JOIN sg_qa_vector_example e
|
|
||||||
ON e.source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND e.source_case_id = q.question_code
|
|
||||||
WHERE q.question_code = 'CZN-02';
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
-- Reusable SINGLE-scope pattern: two AU populations must be aggregated
|
|
||||||
-- independently before comparison. A user-master LEFT JOIN may erase valid
|
|
||||||
-- business-user rows and must not define the business population.
|
|
||||||
|
|
||||||
UPDATE sg_ai_qa_question
|
|
||||||
SET expected_focus = 'Compare standard AU and business AU as two independent single-row aggregates for the same as-of date. '
|
|
||||||
|| 'Standard AU uses the resolved game user master with AU_FLAG=1 and EXPT_USER_YN=''N''. '
|
|
||||||
|| 'Business AU uses the resolved game business-user fact with BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''. '
|
|
||||||
|| 'Do not make the business count depend on a LEFT JOIN from the user-master population. '
|
|
||||||
|| 'STD_USER_YN=''Y'' is an allowed optional cohort filter, not a reason to reject the result.',
|
|
||||||
evaluation_rule_json = '{"required_sql_terms":["CZN_COMN_USER_MST","CZN_CUSTOM_BIZ_USER_TXN","AU_FLAG","BIZ_AU_FLAG","EXPT_USER_YN","COUNT"],"recommended_sql_terms":["BASE_DT","STD_USER_YN"],"optional_sql_terms":["STD_USER_YN"],"required_result_shape":"SINGLE_COMPARISON"}',
|
|
||||||
updated_at = SYSTIMESTAMP
|
|
||||||
WHERE question_code = 'CZN-03';
|
|
||||||
|
|
||||||
/
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_input CLOB;
|
|
||||||
v_embedding VECTOR;
|
|
||||||
v_exists NUMBER;
|
|
||||||
BEGIN
|
|
||||||
v_input := TO_CLOB('Question pattern: compare daily standard active users and business active users for one resolved game and one business date.')
|
|
||||||
|| CHR(10) || 'Question pattern Korean: 한 게임의 기준일 일간 표준 AU와 사업 AU를 비교해줘.'
|
|
||||||
|| CHR(10) || 'Logical object role: USER_BUSINESS_AU_COMPARISON'
|
|
||||||
|| CHR(10) || 'Required result shape: one row with two independent aggregate metrics.'
|
|
||||||
|| CHR(10) || 'Business population must be aggregated independently; a LEFT JOIN from the user-master population may not define it.';
|
|
||||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
v_input, JSON(sg_qa_vector_params('search_document'))
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT COUNT(*) INTO v_exists
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'STD_BIZ_AU_COMPARE';
|
|
||||||
|
|
||||||
IF v_exists = 0 THEN
|
|
||||||
INSERT INTO sg_qa_vector_example (
|
|
||||||
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
|
|
||||||
reference_status, reference_kind, target_type, object_role,
|
|
||||||
inspection_status, inspection_note, verified_at, verified_by,
|
|
||||||
source_case_id, source_type
|
|
||||||
) VALUES (
|
|
||||||
'한 게임의 기준일 일간 표준 AU와 사업 AU를 비교해줘.',
|
|
||||||
TO_CLOB('SELECT' || CHR(10)
|
|
||||||
|| ' (SELECT COUNT(DISTINCT u."GUID")' || CHR(10)
|
|
||||||
|| ' FROM <RESOLVED_GAME_USER_MASTER> u' || CHR(10)
|
|
||||||
|| ' WHERE u."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|
|
||||||
|| ' AND u."AU_FLAG" = 1' || CHR(10)
|
|
||||||
|| ' AND u."EXPT_USER_YN" = ''N'') AS "STANDARD_AU_COUNT",' || CHR(10)
|
|
||||||
|| ' (SELECT COUNT(DISTINCT b."GUID")' || CHR(10)
|
|
||||||
|| ' FROM <RESOLVED_GAME_BUSINESS_USER> b' || CHR(10)
|
|
||||||
|| ' WHERE b."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|
|
||||||
|| ' AND b."BIZ_AU_FLAG" = 1' || CHR(10)
|
|
||||||
|| ' AND b."EXPT_USER_YN" = ''N'') AS "BUSINESS_AU_COUNT"' || CHR(10)
|
|
||||||
|| 'FROM DUAL'),
|
|
||||||
'Applicable metric reference: for this daily AU comparison, the standard metric must use AU_FLAG=1 and EXPT_USER_YN=''N''; do not replace it with LAST_CONN_DT period logic or STD_USER_YN alone. The business metric must use BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''. Produce one row from two independent aggregate subqueries, and do not count business users through a LEFT JOIN from the standard-user population. STD_USER_YN may be added only as an optional cohort filter.',
|
|
||||||
v_input, v_embedding, 'cohere.embed-v4.0',
|
|
||||||
'APPROVED', 'SQL_TEMPLATE', 'SINGLE', 'USER_BUSINESS_AU_COMPARISON',
|
|
||||||
'VERIFIED',
|
|
||||||
'Reusable comparison pattern with logical placeholders only; no customer game, date, result, or physical object is embedded.',
|
|
||||||
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW',
|
|
||||||
'STD_BIZ_AU_COMPARE', 'POLICY_TEMPLATE'
|
|
||||||
);
|
|
||||||
ELSE
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET question = '한 게임의 기준일 일간 표준 AU와 사업 AU를 비교해줘.',
|
|
||||||
answer_sql = TO_CLOB('SELECT' || CHR(10)
|
|
||||||
|| ' (SELECT COUNT(DISTINCT u."GUID")' || CHR(10)
|
|
||||||
|| ' FROM <RESOLVED_GAME_USER_MASTER> u' || CHR(10)
|
|
||||||
|| ' WHERE u."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|
|
||||||
|| ' AND u."AU_FLAG" = 1' || CHR(10)
|
|
||||||
|| ' AND u."EXPT_USER_YN" = ''N'') AS "STANDARD_AU_COUNT",' || CHR(10)
|
|
||||||
|| ' (SELECT COUNT(DISTINCT b."GUID")' || CHR(10)
|
|
||||||
|| ' FROM <RESOLVED_GAME_BUSINESS_USER> b' || CHR(10)
|
|
||||||
|| ' WHERE b."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|
|
||||||
|| ' AND b."BIZ_AU_FLAG" = 1' || CHR(10)
|
|
||||||
|| ' AND b."EXPT_USER_YN" = ''N'') AS "BUSINESS_AU_COUNT"' || CHR(10)
|
|
||||||
|| 'FROM DUAL'),
|
|
||||||
answer_text = 'Applicable metric reference: for this daily AU comparison, the standard metric must use AU_FLAG=1 and EXPT_USER_YN=''N''; do not replace it with LAST_CONN_DT period logic or STD_USER_YN alone. The business metric must use BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''. Produce one row from two independent aggregate subqueries, and do not count business users through a LEFT JOIN from the standard-user population. STD_USER_YN may be added only as an optional cohort filter.',
|
|
||||||
embedding_input = v_input,
|
|
||||||
embedding = v_embedding,
|
|
||||||
reference_status = 'APPROVED', inspection_status = 'VERIFIED',
|
|
||||||
verified_at = SYSTIMESTAMP, verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'STD_BIZ_AU_COMPARE';
|
|
||||||
END IF;
|
|
||||||
COMMIT;
|
|
||||||
END;
|
|
||||||
|
|
||||||
/
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
-- Reusable SINGLE-scope pattern for country-grouped daily AU.
|
|
||||||
|
|
||||||
UPDATE sg_ai_qa_question
|
|
||||||
SET expected_focus = 'Country-grouped daily standard AU uses the resolved game user master with BASE_DT, AU_FLAG=1 and EXPT_USER_YN=''N''. '
|
|
||||||
|| 'Group by LAST_CONN_COUNTRY_CD and use the approved country dimension only for display/classification. '
|
|
||||||
|| 'STD_USER_YN is optional and cannot replace AU_FLAG for the daily metric.',
|
|
||||||
evaluation_rule_json = '{"required_sql_terms":["CZN_COMN_USER_MST","AU_FLAG","EXPT_USER_YN","LAST_CONN_COUNTRY_CD","COUNT"],"recommended_sql_terms":["BASE_DT","COMN_COUNTRY_BAS","STD_USER_YN"],"optional_sql_terms":["STD_USER_YN"],"required_result_shape":"COUNTRY_GROUPED"}',
|
|
||||||
updated_at = SYSTIMESTAMP
|
|
||||||
WHERE question_code = 'CZN-06';
|
|
||||||
|
|
||||||
/
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_input CLOB;
|
|
||||||
v_embedding VECTOR;
|
|
||||||
v_exists NUMBER;
|
|
||||||
BEGIN
|
|
||||||
v_input := TO_CLOB('Question pattern: show daily active-user counts by country for one resolved game and one business date.')
|
|
||||||
|| CHR(10) || 'Question pattern Korean: 한 게임의 기준일 주요 국가별 표준 AU 수를 알려줘.'
|
|
||||||
|| CHR(10) || 'Logical object role: COUNTRY_GROUPED_DAILY_AU'
|
|
||||||
|| CHR(10) || 'Required metric: AU_FLAG=1 and excluded-user filtering; group by the last connection country.';
|
|
||||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(v_input, JSON(sg_qa_vector_params('search_document')));
|
|
||||||
SELECT COUNT(*) INTO v_exists FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE' AND source_case_id = 'COUNTRY_DAILY_AU';
|
|
||||||
IF v_exists = 0 THEN
|
|
||||||
INSERT INTO sg_qa_vector_example (
|
|
||||||
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
|
|
||||||
reference_status, reference_kind, target_type, object_role,
|
|
||||||
inspection_status, inspection_note, verified_at, verified_by, source_case_id, source_type
|
|
||||||
) VALUES (
|
|
||||||
'한 게임의 기준일 주요 국가별 표준 AU 수를 알려줘.',
|
|
||||||
TO_CLOB('SELECT u."LAST_CONN_COUNTRY_CD" AS "COUNTRY_CD",' || CHR(10)
|
|
||||||
|| ' c."COUNTRY_KR_NM" AS "COUNTRY_NAME",' || CHR(10)
|
|
||||||
|| ' COUNT(DISTINCT u."GUID") AS "STANDARD_AU_COUNT"' || CHR(10)
|
|
||||||
|| 'FROM <RESOLVED_GAME_USER_MASTER> u' || CHR(10)
|
|
||||||
|| 'LEFT JOIN <APPROVED_COUNTRY_DIMENSION> c' || CHR(10)
|
|
||||||
|| ' ON c."COUNTRY_2CHAR_CD" = u."LAST_CONN_COUNTRY_CD"' || CHR(10)
|
|
||||||
|| 'WHERE u."BASE_DT" = <BUSINESS_DATE>' || CHR(10)
|
|
||||||
|| ' AND u."AU_FLAG" = 1' || CHR(10)
|
|
||||||
|| ' AND u."EXPT_USER_YN" = ''N''' || CHR(10)
|
|
||||||
|| 'GROUP BY u."LAST_CONN_COUNTRY_CD", c."COUNTRY_KR_NM"'),
|
|
||||||
'Applicable metric reference: country-grouped daily AU must use AU_FLAG=1 and EXPT_USER_YN=''N''; do not replace AU_FLAG with STD_USER_YN alone. Group by LAST_CONN_COUNTRY_CD. Use an approved country dimension for country display or a current approved major-country classification when the request requires it.',
|
|
||||||
v_input, v_embedding, 'cohere.embed-v4.0',
|
|
||||||
'APPROVED', 'SQL_TEMPLATE', 'SINGLE', 'COUNTRY_GROUPED_DAILY_AU',
|
|
||||||
'VERIFIED', 'Reusable country-grouped daily-AU pattern; no customer game, date, result, or physical object is embedded.',
|
|
||||||
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW', 'COUNTRY_DAILY_AU', 'POLICY_TEMPLATE'
|
|
||||||
);
|
|
||||||
ELSE
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET embedding_input = v_input, embedding = v_embedding,
|
|
||||||
reference_status = 'APPROVED', inspection_status = 'VERIFIED',
|
|
||||||
verified_at = SYSTIMESTAMP, verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE' AND source_case_id = 'COUNTRY_DAILY_AU';
|
|
||||||
END IF;
|
|
||||||
COMMIT;
|
|
||||||
END;
|
|
||||||
|
|
||||||
/
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- CZN-06 is a verified, exact customer question/SQL pair. It must be a
|
|
||||||
-- runtime Few-shot when approved; RETIRED is the DB switch that excludes it.
|
|
||||||
-- This is a single-game reference, so keep the retrieval scope explicit.
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
reference_kind = 'SQL_TEMPLATE',
|
|
||||||
target_type = 'SINGLE',
|
|
||||||
object_role = 'COUNTRY_GROUPED_DAILY_AU',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Verified exact CZN-06 Few-shot restored for runtime retrieval. Daily country AU requires AU_FLAG=1 and EXPT_USER_YN=''N''; STD_USER_YN alone is insufficient.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-06'
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
-- Runtime Few-shots remain semantic vector retrieval. Customer examples are
|
|
||||||
-- governed by their DB approval state, not restricted to exact text matches.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
reference_kind = 'SQL_TEMPLATE',
|
|
||||||
target_type = 'SINGLE',
|
|
||||||
object_role = 'GAME_GOODS_HOLDINGS',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Verified CZN-07 semantic Few-shot. Use goods holdings, crystal dimension, RU_FLAG=1, excluded-user filter, nonzero holdings, and daily grouping.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-07'
|
|
||||||
/
|
|
||||||
|
|
||||||
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')));
|
|
||||||
v_max_cosine_distance NUMBER;
|
|
||||||
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, 'invalid target type.');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT number_value INTO v_max_cosine_distance
|
|
||||||
FROM sg_game_scope_policy
|
|
||||||
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE'
|
|
||||||
AND active_yn = 'Y'
|
|
||||||
AND number_value IS NOT NULL;
|
|
||||||
|
|
||||||
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,
|
|
||||||
cosine_distance
|
|
||||||
FROM (
|
|
||||||
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 inspection_status = 'VERIFIED'
|
|
||||||
AND answer_sql IS NOT NULL
|
|
||||||
AND (source_type = 'POLICY_TEMPLATE'
|
|
||||||
OR NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i'))
|
|
||||||
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
|
|
||||||
)
|
|
||||||
WHERE cosine_distance <= v_max_cosine_distance
|
|
||||||
ORDER BY cosine_distance, example_id
|
|
||||||
FETCH FIRST p_top_k ROWS ONLY;
|
|
||||||
RETURN v_results;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
-- Keep semantic vector retrieval, but do not inject weak trailing neighbours
|
|
||||||
-- when a materially stronger example has already been found.
|
|
||||||
MERGE INTO sg_game_scope_policy t
|
|
||||||
USING (
|
|
||||||
SELECT 'QA_VECTOR_NEIGHBOR_DISTANCE_MARGIN' AS policy_key,
|
|
||||||
0.120000 AS number_value,
|
|
||||||
'Maximum additional cosine distance from the best runtime Few-shot candidate.' AS description
|
|
||||||
FROM dual
|
|
||||||
) s
|
|
||||||
ON (t.policy_key = s.policy_key)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
t.number_value = s.number_value,
|
|
||||||
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, NULL, s.description, 'Y'
|
|
||||||
)
|
|
||||||
/
|
|
||||||
|
|
||||||
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')));
|
|
||||||
v_max_cosine_distance NUMBER;
|
|
||||||
v_neighbor_margin NUMBER;
|
|
||||||
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, 'invalid target type.');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT number_value INTO v_max_cosine_distance
|
|
||||||
FROM sg_game_scope_policy
|
|
||||||
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE'
|
|
||||||
AND active_yn = 'Y'
|
|
||||||
AND number_value IS NOT NULL;
|
|
||||||
|
|
||||||
SELECT number_value INTO v_neighbor_margin
|
|
||||||
FROM sg_game_scope_policy
|
|
||||||
WHERE policy_key = 'QA_VECTOR_NEIGHBOR_DISTANCE_MARGIN'
|
|
||||||
AND active_yn = 'Y'
|
|
||||||
AND number_value IS NOT NULL;
|
|
||||||
|
|
||||||
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,
|
|
||||||
cosine_distance
|
|
||||||
FROM (
|
|
||||||
SELECT c.*,
|
|
||||||
MIN(c.cosine_distance) OVER () AS best_cosine_distance
|
|
||||||
FROM (
|
|
||||||
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 inspection_status = 'VERIFIED'
|
|
||||||
AND answer_sql IS NOT NULL
|
|
||||||
AND (source_type = 'POLICY_TEMPLATE'
|
|
||||||
OR NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i'))
|
|
||||||
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
|
|
||||||
) c
|
|
||||||
WHERE c.cosine_distance <= v_max_cosine_distance
|
|
||||||
)
|
|
||||||
WHERE cosine_distance <= best_cosine_distance + v_neighbor_margin
|
|
||||||
ORDER BY cosine_distance, example_id
|
|
||||||
FETCH FIRST p_top_k ROWS ONLY;
|
|
||||||
RETURN v_results;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
-- CZN-08 is the reviewed semantic reference for daily standard-AU crystal
|
|
||||||
-- total and per-user average holdings.
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
reference_kind = 'SQL_TEMPLATE',
|
|
||||||
target_type = 'SINGLE',
|
|
||||||
object_role = 'GAME_GOODS_HOLDINGS',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Verified CZN-08 Few-shot. Standard-AU crystal holdings require AU_FLAG=1, excluded-user filtering, and per-user average as SUM(HAVE_CNT) / COUNT(DISTINCT GUID), not AVG(HAVE_CNT).',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-08'
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- Calibrate the semantic-neighbour window using reviewed CZN patterns:
|
|
||||||
-- retain close paraphrases, exclude adjacent metric shapes.
|
|
||||||
UPDATE sg_game_scope_policy
|
|
||||||
SET number_value = 0.100000,
|
|
||||||
description = 'Maximum additional cosine distance from the best runtime Few-shot candidate.',
|
|
||||||
active_yn = 'Y',
|
|
||||||
updated_at = SYSTIMESTAMP
|
|
||||||
WHERE policy_key = 'QA_VECTOR_NEIGHBOR_DISTANCE_MARGIN'
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- CZN-05 is the reviewed reference for country-grouped business AU.
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
reference_kind = 'SQL_TEMPLATE',
|
|
||||||
target_type = 'SINGLE',
|
|
||||||
object_role = 'COUNTRY_GROUPED_BUSINESS_AU',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Verified CZN-05 Few-shot. Join business-user data to user master on GUID and BASE_DT before grouping by user country; filter BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-05'
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
-- Hybrid retrieval remains database-driven: dense vector similarity handles
|
|
||||||
-- paraphrases, while lexical similarity distinguishes decisive request terms.
|
|
||||||
MERGE INTO sg_game_scope_policy t
|
|
||||||
USING (
|
|
||||||
SELECT 'QA_VECTOR_LEXICAL_WEIGHT' AS policy_key, 0.350000 AS number_value,
|
|
||||||
'Weight of normalized lexical question similarity in runtime Few-shot reranking.' AS description
|
|
||||||
FROM dual
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'QA_VECTOR_HYBRID_SCORE_MARGIN', 0.050000,
|
|
||||||
'Maximum hybrid-score difference from the best runtime Few-shot candidate.'
|
|
||||||
FROM dual
|
|
||||||
) s
|
|
||||||
ON (t.policy_key = s.policy_key)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
t.number_value = s.number_value,
|
|
||||||
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, NULL, s.description, 'Y'
|
|
||||||
)
|
|
||||||
/
|
|
||||||
|
|
||||||
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')));
|
|
||||||
v_max_cosine_distance NUMBER;
|
|
||||||
v_lexical_weight NUMBER;
|
|
||||||
v_hybrid_margin NUMBER;
|
|
||||||
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, 'invalid target type.');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT number_value INTO v_max_cosine_distance FROM sg_game_scope_policy
|
|
||||||
WHERE policy_key = 'QA_VECTOR_MAX_COSINE_DISTANCE' AND active_yn = 'Y';
|
|
||||||
SELECT number_value INTO v_lexical_weight FROM sg_game_scope_policy
|
|
||||||
WHERE policy_key = 'QA_VECTOR_LEXICAL_WEIGHT' AND active_yn = 'Y';
|
|
||||||
SELECT number_value INTO v_hybrid_margin FROM sg_game_scope_policy
|
|
||||||
WHERE policy_key = 'QA_VECTOR_HYBRID_SCORE_MARGIN' AND active_yn = 'Y';
|
|
||||||
|
|
||||||
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,
|
|
||||||
cosine_distance
|
|
||||||
FROM (
|
|
||||||
SELECT s.*,
|
|
||||||
MAX(s.hybrid_score) OVER () AS best_hybrid_score
|
|
||||||
FROM (
|
|
||||||
SELECT c.*,
|
|
||||||
((1 - v_lexical_weight) * (1 - c.cosine_distance)
|
|
||||||
+ v_lexical_weight * c.lexical_similarity) AS hybrid_score
|
|
||||||
FROM (
|
|
||||||
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,
|
|
||||||
UTL_MATCH.JARO_WINKLER_SIMILARITY(
|
|
||||||
DBMS_LOB.SUBSTR(question, 4000, 1),
|
|
||||||
DBMS_LOB.SUBSTR(p_question, 4000, 1)
|
|
||||||
) / 100 AS lexical_similarity
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE reference_status = 'APPROVED'
|
|
||||||
AND inspection_status = 'VERIFIED'
|
|
||||||
AND answer_sql IS NOT NULL
|
|
||||||
AND (source_type = 'POLICY_TEMPLATE'
|
|
||||||
OR NOT REGEXP_LIKE(answer_sql, '<[A-Z][A-Z0-9_]*>', 'i'))
|
|
||||||
AND (target_type = 'ANY' OR v_target_type = 'ANY' OR target_type = v_target_type)
|
|
||||||
) c
|
|
||||||
WHERE c.cosine_distance <= v_max_cosine_distance
|
|
||||||
) s
|
|
||||||
)
|
|
||||||
WHERE hybrid_score >= best_hybrid_score - v_hybrid_margin
|
|
||||||
ORDER BY hybrid_score DESC, cosine_distance, example_id
|
|
||||||
FETCH FIRST p_top_k ROWS ONLY;
|
|
||||||
RETURN v_results;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- CZN-13 is the reviewed reference for Ether usage and distinct users.
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
reference_kind = 'SQL_TEMPLATE',
|
|
||||||
target_type = 'SINGLE',
|
|
||||||
object_role = 'GAME_GOODS_CHANGE',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Verified CZN-13 Few-shot. Ether usage requires goods-change data joined to the goods dimension and user master by GUID and BASE_DT, CHANGE_TYPE_CD=''USE'', active Ether dimension, excluded-user filter, and GOODS_CHANGE_CNT aggregation.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-13'
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- CZN-16 is the reviewed reference for purchasers of a named package.
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
reference_kind = 'SQL_TEMPLATE',
|
|
||||||
target_type = 'SINGLE',
|
|
||||||
object_role = 'SALES_PRODUCT_PURCHASER',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Verified CZN-16 Few-shot. Join sales transactions to product display by GAME_ID and PRODUCT_ID, filter the resolved package name and excluded users, and use the payment business date when counting distinct purchasers.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-16'
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT
|
|
||||||
@@ -1,597 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- 72_hmm_leave_team_vpd.sql
|
|
||||||
--
|
|
||||||
-- HMM HR leave row security:
|
|
||||||
-- * HMM_HR_MANAGER -> self and directly managed employees
|
|
||||||
-- * HMM_HR_VIEWER -> self only
|
|
||||||
-- * HMM_HR_ADMIN -> all rows when the role is explicitly assigned
|
|
||||||
--
|
|
||||||
-- Run as ADMIN after the HMM access-management schema is installed.
|
|
||||||
-- This script is idempotent and never stores a plain bearer token.
|
|
||||||
-- ============================================================
|
|
||||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
|
||||||
SET ECHO OFF
|
|
||||||
SET FEEDBACK ON
|
|
||||||
SET DEFINE OFF
|
|
||||||
|
|
||||||
ALTER SESSION DISABLE PARALLEL DML;
|
|
||||||
|
|
||||||
PROMPT === 1. Ensuring HMM roles and demo role assignments ===
|
|
||||||
MERGE INTO hmm_access_roles dst
|
|
||||||
USING (
|
|
||||||
SELECT 1 role_id, 'HMM_HR_VIEWER' role_name,
|
|
||||||
'HMM employee self-service leave access' description,
|
|
||||||
'PUBLIC' max_sensitivity_level FROM dual
|
|
||||||
UNION ALL
|
|
||||||
SELECT 2, 'HMM_HR_MANAGER',
|
|
||||||
'HMM manager access to self and direct reports',
|
|
||||||
'INTERNAL' FROM dual
|
|
||||||
UNION ALL
|
|
||||||
SELECT 3, 'HMM_HR_ADMIN',
|
|
||||||
'HMM HR administrator full leave access',
|
|
||||||
'CONFIDENTIAL' FROM dual
|
|
||||||
) src
|
|
||||||
ON (dst.role_name = src.role_name)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
dst.description = src.description,
|
|
||||||
dst.max_sensitivity_level = src.max_sensitivity_level,
|
|
||||||
dst.active_yn = 'Y',
|
|
||||||
dst.updated_at = SYSTIMESTAMP
|
|
||||||
WHEN NOT MATCHED THEN INSERT (
|
|
||||||
role_id, role_name, description, max_sensitivity_level,
|
|
||||||
active_yn, created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
src.role_id, src.role_name, src.description, src.max_sensitivity_level,
|
|
||||||
'Y', SYSTIMESTAMP, SYSTIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
MERGE INTO hmm_employee_access_roles dst
|
|
||||||
USING (
|
|
||||||
SELECT e.employee_id, r.role_id
|
|
||||||
FROM hmm_hr_employees e
|
|
||||||
JOIN hmm_access_roles r ON r.role_name = 'HMM_HR_MANAGER'
|
|
||||||
WHERE e.employee_code = 'E1001'
|
|
||||||
AND e.employment_status = 'ACTIVE'
|
|
||||||
) src
|
|
||||||
ON (dst.employee_id = src.employee_id AND dst.role_id = src.role_id)
|
|
||||||
WHEN NOT MATCHED THEN INSERT (employee_id, role_id, created_at)
|
|
||||||
VALUES (src.employee_id, src.role_id, SYSTIMESTAMP);
|
|
||||||
|
|
||||||
MERGE INTO hmm_employee_access_roles dst
|
|
||||||
USING (
|
|
||||||
SELECT e.employee_id, r.role_id
|
|
||||||
FROM hmm_hr_employees e
|
|
||||||
JOIN hmm_access_roles r ON r.role_name = 'HMM_HR_VIEWER'
|
|
||||||
WHERE e.manager_employee_id = (
|
|
||||||
SELECT manager.employee_id
|
|
||||||
FROM hmm_hr_employees manager
|
|
||||||
WHERE manager.employee_code = 'E1001'
|
|
||||||
)
|
|
||||||
AND e.employment_status = 'ACTIVE'
|
|
||||||
) src
|
|
||||||
ON (dst.employee_id = src.employee_id AND dst.role_id = src.role_id)
|
|
||||||
WHEN NOT MATCHED THEN INSERT (employee_id, role_id, created_at)
|
|
||||||
VALUES (src.employee_id, src.role_id, SYSTIMESTAMP);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
PROMPT === 2. Ensuring protected leave objects and columns ===
|
|
||||||
DECLARE
|
|
||||||
PROCEDURE ensure_object(
|
|
||||||
p_object_name IN VARCHAR2,
|
|
||||||
p_description IN VARCHAR2
|
|
||||||
) AS
|
|
||||||
v_object_id NUMBER;
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
SELECT object_id
|
|
||||||
INTO v_object_id
|
|
||||||
FROM hmm_access_objects
|
|
||||||
WHERE owner = 'ADMIN'
|
|
||||||
AND object_name = p_object_name;
|
|
||||||
|
|
||||||
UPDATE hmm_access_objects
|
|
||||||
SET enabled_yn = 'Y',
|
|
||||||
description = p_description,
|
|
||||||
ords_path = 'cb-ords/cb-object-query/admin/' || LOWER(p_object_name)
|
|
||||||
WHERE object_id = v_object_id;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN NO_DATA_FOUND THEN
|
|
||||||
SELECT NVL(MAX(object_id), 0) + 1
|
|
||||||
INTO v_object_id
|
|
||||||
FROM hmm_access_objects;
|
|
||||||
|
|
||||||
INSERT INTO hmm_access_objects (
|
|
||||||
object_id, owner, object_name, ords_path, enabled_yn, description
|
|
||||||
) VALUES (
|
|
||||||
v_object_id,
|
|
||||||
'ADMIN',
|
|
||||||
p_object_name,
|
|
||||||
'cb-ords/cb-object-query/admin/' || LOWER(p_object_name),
|
|
||||||
'Y',
|
|
||||||
p_description
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
|
|
||||||
FOR c IN (
|
|
||||||
SELECT column_name
|
|
||||||
FROM user_tab_columns
|
|
||||||
WHERE table_name = p_object_name
|
|
||||||
ORDER BY column_id
|
|
||||||
) LOOP
|
|
||||||
MERGE INTO hmm_access_object_columns dst
|
|
||||||
USING (
|
|
||||||
SELECT v_object_id object_id, c.column_name column_name
|
|
||||||
FROM dual
|
|
||||||
) src
|
|
||||||
ON (
|
|
||||||
dst.object_id = src.object_id
|
|
||||||
AND dst.column_name = src.column_name
|
|
||||||
)
|
|
||||||
WHEN NOT MATCHED THEN INSERT (
|
|
||||||
column_id, object_id, column_name, sensitive_yn,
|
|
||||||
sensitivity_level, redaction_method
|
|
||||||
) VALUES (
|
|
||||||
(SELECT NVL(MAX(column_id), 0) + 1 FROM hmm_access_object_columns),
|
|
||||||
src.object_id,
|
|
||||||
src.column_name,
|
|
||||||
'N',
|
|
||||||
'PUBLIC',
|
|
||||||
'NONE'
|
|
||||||
);
|
|
||||||
END LOOP;
|
|
||||||
END;
|
|
||||||
BEGIN
|
|
||||||
ensure_object(
|
|
||||||
'HMM_LEAVE_BALANCES',
|
|
||||||
'HMM employee leave balance protected by employee hierarchy'
|
|
||||||
);
|
|
||||||
ensure_object(
|
|
||||||
'HMM_LEAVE_REQUESTS',
|
|
||||||
'HMM employee leave requests protected by employee hierarchy'
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
PROMPT === 3. Ensuring role permission rules ===
|
|
||||||
DECLARE
|
|
||||||
PROCEDURE ensure_permission(
|
|
||||||
p_role_name IN VARCHAR2,
|
|
||||||
p_target_name IN VARCHAR2,
|
|
||||||
p_rule_type IN VARCHAR2
|
|
||||||
) AS
|
|
||||||
v_role_id NUMBER;
|
|
||||||
v_perm_id NUMBER;
|
|
||||||
BEGIN
|
|
||||||
SELECT role_id
|
|
||||||
INTO v_role_id
|
|
||||||
FROM hmm_access_roles
|
|
||||||
WHERE role_name = p_role_name
|
|
||||||
AND active_yn = 'Y';
|
|
||||||
|
|
||||||
BEGIN
|
|
||||||
SELECT perm_id
|
|
||||||
INTO v_perm_id
|
|
||||||
FROM hmm_access_permissions
|
|
||||||
WHERE role_id = v_role_id
|
|
||||||
AND target_name = p_target_name
|
|
||||||
AND action_name = 'SELECT';
|
|
||||||
|
|
||||||
UPDATE hmm_access_permissions
|
|
||||||
SET permission_effect = 'ALLOW'
|
|
||||||
WHERE perm_id = v_perm_id;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN NO_DATA_FOUND THEN
|
|
||||||
SELECT cb_permission_seq.NEXTVAL
|
|
||||||
INTO v_perm_id
|
|
||||||
FROM dual;
|
|
||||||
|
|
||||||
INSERT INTO hmm_access_permissions (
|
|
||||||
perm_id, role_id, target_name, action_name, permission_effect
|
|
||||||
) VALUES (
|
|
||||||
v_perm_id, v_role_id, p_target_name, 'SELECT', 'ALLOW'
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
|
|
||||||
DELETE FROM hmm_access_permission_rules
|
|
||||||
WHERE perm_id = v_perm_id;
|
|
||||||
|
|
||||||
INSERT INTO hmm_access_permission_rules (
|
|
||||||
rule_id, perm_id, rule_column, rule_type, rule_value
|
|
||||||
) VALUES (
|
|
||||||
cb_permission_rule_seq.NEXTVAL,
|
|
||||||
v_perm_id,
|
|
||||||
'EMPLOYEE_ID',
|
|
||||||
p_rule_type,
|
|
||||||
NULL
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
BEGIN
|
|
||||||
ensure_permission('HMM_HR_VIEWER', 'HMM_LEAVE_BALANCES', 'SELF');
|
|
||||||
ensure_permission('HMM_HR_VIEWER', 'HMM_LEAVE_REQUESTS', 'SELF');
|
|
||||||
ensure_permission('HMM_HR_MANAGER', 'HMM_LEAVE_BALANCES', 'MANAGED_TEAM');
|
|
||||||
ensure_permission('HMM_HR_MANAGER', 'HMM_LEAVE_REQUESTS', 'MANAGED_TEAM');
|
|
||||||
ensure_permission('HMM_HR_ADMIN', 'HMM_LEAVE_BALANCES', 'ALL');
|
|
||||||
ensure_permission('HMM_HR_ADMIN', 'HMM_LEAVE_REQUESTS', 'ALL');
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
PROMPT === 4. Creating secure HMM access context ===
|
|
||||||
CREATE OR REPLACE PACKAGE hmm_access_ctx_pkg AUTHID DEFINER AS
|
|
||||||
PROCEDURE clear_user;
|
|
||||||
PROCEDURE set_user_by_bearer(p_bearer_token IN VARCHAR2);
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
CREATE OR REPLACE PACKAGE BODY hmm_access_ctx_pkg AS
|
|
||||||
PROCEDURE clear_user AS
|
|
||||||
BEGIN
|
|
||||||
DBMS_SESSION.SET_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID', NULL);
|
|
||||||
DBMS_SESSION.SET_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_CODE', NULL);
|
|
||||||
DBMS_SESSION.SET_CONTEXT('HMM_ACCESS_CTX', 'TEAM_ID', NULL);
|
|
||||||
END;
|
|
||||||
|
|
||||||
PROCEDURE set_user_by_bearer(p_bearer_token IN VARCHAR2) AS
|
|
||||||
v_employee_id hmm_hr_employees.employee_id%TYPE;
|
|
||||||
v_employee_code hmm_hr_employees.employee_code%TYPE;
|
|
||||||
v_team_id hmm_hr_employees.team_id%TYPE;
|
|
||||||
BEGIN
|
|
||||||
IF p_bearer_token IS NULL OR TRIM(p_bearer_token) IS NULL THEN
|
|
||||||
clear_user;
|
|
||||||
RAISE_APPLICATION_ERROR(-20101, 'Bearer token is required');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT e.employee_id,
|
|
||||||
e.employee_code,
|
|
||||||
e.team_id
|
|
||||||
INTO v_employee_id,
|
|
||||||
v_employee_code,
|
|
||||||
v_team_id
|
|
||||||
FROM hmm_access_bearer_tokens t
|
|
||||||
JOIN hmm_hr_employees e
|
|
||||||
ON e.employee_id = t.employee_id
|
|
||||||
WHERE t.key_hash = STANDARD_HASH(p_bearer_token, 'SHA256')
|
|
||||||
AND t.revoked_at IS NULL
|
|
||||||
AND t.expires_at > CAST(SYSTIMESTAMP AS TIMESTAMP)
|
|
||||||
AND e.employment_status = 'ACTIVE';
|
|
||||||
|
|
||||||
DBMS_SESSION.SET_CONTEXT(
|
|
||||||
'HMM_ACCESS_CTX', 'EMPLOYEE_ID', TO_CHAR(v_employee_id)
|
|
||||||
);
|
|
||||||
DBMS_SESSION.SET_CONTEXT(
|
|
||||||
'HMM_ACCESS_CTX', 'EMPLOYEE_CODE', v_employee_code
|
|
||||||
);
|
|
||||||
DBMS_SESSION.SET_CONTEXT(
|
|
||||||
'HMM_ACCESS_CTX', 'TEAM_ID', TO_CHAR(v_team_id)
|
|
||||||
);
|
|
||||||
EXCEPTION
|
|
||||||
WHEN NO_DATA_FOUND THEN
|
|
||||||
clear_user;
|
|
||||||
RAISE_APPLICATION_ERROR(-20102, 'Invalid or expired bearer token');
|
|
||||||
WHEN TOO_MANY_ROWS THEN
|
|
||||||
clear_user;
|
|
||||||
RAISE_APPLICATION_ERROR(-20103, 'Bearer token mapping is not unique');
|
|
||||||
END;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
CREATE OR REPLACE CONTEXT hmm_access_ctx USING hmm_access_ctx_pkg;
|
|
||||||
|
|
||||||
PROMPT === 5. Creating the HMM leave VPD filter ===
|
|
||||||
CREATE OR REPLACE FUNCTION hmm_leave_vpd_filter(
|
|
||||||
p_schema IN VARCHAR2,
|
|
||||||
p_object IN VARCHAR2
|
|
||||||
) RETURN VARCHAR2
|
|
||||||
AUTHID DEFINER
|
|
||||||
AS
|
|
||||||
v_employee_id NUMBER;
|
|
||||||
v_target VARCHAR2(128);
|
|
||||||
v_allow_predicate VARCHAR2(32767);
|
|
||||||
v_deny_predicate VARCHAR2(32767);
|
|
||||||
v_permission_clause VARCHAR2(32767);
|
|
||||||
|
|
||||||
FUNCTION quote_literal(p_value IN VARCHAR2) RETURN VARCHAR2 IS
|
|
||||||
BEGIN
|
|
||||||
RETURN '''' || REPLACE(NVL(p_value, ''), '''', '''''') || '''';
|
|
||||||
END;
|
|
||||||
|
|
||||||
FUNCTION safe_column(p_column_name IN VARCHAR2) RETURN VARCHAR2 IS
|
|
||||||
v_column VARCHAR2(128);
|
|
||||||
v_count NUMBER;
|
|
||||||
BEGIN
|
|
||||||
v_column := DBMS_ASSERT.SIMPLE_SQL_NAME(UPPER(TRIM(p_column_name)));
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_count
|
|
||||||
FROM all_tab_columns
|
|
||||||
WHERE owner = UPPER(p_schema)
|
|
||||||
AND table_name = UPPER(p_object)
|
|
||||||
AND column_name = v_column;
|
|
||||||
|
|
||||||
RETURN CASE WHEN v_count = 1 THEN v_column ELSE NULL END;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
|
|
||||||
PROCEDURE append_and(p_clause IN VARCHAR2) IS
|
|
||||||
BEGIN
|
|
||||||
IF p_clause IS NULL THEN
|
|
||||||
v_permission_clause := NULL;
|
|
||||||
ELSIF v_permission_clause IS NULL THEN
|
|
||||||
v_permission_clause := '(' || p_clause || ')';
|
|
||||||
ELSE
|
|
||||||
v_permission_clause := v_permission_clause || ' AND (' || p_clause || ')';
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
|
|
||||||
PROCEDURE append_permission(
|
|
||||||
p_effect IN VARCHAR2,
|
|
||||||
p_clause IN VARCHAR2
|
|
||||||
) IS
|
|
||||||
BEGIN
|
|
||||||
IF p_clause IS NULL THEN
|
|
||||||
RETURN;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF p_effect = 'DENY' THEN
|
|
||||||
IF v_deny_predicate IS NULL THEN
|
|
||||||
v_deny_predicate := '(' || p_clause || ')';
|
|
||||||
ELSE
|
|
||||||
v_deny_predicate := v_deny_predicate || ' OR (' || p_clause || ')';
|
|
||||||
END IF;
|
|
||||||
ELSIF v_allow_predicate IS NULL THEN
|
|
||||||
v_allow_predicate := '(' || p_clause || ')';
|
|
||||||
ELSE
|
|
||||||
v_allow_predicate := v_allow_predicate || ' OR (' || p_clause || ')';
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
v_employee_id := TO_NUMBER(
|
|
||||||
SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_ID')
|
|
||||||
);
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN '1 = 0';
|
|
||||||
END;
|
|
||||||
|
|
||||||
IF v_employee_id IS NULL OR p_schema IS NULL OR p_object IS NULL THEN
|
|
||||||
RETURN '1 = 0';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
v_target := UPPER(TRIM(p_object));
|
|
||||||
IF v_target NOT IN ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS') THEN
|
|
||||||
RETURN '1 = 0';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
FOR permission_row IN (
|
|
||||||
SELECT DISTINCT
|
|
||||||
p.perm_id,
|
|
||||||
NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') permission_effect
|
|
||||||
FROM (
|
|
||||||
SELECT er.role_id
|
|
||||||
FROM hmm_employee_access_roles er
|
|
||||||
WHERE er.employee_id = v_employee_id
|
|
||||||
UNION
|
|
||||||
SELECT gr.role_id
|
|
||||||
FROM hmm_access_group_members gm
|
|
||||||
JOIN hmm_access_groups g
|
|
||||||
ON g.group_id = gm.group_id
|
|
||||||
AND g.active_yn = 'Y'
|
|
||||||
JOIN hmm_group_access_roles gr
|
|
||||||
ON gr.group_id = gm.group_id
|
|
||||||
WHERE gm.employee_id = v_employee_id
|
|
||||||
) effective_role
|
|
||||||
JOIN hmm_access_roles role
|
|
||||||
ON role.role_id = effective_role.role_id
|
|
||||||
AND role.active_yn = 'Y'
|
|
||||||
JOIN hmm_access_permissions p
|
|
||||||
ON p.role_id = role.role_id
|
|
||||||
AND p.target_name = v_target
|
|
||||||
AND p.action_name = 'SELECT'
|
|
||||||
ORDER BY p.perm_id
|
|
||||||
) LOOP
|
|
||||||
v_permission_clause := NULL;
|
|
||||||
|
|
||||||
FOR rule_row IN (
|
|
||||||
SELECT UPPER(TRIM(rule_type)) rule_type,
|
|
||||||
UPPER(TRIM(rule_column)) rule_column,
|
|
||||||
rule_value
|
|
||||||
FROM hmm_access_permission_rules
|
|
||||||
WHERE perm_id = permission_row.perm_id
|
|
||||||
ORDER BY rule_id
|
|
||||||
) LOOP
|
|
||||||
DECLARE
|
|
||||||
v_column VARCHAR2(128);
|
|
||||||
v_clause VARCHAR2(32767);
|
|
||||||
BEGIN
|
|
||||||
v_column := safe_column(NVL(rule_row.rule_column, 'EMPLOYEE_ID'));
|
|
||||||
v_clause := NULL;
|
|
||||||
|
|
||||||
IF rule_row.rule_type = 'ALL' THEN
|
|
||||||
v_clause := '1 = 1';
|
|
||||||
ELSIF rule_row.rule_type = 'SELF' AND v_column IS NOT NULL THEN
|
|
||||||
v_clause := v_column
|
|
||||||
|| ' = TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID''))';
|
|
||||||
ELSIF rule_row.rule_type = 'MANAGED_TEAM' AND v_column IS NOT NULL THEN
|
|
||||||
v_clause := v_column || ' IN ('
|
|
||||||
|| 'SELECT employee.employee_id '
|
|
||||||
|| 'FROM ADMIN.HMM_HR_EMPLOYEES employee '
|
|
||||||
|| 'WHERE employee.employee_id = '
|
|
||||||
|| 'TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID'')) '
|
|
||||||
|| 'OR employee.manager_employee_id = '
|
|
||||||
|| 'TO_NUMBER(SYS_CONTEXT(''HMM_ACCESS_CTX'', ''EMPLOYEE_ID''))'
|
|
||||||
|| ')';
|
|
||||||
ELSIF rule_row.rule_type = '='
|
|
||||||
AND v_column IS NOT NULL
|
|
||||||
AND rule_row.rule_value IS NOT NULL THEN
|
|
||||||
v_clause := 'TO_CHAR(' || v_column || ') = '
|
|
||||||
|| quote_literal(rule_row.rule_value);
|
|
||||||
ELSIF rule_row.rule_type IN ('!=', '<>')
|
|
||||||
AND v_column IS NOT NULL
|
|
||||||
AND rule_row.rule_value IS NOT NULL THEN
|
|
||||||
v_clause := 'TO_CHAR(' || v_column || ') <> '
|
|
||||||
|| quote_literal(rule_row.rule_value);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_clause IS NULL THEN
|
|
||||||
v_permission_clause := NULL;
|
|
||||||
EXIT;
|
|
||||||
END IF;
|
|
||||||
append_and(v_clause);
|
|
||||||
END;
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
append_permission(
|
|
||||||
permission_row.permission_effect,
|
|
||||||
v_permission_clause
|
|
||||||
);
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
IF v_allow_predicate IS NULL THEN
|
|
||||||
RETURN '1 = 0';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_deny_predicate IS NULL THEN
|
|
||||||
RETURN v_allow_predicate;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN '((' || v_allow_predicate || ') AND NOT ('
|
|
||||||
|| v_deny_predicate || '))';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN '1 = 0';
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SHOW ERRORS PACKAGE hmm_access_ctx_pkg
|
|
||||||
SHOW ERRORS PACKAGE BODY hmm_access_ctx_pkg
|
|
||||||
SHOW ERRORS FUNCTION hmm_leave_vpd_filter
|
|
||||||
|
|
||||||
PROMPT === 6. Attaching context-sensitive VPD policies ===
|
|
||||||
DECLARE
|
|
||||||
PROCEDURE replace_policy(p_object_name IN VARCHAR2) AS
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
DBMS_RLS.DROP_POLICY(
|
|
||||||
object_schema => 'ADMIN',
|
|
||||||
object_name => p_object_name,
|
|
||||||
policy_name => 'HMM_LEAVE_SCOPE_POLICY'
|
|
||||||
);
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
IF SQLCODE != -28102 THEN
|
|
||||||
RAISE;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
|
|
||||||
DBMS_RLS.ADD_POLICY(
|
|
||||||
object_schema => 'ADMIN',
|
|
||||||
object_name => p_object_name,
|
|
||||||
policy_name => 'HMM_LEAVE_SCOPE_POLICY',
|
|
||||||
function_schema => 'ADMIN',
|
|
||||||
policy_function => 'HMM_LEAVE_VPD_FILTER',
|
|
||||||
statement_types => 'SELECT',
|
|
||||||
update_check => FALSE,
|
|
||||||
enable => TRUE,
|
|
||||||
policy_type => DBMS_RLS.CONTEXT_SENSITIVE
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
BEGIN
|
|
||||||
replace_policy('HMM_LEAVE_BALANCES');
|
|
||||||
replace_policy('HMM_LEAVE_REQUESTS');
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
PROMPT === 7. Recording backoffice descriptions ===
|
|
||||||
MERGE INTO hmm_access_vpd_filter_notes dst
|
|
||||||
USING (
|
|
||||||
SELECT 'ADMIN' function_owner,
|
|
||||||
'HMM_LEAVE_VPD_FILTER' function_name,
|
|
||||||
'Builds self, managed-team, and administrator leave predicates from HMM roles and access rules.'
|
|
||||||
description
|
|
||||||
FROM dual
|
|
||||||
) src
|
|
||||||
ON (
|
|
||||||
dst.function_owner = src.function_owner
|
|
||||||
AND dst.function_name = src.function_name
|
|
||||||
)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
dst.description = src.description,
|
|
||||||
dst.updated_at = SYSTIMESTAMP
|
|
||||||
WHEN NOT MATCHED THEN INSERT (
|
|
||||||
function_owner, function_name, description, updated_at
|
|
||||||
) VALUES (
|
|
||||||
src.function_owner, src.function_name, src.description, SYSTIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
MERGE INTO hmm_access_vpd_policy_notes dst
|
|
||||||
USING (
|
|
||||||
SELECT 'ADMIN' object_owner,
|
|
||||||
object_name,
|
|
||||||
'HMM_LEAVE_SCOPE_POLICY' policy_name,
|
|
||||||
'Restricts leave rows to self, direct reports, or explicit HR administrator access.'
|
|
||||||
description
|
|
||||||
FROM (
|
|
||||||
SELECT 'HMM_LEAVE_BALANCES' object_name FROM dual
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'HMM_LEAVE_REQUESTS' FROM dual
|
|
||||||
)
|
|
||||||
) src
|
|
||||||
ON (
|
|
||||||
dst.object_owner = src.object_owner
|
|
||||||
AND dst.object_name = src.object_name
|
|
||||||
AND dst.policy_name = src.policy_name
|
|
||||||
)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
dst.description = src.description,
|
|
||||||
dst.updated_at = SYSTIMESTAMP
|
|
||||||
WHEN NOT MATCHED THEN INSERT (
|
|
||||||
object_owner, object_name, policy_name, description, updated_at
|
|
||||||
) VALUES (
|
|
||||||
src.object_owner, src.object_name, src.policy_name,
|
|
||||||
src.description, SYSTIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
PROMPT === 8. Deployment inventory ===
|
|
||||||
SELECT object_name, object_type, status
|
|
||||||
FROM user_objects
|
|
||||||
WHERE object_name IN (
|
|
||||||
'HMM_ACCESS_CTX',
|
|
||||||
'HMM_ACCESS_CTX_PKG',
|
|
||||||
'HMM_LEAVE_VPD_FILTER'
|
|
||||||
)
|
|
||||||
ORDER BY object_name, object_type;
|
|
||||||
|
|
||||||
SELECT object_name, policy_name, function, sel, enable, policy_type
|
|
||||||
FROM all_policies
|
|
||||||
WHERE object_owner = 'ADMIN'
|
|
||||||
AND object_name IN ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS')
|
|
||||||
AND policy_name = 'HMM_LEAVE_SCOPE_POLICY'
|
|
||||||
ORDER BY object_name;
|
|
||||||
|
|
||||||
SELECT e.employee_code, r.role_name
|
|
||||||
FROM hmm_employee_access_roles er
|
|
||||||
JOIN hmm_hr_employees e ON e.employee_id = er.employee_id
|
|
||||||
JOIN hmm_access_roles r ON r.role_id = er.role_id
|
|
||||||
WHERE r.role_name IN ('HMM_HR_VIEWER', 'HMM_HR_MANAGER')
|
|
||||||
ORDER BY e.employee_code, r.role_name;
|
|
||||||
|
|
||||||
SELECT r.role_name,
|
|
||||||
p.target_name,
|
|
||||||
pr.rule_column,
|
|
||||||
pr.rule_type
|
|
||||||
FROM hmm_access_permissions p
|
|
||||||
JOIN hmm_access_roles r ON r.role_id = p.role_id
|
|
||||||
JOIN hmm_access_permission_rules pr ON pr.perm_id = p.perm_id
|
|
||||||
WHERE p.target_name IN ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS')
|
|
||||||
ORDER BY r.role_name, p.target_name;
|
|
||||||
|
|
||||||
PROMPT === HMM leave team VPD ready ===
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
-- #740 HMM MCP VPD execution boundary
|
|
||||||
-- Run as ADMIN with SQLcl. The password is prompted without echo and is used
|
|
||||||
-- only when CB_ORDS does not already exist.
|
|
||||||
set define on verify off echo off feedback on serveroutput on
|
|
||||||
whenever sqlerror exit sql.sqlcode rollback
|
|
||||||
|
|
||||||
accept hmm_cb_ords_password char prompt 'CB_ORDS initial password: ' hide
|
|
||||||
|
|
||||||
declare
|
|
||||||
v_user_count number;
|
|
||||||
v_password varchar2(4000) := q'~&hmm_cb_ords_password~';
|
|
||||||
begin
|
|
||||||
select count(*)
|
|
||||||
into v_user_count
|
|
||||||
from dba_users
|
|
||||||
where username = 'CB_ORDS';
|
|
||||||
|
|
||||||
if v_user_count = 0 then
|
|
||||||
if length(v_password) < 16
|
|
||||||
or instr(v_password, '"') > 0
|
|
||||||
or instr(v_password, chr(10)) > 0
|
|
||||||
or instr(v_password, chr(13)) > 0
|
|
||||||
or not regexp_like(v_password, '[A-Z]')
|
|
||||||
or not regexp_like(v_password, '[a-z]')
|
|
||||||
or not regexp_like(v_password, '[0-9]')
|
|
||||||
or not regexp_like(v_password, '[^A-Za-z0-9]') then
|
|
||||||
raise_application_error(
|
|
||||||
-20001,
|
|
||||||
'CB_ORDS password must be 16+ chars with upper/lower/digit/special and no quote/newline.');
|
|
||||||
end if;
|
|
||||||
execute immediate
|
|
||||||
'create user CB_ORDS identified by "' || v_password || '" '
|
|
||||||
|| 'default tablespace DATA temporary tablespace TEMP quota 0 on DATA';
|
|
||||||
dbms_output.put_line('Created CB_ORDS.');
|
|
||||||
else
|
|
||||||
dbms_output.put_line('CB_ORDS already exists; password was not changed.');
|
|
||||||
end if;
|
|
||||||
end;
|
|
||||||
/
|
|
||||||
|
|
||||||
alter user CB_ORDS account unlock;
|
|
||||||
grant create session to CB_ORDS;
|
|
||||||
|
|
||||||
create or replace package CB_ORDS_HANDLER_PKG
|
|
||||||
authid definer
|
|
||||||
as
|
|
||||||
procedure SET_VPD_CONTEXT(p_authorization in varchar2);
|
|
||||||
procedure CLEAR_VPD_CONTEXT;
|
|
||||||
end CB_ORDS_HANDLER_PKG;
|
|
||||||
/
|
|
||||||
|
|
||||||
create or replace package body CB_ORDS_HANDLER_PKG
|
|
||||||
as
|
|
||||||
c_bearer_prefix constant varchar2(7) := 'Bearer ';
|
|
||||||
|
|
||||||
procedure CLEAR_VPD_CONTEXT
|
|
||||||
is
|
|
||||||
begin
|
|
||||||
HMM_ACCESS_CTX_PKG.CLEAR_USER;
|
|
||||||
dbms_session.clear_identifier;
|
|
||||||
end CLEAR_VPD_CONTEXT;
|
|
||||||
|
|
||||||
procedure SET_VPD_CONTEXT(p_authorization in varchar2)
|
|
||||||
is
|
|
||||||
v_authorization varchar2(2048) := trim(p_authorization);
|
|
||||||
v_token varchar2(512);
|
|
||||||
begin
|
|
||||||
CLEAR_VPD_CONTEXT;
|
|
||||||
if v_authorization is null
|
|
||||||
or length(v_authorization) > 2048
|
|
||||||
or upper(substr(v_authorization, 1, length(c_bearer_prefix)))
|
|
||||||
<> upper(c_bearer_prefix) then
|
|
||||||
raise_application_error(-20002, 'A valid Bearer authorization header is required.');
|
|
||||||
end if;
|
|
||||||
|
|
||||||
v_token := trim(substr(v_authorization, length(c_bearer_prefix) + 1));
|
|
||||||
if v_token is null or length(v_token) > 512 then
|
|
||||||
raise_application_error(-20003, 'A valid Bearer token is required.');
|
|
||||||
end if;
|
|
||||||
|
|
||||||
HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(v_token);
|
|
||||||
dbms_session.set_identifier(
|
|
||||||
sys_context('HMM_ACCESS_CTX', 'EMPLOYEE_CODE'));
|
|
||||||
exception
|
|
||||||
when others then
|
|
||||||
CLEAR_VPD_CONTEXT;
|
|
||||||
raise;
|
|
||||||
end SET_VPD_CONTEXT;
|
|
||||||
end CB_ORDS_HANDLER_PKG;
|
|
||||||
/
|
|
||||||
|
|
||||||
grant execute on CB_ORDS_HANDLER_PKG to CB_ORDS;
|
|
||||||
|
|
||||||
grant select on HMM_ORG_TEAMS to CB_ORDS;
|
|
||||||
grant select on HMM_HR_EMPLOYEES to CB_ORDS;
|
|
||||||
grant select on HMM_LEAVE_BALANCES to CB_ORDS;
|
|
||||||
grant select on HMM_LEAVE_REQUESTS to CB_ORDS;
|
|
||||||
grant select on HMM_ATTENDANCE_DAILY to CB_ORDS;
|
|
||||||
grant select on HMM_HR_TERMS to CB_ORDS;
|
|
||||||
|
|
||||||
create or replace synonym CB_ORDS.CB_ORDS_HANDLER_PKG
|
|
||||||
for ADMIN.CB_ORDS_HANDLER_PKG;
|
|
||||||
create or replace synonym CB_ORDS.HMM_ORG_TEAMS
|
|
||||||
for ADMIN.HMM_ORG_TEAMS;
|
|
||||||
create or replace synonym CB_ORDS.HMM_HR_EMPLOYEES
|
|
||||||
for ADMIN.HMM_HR_EMPLOYEES;
|
|
||||||
create or replace synonym CB_ORDS.HMM_LEAVE_BALANCES
|
|
||||||
for ADMIN.HMM_LEAVE_BALANCES;
|
|
||||||
create or replace synonym CB_ORDS.HMM_LEAVE_REQUESTS
|
|
||||||
for ADMIN.HMM_LEAVE_REQUESTS;
|
|
||||||
create or replace synonym CB_ORDS.HMM_ATTENDANCE_DAILY
|
|
||||||
for ADMIN.HMM_ATTENDANCE_DAILY;
|
|
||||||
create or replace synonym CB_ORDS.HMM_HR_TERMS
|
|
||||||
for ADMIN.HMM_HR_TERMS;
|
|
||||||
|
|
||||||
declare
|
|
||||||
v_exempt_count number;
|
|
||||||
v_policy_count number;
|
|
||||||
begin
|
|
||||||
select count(*)
|
|
||||||
into v_exempt_count
|
|
||||||
from dba_sys_privs
|
|
||||||
where grantee = 'CB_ORDS'
|
|
||||||
and privilege = 'EXEMPT ACCESS POLICY';
|
|
||||||
if v_exempt_count <> 0 then
|
|
||||||
raise_application_error(-20004, 'CB_ORDS must not have EXEMPT ACCESS POLICY.');
|
|
||||||
end if;
|
|
||||||
|
|
||||||
select count(*)
|
|
||||||
into v_policy_count
|
|
||||||
from dba_policies
|
|
||||||
where object_owner = 'ADMIN'
|
|
||||||
and object_name in ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS')
|
|
||||||
and policy_name = 'HMM_LEAVE_SCOPE_POLICY'
|
|
||||||
and enable = 'YES'
|
|
||||||
and sel = 'YES';
|
|
||||||
if v_policy_count <> 2 then
|
|
||||||
raise_application_error(-20005, 'Both HMM leave VPD policies must be enabled.');
|
|
||||||
end if;
|
|
||||||
end;
|
|
||||||
/
|
|
||||||
|
|
||||||
select username, account_status
|
|
||||||
from dba_users
|
|
||||||
where username = 'CB_ORDS';
|
|
||||||
|
|
||||||
select privilege
|
|
||||||
from dba_sys_privs
|
|
||||||
where grantee = 'CB_ORDS'
|
|
||||||
order by privilege;
|
|
||||||
|
|
||||||
select object_name, object_type, status
|
|
||||||
from dba_objects
|
|
||||||
where owner = 'ADMIN'
|
|
||||||
and object_name = 'CB_ORDS_HANDLER_PKG'
|
|
||||||
order by object_type;
|
|
||||||
|
|
||||||
undefine hmm_cb_ords_password
|
|
||||||
prompt HMM MCP VPD runtime boundary is ready.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- HMM ADB -> AWS RDS PostgreSQL federation demo
|
|
||||||
-- Step 0: precheck
|
|
||||||
-- Run in Database Actions SQL Worksheet as ADMIN.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
USER AS session_user,
|
|
||||||
SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS current_schema,
|
|
||||||
SYS_CONTEXT('USERENV', 'DB_NAME') AS database_name
|
|
||||||
FROM dual;
|
|
||||||
|
|
||||||
SELECT credential_name, username
|
|
||||||
FROM all_credentials
|
|
||||||
WHERE credential_name = 'HMM_RDS_PG_CRED';
|
|
||||||
|
|
||||||
SELECT db_link, username, host, created
|
|
||||||
FROM user_db_links
|
|
||||||
WHERE db_link = 'HMM_RDS_PG_LINK';
|
|
||||||
|
|
||||||
SELECT object_name, object_type, status
|
|
||||||
FROM user_objects
|
|
||||||
WHERE object_name IN (
|
|
||||||
'HMM_RDS_CARRIERS_V',
|
|
||||||
'HMM_RDS_CARRIER_PERF_V',
|
|
||||||
'HMM_RDS_CARRIER_LATEST_V'
|
|
||||||
)
|
|
||||||
ORDER BY object_name;
|
|
||||||
|
|
||||||
-- First recording: the last three queries should return no rows.
|
|
||||||
-- If objects already exist, run 80_hmm_rds_pg_99_cleanup.sql first.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Step 1: create the PostgreSQL credential.
|
|
||||||
-- Run only this statement after replacing <RDS_PASSWORD> in the
|
|
||||||
-- Worksheet. Never save the actual password in this file.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
BEGIN
|
|
||||||
DBMS_CLOUD.CREATE_CREDENTIAL(
|
|
||||||
credential_name => 'HMM_RDS_PG_CRED',
|
|
||||||
username => 'postgres',
|
|
||||||
password => '<RDS_PASSWORD>'
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
-- Passwords are never returned by ALL_CREDENTIALS.
|
|
||||||
SELECT credential_name, username
|
|
||||||
FROM all_credentials
|
|
||||||
WHERE credential_name = 'HMM_RDS_PG_CRED';
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Step 2: register and verify the host-specific ACL.
|
|
||||||
-- Run as ADMIN.
|
|
||||||
--
|
|
||||||
-- Important:
|
|
||||||
-- Oracle-managed heterogeneous Database Link traffic uses the
|
|
||||||
-- managed gateway. This ACL records SQL-user host permissions;
|
|
||||||
-- it does not replace the AWS RDS Security Group rule.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- DNS resolution privilege must not have a port range.
|
|
||||||
BEGIN
|
|
||||||
DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(
|
|
||||||
host => 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com',
|
|
||||||
ace => XS$ACE_TYPE(
|
|
||||||
privilege_list => XS$NAME_LIST('resolve'),
|
|
||||||
principal_name => SYS_CONTEXT('USERENV', 'SESSION_USER'),
|
|
||||||
principal_type => XS_ACL.PTYPE_DB
|
|
||||||
)
|
|
||||||
);
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
IF SQLCODE != -24243 THEN
|
|
||||||
RAISE;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
-- Permit only the PostgreSQL service port.
|
|
||||||
BEGIN
|
|
||||||
DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(
|
|
||||||
host => 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com',
|
|
||||||
lower_port => 5432,
|
|
||||||
upper_port => 5432,
|
|
||||||
ace => XS$ACE_TYPE(
|
|
||||||
privilege_list => XS$NAME_LIST('connect'),
|
|
||||||
principal_name => SYS_CONTEXT('USERENV', 'SESSION_USER'),
|
|
||||||
principal_type => XS_ACL.PTYPE_DB
|
|
||||||
)
|
|
||||||
);
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
IF SQLCODE != -24243 THEN
|
|
||||||
RAISE;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
host,
|
|
||||||
lower_port,
|
|
||||||
upper_port,
|
|
||||||
principal,
|
|
||||||
privilege,
|
|
||||||
grant_type
|
|
||||||
FROM dba_host_aces
|
|
||||||
WHERE host = 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com'
|
|
||||||
AND principal = USER
|
|
||||||
AND privilege IN ('resolve', 'connect')
|
|
||||||
ORDER BY privilege, lower_port;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Step 3: create an Oracle-managed PostgreSQL Database Link.
|
|
||||||
-- Based on FY26 internal training, section 3.2.3.
|
|
||||||
-- Run as ADMIN.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
BEGIN
|
|
||||||
DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK(
|
|
||||||
db_link_name => 'HMM_RDS_PG_LINK',
|
|
||||||
hostname => 'database-1.czaaygccsncp.ap-northeast-2.rds.amazonaws.com',
|
|
||||||
port => 5432,
|
|
||||||
service_name => 'postgres',
|
|
||||||
credential_name => 'HMM_RDS_PG_CRED',
|
|
||||||
ssl_server_cert_dn => NULL,
|
|
||||||
private_target => FALSE,
|
|
||||||
gateway_params => JSON_OBJECT('db_type' VALUE 'postgres')
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT db_link, username, host, created
|
|
||||||
FROM user_db_links
|
|
||||||
WHERE db_link = 'HMM_RDS_PG_LINK';
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Step 4: query PostgreSQL directly through the Database Link.
|
|
||||||
-- PostgreSQL schema, table, view, and column identifiers are
|
|
||||||
-- lower-case and must be double quoted.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
SELECT COUNT(*) AS carrier_count
|
|
||||||
FROM "hmm_demo"."carriers"@HMM_RDS_PG_LINK;
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
COUNT(*) AS performance_count,
|
|
||||||
MIN("performance_month") AS first_month,
|
|
||||||
MAX("performance_month") AS latest_month
|
|
||||||
FROM "hmm_demo"."carrier_monthly_performance"@HMM_RDS_PG_LINK;
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
"carrier_code",
|
|
||||||
"carrier_name",
|
|
||||||
"service_region",
|
|
||||||
"performance_tier"
|
|
||||||
FROM "hmm_demo"."carriers"@HMM_RDS_PG_LINK
|
|
||||||
ORDER BY "carrier_code";
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
"carrier_code",
|
|
||||||
"carrier_name",
|
|
||||||
"performance_month",
|
|
||||||
"shipped_teu",
|
|
||||||
"revenue_usd",
|
|
||||||
"schedule_reliability_pct",
|
|
||||||
"risk_level"
|
|
||||||
FROM "hmm_demo"."carrier_performance_latest_v"@HMM_RDS_PG_LINK
|
|
||||||
ORDER BY "carrier_code";
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Step 5: create local Oracle Views and Select AI metadata.
|
|
||||||
-- Explicit aliases normalize PostgreSQL lower-case identifiers
|
|
||||||
-- into ordinary Oracle identifiers.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW HMM_RDS_CARRIERS_V AS
|
|
||||||
SELECT
|
|
||||||
"carrier_code" AS carrier_code,
|
|
||||||
"carrier_name" AS carrier_name,
|
|
||||||
"country_code" AS country_code,
|
|
||||||
"alliance_name" AS alliance_name,
|
|
||||||
"service_region" AS service_region,
|
|
||||||
"performance_tier" AS performance_tier
|
|
||||||
FROM "hmm_demo"."carriers"@HMM_RDS_PG_LINK;
|
|
||||||
|
|
||||||
COMMENT ON TABLE HMM_RDS_CARRIERS_V IS
|
|
||||||
'HMM federation demo fictional carrier master. Primary Key: CARRIER_CODE. Referenced by HMM_RDS_CARRIER_PERF_V(CARRIER_CODE).';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.CARRIER_CODE IS
|
|
||||||
'Primary Key. Fictional carrier identifier such as C001.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.CARRIER_NAME IS
|
|
||||||
'Fictional carrier display name; not an actual carrier.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.COUNTRY_CODE IS
|
|
||||||
'Two-letter demo country code.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.ALLIANCE_NAME IS
|
|
||||||
'Fictional carrier cooperation network.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.SERVICE_REGION IS
|
|
||||||
'Primary service region for the demo carrier.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIERS_V.PERFORMANCE_TIER IS
|
|
||||||
'Demo portfolio tier: CORE, GROWTH, or WATCH.';
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW HMM_RDS_CARRIER_PERF_V AS
|
|
||||||
SELECT
|
|
||||||
"carrier_code" AS carrier_code,
|
|
||||||
"performance_month" AS performance_month,
|
|
||||||
"contracted_teu" AS contracted_teu,
|
|
||||||
"shipped_teu" AS shipped_teu,
|
|
||||||
"revenue_usd" AS revenue_usd,
|
|
||||||
"gross_margin_usd" AS gross_margin_usd,
|
|
||||||
"schedule_reliability_pct" AS schedule_reliability_pct,
|
|
||||||
"vessel_utilization_pct" AS vessel_utilization_pct,
|
|
||||||
"claim_rate_pct" AS claim_rate_pct,
|
|
||||||
"yoy_growth_pct" AS yoy_growth_pct,
|
|
||||||
"risk_level" AS risk_level
|
|
||||||
FROM "hmm_demo"."carrier_monthly_performance"@HMM_RDS_PG_LINK;
|
|
||||||
|
|
||||||
COMMENT ON TABLE HMM_RDS_CARRIER_PERF_V IS
|
|
||||||
'HMM federation demo monthly carrier KPI. Composite Primary Key: CARRIER_CODE and PERFORMANCE_MONTH. Foreign Key: CARRIER_CODE references HMM_RDS_CARRIERS_V(CARRIER_CODE). All values are fictional.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.CARRIER_CODE IS
|
|
||||||
'Foreign Key referencing HMM_RDS_CARRIERS_V(CARRIER_CODE).';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.PERFORMANCE_MONTH IS
|
|
||||||
'First calendar day of the KPI month; part of the composite Primary Key.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.CONTRACTED_TEU IS
|
|
||||||
'Contracted container volume in TEU.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.SHIPPED_TEU IS
|
|
||||||
'Actually shipped container volume in TEU.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.REVENUE_USD IS
|
|
||||||
'Fictional monthly revenue in USD.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.GROSS_MARGIN_USD IS
|
|
||||||
'Fictional monthly gross margin in USD; negative values indicate a loss.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.SCHEDULE_RELIABILITY_PCT IS
|
|
||||||
'Schedule reliability percentage from 0 through 100.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.VESSEL_UTILIZATION_PCT IS
|
|
||||||
'Vessel capacity utilization percentage from 0 through 100.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.CLAIM_RATE_PCT IS
|
|
||||||
'Cargo claim rate percentage from 0 through 100.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.YOY_GROWTH_PCT IS
|
|
||||||
'Year-over-year performance growth percentage.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_PERF_V.RISK_LEVEL IS
|
|
||||||
'Derived demo risk category: GREEN, AMBER, or RED.';
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW HMM_RDS_CARRIER_LATEST_V AS
|
|
||||||
SELECT
|
|
||||||
"carrier_code" AS carrier_code,
|
|
||||||
"carrier_name" AS carrier_name,
|
|
||||||
"country_code" AS country_code,
|
|
||||||
"alliance_name" AS alliance_name,
|
|
||||||
"service_region" AS service_region,
|
|
||||||
"performance_tier" AS performance_tier,
|
|
||||||
"performance_month" AS performance_month,
|
|
||||||
"contracted_teu" AS contracted_teu,
|
|
||||||
"shipped_teu" AS shipped_teu,
|
|
||||||
"revenue_usd" AS revenue_usd,
|
|
||||||
"gross_margin_usd" AS gross_margin_usd,
|
|
||||||
"schedule_reliability_pct" AS schedule_reliability_pct,
|
|
||||||
"vessel_utilization_pct" AS vessel_utilization_pct,
|
|
||||||
"claim_rate_pct" AS claim_rate_pct,
|
|
||||||
"yoy_growth_pct" AS yoy_growth_pct,
|
|
||||||
"risk_level" AS risk_level
|
|
||||||
FROM "hmm_demo"."carrier_performance_latest_v"@HMM_RDS_PG_LINK;
|
|
||||||
|
|
||||||
COMMENT ON TABLE HMM_RDS_CARRIER_LATEST_V IS
|
|
||||||
'Latest fictional monthly KPI for each HMM federation demo carrier. Primary Key: CARRIER_CODE.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE IS
|
|
||||||
'Primary Key for the latest carrier KPI row.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_LATEST_V.PERFORMANCE_MONTH IS
|
|
||||||
'Latest KPI month available in the PostgreSQL source.';
|
|
||||||
COMMENT ON COLUMN HMM_RDS_CARRIER_LATEST_V.RISK_LEVEL IS
|
|
||||||
'Latest derived risk category: GREEN, AMBER, or RED.';
|
|
||||||
|
|
||||||
SELECT object_name, object_type, status
|
|
||||||
FROM user_objects
|
|
||||||
WHERE object_name IN (
|
|
||||||
'HMM_RDS_CARRIERS_V',
|
|
||||||
'HMM_RDS_CARRIER_PERF_V',
|
|
||||||
'HMM_RDS_CARRIER_LATEST_V'
|
|
||||||
)
|
|
||||||
ORDER BY object_name;
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Step 6: verify the local federation Views.
|
|
||||||
-- Expected: carrier/performance/latest = 8/144/8.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_carrier_count PLS_INTEGER;
|
|
||||||
v_performance_count PLS_INTEGER;
|
|
||||||
v_latest_count PLS_INTEGER;
|
|
||||||
v_risk_count PLS_INTEGER;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*) INTO v_carrier_count
|
|
||||||
FROM HMM_RDS_CARRIERS_V;
|
|
||||||
|
|
||||||
SELECT COUNT(*) INTO v_performance_count
|
|
||||||
FROM HMM_RDS_CARRIER_PERF_V;
|
|
||||||
|
|
||||||
SELECT COUNT(*), COUNT(DISTINCT risk_level)
|
|
||||||
INTO v_latest_count, v_risk_count
|
|
||||||
FROM HMM_RDS_CARRIER_LATEST_V;
|
|
||||||
|
|
||||||
IF v_carrier_count != 8 THEN
|
|
||||||
RAISE_APPLICATION_ERROR(
|
|
||||||
-20001,
|
|
||||||
'Expected 8 carriers, found ' || v_carrier_count
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_performance_count != 144 THEN
|
|
||||||
RAISE_APPLICATION_ERROR(
|
|
||||||
-20002,
|
|
||||||
'Expected 144 performance rows, found ' || v_performance_count
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_latest_count != 8 OR v_risk_count != 3 THEN
|
|
||||||
RAISE_APPLICATION_ERROR(
|
|
||||||
-20003,
|
|
||||||
'Expected 8 latest rows and 3 risk levels, found '
|
|
||||||
|| v_latest_count || ' rows and ' || v_risk_count || ' levels'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM HMM_RDS_CARRIERS_V) AS carrier_count,
|
|
||||||
(SELECT COUNT(*) FROM HMM_RDS_CARRIER_PERF_V) AS performance_count,
|
|
||||||
(SELECT COUNT(*) FROM HMM_RDS_CARRIER_LATEST_V) AS latest_count
|
|
||||||
FROM dual;
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
MIN(performance_month) AS first_month,
|
|
||||||
MAX(performance_month) AS latest_month,
|
|
||||||
COUNT(DISTINCT carrier_code) AS carrier_count
|
|
||||||
FROM HMM_RDS_CARRIER_PERF_V;
|
|
||||||
|
|
||||||
SELECT risk_level, COUNT(*) AS carrier_count
|
|
||||||
FROM HMM_RDS_CARRIER_LATEST_V
|
|
||||||
GROUP BY risk_level
|
|
||||||
ORDER BY risk_level;
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
carrier_code,
|
|
||||||
carrier_name,
|
|
||||||
performance_month,
|
|
||||||
shipped_teu,
|
|
||||||
revenue_usd,
|
|
||||||
gross_margin_usd,
|
|
||||||
schedule_reliability_pct,
|
|
||||||
claim_rate_pct,
|
|
||||||
risk_level
|
|
||||||
FROM HMM_RDS_CARRIER_LATEST_V
|
|
||||||
ORDER BY carrier_code;
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
table_name,
|
|
||||||
comments
|
|
||||||
FROM user_tab_comments
|
|
||||||
WHERE table_name IN (
|
|
||||||
'HMM_RDS_CARRIERS_V',
|
|
||||||
'HMM_RDS_CARRIER_PERF_V',
|
|
||||||
'HMM_RDS_CARRIER_LATEST_V'
|
|
||||||
)
|
|
||||||
ORDER BY table_name;
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Optional pre-recording reset.
|
|
||||||
-- Drops only the three local Views, Database Link, and
|
|
||||||
-- Credential owned by this demo. Host ACL entries are retained.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_count PLS_INTEGER;
|
|
||||||
BEGIN
|
|
||||||
FOR object_row IN (
|
|
||||||
SELECT object_name
|
|
||||||
FROM user_objects
|
|
||||||
WHERE object_type = 'VIEW'
|
|
||||||
AND object_name IN (
|
|
||||||
'HMM_RDS_CARRIERS_V',
|
|
||||||
'HMM_RDS_CARRIER_PERF_V',
|
|
||||||
'HMM_RDS_CARRIER_LATEST_V'
|
|
||||||
)
|
|
||||||
ORDER BY CASE object_name
|
|
||||||
WHEN 'HMM_RDS_CARRIER_LATEST_V' THEN 1
|
|
||||||
WHEN 'HMM_RDS_CARRIER_PERF_V' THEN 2
|
|
||||||
ELSE 3
|
|
||||||
END
|
|
||||||
) LOOP
|
|
||||||
EXECUTE IMMEDIATE 'DROP VIEW ' || object_row.object_name;
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
SELECT COUNT(*) INTO v_count
|
|
||||||
FROM user_db_links
|
|
||||||
WHERE db_link = 'HMM_RDS_PG_LINK';
|
|
||||||
|
|
||||||
IF v_count = 1 THEN
|
|
||||||
EXECUTE IMMEDIATE 'DROP DATABASE LINK HMM_RDS_PG_LINK';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT COUNT(*) INTO v_count
|
|
||||||
FROM all_credentials
|
|
||||||
WHERE credential_name = 'HMM_RDS_PG_CRED';
|
|
||||||
|
|
||||||
IF v_count = 1 THEN
|
|
||||||
DBMS_CLOUD.DROP_CREDENTIAL(
|
|
||||||
credential_name => 'HMM_RDS_PG_CRED'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT credential_name, username
|
|
||||||
FROM all_credentials
|
|
||||||
WHERE credential_name = 'HMM_RDS_PG_CRED';
|
|
||||||
|
|
||||||
SELECT db_link, username, host
|
|
||||||
FROM user_db_links
|
|
||||||
WHERE db_link = 'HMM_RDS_PG_LINK';
|
|
||||||
|
|
||||||
SELECT object_name, object_type
|
|
||||||
FROM user_objects
|
|
||||||
WHERE object_name IN (
|
|
||||||
'HMM_RDS_CARRIERS_V',
|
|
||||||
'HMM_RDS_CARRIER_PERF_V',
|
|
||||||
'HMM_RDS_CARRIER_LATEST_V'
|
|
||||||
);
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
-- 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 identity mentions from the user question. '
|
|
||||||
|| 'A registered game title, alias, GAME_ID, GAME_PREFIX, or catalog key is a game mention and must be preserved exactly as written. '
|
|
||||||
|| 'Metrics, acronyms, dates, filters, and database object or column names are not game mentions unless they are themselves an explicit registered game identity. '
|
|
||||||
|| '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 one game identity to another 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;
|
|
||||||
/
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- HMM carrier assignments using existing access-group tables.
|
|
||||||
--
|
|
||||||
-- Existing tables:
|
|
||||||
-- HMM_HR_EMPLOYEES
|
|
||||||
-- HMM_ACCESS_GROUPS
|
|
||||||
-- HMM_ACCESS_GROUP_MEMBERS
|
|
||||||
--
|
|
||||||
-- No new assignment table is created.
|
|
||||||
-- Korean values are reconstructed from UTF-8 base64 so SQLcl
|
|
||||||
-- cannot corrupt the literals.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
|
||||||
SET DEFINE OFF
|
|
||||||
SET FEEDBACK ON
|
|
||||||
SET SERVEROUTPUT ON
|
|
||||||
|
|
||||||
MERGE INTO hmm_access_groups dst
|
|
||||||
USING (
|
|
||||||
SELECT
|
|
||||||
base.max_group_id + carrier.seq_no AS group_id,
|
|
||||||
'CARRIER_' || carrier.carrier_code AS group_code,
|
|
||||||
carrier.carrier_code || ' ' ||
|
|
||||||
utl_i18n.raw_to_char(
|
|
||||||
utl_encode.base64_decode(
|
|
||||||
utl_raw.cast_to_raw('7ISg7IKsIOuLtOuLuSDqt7jro7k=')
|
|
||||||
),
|
|
||||||
'AL32UTF8'
|
|
||||||
) AS group_name,
|
|
||||||
utl_i18n.raw_to_char(
|
|
||||||
utl_encode.base64_decode(
|
|
||||||
utl_raw.cast_to_raw(
|
|
||||||
'642w66qo7JqpIOyEoOyCrCDsoJHqt7wg67CPIOuLtOuLueyekCDqt7jro7k='
|
|
||||||
)
|
|
||||||
),
|
|
||||||
'AL32UTF8'
|
|
||||||
) || ' (' || carrier.carrier_code || ')' AS description
|
|
||||||
FROM (
|
|
||||||
SELECT 1 seq_no, 'C001' carrier_code FROM dual
|
|
||||||
UNION ALL SELECT 2, 'C002' FROM dual
|
|
||||||
UNION ALL SELECT 3, 'C003' FROM dual
|
|
||||||
UNION ALL SELECT 4, 'C004' FROM dual
|
|
||||||
UNION ALL SELECT 5, 'C005' FROM dual
|
|
||||||
UNION ALL SELECT 6, 'C006' FROM dual
|
|
||||||
UNION ALL SELECT 7, 'C007' FROM dual
|
|
||||||
UNION ALL SELECT 8, 'C008' FROM dual
|
|
||||||
) carrier
|
|
||||||
CROSS JOIN (
|
|
||||||
SELECT NVL(MAX(group_id), 0) max_group_id
|
|
||||||
FROM hmm_access_groups
|
|
||||||
) base
|
|
||||||
) src
|
|
||||||
ON (dst.group_code = src.group_code)
|
|
||||||
WHEN MATCHED THEN UPDATE SET
|
|
||||||
dst.group_name = src.group_name,
|
|
||||||
dst.description = src.description,
|
|
||||||
dst.active_yn = 'Y',
|
|
||||||
dst.updated_at = SYSTIMESTAMP
|
|
||||||
WHEN NOT MATCHED THEN INSERT (
|
|
||||||
group_id,
|
|
||||||
group_code,
|
|
||||||
group_name,
|
|
||||||
description,
|
|
||||||
active_yn,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
) VALUES (
|
|
||||||
src.group_id,
|
|
||||||
src.group_code,
|
|
||||||
src.group_name,
|
|
||||||
src.description,
|
|
||||||
'Y',
|
|
||||||
SYSTIMESTAMP,
|
|
||||||
SYSTIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
MERGE INTO hmm_access_group_members dst
|
|
||||||
USING (
|
|
||||||
SELECT
|
|
||||||
access_group.group_id,
|
|
||||||
employee.employee_id
|
|
||||||
FROM (
|
|
||||||
SELECT 'E1002' employee_code, 'CARRIER_C001' group_code FROM dual
|
|
||||||
UNION ALL SELECT 'E1002', 'CARRIER_C002' FROM dual
|
|
||||||
UNION ALL SELECT 'E1003', 'CARRIER_C003' FROM dual
|
|
||||||
UNION ALL SELECT 'E1004', 'CARRIER_C004' FROM dual
|
|
||||||
UNION ALL SELECT 'E1005', 'CARRIER_C005' FROM dual
|
|
||||||
UNION ALL SELECT 'E1006', 'CARRIER_C006' FROM dual
|
|
||||||
UNION ALL SELECT 'E1006', 'CARRIER_C007' FROM dual
|
|
||||||
UNION ALL SELECT 'E1007', 'CARRIER_C008' FROM dual
|
|
||||||
) assignment
|
|
||||||
JOIN hmm_hr_employees employee
|
|
||||||
ON employee.employee_code = assignment.employee_code
|
|
||||||
AND employee.employment_status = 'ACTIVE'
|
|
||||||
JOIN hmm_access_groups access_group
|
|
||||||
ON access_group.group_code = assignment.group_code
|
|
||||||
AND access_group.active_yn = 'Y'
|
|
||||||
) src
|
|
||||||
ON (
|
|
||||||
dst.group_id = src.group_id
|
|
||||||
AND dst.employee_id = src.employee_id
|
|
||||||
)
|
|
||||||
WHEN NOT MATCHED THEN INSERT (
|
|
||||||
group_id,
|
|
||||||
employee_id,
|
|
||||||
created_at
|
|
||||||
) VALUES (
|
|
||||||
src.group_id,
|
|
||||||
src.employee_id,
|
|
||||||
SYSTIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW hmm_carrier_assignments_v AS
|
|
||||||
SELECT
|
|
||||||
employee.employee_id,
|
|
||||||
employee.employee_code,
|
|
||||||
employee.full_name AS employee_name,
|
|
||||||
employee.job_title,
|
|
||||||
employee.team_id,
|
|
||||||
employee.manager_employee_id,
|
|
||||||
manager.employee_code AS manager_employee_code,
|
|
||||||
manager.full_name AS manager_name,
|
|
||||||
SUBSTR(access_group.group_code, LENGTH('CARRIER_') + 1) AS carrier_code,
|
|
||||||
access_group.group_code,
|
|
||||||
access_group.group_name
|
|
||||||
FROM hmm_access_group_members membership
|
|
||||||
JOIN hmm_access_groups access_group
|
|
||||||
ON access_group.group_id = membership.group_id
|
|
||||||
AND access_group.active_yn = 'Y'
|
|
||||||
JOIN hmm_hr_employees employee
|
|
||||||
ON employee.employee_id = membership.employee_id
|
|
||||||
AND employee.employment_status = 'ACTIVE'
|
|
||||||
LEFT JOIN hmm_hr_employees manager
|
|
||||||
ON manager.employee_id = employee.manager_employee_id
|
|
||||||
WHERE access_group.group_code LIKE 'CARRIER_C%';
|
|
||||||
|
|
||||||
COMMENT ON TABLE hmm_carrier_assignments_v IS
|
|
||||||
'ADB employee-to-carrier assignments from HMM_HR_EMPLOYEES, HMM_ACCESS_GROUPS, and HMM_ACCESS_GROUP_MEMBERS. Join relationship: HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE joins HMM_RDS_CARRIERS_V.CARRIER_CODE, HMM_RDS_CARRIER_PERF_V.CARRIER_CODE, and HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE.';
|
|
||||||
COMMENT ON COLUMN hmm_carrier_assignments_v.EMPLOYEE_CODE IS
|
|
||||||
'HMM employee code sourced from HMM_HR_EMPLOYEES.EMPLOYEE_CODE. Example: E1002.';
|
|
||||||
COMMENT ON COLUMN hmm_carrier_assignments_v.MANAGER_EMPLOYEE_CODE IS
|
|
||||||
'Direct manager employee code sourced from HMM_HR_EMPLOYEES. Use E1001 to identify the HMM demo manager team.';
|
|
||||||
COMMENT ON COLUMN hmm_carrier_assignments_v.CARRIER_CODE IS
|
|
||||||
'Join Key and Foreign Key to HMM_RDS_CARRIERS_V(CARRIER_CODE), HMM_RDS_CARRIER_PERF_V(CARRIER_CODE), and HMM_RDS_CARRIER_LATEST_V(CARRIER_CODE).';
|
|
||||||
COMMENT ON COLUMN hmm_carrier_assignments_v.GROUP_CODE IS
|
|
||||||
'Existing access-group code in the form CARRIER_C001.';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_group_count PLS_INTEGER;
|
|
||||||
v_member_count PLS_INTEGER;
|
|
||||||
v_assignment_count PLS_INTEGER;
|
|
||||||
v_federated_count PLS_INTEGER;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_group_count
|
|
||||||
FROM hmm_access_groups
|
|
||||||
WHERE group_code BETWEEN 'CARRIER_C001' AND 'CARRIER_C008'
|
|
||||||
AND active_yn = 'Y';
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_member_count
|
|
||||||
FROM hmm_access_group_members membership
|
|
||||||
JOIN hmm_access_groups access_group
|
|
||||||
ON access_group.group_id = membership.group_id
|
|
||||||
WHERE access_group.group_code BETWEEN 'CARRIER_C001' AND 'CARRIER_C008';
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_assignment_count
|
|
||||||
FROM hmm_carrier_assignments_v;
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_federated_count
|
|
||||||
FROM hmm_carrier_assignments_v assignment
|
|
||||||
JOIN hmm_rds_carrier_latest_v performance
|
|
||||||
ON performance.carrier_code = assignment.carrier_code;
|
|
||||||
|
|
||||||
IF v_group_count != 8
|
|
||||||
OR v_member_count != 8
|
|
||||||
OR v_assignment_count != 8
|
|
||||||
OR v_federated_count != 8 THEN
|
|
||||||
RAISE_APPLICATION_ERROR(
|
|
||||||
-20081,
|
|
||||||
'Expected group/member/assignment/federated counts 8/8/8/8; found '
|
|
||||||
|| v_group_count || '/'
|
|
||||||
|| v_member_count || '/'
|
|
||||||
|| v_assignment_count || '/'
|
|
||||||
|| v_federated_count
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
assignment.employee_code,
|
|
||||||
assignment.employee_name,
|
|
||||||
assignment.manager_employee_code,
|
|
||||||
assignment.carrier_code,
|
|
||||||
performance.carrier_name,
|
|
||||||
performance.performance_month,
|
|
||||||
performance.revenue_usd,
|
|
||||||
performance.risk_level
|
|
||||||
FROM hmm_carrier_assignments_v assignment
|
|
||||||
JOIN hmm_rds_carrier_latest_v performance
|
|
||||||
ON performance.carrier_code = assignment.carrier_code
|
|
||||||
ORDER BY assignment.employee_code, assignment.carrier_code;
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Explicit Select AI relationship metadata for ADB + RDS Views.
|
|
||||||
-- Redmine: #730
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
|
||||||
SET DEFINE OFF
|
|
||||||
SET FEEDBACK ON
|
|
||||||
|
|
||||||
COMMENT ON TABLE hmm_carrier_assignments_v IS
|
|
||||||
'ADB employee-to-carrier assignments from HMM_HR_EMPLOYEES, HMM_ACCESS_GROUPS, and HMM_ACCESS_GROUP_MEMBERS. Join relationship: HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE joins HMM_RDS_CARRIERS_V.CARRIER_CODE, HMM_RDS_CARRIER_PERF_V.CARRIER_CODE, and HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE.';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN hmm_carrier_assignments_v.EMPLOYEE_CODE IS
|
|
||||||
'HMM employee code sourced from HMM_HR_EMPLOYEES.EMPLOYEE_CODE. Example: E1002.';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN hmm_carrier_assignments_v.MANAGER_EMPLOYEE_CODE IS
|
|
||||||
'Direct manager employee code sourced from HMM_HR_EMPLOYEES. Use E1001 to identify the HMM demo manager team.';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN hmm_carrier_assignments_v.CARRIER_CODE IS
|
|
||||||
'Join Key and Foreign Key to HMM_RDS_CARRIERS_V(CARRIER_CODE), HMM_RDS_CARRIER_PERF_V(CARRIER_CODE), and HMM_RDS_CARRIER_LATEST_V(CARRIER_CODE).';
|
|
||||||
|
|
||||||
COMMENT ON TABLE hmm_rds_carriers_v IS
|
|
||||||
'PostgreSQL fictional carrier master exposed through HMM_RDS_PG_LINK. Primary Key: CARRIER_CODE. Join relationship: HMM_RDS_CARRIERS_V.CARRIER_CODE joins HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE and HMM_RDS_CARRIER_PERF_V.CARRIER_CODE.';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN hmm_rds_carriers_v.CARRIER_CODE IS
|
|
||||||
'Primary Key. Join Key to HMM_CARRIER_ASSIGNMENTS_V(CARRIER_CODE), HMM_RDS_CARRIER_PERF_V(CARRIER_CODE), and HMM_RDS_CARRIER_LATEST_V(CARRIER_CODE).';
|
|
||||||
|
|
||||||
COMMENT ON TABLE hmm_rds_carrier_perf_v IS
|
|
||||||
'PostgreSQL monthly carrier KPI exposed through HMM_RDS_PG_LINK. Composite Primary Key: CARRIER_CODE and PERFORMANCE_MONTH. Foreign Key: CARRIER_CODE references HMM_RDS_CARRIERS_V(CARRIER_CODE). Join relationship: CARRIER_CODE joins HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE.';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN hmm_rds_carrier_perf_v.CARRIER_CODE IS
|
|
||||||
'Foreign Key to HMM_RDS_CARRIERS_V(CARRIER_CODE). Join Key to HMM_CARRIER_ASSIGNMENTS_V(CARRIER_CODE).';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN hmm_rds_carrier_perf_v.PERFORMANCE_MONTH IS
|
|
||||||
'Part of the composite Primary Key with CARRIER_CODE. First calendar day of the KPI month.';
|
|
||||||
|
|
||||||
COMMENT ON TABLE hmm_rds_carrier_latest_v IS
|
|
||||||
'Latest PostgreSQL KPI for each fictional carrier exposed through HMM_RDS_PG_LINK. Primary Key: CARRIER_CODE. Join relationship: HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE joins HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE and HMM_RDS_CARRIERS_V.CARRIER_CODE.';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN hmm_rds_carrier_latest_v.CARRIER_CODE IS
|
|
||||||
'Primary Key. Join Key to HMM_CARRIER_ASSIGNMENTS_V(CARRIER_CODE) and HMM_RDS_CARRIERS_V(CARRIER_CODE).';
|
|
||||||
|
|
||||||
SELECT table_name, comments
|
|
||||||
FROM user_tab_comments
|
|
||||||
WHERE table_name IN (
|
|
||||||
'HMM_CARRIER_ASSIGNMENTS_V',
|
|
||||||
'HMM_RDS_CARRIERS_V',
|
|
||||||
'HMM_RDS_CARRIER_PERF_V',
|
|
||||||
'HMM_RDS_CARRIER_LATEST_V'
|
|
||||||
)
|
|
||||||
ORDER BY table_name;
|
|
||||||
|
|
||||||
SELECT table_name, column_name, comments
|
|
||||||
FROM user_col_comments
|
|
||||||
WHERE table_name IN (
|
|
||||||
'HMM_CARRIER_ASSIGNMENTS_V',
|
|
||||||
'HMM_RDS_CARRIERS_V',
|
|
||||||
'HMM_RDS_CARRIER_PERF_V',
|
|
||||||
'HMM_RDS_CARRIER_LATEST_V'
|
|
||||||
)
|
|
||||||
AND column_name IN (
|
|
||||||
'EMPLOYEE_CODE',
|
|
||||||
'MANAGER_EMPLOYEE_CODE',
|
|
||||||
'CARRIER_CODE',
|
|
||||||
'PERFORMANCE_MONTH'
|
|
||||||
)
|
|
||||||
ORDER BY table_name, column_name;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- Select AI profile instructions contain only common SQL-generation guidance.
|
|
||||||
-- Game target routing and execution policy are supplied at runtime by
|
|
||||||
-- SG_GAME_QUERY_PLAN; they do not belong in the profile-wide prompt.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
~'
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT attribute_name, attribute_value
|
|
||||||
FROM user_cloud_ai_profile_attributes
|
|
||||||
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
|
||||||
AND attribute_name = 'additional_instructions';
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
-- Benchmark correction: common-fact eligibility is determined by the active
|
|
||||||
-- alias source. Registry-only games are reported separately while eligible
|
|
||||||
-- targets continue through the common-fact query.
|
|
||||||
-- Korean baseline text and SQL are reconstructed from UTF-8 base64 for SQLcl safety.
|
|
||||||
|
|
||||||
UPDATE sg_ai_qa_question
|
|
||||||
SET expected_focus = utl_i18n.raw_to_char(
|
|
||||||
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
|
||||||
'7Jes65+sIOqyjOyehCDruYTqtZDsl5DshJwg7Lm07YOI66Gc6re466GcIO2ZleyduOuQnCDrjIDsg4HsnYAg67OE7LmtIOy5tO2TiOuhnOq3uOulvCDqtazrj5kg7KeR7ZWp7Jy866GcIO2VmOqzoCDqs7XthrUg7IKs7IukIO2FjOydtOu4lOydhCBMRUZUIEpPSU7tlZjsl6wg6rKM7J6E67OEIOynkeqzhO2VnOuLpC4g7IKs7IukIO2WieydtCDsl4bripQg64yA7IOB7J2AIDDsnLzroZwg67O07KG07ZWY6rOgLCDrp6Tsua3rkJwg64uk66W4IOuMgOyDgeydmCDqsrDqs7zrpbwg7IOd65617ZWY7KeAIOyViuuKlOuLpC4gRFVBTCBVTklPTuycvOuhnCDrjIDsg4HrqoXqs7wg6rCS7J2EIO2VqeyEse2VmOyngCDslYrripTri6Qu'
|
|
||||||
)),
|
|
||||||
'AL32UTF8'
|
|
||||||
),
|
|
||||||
baseline_answer = utl_i18n.raw_to_char(
|
|
||||||
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
|
||||||
'7Lm07YOI66Gc6re4IOunpOy5rSDrjIDsg4Hrs4Qg66ek7Lac7J2EIOuwmO2ZmO2VnOuLpC4g7IKs7IukIO2WieydtCDsl4bripQg64yA7IOB7J2AIDAsIOuLpOuluCDrp6Tsua0g64yA7IOB7J2AIO2VtOuLuSDsnbzsnpDsnZgg7KeR6rOE6rCS7J2EIOuwmO2ZmO2VnOuLpC4='
|
|
||||||
)),
|
|
||||||
'AL32UTF8'
|
|
||||||
),
|
|
||||||
baseline_sql = utl_i18n.raw_to_char(
|
|
||||||
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
|
||||||
'V0lUSCByZXF1ZXN0ZWRfZ2FtZXMgQVMgKAogIFNFTEVDVCBESVNUSU5DVCBnLiJHQU1FX0lEIiwgZy4iR0FNRV9OTSIKICBGUk9NICJTR01QX1BPQyIuIkNPTU5fR0FNRV9BTElBU19CQVMiIGcKICBXSEVSRSBnLiJVU0VfWU4iID0gJ1knCiAgICBBTkQgKAogICAgICBVUFBFUihnLiJHQU1FX05NIikgTElLRSBVUFBFUignJUJ1YmJseXolJykKICAgICAgT1IgVVBQRVIoZy4iR0FNRV9BTElBU19OTSIpIExJS0UgVVBQRVIoJyVCdWJibHl6JScpCiAgICAgIE9SIFVQUEVSKGcuIkdBTUVfTk0iKSBMSUtFIFVQUEVSKCcl7Lm07KCc64KYJScpCiAgICAgIE9SIFVQUEVSKGcuIkdBTUVfQUxJQVNfTk0iKSBMSUtFIFVQUEVSKCcl7Lm07KCc64KYJScpCiAgICApCikKU0VMRUNUIHJnLiJHQU1FX05NIiBBUyAiR0FNRV9OQU1FIiwKICAgICAgIE5WTChTVU0oQ0FTRQogICAgICAgICBXSEVOIHMuIlBBWU1UX0RUTSIgPj0gVE9fREFURSgnMjAyNjA3MTUnLCAnWVlZWU1NREQnKQogICAgICAgICAgQU5EIHMuIlBBWU1UX0RUTSIgPCBUT19EQVRFKCcyMDI2MDcxNicsICdZWVlZTU1ERCcpCiAgICAgICAgICBBTkQgcy4iRVhQVF9VU0VSX1lOIiA9ICdOJwogICAgICAgICBUSEVOIENBU1Qocy4iUEFZTVRfQU1UIiBBUyBOVU1CRVIpIEVMU0UgMCBFTkQpLCAwKSBBUyAiU0FMRVNfQU1UIgpGUk9NIHJlcXVlc3RlZF9nYW1lcyByZwpMRUZUIEpPSU4gIlNHTVBfUE9DIi4iQ09NTl9TQUxFU19UWE4iIHMKICBPTiBzLiJHQU1FX0lEIiA9IHJnLiJHQU1FX0lEIgpHUk9VUCBCWSByZy4iR0FNRV9OTSIKT1JERVIgQlkgcmcuIkdBTUVfTk0iCg=='
|
|
||||||
)),
|
|
||||||
'AL32UTF8'
|
|
||||||
)
|
|
||||||
WHERE question_code = 'STD-11';
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET inspection_status = 'REVIEW',
|
|
||||||
inspection_note = 'Customer benchmark criterion updated: multi-target common-fact comparisons preserve catalog-resolved zero-fact targets through a catalog-driven left join.'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'STD-11';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
-- Correct the earlier catalog-only interpretation in this same migration. A
|
|
||||||
-- registry can identify a game to the operator, but does not by itself make it
|
|
||||||
-- an approved source for a common fact query.
|
|
||||||
UPDATE sg_ai_qa_question
|
|
||||||
SET expected_focus = utl_i18n.raw_to_char(
|
|
||||||
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
|
||||||
'67O17IiYIOqyjOyehCDqs7XthrUg7IKs7IukIOuNsOydtO2EsCDsp4jsnZjripQg7Zmc7ISxIOqyjOyehCDrs4Tsua0g7JuQ7LKc7JeQ7IScIO2ZleyduOuQnCDrjIDsg4Hrp4wg7IKs7IukIOyhsO2ajCDrjIDsg4HsnLzroZwg7IKs7Jqp7ZWY6rOgLCDrs4Tsua0g7JuQ7LKc7J20IOyXhuuKlCDroIjsp4DsiqTtirjrpqwg7KCE7JqpIOuMgOyDgeydgCDsobDtmowg67aI6rCAIOyDge2DnOuhnCDrs4Trj4Qg7JWI64K07ZWc64ukLiDsobDtmowg6rCA64ql7ZWcIOuMgOyDgeydmCDqsrDqs7zripQg7Jyg7KeA7ZWY66mwIOyghOyytCDsmpTssq3snYQg7LCo64uo7ZWY7KeAIOyViuuKlOuLpC4gRFVBTCBVTklPTuycvOuhnCDrjIDsg4Eg7ZaJ7J2064KYIOqwkuydhCDrp4zrk6Tsp4Ag7JWK64qU64ukLg=='
|
|
||||||
)),
|
|
||||||
'AL32UTF8'
|
|
||||||
),
|
|
||||||
baseline_answer = utl_i18n.raw_to_char(
|
|
||||||
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
|
||||||
'7Lm07KCc64KYIOunpOy2nOydgCAyMjcsNjgx7J6F64uI64ukLiBCdWJibHl664qUIO2ZnOyEsSDqsozsnoQg67OE7LmtIOybkOyynOydtCDsl4bslrQg66ek7LacIOyhsO2ajCDrjIDsg4HsnbQg7JWE64uZ64uI64ukLg=='
|
|
||||||
)),
|
|
||||||
'AL32UTF8'
|
|
||||||
)
|
|
||||||
WHERE question_code = 'STD-11';
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET inspection_status = 'REVIEW',
|
|
||||||
inspection_note = 'Customer benchmark criterion updated: common-fact comparison queries use active-alias eligible targets; registry-only targets are separately unavailable and do not become synthetic fact rows.'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'STD-11';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT question_code, expected_focus, baseline_answer
|
|
||||||
FROM sg_ai_qa_question
|
|
||||||
WHERE question_code = 'STD-11';
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- Object-specific, data-driven guidance for comparison queries on a common fact table.
|
|
||||||
-- No game, prefix, ID, or physical per-game object is embedded in this annotation.
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_result VARCHAR2(4000);
|
|
||||||
BEGIN
|
|
||||||
v_result := sgmp_set_annotation(
|
|
||||||
'SGMP_POC',
|
|
||||||
'TABLE',
|
|
||||||
'COMN_SALES_TXN',
|
|
||||||
NULL,
|
|
||||||
'Game fact scope: query this table only for plan targets marked ACTIVE_ALIAS. Derive target GAME_ID values through active COMN_GAME_ALIAS_BAS aliases rather than direct identifier or prefix literals. For a mixed request, retain the eligible target results and report other plan statuses separately; do not substitute or manufacture a target result.',
|
|
||||||
'MULTI_TARGET_COMPARISON'
|
|
||||||
);
|
|
||||||
DBMS_OUTPUT.PUT_LINE(v_result);
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT annotation_name, annotation_value
|
|
||||||
FROM user_annotations_usage
|
|
||||||
WHERE object_name = 'COMN_SALES_TXN'
|
|
||||||
AND annotation_name = 'MULTI_TARGET_COMPARISON';
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
-- Generic Few-shot structure for multi-target comparisons on a common fact object.
|
|
||||||
-- The template is intentionally logical: no current game, prefix, ID, date, or result value is embedded.
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_exists NUMBER;
|
|
||||||
v_input CLOB;
|
|
||||||
v_embedding VECTOR;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_exists
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'MULTI_COMMON_FACT_LEFT_JOIN';
|
|
||||||
|
|
||||||
IF v_exists = 0 THEN
|
|
||||||
v_input := TO_CLOB('Question pattern: Compare a common fact metric across multiple resolved games. Use only fact-query-eligible targets and separately report known registry-only or unavailable targets.')
|
|
||||||
|| CHR(10) || 'Logical object role: SALES_TRANSACTION'
|
|
||||||
|| CHR(10) || 'Required structure: active alias catalog distinct game set, left join fact, aggregate by catalog display identifier.';
|
|
||||||
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, object_role,
|
|
||||||
inspection_status, inspection_note, verified_at, verified_by,
|
|
||||||
source_case_id, source_type
|
|
||||||
) VALUES (
|
|
||||||
'Compare a common fact metric across multiple resolved games, including games with no fact rows.',
|
|
||||||
TO_CLOB('WITH resolved_games AS (' || CHR(10)
|
|
||||||
|| ' SELECT DISTINCT a."GAME_ID", a."GAME_NM"' || CHR(10)
|
|
||||||
|| ' FROM "SGMP_POC"."COMN_GAME_ALIAS_BAS" a' || CHR(10)
|
|
||||||
|| ' WHERE a."USE_YN" = ''Y''' || CHR(10)
|
|
||||||
|| ' AND (<ACTIVE_ALIAS_MATCHES_FOR_EACH_REQUESTED_GAME_TERM>)' || CHR(10)
|
|
||||||
|| ')' || CHR(10)
|
|
||||||
|| 'SELECT g."GAME_NM" AS "GAME_NAME",' || CHR(10)
|
|
||||||
|| ' NVL(SUM(CASE WHEN <FACT_DATE_AND_EXCLUSION_CONDITION>' || CHR(10)
|
|
||||||
|| ' THEN CAST(f."<METRIC_COLUMN>" AS NUMBER) ELSE 0 END), 0) AS "METRIC_VALUE"' || CHR(10)
|
|
||||||
|| 'FROM resolved_games g' || CHR(10)
|
|
||||||
|| 'LEFT JOIN "SGMP_POC"."<APPROVED_COMMON_FACT_OBJECT>" f' || CHR(10)
|
|
||||||
|| ' ON f."GAME_ID" = g."GAME_ID"' || CHR(10)
|
|
||||||
|| 'GROUP BY g."GAME_NM"' || CHR(10)
|
|
||||||
|| 'ORDER BY g."GAME_NM"'),
|
|
||||||
'Structural Few-shot only. Replace every angle-bracket placeholder from the current approved object metadata, the current game query plan, and the original question. Only ACTIVE_ALIAS targets enter the alias-driven LEFT JOIN and grouping; report registry-only or unavailable targets from the plan without manufacturing fact rows with DUAL/UNION.',
|
|
||||||
v_input,
|
|
||||||
v_embedding,
|
|
||||||
'cohere.embed-v4.0',
|
|
||||||
'APPROVED', 'SQL_TEMPLATE', 'MULTI', 'SALES_TRANSACTION',
|
|
||||||
'VERIFIED',
|
|
||||||
'Generic, non-customer-specific comparison structure. Verified against the game-alias and common-fact metadata contract; not an executable answer key.',
|
|
||||||
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW',
|
|
||||||
'MULTI_COMMON_FACT_LEFT_JOIN', 'POLICY_TEMPLATE'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
COMMIT;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
-- Keep the approved template current when the policy text evolves. The vector
|
|
||||||
-- is rebuilt from its generic retrieval text; no customer answer is embedded.
|
|
||||||
DECLARE
|
|
||||||
v_input CLOB;
|
|
||||||
v_embedding VECTOR;
|
|
||||||
BEGIN
|
|
||||||
v_input := TO_CLOB('Question pattern: Compare a common fact metric across multiple resolved games. Use only fact-query-eligible targets and separately report known registry-only or unavailable targets.')
|
|
||||||
|| CHR(10) || 'Logical object role: SALES_TRANSACTION'
|
|
||||||
|| CHR(10) || 'Required structure: active alias catalog distinct game set, left join fact, aggregate by catalog display identifier.';
|
|
||||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
v_input,
|
|
||||||
JSON(sg_qa_vector_params('search_document'))
|
|
||||||
);
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET question = 'Compare a common fact metric across multiple resolved games, using only fact-query-eligible targets.',
|
|
||||||
answer_text = 'Structural Few-shot only. Replace every angle-bracket placeholder from the current approved object metadata, the current game query plan, and the original question. Only ACTIVE_ALIAS targets enter the alias-driven LEFT JOIN and grouping; report registry-only or unavailable targets from the plan without manufacturing fact rows with DUAL/UNION.',
|
|
||||||
embedding_input = v_input,
|
|
||||||
embedding = v_embedding,
|
|
||||||
inspection_note = 'Generic, non-customer-specific comparison structure. Active-alias targets are fact-query eligible; registry-only targets are reported separately. Not an executable answer key.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'MULTI_COMMON_FACT_LEFT_JOIN';
|
|
||||||
COMMIT;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT example_id, reference_status, reference_kind, target_type, object_role,
|
|
||||||
source_case_id, source_type
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'MULTI_COMMON_FACT_LEFT_JOIN';
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- Retire the overly specific no-target template. The profile and table
|
|
||||||
-- metadata carry this general scope policy without a case-shaped example.
|
|
||||||
|
|
||||||
DELETE FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'NO_ELIGIBLE_FACT_TARGET';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT COUNT(*) AS remaining_template_count
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'NO_ELIGIBLE_FACT_TARGET';
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
-- Generic Few-shot structure for a filtered transaction/order detail request.
|
|
||||||
-- It fixes the output grain through an approved object pattern, not a global
|
|
||||||
-- instruction or a customer-specific answer.
|
|
||||||
|
|
||||||
DECLARE
|
|
||||||
v_input CLOB;
|
|
||||||
v_embedding VECTOR;
|
|
||||||
v_exists NUMBER;
|
|
||||||
BEGIN
|
|
||||||
v_input := TO_CLOB('Question pattern: List individual payment orders that match a business date and an amount condition.')
|
|
||||||
|| CHR(10) || 'Logical object role: SALES_TRANSACTION'
|
|
||||||
|| CHR(10) || 'Required output: transaction identifiers, game, user, payment timestamp, and payment amount.';
|
|
||||||
v_embedding := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
|
||||||
v_input,
|
|
||||||
JSON(sg_qa_vector_params('search_document'))
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_exists
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'FILTERED_TRANSACTION_DETAIL';
|
|
||||||
|
|
||||||
IF v_exists = 0 THEN
|
|
||||||
INSERT INTO sg_qa_vector_example (
|
|
||||||
question, answer_sql, answer_text, embedding_input, embedding, embedding_model,
|
|
||||||
reference_status, reference_kind, target_type, object_role,
|
|
||||||
inspection_status, inspection_note, verified_at, verified_by,
|
|
||||||
source_case_id, source_type
|
|
||||||
) VALUES (
|
|
||||||
'List payment orders matching a date and amount condition.',
|
|
||||||
TO_CLOB('SELECT t."PAYMT_TRANSAC_ID" AS "PAYMENT_TRANSACTION_ID",' || CHR(10)
|
|
||||||
|| ' t."PAYMT_TRANSAC_DTL_ID" AS "PAYMENT_TRANSACTION_DETAIL_ID",' || CHR(10)
|
|
||||||
|| ' t."GAME_ID" AS "GAME_ID",' || CHR(10)
|
|
||||||
|| ' t."GUID" AS "USER_ID",' || CHR(10)
|
|
||||||
|| ' t."PAYMT_DTM" AS "PAYMENT_DATETIME",' || CHR(10)
|
|
||||||
|| ' t."PAYMT_AMT" AS "PAYMENT_AMOUNT"' || CHR(10)
|
|
||||||
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" t' || CHR(10)
|
|
||||||
|| 'WHERE t."PAYMT_DTM" >= <BUSINESS_DATE_START>' || CHR(10)
|
|
||||||
|| ' AND t."PAYMT_DTM" < <BUSINESS_DATE_END>' || CHR(10)
|
|
||||||
|| ' AND CAST(t."PAYMT_AMT" AS NUMBER) <AMOUNT_CONDITION>' || CHR(10)
|
|
||||||
|| ' AND t."EXPT_USER_YN" = ''N''' || CHR(10)
|
|
||||||
|| 'ORDER BY t."PAYMT_DTM", t."PAYMT_TRANSAC_ID", t."PAYMT_TRANSAC_DTL_ID"'),
|
|
||||||
'Structural Few-shot only. Replace placeholders using the original request and approved metadata. Use the business payment timestamp for a payment-date condition. This pattern is for individual transaction detail; do not substitute an aggregate-only result for a requested order list.',
|
|
||||||
v_input,
|
|
||||||
v_embedding,
|
|
||||||
'cohere.embed-v4.0',
|
|
||||||
'APPROVED', 'SQL_TEMPLATE', 'NONE', 'SALES_TRANSACTION',
|
|
||||||
'VERIFIED',
|
|
||||||
'Generic transaction-detail output shape with no customer date, amount, game, or result value; not an executable answer key.',
|
|
||||||
SYSTIMESTAMP, 'SGMP_POC_METADATA_REVIEW',
|
|
||||||
'FILTERED_TRANSACTION_DETAIL', 'POLICY_TEMPLATE'
|
|
||||||
);
|
|
||||||
ELSE
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET embedding_input = v_input,
|
|
||||||
embedding = v_embedding,
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'FILTERED_TRANSACTION_DETAIL';
|
|
||||||
END IF;
|
|
||||||
COMMIT;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
|
|
||||||
SELECT example_id, reference_status, target_type, object_role, source_case_id
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'POLICY_TEMPLATE'
|
|
||||||
AND source_case_id = 'FILTERED_TRANSACTION_DETAIL';
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
-- Customer question wording requests individual orders. Align the benchmark
|
|
||||||
-- with the transaction-detail output pattern rather than forcing KPI summary.
|
|
||||||
|
|
||||||
UPDATE sg_ai_qa_question
|
|
||||||
SET expected_focus = utl_i18n.raw_to_char(
|
|
||||||
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
|
||||||
'Q09NTl9TQUxFU19UWE7sl5DshJwgUEFZTVRfRFRNIOq4sOykgOydvCwgUEFZTVRfQU1UID4gMTAwMDAsIEVYUFRfVVNFUl9ZTj0nTicg7KGw6rG07J2YIOqwnOuzhCDso7zrrLgg7IOB7IS466W8IOyhsO2ajO2VnOuLpC4g7KO866y4IOyLneuzhOyekCwg6rKM7J6ELCDsgqzsmqnsnpAsIOqysOygnCDsnbzsi5zsmYAg6riI7JWh7J2EIOygnOqzte2VnOuLpC4='
|
|
||||||
)),
|
|
||||||
'AL32UTF8'
|
|
||||||
),
|
|
||||||
baseline_sql = TO_CLOB('SELECT s."PAYMT_TRANSAC_ID" AS "PAYMENT_TRANSACTION_ID",' || CHR(10)
|
|
||||||
|| ' s."PAYMT_TRANSAC_DTL_ID" AS "PAYMENT_TRANSACTION_DETAIL_ID",' || CHR(10)
|
|
||||||
|| ' s."GAME_ID" AS "GAME_ID",' || CHR(10)
|
|
||||||
|| ' s."GUID" AS "USER_ID",' || CHR(10)
|
|
||||||
|| ' s."PAYMT_DTM" AS "PAYMENT_DATETIME",' || CHR(10)
|
|
||||||
|| ' s."PAYMT_AMT" AS "PAYMENT_AMOUNT"' || CHR(10)
|
|
||||||
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" s' || CHR(10)
|
|
||||||
|| 'WHERE s."PAYMT_DTM" >= DATE ''2026-07-15''' || CHR(10)
|
|
||||||
|| ' AND s."PAYMT_DTM" < DATE ''2026-07-16''' || CHR(10)
|
|
||||||
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) > 10000' || CHR(10)
|
|
||||||
|| ' AND s."EXPT_USER_YN" = ''N''' || CHR(10)
|
|
||||||
|| 'ORDER BY s."PAYMT_DTM", s."PAYMT_TRANSAC_ID", s."PAYMT_TRANSAC_DTL_ID"'),
|
|
||||||
baseline_answer = utl_i18n.raw_to_char(
|
|
||||||
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
|
||||||
'6rKw7KCc6riI7JWhIDHrp4zsm5Ag7LSI6rO8IOyjvOusuCA26rG07J2EIOyjvOusuCDsi53rs4TsnpAsIOqyjOyehCwg7IKs7Jqp7J6QLCDqsrDsoJwg7J287IucLCDqsrDsoJzquIjslaHqs7wg7ZWo6ruYIOuwmO2ZmO2VnOuLpC4='
|
|
||||||
)),
|
|
||||||
'AL32UTF8'
|
|
||||||
)
|
|
||||||
WHERE question_code = 'STD-18';
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET answer_sql = TO_CLOB('SELECT s."PAYMT_TRANSAC_ID" AS "PAYMENT_TRANSACTION_ID",' || CHR(10)
|
|
||||||
|| ' s."PAYMT_TRANSAC_DTL_ID" AS "PAYMENT_TRANSACTION_DETAIL_ID",' || CHR(10)
|
|
||||||
|| ' s."GAME_ID" AS "GAME_ID",' || CHR(10)
|
|
||||||
|| ' s."GUID" AS "USER_ID",' || CHR(10)
|
|
||||||
|| ' s."PAYMT_DTM" AS "PAYMENT_DATETIME",' || CHR(10)
|
|
||||||
|| ' s."PAYMT_AMT" AS "PAYMENT_AMOUNT"' || CHR(10)
|
|
||||||
|| 'FROM "SGMP_POC"."COMN_SALES_TXN" s' || CHR(10)
|
|
||||||
|| 'WHERE s."PAYMT_DTM" >= DATE ''2026-07-15''' || CHR(10)
|
|
||||||
|| ' AND s."PAYMT_DTM" < DATE ''2026-07-16''' || CHR(10)
|
|
||||||
|| ' AND CAST(s."PAYMT_AMT" AS NUMBER) > 10000' || CHR(10)
|
|
||||||
|| ' AND s."EXPT_USER_YN" = ''N''' || CHR(10)
|
|
||||||
|| 'ORDER BY s."PAYMT_DTM", s."PAYMT_TRANSAC_ID", s."PAYMT_TRANSAC_DTL_ID"'),
|
|
||||||
answer_text = 'Approved customer Few-shot: return individual qualifying payment orders with transaction identifiers, game, user, payment timestamp, and payment amount. Use PAYMT_DTM for the payment business date.',
|
|
||||||
reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Customer benchmark aligned to detailed qualifying orders and the payment business timestamp.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'STD-18';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT question_code, expected_focus, baseline_answer
|
|
||||||
FROM sg_ai_qa_question
|
|
||||||
WHERE question_code = 'STD-18';
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- Customer benchmark evidence is retained for evaluation only. It is not a
|
|
||||||
-- runtime Few-shot because the vector store must not become a collection of
|
|
||||||
-- case-specific benchmark overrides.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'RETIRED',
|
|
||||||
inspection_status = 'RETIRED',
|
|
||||||
inspection_note = 'Retired from runtime Few-shot retrieval; retained as customer QA evaluation evidence.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'STD-21';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT example_id, reference_status, inspection_status, source_case_id
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'STD-21';
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- Promote the reviewed customer QA example for the NRU metric.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Customer QA reviewed: NRU is measured with NRU_FLAG, with the stated date and excluded-user condition.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'STD-22';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT example_id, reference_status, inspection_status, source_case_id
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'STD-22';
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- Promote the reviewed customer QA example for the standard AU metric.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
inspection_note = 'Customer QA reviewed: standard AU is measured with AU_FLAG and excluded-user filtering.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-02';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT example_id, reference_status, inspection_status, source_case_id
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-02';
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
-- Prefer an exact approved customer QA question over semantically adjacent
|
|
||||||
-- vector neighbours. This is a general retrieval rule; it does not encode
|
|
||||||
-- a game, metric, table, or customer-case-specific SQL policy.
|
|
||||||
|
|
||||||
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 CASE
|
|
||||||
WHEN DBMS_LOB.COMPARE(
|
|
||||||
LOWER(TRIM(question)), LOWER(TRIM(p_question))
|
|
||||||
) = 0 THEN 0
|
|
||||||
ELSE 1
|
|
||||||
END,
|
|
||||||
vector_distance(embedding, v_query_vector, COSINE),
|
|
||||||
example_id
|
|
||||||
FETCH FIRST p_top_k ROWS ONLY;
|
|
||||||
RETURN v_results;
|
|
||||||
END;
|
|
||||||
/
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Strengthen the approved customer QA example itself. The wording belongs
|
|
||||||
-- to the benchmark Few-shot record, not to a global Select AI profile rule.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET answer_text = 'Expected focus: CZN_COMN_USER_MST, BASE_DT=2026-07-15, AU_FLAG=1, EXPT_USER_YN=''N''. '
|
|
||||||
|| 'The phrase standard AU is the report metric label; do not add STD_USER_YN unless the question separately asks for the standard-user cohort. '
|
|
||||||
|| 'Historical answer: STD_AU_COUNT=0',
|
|
||||||
inspection_note = 'Customer QA verified: standard AU uses the AU flag and excluded-user filtering; standard-user cohort is a separate request.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-02'
|
|
||||||
AND reference_status = 'APPROVED';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT example_id, reference_status, inspection_status, answer_text
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-02';
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- Approve the reviewed customer QA comparison example. Metric definitions
|
|
||||||
-- stay in the exact Few-shot example rather than becoming global profile text.
|
|
||||||
|
|
||||||
UPDATE sg_qa_vector_example
|
|
||||||
SET reference_status = 'APPROVED',
|
|
||||||
inspection_status = 'VERIFIED',
|
|
||||||
answer_text = 'Expected focus: compare two independently aggregated metrics for the same resolved game and date. '
|
|
||||||
|| 'Standard AU: CZN_COMN_USER_MST with AU_FLAG=1 and EXPT_USER_YN=''N''. '
|
|
||||||
|| 'Business AU: CZN_CUSTOM_BIZ_USER_TXN with BIZ_AU_FLAG=1 and EXPT_USER_YN=''N''. '
|
|
||||||
|| 'The labels standard AU and business AU do not imply STD_USER_YN. '
|
|
||||||
|| 'Historical answer: STD_AU_COUNT=0, BIZ_AU_COUNT=1.',
|
|
||||||
inspection_note = 'Customer QA verified: standard and business AU are separate aggregates with their respective AU flags.',
|
|
||||||
verified_at = SYSTIMESTAMP,
|
|
||||||
verified_by = 'SGMP_POC_METADATA_REVIEW'
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-03';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
SELECT example_id, reference_status, inspection_status, source_case_id, answer_text
|
|
||||||
FROM sg_qa_vector_example
|
|
||||||
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
|
|
||||||
AND source_case_id = 'CZN-03';
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- HMM demo carrier performance source data for PostgreSQL.
|
|
||||||
--
|
|
||||||
-- Target: AWS RDS for PostgreSQL, database postgres
|
|
||||||
-- Schema: hmm_demo
|
|
||||||
-- Redmine: #729
|
|
||||||
--
|
|
||||||
-- This script is idempotent. It owns only C001..C008 and their
|
|
||||||
-- 2025-02 through 2026-07 monthly demo rows. It never stores an
|
|
||||||
-- RDS endpoint, password, or certificate path.
|
|
||||||
--
|
|
||||||
-- Example:
|
|
||||||
-- PGPASSWORD="$PG_PASSWORD" psql \
|
|
||||||
-- "host=$PG_HOST port=5432 dbname=postgres user=$PG_USER \
|
|
||||||
-- sslmode=verify-full sslrootcert=$PG_SSL_ROOT_CERT" \
|
|
||||||
-- -X -f database/source/postgres_hmm_carrier_performance.sql
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
\set ON_ERROR_STOP on
|
|
||||||
\echo === HMM carrier performance demo: begin ===
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS hmm_demo AUTHORIZATION postgres;
|
|
||||||
COMMENT ON SCHEMA hmm_demo IS
|
|
||||||
'HMM federation demonstration data. All carrier names and KPI values are fictional.';
|
|
||||||
|
|
||||||
REVOKE CREATE ON SCHEMA hmm_demo FROM PUBLIC;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS hmm_demo.carriers (
|
|
||||||
carrier_code VARCHAR(10) PRIMARY KEY,
|
|
||||||
carrier_name VARCHAR(120) NOT NULL UNIQUE,
|
|
||||||
country_code CHAR(2) NOT NULL CHECK (country_code ~ '^[A-Z]{2}$'),
|
|
||||||
alliance_name VARCHAR(80),
|
|
||||||
service_region VARCHAR(40) NOT NULL,
|
|
||||||
performance_tier VARCHAR(10) NOT NULL
|
|
||||||
CHECK (performance_tier IN ('CORE', 'GROWTH', 'WATCH')),
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE hmm_demo.carriers IS
|
|
||||||
'Fictional carrier master data for the HMM ADB and PostgreSQL federation demo.';
|
|
||||||
COMMENT ON COLUMN hmm_demo.carriers.carrier_code IS 'Fictional carrier identifier.';
|
|
||||||
COMMENT ON COLUMN hmm_demo.carriers.performance_tier IS
|
|
||||||
'Demo portfolio tier: CORE, GROWTH, or WATCH.';
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS hmm_demo.carrier_monthly_performance (
|
|
||||||
carrier_code VARCHAR(10) NOT NULL
|
|
||||||
REFERENCES hmm_demo.carriers (carrier_code),
|
|
||||||
performance_month DATE NOT NULL
|
|
||||||
CHECK (performance_month = date_trunc('month', performance_month)::DATE),
|
|
||||||
contracted_teu INTEGER NOT NULL CHECK (contracted_teu >= 0),
|
|
||||||
shipped_teu INTEGER NOT NULL CHECK (shipped_teu >= 0),
|
|
||||||
revenue_usd NUMERIC(16,2) NOT NULL CHECK (revenue_usd >= 0),
|
|
||||||
gross_margin_usd NUMERIC(16,2) NOT NULL,
|
|
||||||
schedule_reliability_pct NUMERIC(5,2) NOT NULL
|
|
||||||
CHECK (schedule_reliability_pct BETWEEN 0 AND 100),
|
|
||||||
vessel_utilization_pct NUMERIC(5,2) NOT NULL
|
|
||||||
CHECK (vessel_utilization_pct BETWEEN 0 AND 100),
|
|
||||||
claim_rate_pct NUMERIC(5,2) NOT NULL
|
|
||||||
CHECK (claim_rate_pct BETWEEN 0 AND 100),
|
|
||||||
yoy_growth_pct NUMERIC(7,2) NOT NULL,
|
|
||||||
risk_level VARCHAR(10) NOT NULL
|
|
||||||
CHECK (risk_level IN ('GREEN', 'AMBER', 'RED')),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL,
|
|
||||||
PRIMARY KEY (carrier_code, performance_month)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE hmm_demo.carrier_monthly_performance IS
|
|
||||||
'Deterministic fictional monthly carrier KPIs for 2025-02 through 2026-07.';
|
|
||||||
COMMENT ON COLUMN hmm_demo.carrier_monthly_performance.performance_month IS
|
|
||||||
'First calendar day of the KPI month.';
|
|
||||||
COMMENT ON COLUMN hmm_demo.carrier_monthly_performance.risk_level IS
|
|
||||||
'Derived demo risk level based on margin, reliability, and claim rate.';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_carrier_perf_month
|
|
||||||
ON hmm_demo.carrier_monthly_performance (performance_month, carrier_code);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_carrier_perf_risk
|
|
||||||
ON hmm_demo.carrier_monthly_performance (risk_level, performance_month);
|
|
||||||
|
|
||||||
INSERT INTO hmm_demo.carriers (
|
|
||||||
carrier_code,
|
|
||||||
carrier_name,
|
|
||||||
country_code,
|
|
||||||
alliance_name,
|
|
||||||
service_region,
|
|
||||||
performance_tier,
|
|
||||||
active
|
|
||||||
) VALUES
|
|
||||||
('C001', 'Bluewave Maritime', 'SG', 'North Pacific Network', 'ASIA-PACIFIC', 'CORE', TRUE),
|
|
||||||
('C002', 'Pacific Star Lines', 'JP', 'East Ocean Connect', 'ASIA-PACIFIC', 'CORE', TRUE),
|
|
||||||
('C003', 'Korea Ocean Link', 'KR', 'East Ocean Connect', 'ASIA-PACIFIC', 'GROWTH', TRUE),
|
|
||||||
('C004', 'Nordic Seaways', 'DK', 'Northern Trade Group', 'EUROPE', 'CORE', TRUE),
|
|
||||||
('C005', 'Orient Bridge Shipping', 'TW', 'North Pacific Network', 'ASIA-PACIFIC', 'WATCH', TRUE),
|
|
||||||
('C006', 'Atlantic Merchant Line', 'DE', 'Atlantic Cargo Forum', 'EUROPE-AMERICAS', 'WATCH', TRUE),
|
|
||||||
('C007', 'Global Horizon Cargo', 'US', 'Atlantic Cargo Forum', 'AMERICAS', 'GROWTH', TRUE),
|
|
||||||
('C008', 'Southern Cross Marine', 'FR', 'Southern Ocean Group', 'OCEANIA', 'WATCH', TRUE)
|
|
||||||
ON CONFLICT (carrier_code) DO UPDATE SET
|
|
||||||
carrier_name = EXCLUDED.carrier_name,
|
|
||||||
country_code = EXCLUDED.country_code,
|
|
||||||
alliance_name = EXCLUDED.alliance_name,
|
|
||||||
service_region = EXCLUDED.service_region,
|
|
||||||
performance_tier = EXCLUDED.performance_tier,
|
|
||||||
active = EXCLUDED.active;
|
|
||||||
|
|
||||||
WITH month_series AS (
|
|
||||||
SELECT
|
|
||||||
month_index,
|
|
||||||
(DATE '2025-02-01' + make_interval(months => month_index))::DATE AS performance_month,
|
|
||||||
CASE month_index % 6
|
|
||||||
WHEN 0 THEN -1
|
|
||||||
WHEN 1 THEN 0
|
|
||||||
WHEN 2 THEN 1
|
|
||||||
WHEN 3 THEN 2
|
|
||||||
WHEN 4 THEN 1
|
|
||||||
ELSE -1
|
|
||||||
END::NUMERIC AS seasonal_factor
|
|
||||||
FROM generate_series(0, 17) AS series(month_index)
|
|
||||||
),
|
|
||||||
profiles (
|
|
||||||
carrier_code,
|
|
||||||
base_teu,
|
|
||||||
monthly_teu_step,
|
|
||||||
seasonal_teu,
|
|
||||||
utilization_base,
|
|
||||||
utilization_step,
|
|
||||||
revenue_per_teu,
|
|
||||||
revenue_step,
|
|
||||||
margin_pct_base,
|
|
||||||
margin_pct_step,
|
|
||||||
reliability_base,
|
|
||||||
reliability_step,
|
|
||||||
claim_rate_base,
|
|
||||||
claim_rate_step,
|
|
||||||
yoy_growth_base,
|
|
||||||
yoy_growth_step
|
|
||||||
) AS (
|
|
||||||
VALUES
|
|
||||||
('C001', 1800::NUMERIC, 25::NUMERIC, 80::NUMERIC, 91.5::NUMERIC, 0.10::NUMERIC, 1850::NUMERIC, 4::NUMERIC, 15.0::NUMERIC, 0.10::NUMERIC, 95.0::NUMERIC, 0.05::NUMERIC, 0.35::NUMERIC, -0.005::NUMERIC, 8.0::NUMERIC, 0.25::NUMERIC),
|
|
||||||
('C002', 1500::NUMERIC, 5::NUMERIC, 60::NUMERIC, 88.5::NUMERIC, 0.02::NUMERIC, 1780::NUMERIC, 3::NUMERIC, 12.0::NUMERIC, 0.00::NUMERIC, 92.5::NUMERIC, 0.00::NUMERIC, 0.45::NUMERIC, 0.005::NUMERIC, 3.0::NUMERIC, 0.05::NUMERIC),
|
|
||||||
('C003', 1050::NUMERIC, 15::NUMERIC, 50::NUMERIC, 83.0::NUMERIC, 0.20::NUMERIC, 1920::NUMERIC, 2::NUMERIC, 8.0::NUMERIC, 0.15::NUMERIC, 88.5::NUMERIC, 0.25::NUMERIC, 1.20::NUMERIC, -0.040::NUMERIC, -4.0::NUMERIC, 0.60::NUMERIC),
|
|
||||||
('C004', 1350::NUMERIC, 0::NUMERIC, 180::NUMERIC, 86.0::NUMERIC, 0.00::NUMERIC, 1810::NUMERIC, 3::NUMERIC, 10.0::NUMERIC, 0.00::NUMERIC, 93.0::NUMERIC, 0.00::NUMERIC, 0.55::NUMERIC, 0.010::NUMERIC, 1.0::NUMERIC, 0.10::NUMERIC),
|
|
||||||
('C005', 1300::NUMERIC, -20::NUMERIC, 70::NUMERIC, 88.0::NUMERIC, -0.10::NUMERIC, 1850::NUMERIC, -2::NUMERIC, 8.0::NUMERIC, -0.20::NUMERIC, 91.0::NUMERIC, -0.15::NUMERIC, 0.80::NUMERIC, 0.030::NUMERIC, -2.0::NUMERIC, -0.50::NUMERIC),
|
|
||||||
('C006', 1250::NUMERIC, -10::NUMERIC, 90::NUMERIC, 84.0::NUMERIC, -0.12::NUMERIC, 1800::NUMERIC, -3::NUMERIC, 6.0::NUMERIC, -0.15::NUMERIC, 87.0::NUMERIC, -0.25::NUMERIC, 1.50::NUMERIC, 0.050::NUMERIC, -5.0::NUMERIC, -0.70::NUMERIC),
|
|
||||||
('C007', 800::NUMERIC, 30::NUMERIC, 100::NUMERIC, 82.0::NUMERIC, 0.25::NUMERIC, 2050::NUMERIC, 5::NUMERIC, 9.0::NUMERIC, 0.18::NUMERIC, 90.5::NUMERIC, 0.15::NUMERIC, 0.70::NUMERIC, -0.020::NUMERIC, 12.0::NUMERIC, 0.50::NUMERIC),
|
|
||||||
('C008', 1150::NUMERIC, -25::NUMERIC, 60::NUMERIC, 80.0::NUMERIC, -0.15::NUMERIC, 1750::NUMERIC, -4::NUMERIC, 3.0::NUMERIC, -0.20::NUMERIC, 85.0::NUMERIC, -0.30::NUMERIC, 2.00::NUMERIC, 0.080::NUMERIC, -8.0::NUMERIC, -0.80::NUMERIC)
|
|
||||||
),
|
|
||||||
base_metrics AS (
|
|
||||||
SELECT
|
|
||||||
p.carrier_code,
|
|
||||||
m.performance_month,
|
|
||||||
ROUND(
|
|
||||||
p.base_teu
|
|
||||||
+ p.monthly_teu_step * m.month_index
|
|
||||||
+ p.seasonal_teu * m.seasonal_factor
|
|
||||||
)::INTEGER AS contracted_teu,
|
|
||||||
LEAST(
|
|
||||||
98.0::NUMERIC,
|
|
||||||
GREATEST(
|
|
||||||
60.0::NUMERIC,
|
|
||||||
p.utilization_base
|
|
||||||
+ p.utilization_step * m.month_index
|
|
||||||
+ m.seasonal_factor * 0.50
|
|
||||||
)
|
|
||||||
) AS utilization_pct,
|
|
||||||
p.revenue_per_teu + p.revenue_step * m.month_index AS unit_revenue,
|
|
||||||
p.margin_pct_base + p.margin_pct_step * m.month_index AS margin_pct,
|
|
||||||
LEAST(
|
|
||||||
99.5::NUMERIC,
|
|
||||||
GREATEST(
|
|
||||||
60.0::NUMERIC,
|
|
||||||
p.reliability_base
|
|
||||||
+ p.reliability_step * m.month_index
|
|
||||||
+ m.seasonal_factor * 0.20
|
|
||||||
)
|
|
||||||
) AS reliability_pct,
|
|
||||||
LEAST(
|
|
||||||
100.0::NUMERIC,
|
|
||||||
GREATEST(
|
|
||||||
0.0::NUMERIC,
|
|
||||||
p.claim_rate_base + p.claim_rate_step * m.month_index
|
|
||||||
)
|
|
||||||
) AS claim_pct,
|
|
||||||
p.yoy_growth_base + p.yoy_growth_step * m.month_index AS yoy_pct
|
|
||||||
FROM profiles p
|
|
||||||
CROSS JOIN month_series m
|
|
||||||
),
|
|
||||||
volume_metrics AS (
|
|
||||||
SELECT
|
|
||||||
b.*,
|
|
||||||
ROUND(b.contracted_teu * b.utilization_pct / 100.0)::INTEGER AS shipped_teu
|
|
||||||
FROM base_metrics b
|
|
||||||
),
|
|
||||||
financial_metrics AS (
|
|
||||||
SELECT
|
|
||||||
v.*,
|
|
||||||
ROUND(v.shipped_teu * v.unit_revenue, 2) AS revenue_usd
|
|
||||||
FROM volume_metrics v
|
|
||||||
),
|
|
||||||
final_metrics AS (
|
|
||||||
SELECT
|
|
||||||
f.*,
|
|
||||||
ROUND(f.revenue_usd * f.margin_pct / 100.0, 2) AS gross_margin_usd
|
|
||||||
FROM financial_metrics f
|
|
||||||
)
|
|
||||||
INSERT INTO hmm_demo.carrier_monthly_performance (
|
|
||||||
carrier_code,
|
|
||||||
performance_month,
|
|
||||||
contracted_teu,
|
|
||||||
shipped_teu,
|
|
||||||
revenue_usd,
|
|
||||||
gross_margin_usd,
|
|
||||||
schedule_reliability_pct,
|
|
||||||
vessel_utilization_pct,
|
|
||||||
claim_rate_pct,
|
|
||||||
yoy_growth_pct,
|
|
||||||
risk_level,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
f.carrier_code,
|
|
||||||
f.performance_month,
|
|
||||||
f.contracted_teu,
|
|
||||||
f.shipped_teu,
|
|
||||||
f.revenue_usd,
|
|
||||||
f.gross_margin_usd,
|
|
||||||
ROUND(f.reliability_pct, 2),
|
|
||||||
ROUND(f.utilization_pct, 2),
|
|
||||||
ROUND(f.claim_pct, 2),
|
|
||||||
ROUND(f.yoy_pct, 2),
|
|
||||||
CASE
|
|
||||||
WHEN f.gross_margin_usd < 0
|
|
||||||
OR f.reliability_pct < 87
|
|
||||||
OR f.claim_pct >= 2 THEN 'RED'
|
|
||||||
WHEN f.margin_pct < 7
|
|
||||||
OR f.reliability_pct < 92
|
|
||||||
OR f.claim_pct >= 1 THEN 'AMBER'
|
|
||||||
ELSE 'GREEN'
|
|
||||||
END,
|
|
||||||
TIMESTAMPTZ '2026-07-24 00:00:00+00'
|
|
||||||
FROM final_metrics f
|
|
||||||
ON CONFLICT (carrier_code, performance_month) DO UPDATE SET
|
|
||||||
contracted_teu = EXCLUDED.contracted_teu,
|
|
||||||
shipped_teu = EXCLUDED.shipped_teu,
|
|
||||||
revenue_usd = EXCLUDED.revenue_usd,
|
|
||||||
gross_margin_usd = EXCLUDED.gross_margin_usd,
|
|
||||||
schedule_reliability_pct = EXCLUDED.schedule_reliability_pct,
|
|
||||||
vessel_utilization_pct = EXCLUDED.vessel_utilization_pct,
|
|
||||||
claim_rate_pct = EXCLUDED.claim_rate_pct,
|
|
||||||
yoy_growth_pct = EXCLUDED.yoy_growth_pct,
|
|
||||||
risk_level = EXCLUDED.risk_level,
|
|
||||||
updated_at = EXCLUDED.updated_at;
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW hmm_demo.carrier_performance_latest_v AS
|
|
||||||
SELECT
|
|
||||||
c.carrier_code,
|
|
||||||
c.carrier_name,
|
|
||||||
c.country_code,
|
|
||||||
c.alliance_name,
|
|
||||||
c.service_region,
|
|
||||||
c.performance_tier,
|
|
||||||
p.performance_month,
|
|
||||||
p.contracted_teu,
|
|
||||||
p.shipped_teu,
|
|
||||||
p.revenue_usd,
|
|
||||||
p.gross_margin_usd,
|
|
||||||
p.schedule_reliability_pct,
|
|
||||||
p.vessel_utilization_pct,
|
|
||||||
p.claim_rate_pct,
|
|
||||||
p.yoy_growth_pct,
|
|
||||||
p.risk_level
|
|
||||||
FROM hmm_demo.carriers c
|
|
||||||
JOIN hmm_demo.carrier_monthly_performance p
|
|
||||||
ON p.carrier_code = c.carrier_code
|
|
||||||
WHERE p.performance_month = (
|
|
||||||
SELECT MAX(latest.performance_month)
|
|
||||||
FROM hmm_demo.carrier_monthly_performance latest
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON VIEW hmm_demo.carrier_performance_latest_v IS
|
|
||||||
'Latest fictional monthly KPI for each active demo carrier.';
|
|
||||||
|
|
||||||
DO $role$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hmm_federation_reader') THEN
|
|
||||||
CREATE ROLE hmm_federation_reader NOLOGIN;
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$role$;
|
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA hmm_demo TO hmm_federation_reader;
|
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA hmm_demo TO hmm_federation_reader;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA hmm_demo
|
|
||||||
GRANT SELECT ON TABLES TO hmm_federation_reader;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
\echo === HMM carrier performance demo: complete ===
|
|
||||||
\echo Next: run database/source/postgres_hmm_carrier_performance_verify.sql
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Verification for HMM demo carrier performance data.
|
|
||||||
-- Redmine: #729
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
\set ON_ERROR_STOP on
|
|
||||||
\pset pager off
|
|
||||||
\echo === HMM carrier performance demo: verification begin ===
|
|
||||||
|
|
||||||
DO $verify$
|
|
||||||
DECLARE
|
|
||||||
carrier_count INTEGER;
|
|
||||||
performance_count INTEGER;
|
|
||||||
invalid_month_count INTEGER;
|
|
||||||
latest_month DATE;
|
|
||||||
earliest_month DATE;
|
|
||||||
wrong_series_count INTEGER;
|
|
||||||
latest_risk_count INTEGER;
|
|
||||||
wrong_trend_count INTEGER;
|
|
||||||
reader_can_login BOOLEAN;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO carrier_count
|
|
||||||
FROM hmm_demo.carriers
|
|
||||||
WHERE carrier_code BETWEEN 'C001' AND 'C008';
|
|
||||||
|
|
||||||
SELECT COUNT(*), MIN(performance_month), MAX(performance_month)
|
|
||||||
INTO performance_count, earliest_month, latest_month
|
|
||||||
FROM hmm_demo.carrier_monthly_performance
|
|
||||||
WHERE carrier_code BETWEEN 'C001' AND 'C008'
|
|
||||||
AND performance_month BETWEEN DATE '2025-02-01' AND DATE '2026-07-01';
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO invalid_month_count
|
|
||||||
FROM hmm_demo.carrier_monthly_performance
|
|
||||||
WHERE performance_month <> date_trunc('month', performance_month)::DATE;
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO wrong_series_count
|
|
||||||
FROM (
|
|
||||||
SELECT carrier_code, COUNT(*) AS month_count
|
|
||||||
FROM hmm_demo.carrier_monthly_performance
|
|
||||||
WHERE carrier_code BETWEEN 'C001' AND 'C008'
|
|
||||||
AND performance_month BETWEEN DATE '2025-02-01' AND DATE '2026-07-01'
|
|
||||||
GROUP BY carrier_code
|
|
||||||
HAVING COUNT(*) <> 18
|
|
||||||
) wrong_series;
|
|
||||||
|
|
||||||
SELECT COUNT(DISTINCT risk_level)
|
|
||||||
INTO latest_risk_count
|
|
||||||
FROM hmm_demo.carrier_performance_latest_v;
|
|
||||||
|
|
||||||
WITH boundaries AS (
|
|
||||||
SELECT
|
|
||||||
carrier_code,
|
|
||||||
MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2025-02-01')
|
|
||||||
AS first_revenue,
|
|
||||||
MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2026-07-01')
|
|
||||||
AS latest_revenue
|
|
||||||
FROM hmm_demo.carrier_monthly_performance
|
|
||||||
WHERE carrier_code BETWEEN 'C001' AND 'C008'
|
|
||||||
GROUP BY carrier_code
|
|
||||||
)
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO wrong_trend_count
|
|
||||||
FROM boundaries
|
|
||||||
WHERE (carrier_code IN ('C001', 'C002', 'C003', 'C004', 'C007')
|
|
||||||
AND latest_revenue <= first_revenue)
|
|
||||||
OR (carrier_code IN ('C005', 'C006', 'C008')
|
|
||||||
AND latest_revenue >= first_revenue);
|
|
||||||
|
|
||||||
SELECT rolcanlogin
|
|
||||||
INTO reader_can_login
|
|
||||||
FROM pg_roles
|
|
||||||
WHERE rolname = 'hmm_federation_reader';
|
|
||||||
|
|
||||||
IF carrier_count <> 8 THEN
|
|
||||||
RAISE EXCEPTION 'Expected 8 carriers, found %', carrier_count;
|
|
||||||
END IF;
|
|
||||||
IF performance_count <> 144 THEN
|
|
||||||
RAISE EXCEPTION 'Expected 144 monthly performance rows, found %', performance_count;
|
|
||||||
END IF;
|
|
||||||
IF earliest_month <> DATE '2025-02-01' OR latest_month <> DATE '2026-07-01' THEN
|
|
||||||
RAISE EXCEPTION 'Unexpected period: % through %', earliest_month, latest_month;
|
|
||||||
END IF;
|
|
||||||
IF invalid_month_count <> 0 OR wrong_series_count <> 0 THEN
|
|
||||||
RAISE EXCEPTION
|
|
||||||
'Invalid monthly series: invalid dates %, wrong carrier series %',
|
|
||||||
invalid_month_count,
|
|
||||||
wrong_series_count;
|
|
||||||
END IF;
|
|
||||||
IF latest_risk_count <> 3 THEN
|
|
||||||
RAISE EXCEPTION 'Expected GREEN, AMBER, RED latest risks, found % levels',
|
|
||||||
latest_risk_count;
|
|
||||||
END IF;
|
|
||||||
IF wrong_trend_count <> 0 THEN
|
|
||||||
RAISE EXCEPTION 'Unexpected first-to-latest trend for % carriers', wrong_trend_count;
|
|
||||||
END IF;
|
|
||||||
IF reader_can_login IS DISTINCT FROM FALSE THEN
|
|
||||||
RAISE EXCEPTION 'hmm_federation_reader must remain a NOLOGIN role';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'USAGE') THEN
|
|
||||||
RAISE EXCEPTION 'hmm_federation_reader lacks schema USAGE';
|
|
||||||
END IF;
|
|
||||||
IF has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'CREATE') THEN
|
|
||||||
RAISE EXCEPTION 'hmm_federation_reader unexpectedly has schema CREATE';
|
|
||||||
END IF;
|
|
||||||
IF NOT has_table_privilege(
|
|
||||||
'hmm_federation_reader',
|
|
||||||
'hmm_demo.carrier_monthly_performance',
|
|
||||||
'SELECT'
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'hmm_federation_reader lacks SELECT';
|
|
||||||
END IF;
|
|
||||||
IF has_table_privilege(
|
|
||||||
'hmm_federation_reader',
|
|
||||||
'hmm_demo.carrier_monthly_performance',
|
|
||||||
'INSERT'
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'hmm_federation_reader unexpectedly has INSERT';
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$verify$;
|
|
||||||
|
|
||||||
DO $constraint_tests$
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO hmm_demo.carrier_monthly_performance (
|
|
||||||
carrier_code,
|
|
||||||
performance_month,
|
|
||||||
contracted_teu,
|
|
||||||
shipped_teu,
|
|
||||||
revenue_usd,
|
|
||||||
gross_margin_usd,
|
|
||||||
schedule_reliability_pct,
|
|
||||||
vessel_utilization_pct,
|
|
||||||
claim_rate_pct,
|
|
||||||
yoy_growth_pct,
|
|
||||||
risk_level,
|
|
||||||
updated_at
|
|
||||||
) VALUES (
|
|
||||||
'C001',
|
|
||||||
DATE '2026-07-02',
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
90,
|
|
||||||
90,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
'GREEN',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
RAISE EXCEPTION 'Month-first-day constraint did not reject invalid input';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN check_violation THEN
|
|
||||||
NULL;
|
|
||||||
END;
|
|
||||||
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO hmm_demo.carrier_monthly_performance (
|
|
||||||
carrier_code,
|
|
||||||
performance_month,
|
|
||||||
contracted_teu,
|
|
||||||
shipped_teu,
|
|
||||||
revenue_usd,
|
|
||||||
gross_margin_usd,
|
|
||||||
schedule_reliability_pct,
|
|
||||||
vessel_utilization_pct,
|
|
||||||
claim_rate_pct,
|
|
||||||
yoy_growth_pct,
|
|
||||||
risk_level,
|
|
||||||
updated_at
|
|
||||||
) VALUES (
|
|
||||||
'UNKNOWN',
|
|
||||||
DATE '2026-08-01',
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
90,
|
|
||||||
90,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
'GREEN',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
RAISE EXCEPTION 'Foreign key constraint did not reject unknown carrier';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN foreign_key_violation THEN
|
|
||||||
NULL;
|
|
||||||
END;
|
|
||||||
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO hmm_demo.carrier_monthly_performance (
|
|
||||||
carrier_code,
|
|
||||||
performance_month,
|
|
||||||
contracted_teu,
|
|
||||||
shipped_teu,
|
|
||||||
revenue_usd,
|
|
||||||
gross_margin_usd,
|
|
||||||
schedule_reliability_pct,
|
|
||||||
vessel_utilization_pct,
|
|
||||||
claim_rate_pct,
|
|
||||||
yoy_growth_pct,
|
|
||||||
risk_level,
|
|
||||||
updated_at
|
|
||||||
) VALUES (
|
|
||||||
'C001',
|
|
||||||
DATE '2026-08-01',
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
101,
|
|
||||||
90,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
'GREEN',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
RAISE EXCEPTION 'Percentage constraint did not reject invalid input';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN check_violation THEN
|
|
||||||
NULL;
|
|
||||||
END;
|
|
||||||
END
|
|
||||||
$constraint_tests$;
|
|
||||||
|
|
||||||
\echo --- Row counts and period ---
|
|
||||||
SELECT
|
|
||||||
COUNT(DISTINCT carrier_code) AS carrier_count,
|
|
||||||
COUNT(*) AS monthly_row_count,
|
|
||||||
MIN(performance_month) AS first_month,
|
|
||||||
MAX(performance_month) AS latest_month
|
|
||||||
FROM hmm_demo.carrier_monthly_performance
|
|
||||||
WHERE carrier_code BETWEEN 'C001' AND 'C008'
|
|
||||||
AND performance_month BETWEEN DATE '2025-02-01' AND DATE '2026-07-01';
|
|
||||||
|
|
||||||
\echo --- Per-carrier series ---
|
|
||||||
SELECT
|
|
||||||
c.carrier_code,
|
|
||||||
c.carrier_name,
|
|
||||||
COUNT(p.*) AS month_count,
|
|
||||||
MIN(p.performance_month) AS first_month,
|
|
||||||
MAX(p.performance_month) AS latest_month
|
|
||||||
FROM hmm_demo.carriers c
|
|
||||||
JOIN hmm_demo.carrier_monthly_performance p
|
|
||||||
ON p.carrier_code = c.carrier_code
|
|
||||||
WHERE c.carrier_code BETWEEN 'C001' AND 'C008'
|
|
||||||
GROUP BY c.carrier_code, c.carrier_name
|
|
||||||
ORDER BY c.carrier_code;
|
|
||||||
|
|
||||||
\echo --- Latest KPI and risk distribution ---
|
|
||||||
SELECT
|
|
||||||
carrier_code,
|
|
||||||
carrier_name,
|
|
||||||
performance_month,
|
|
||||||
shipped_teu,
|
|
||||||
revenue_usd,
|
|
||||||
gross_margin_usd,
|
|
||||||
schedule_reliability_pct,
|
|
||||||
claim_rate_pct,
|
|
||||||
risk_level
|
|
||||||
FROM hmm_demo.carrier_performance_latest_v
|
|
||||||
ORDER BY carrier_code;
|
|
||||||
|
|
||||||
SELECT risk_level, COUNT(*) AS carrier_count
|
|
||||||
FROM hmm_demo.carrier_performance_latest_v
|
|
||||||
GROUP BY risk_level
|
|
||||||
ORDER BY risk_level;
|
|
||||||
|
|
||||||
\echo --- First-to-latest revenue trend ---
|
|
||||||
WITH boundaries AS (
|
|
||||||
SELECT
|
|
||||||
carrier_code,
|
|
||||||
MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2025-02-01') AS first_revenue,
|
|
||||||
MAX(revenue_usd) FILTER (WHERE performance_month = DATE '2026-07-01') AS latest_revenue
|
|
||||||
FROM hmm_demo.carrier_monthly_performance
|
|
||||||
WHERE carrier_code BETWEEN 'C001' AND 'C008'
|
|
||||||
GROUP BY carrier_code
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
carrier_code,
|
|
||||||
first_revenue,
|
|
||||||
latest_revenue,
|
|
||||||
CASE
|
|
||||||
WHEN latest_revenue > first_revenue THEN 'UP'
|
|
||||||
WHEN latest_revenue < first_revenue THEN 'DOWN'
|
|
||||||
ELSE 'FLAT'
|
|
||||||
END AS trend
|
|
||||||
FROM boundaries
|
|
||||||
ORDER BY carrier_code;
|
|
||||||
|
|
||||||
\echo --- TLS session ---
|
|
||||||
SELECT
|
|
||||||
ssl,
|
|
||||||
version AS tls_version,
|
|
||||||
cipher,
|
|
||||||
bits
|
|
||||||
FROM pg_stat_ssl
|
|
||||||
WHERE pid = pg_backend_pid();
|
|
||||||
|
|
||||||
\echo --- Read-only role privileges ---
|
|
||||||
SELECT
|
|
||||||
(SELECT NOT rolcanlogin
|
|
||||||
FROM pg_roles
|
|
||||||
WHERE rolname = 'hmm_federation_reader') AS no_login,
|
|
||||||
has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'USAGE') AS schema_usage,
|
|
||||||
has_schema_privilege('hmm_federation_reader', 'hmm_demo', 'CREATE') AS schema_create,
|
|
||||||
has_table_privilege(
|
|
||||||
'hmm_federation_reader',
|
|
||||||
'hmm_demo.carrier_monthly_performance',
|
|
||||||
'SELECT'
|
|
||||||
) AS can_select,
|
|
||||||
has_table_privilege(
|
|
||||||
'hmm_federation_reader',
|
|
||||||
'hmm_demo.carrier_monthly_performance',
|
|
||||||
'INSERT'
|
|
||||||
) AS can_insert;
|
|
||||||
|
|
||||||
\echo === HMM carrier performance demo: verification complete ===
|
|
||||||
@@ -56,10 +56,10 @@ ORDS Handler가 Bearer 값을 문자열로 바꾸는 것만으로 DDS Context가
|
|||||||
먼저 DDS 데모 전용 로컬 데이터셋과 권한 객체를 적용합니다. 이 스크립트는 VPD VIEW나 외부 RDS DB Link를 사용하지 않습니다.
|
먼저 DDS 데모 전용 로컬 데이터셋과 권한 객체를 적용합니다. 이 스크립트는 VPD VIEW나 외부 RDS DB Link를 사용하지 않습니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/31_dds_standalone_demo_setup.sql
|
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/31_dds_standalone_demo_setup.sql
|
||||||
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/32_dds_vector_tag_setup.sql
|
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/32_dds_vector_tag_setup.sql
|
||||||
bash scripts/setup-dds-token-data-grant.sh
|
bash scripts/setup-dds-token-data-grant.sh
|
||||||
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/36_dds_sales_knowledge_scenario.sql
|
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/36_dds_sales_knowledge_scenario.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
`32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, 동일한 `CB_PERMISSION`/`CB_PERMISSION_RULE`에 역할별 TAG 규칙을 등록합니다. `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP`처럼 한 역할에 여러 TAG가 있으면 `/dds-provision`이 OR 조건으로 합쳐 DDS DATA GRANT를 다시 만듭니다. 벡터 검색에 필요한 `EMBEDDING`은 DDS 엔진이 거리 계산에 사용하므로 Grant에서 제외하지 않고, 검색 응답에서는 애플리케이션이 반환하지 않습니다. 그 외 민감 컬럼은 애플리케이션 권한의 원문 허용 목록에 없으면 `ALL COLUMNS EXCEPT`로 제외됩니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS Grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다.
|
`32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, 동일한 `CB_PERMISSION`/`CB_PERMISSION_RULE`에 역할별 TAG 규칙을 등록합니다. `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP`처럼 한 역할에 여러 TAG가 있으면 `/dds-provision`이 OR 조건으로 합쳐 DDS DATA GRANT를 다시 만듭니다. 벡터 검색에 필요한 `EMBEDDING`은 DDS 엔진이 거리 계산에 사용하므로 Grant에서 제외하지 않고, 검색 응답에서는 애플리케이션이 반환하지 않습니다. 그 외 민감 컬럼은 애플리케이션 권한의 원문 허용 목록에 없으면 `ALL COLUMNS EXCEPT`로 제외됩니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS Grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다.
|
||||||
@@ -68,7 +68,7 @@ sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/36_dds_sales_knowledge_
|
|||||||
|
|
||||||
`36_dds_sales_knowledge_scenario.sql`은 `agent_sales`와 `SALES_KNOWLEDGE_ROLE`을 만들고 `TECH_TAG=SALES`인 세일즈 청크(28004)를 추가합니다. 짧은 데모 토큰 `dds_sales_demo_token`을 사용하면 결과는 28004만 남아야 합니다. 운영에서는 이 토큰 대신 Bearer 관리 화면에서 발급한 임시 토큰을 사용합니다.
|
`36_dds_sales_knowledge_scenario.sql`은 `agent_sales`와 `SALES_KNOWLEDGE_ROLE`을 만들고 `TECH_TAG=SALES`인 세일즈 청크(28004)를 추가합니다. 짧은 데모 토큰 `dds_sales_demo_token`을 사용하면 결과는 28004만 남아야 합니다. 운영에서는 이 토큰 대신 Bearer 관리 화면에서 발급한 임시 토큰을 사용합니다.
|
||||||
|
|
||||||
토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `database/adb/37_dds_sales_knowledge_scenario_cleanup.sql`과 `database/adb/35_dds_token_data_grant_cleanup.sql`을 별도로 승인해 실행합니다.
|
토큰 경로를 제거할 때는 사용 중인 애플리케이션이 없는지 확인한 뒤 `sql/adb/37_dds_sales_knowledge_scenario_cleanup.sql`과 `sql/adb/35_dds_token_data_grant_cleanup.sql`을 별도로 승인해 실행합니다.
|
||||||
|
|
||||||
그 다음 DDS 인스턴스를 실행합니다.
|
그 다음 DDS 인스턴스를 실행합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
location = /auth/check {
|
|
||||||
internal;
|
|
||||||
proxy_pass http://127.0.0.1:8621/auth/check;
|
|
||||||
proxy_pass_request_body off;
|
|
||||||
proxy_set_header Content-Length "";
|
|
||||||
proxy_set_header Cookie $http_cookie;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /auth/ {
|
|
||||||
proxy_pass http://127.0.0.1:8621;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location @hmm_portal_login {
|
|
||||||
return 302 /auth/login;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
auth_request /auth/check;
|
|
||||||
error_page 401 = @hmm_portal_login;
|
|
||||||
auth_request_set $hmm_auth_user $upstream_http_x_auth_user;
|
|
||||||
auth_request_set $hmm_auth_expires $upstream_http_x_auth_expires;
|
|
||||||
|
|
||||||
proxy_pass http://127.0.0.1:8622;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-HMM-Authenticated-User $hmm_auth_user;
|
|
||||||
proxy_set_header X-HMM-Auth-Expires $hmm_auth_expires;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_read_timeout 300;
|
|
||||||
proxy_send_timeout 300;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[Service]
|
|
||||||
EnvironmentFile=/etc/hmm-poc4-audit.env
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=HMM Portal HttpOnly Cookie Authentication
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=opc
|
|
||||||
Group=opc
|
|
||||||
WorkingDirectory=/opt/hmm-poc4
|
|
||||||
Environment=PYTHONUNBUFFERED=1
|
|
||||||
EnvironmentFile=/opt/hmm-poc4/.env
|
|
||||||
ExecStart=/opt/hmm-poc4/.venv/bin/python -m ai_web_agent_console.auth_gateway
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=3
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
LockPersonality=true
|
|
||||||
RestrictAddressFamilies=AF_INET AF_INET6
|
|
||||||
UMask=0077
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Smilegate PoC4 Portal Authentication Gateway
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=opc
|
|
||||||
Group=opc
|
|
||||||
WorkingDirectory=/home/opc/workspaces/vpd-permission-poc-20260628213409/poc4_active_source_20260714
|
|
||||||
EnvironmentFile=/etc/smilegate/backoffice.env
|
|
||||||
EnvironmentFile=/etc/smilegate/poc4-console.env
|
|
||||||
ExecStart=/opt/smilegate/poc4-console/venv/bin/python /home/opc/workspaces/vpd-permission-poc-20260628213409/poc4_active_source_20260714/apps/poc4/portal_auth_gateway.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=3
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[Service]
|
|
||||||
ExecStart=
|
|
||||||
ExecStart=/opt/smilegate/poc4-console/venv/bin/streamlit run /home/opc/workspaces/vpd-permission-poc-20260628213409/poc4_active_source_20260714/apps/smilegate_demo/main.py --server.address 127.0.0.1 --server.port 8622 --server.headless true --server.enableCORS false --server.enableXsrfProtection false --browser.serverAddress smilegate.cloud-handson.com --browser.serverPort 443 --browser.gatherUsageStats false --logger.level=warn
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# HMM deployment profile for /etc/vpd-backoffice.env.
|
|
||||||
# Append these non-secret values to the existing file. Keep DB passwords,
|
|
||||||
# remember-me keys and user Bearer tokens outside Git.
|
|
||||||
|
|
||||||
BACKOFFICE_CATALOG_OWNER=ADMIN
|
|
||||||
BACKOFFICE_CATALOG_OBJECTS='[{"key":"teams","tableName":"HMM_ORG_TEAMS","objectType":"TABLE","businessName":"조직 원장","description":"HMM HR 조직·팀 기본정보"},{"key":"employees","tableName":"HMM_HR_EMPLOYEES","objectType":"TABLE","businessName":"직원 원장","description":"직원·매니저·소속팀 정보"},{"key":"leave-balances","tableName":"HMM_LEAVE_BALANCES","objectType":"TABLE","businessName":"휴가 잔여 원장","description":"직원별 연도·휴가 유형별 부여·사용·잔여 일수"},{"key":"leave-requests","tableName":"HMM_LEAVE_REQUESTS","objectType":"TABLE","businessName":"휴가 신청 원장","description":"직원별 휴가 신청·승인 상태와 기간"},{"key":"attendance-daily","tableName":"HMM_ATTENDANCE_DAILY","objectType":"TABLE","businessName":"일별 근태 원장","description":"직원별 출퇴근·근무 상태와 근무 시간"},{"key":"hr-terms","tableName":"HMM_HR_TERMS","objectType":"TABLE","businessName":"HR 표준 용어 원장","description":"휴가·근태 표준 코드, 명칭과 유사 표현","previewColumns":["TERM_ID","TERM_CODE","TERM_KIND","CANONICAL_NAME","TERM_NAME","IS_CANONICAL","DESCRIPTION","EMBEDDED_AT"]}]'
|
|
||||||
|
|
||||||
BACKOFFICE_PRODUCT_NAME='HMM HR Access Console'
|
|
||||||
BACKOFFICE_PRODUCT_TITLE='HMM HR Access Console'
|
|
||||||
BACKOFFICE_PRODUCT_DATA_LABEL='HMM HR 데이터'
|
|
||||||
|
|
||||||
BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp'
|
|
||||||
BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice'
|
|
||||||
# AGENT_TOOL targetName must exist with STATUS=ENABLED in USER_AI_AGENT_TOOLS.
|
|
||||||
# The application fails startup before advertising an invalid configured contract.
|
|
||||||
BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"SELECT_AI"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"}]'
|
|
||||||
|
|
||||||
BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]'
|
|
||||||
|
|
||||||
BACKOFFICE_SECURITY_SQL_SCRIPTS='[{"scriptId":"hmm-leave-vpd","category":"HMM / VPD","fileName":"72_hmm_leave_team_vpd.sql","title":"HMM 휴가 팀 접근 정책","description":"직원 본인과 팀장의 직접 보고 팀원 휴가 행 접근 정책 및 토큰 컨텍스트를 구성합니다."}]'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[Service]
|
|
||||||
EnvironmentFile=/etc/vpd-backoffice.env
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
> 단위 매트릭스** 입니다 (README 참고). 본 상세 가이드는 그 위에 얹을 수 있는 **행
|
> 단위 매트릭스** 입니다 (README 참고). 본 상세 가이드는 그 위에 얹을 수 있는 **행
|
||||||
> 단위 region 필터링** 변형(`KR_ANALYSTS → APAC`, `GLOBAL_ADMINS → '*'`) 을 예시로
|
> 단위 region 필터링** 변형(`KR_ANALYSTS → APAC`, `GLOBAL_ADMINS → '*'`) 을 예시로
|
||||||
> 사용합니다 — VPD 메커니즘 자체는 동일하므로 개념 이해에는 차이가 없습니다.
|
> 사용합니다 — VPD 메커니즘 자체는 동일하므로 개념 이해에는 차이가 없습니다.
|
||||||
> region 필터를 실제로 켜려면 `database/adb/03_seed.sql` 하단의 주석을 해제하세요.
|
> region 필터를 실제로 켜려면 `sql/adb/03_seed.sql` 하단의 주석을 해제하세요.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -543,7 +543,7 @@ SQL> SELECT COUNT(*) FROM admin.v_customers_pg; -- 0
|
|||||||
SQL> SELECT COUNT(*) FROM admin.v_customers_my; -- 0
|
SQL> SELECT COUNT(*) FROM admin.v_customers_my; -- 0
|
||||||
|
|
||||||
# region 필터 변형을 켰을 때 — vpduser_both 에게 APAC 만 허용한 경우
|
# region 필터 변형을 켰을 때 — vpduser_both 에게 APAC 만 허용한 경우
|
||||||
# (database/adb/03_seed.sql 하단 UPDATE 주석 해제 후)
|
# (sql/adb/03_seed.sql 하단 UPDATE 주석 해제 후)
|
||||||
sqlplus "vpduser_both/\"${VPDUSER_BOTH_PASSWORD}\"@$ADB_TNS"
|
sqlplus "vpduser_both/\"${VPDUSER_BOTH_PASSWORD}\"@$ADB_TNS"
|
||||||
SQL> SELECT region, COUNT(*) FROM admin.v_customers_pg GROUP BY region;
|
SQL> SELECT region, COUNT(*) FROM admin.v_customers_pg GROUP BY region;
|
||||||
-- 결과: APAC 만 보임
|
-- 결과: APAC 만 보임
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Postgres (`database/source/postgres_setup.sql`)
|
## Postgres (`sql/source/postgres_setup.sql`)
|
||||||
|
|
||||||
```
|
```
|
||||||
DB : 사용자가 .env 에서 지정 (PG_DB, 기본값 vpdpoc)
|
DB : 사용자가 .env 에서 지정 (PG_DB, 기본값 vpdpoc)
|
||||||
@@ -22,12 +22,12 @@ seed rows : 12 (APAC 4 / EMEA 4 / AMER 4)
|
|||||||
```bash
|
```bash
|
||||||
PGPASSWORD=$PG_PASSWORD psql \
|
PGPASSWORD=$PG_PASSWORD psql \
|
||||||
-h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \
|
-h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \
|
||||||
-f database/source/postgres_setup.sql
|
-f sql/source/postgres_setup.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MySQL (`database/source/mysql_setup.sql`)
|
## MySQL (`sql/source/mysql_setup.sql`)
|
||||||
|
|
||||||
```
|
```
|
||||||
DB : MY_DB (기본 ecommerce_poc)
|
DB : MY_DB (기본 ecommerce_poc)
|
||||||
@@ -47,12 +47,12 @@ PK 범위를 PG (1~12) 와 다르게 가져간 이유:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mysql -h $MY_HOST -P $MY_PORT -u $MY_USER -p"$MY_PASSWORD" $MY_DB \
|
mysql -h $MY_HOST -P $MY_PORT -u $MY_USER -p"$MY_PASSWORD" $MY_DB \
|
||||||
< database/source/mysql_setup.sql
|
< sql/source/mysql_setup.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADB → 원격 DB Link (`database/adb/01_dblinks.sql`)
|
## ADB → 원격 DB Link (`sql/adb/01_dblinks.sql`)
|
||||||
|
|
||||||
`DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 가 ADB 안에서 직접 heterogeneous 연결을
|
`DBMS_CLOUD_ADMIN.CREATE_DATABASE_LINK` 가 ADB 안에서 직접 heterogeneous 연결을
|
||||||
처리합니다. 별도 Database Gateway 설치 불필요.
|
처리합니다. 별도 Database Gateway 설치 불필요.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 본 문서는 **선택사항** 입니다. 메인 데모(`run.sh all`) 는 전통적 **VPD** 경로로
|
> 본 문서는 **선택사항** 입니다. 메인 데모(`run.sh all`) 는 전통적 **VPD** 경로로
|
||||||
> 그대로 동작합니다. 이 문서는 동일한 4-user 매트릭스를 Oracle AI Database
|
> 그대로 동작합니다. 이 문서는 동일한 4-user 매트릭스를 Oracle AI Database
|
||||||
> **26ai 신기능 Deep Data Security (DDS)** 로 재구현한 `database/adb/13_dds_variant.sql`
|
> **26ai 신기능 Deep Data Security (DDS)** 로 재구현한 `sql/adb/13_dds_variant.sql`
|
||||||
> 의 사용법과, VPD ↔ DDS 1:1 매핑을 다룹니다.
|
> 의 사용법과, VPD ↔ DDS 1:1 매핑을 다룹니다.
|
||||||
>
|
>
|
||||||
> 본 변형은 **2026-05-26 실제 ADB (23.26.2.2.0) 에서 E2E 검증** 됨 (8장 결과).
|
> 본 변형은 **2026-05-26 실제 ADB (23.26.2.2.0) 에서 E2E 검증** 됨 (8장 결과).
|
||||||
@@ -84,7 +84,7 @@ Oracle 이 **2026-04-09** 에 VPD/RAS 의 공식 후계자로 발표한 native a
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
source .env # DDSUSER_*_PASSWORD 로드
|
source .env # DDSUSER_*_PASSWORD 로드
|
||||||
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @database/adb/13_dds_variant.sql
|
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/13_dds_variant.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
스크립트가 만드는 객체:
|
스크립트가 만드는 객체:
|
||||||
@@ -218,7 +218,7 @@ DROP VIEW v_dds_customers_my;
|
|||||||
|
|
||||||
## 8. E2E 검증 결과 (2026-05-26, ADB 23.26.2.2.0)
|
## 8. E2E 검증 결과 (2026-05-26, ADB 23.26.2.2.0)
|
||||||
|
|
||||||
`database/adb/13_dds_variant.sql` 실행 후 4명의 ddsuser 로 `v_dds_customers_*` 조회:
|
`sql/adb/13_dds_variant.sql` 실행 후 4명의 ddsuser 로 `v_dds_customers_*` 조회:
|
||||||
|
|
||||||
| 사용자 | `v_dds_customers_pg` | `v_dds_customers_my` | 판정 |
|
| 사용자 | `v_dds_customers_pg` | `v_dds_customers_my` | 판정 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
|||||||
@@ -1165,15 +1165,15 @@ ORDER BY rg.grantee, dg.object_name, dg.grant_name;
|
|||||||
| 파일 | 역할 |
|
| 파일 | 역할 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `scripts/run_agent_ords_security_adb_local.sh` | 전체 실행 |
|
| `scripts/run_agent_ords_security_adb_local.sh` | 전체 실행 |
|
||||||
| `database/adb/16_agent_ords_security_local_cleanup.sql` | `CB_*` 예제 객체 정리 |
|
| `sql/adb/16_agent_ords_security_local_cleanup.sql` | `CB_*` 예제 객체 정리 |
|
||||||
| `database/adb/17_agent_ords_security_local_vpd_setup.sql` | VPD용 로컬 테이블, 권한 테이블, context, redaction, 정책 생성 |
|
| `sql/adb/17_agent_ords_security_local_vpd_setup.sql` | VPD용 로컬 테이블, 권한 테이블, context, redaction, 정책 생성 |
|
||||||
| `database/adb/18_agent_ords_security_local_vpd_test.sql` | `CB_ORDS`로 Bearer Key 기반 VPD 테스트 |
|
| `sql/adb/18_agent_ords_security_local_vpd_test.sql` | `CB_ORDS`로 Bearer Key 기반 VPD 테스트 |
|
||||||
| `database/adb/19_agent_ords_security_local_dds_setup.sql` | DDS용 로컬 테이블, END USER, DATA ROLE, DATA GRANT 생성 |
|
| `sql/adb/19_agent_ords_security_local_dds_setup.sql` | DDS용 로컬 테이블, END USER, DATA ROLE, DATA GRANT 생성 |
|
||||||
| `database/adb/20_agent_ords_security_local_dds_test.sql` | DDS end user별 조회 테스트 |
|
| `sql/adb/20_agent_ords_security_local_dds_test.sql` | DDS end user별 조회 테스트 |
|
||||||
| `database/adb/21_agent_ords_security_ords_enable_schema.sql` | `CB_ORDS` schema를 ORDS에 enable |
|
| `sql/adb/21_agent_ords_security_ords_enable_schema.sql` | `CB_ORDS` schema를 ORDS에 enable |
|
||||||
| `database/adb/22_agent_ords_security_ords_handler_setup.sql` | ORDS Module/Handler와 Handler Package 생성 |
|
| `sql/adb/22_agent_ords_security_ords_handler_setup.sql` | ORDS Module/Handler와 Handler Package 생성 |
|
||||||
| `database/adb/23_agent_ords_security_ords_handler_test.sql` | Handler Package 직접 실행으로 VPD/DDS Bearer 경로 검증 |
|
| `sql/adb/23_agent_ords_security_ords_handler_test.sql` | Handler Package 직접 실행으로 VPD/DDS Bearer 경로 검증 |
|
||||||
| `database/adb/24_agent_ords_security_inventory.sql` | VPD/DDS 정책과 권한을 중앙 조회 |
|
| `sql/adb/24_agent_ords_security_inventory.sql` | VPD/DDS 정책과 권한을 중앙 조회 |
|
||||||
|
|
||||||
### 7.1 VPD + Redaction 실행 검증
|
### 7.1 VPD + Redaction 실행 검증
|
||||||
|
|
||||||
@@ -1634,7 +1634,7 @@ ORA-20101: Authorization header must be Bearer <key>
|
|||||||
|
|
||||||
### 7.4 중앙 권한 인벤토리
|
### 7.4 중앙 권한 인벤토리
|
||||||
|
|
||||||
VPD와 DDS 모두 적용 결과를 Dictionary View로 확인할 수 있다. 이 예제에서는 `database/adb/24_agent_ords_security_inventory.sql`을 실행해 정책 연결과 DDS Grant Matrix를 확인한다.
|
VPD와 DDS 모두 적용 결과를 Dictionary View로 확인할 수 있다. 이 예제에서는 `sql/adb/24_agent_ords_security_inventory.sql`을 실행해 정책 연결과 DDS Grant Matrix를 확인한다.
|
||||||
|
|
||||||
VPD 정책 연결 확인:
|
VPD 정책 연결 확인:
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> **상태**: Draft
|
> **상태**: Draft
|
||||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-23
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-23
|
||||||
> **추적성** — Redmine: #424 · 관련 ADR: 없음
|
> **추적성** — Redmine: #424 · 관련 ADR: 없음
|
||||||
> · 구현 파일: `vpd-backoffice/pom.xml`, `vpd-backoffice/src/main/**`, `vpd-backoffice/src/test/**` · 테스트: `mvn test`
|
> · 구현 파일: `pom.xml`, `src/main/**`, `src/test/**` · 테스트: `mvn test`
|
||||||
|
|
||||||
## 1. 목적 (Why)
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
### 모듈/파일 구조
|
### 모듈/파일 구조
|
||||||
|
|
||||||
```text
|
```text
|
||||||
vpd-backoffice/pom.xml
|
pom.xml
|
||||||
vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/
|
src/main/java/com/cloudhandson/vpdbackoffice/
|
||||||
VpdBackofficeApplication.java
|
VpdBackofficeApplication.java
|
||||||
config/
|
config/
|
||||||
DataSourceConfig.java
|
DataSourceConfig.java
|
||||||
@@ -88,13 +88,13 @@ vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/
|
|||||||
TokenController.java
|
TokenController.java
|
||||||
ProbeController.java
|
ProbeController.java
|
||||||
DashboardController.java
|
DashboardController.java
|
||||||
vpd-backoffice/src/main/resources/
|
src/main/resources/
|
||||||
application.yml
|
application.yml
|
||||||
mapper/*.xml
|
mapper/*.xml
|
||||||
templates/**/*.html
|
templates/**/*.html
|
||||||
static/css/app.css
|
static/css/app.css
|
||||||
static/js/app.js
|
static/js/app.js
|
||||||
vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/
|
src/test/java/com/cloudhandson/vpdbackoffice/
|
||||||
```
|
```
|
||||||
|
|
||||||
### 데이터 흐름
|
### 데이터 흐름
|
||||||
@@ -251,7 +251,7 @@ MCP-style reasoning
|
|||||||
|
|
||||||
## 12. 미해결 질문 (Open Questions)
|
## 12. 미해결 질문 (Open Questions)
|
||||||
|
|
||||||
- ORDS 검증 API는 기존 `database/adb/22_agent_ords_security_ords_handler_setup.sql`의 Handler를 그대로 사용할지, 백오피스 전용 Handler를 추가할지 결정이 필요하다.
|
- ORDS 검증 API는 기존 `sql/adb/22_agent_ords_security_ords_handler_setup.sql`의 Handler를 그대로 사용할지, 백오피스 전용 Handler를 추가할지 결정이 필요하다.
|
||||||
- 백오피스 관리자 로그인은 초기에는 local user로 둘지, 사내 인증과 연결할지 후속 결정이 필요하다.
|
- 백오피스 관리자 로그인은 초기에는 local user로 둘지, 사내 인증과 연결할지 후속 결정이 필요하다.
|
||||||
- 컬럼 정책을 Redaction DDL까지 자동 생성할지, 관리 테이블 저장 후 DBA 적용으로 둘지 결정이 필요하다.
|
- 컬럼 정책을 Redaction DDL까지 자동 생성할지, 관리 테이블 저장 후 DBA 적용으로 둘지 결정이 필요하다.
|
||||||
- 실제 구현 issue를 별도 Redmine 하위 이슈로 나눌지, #424를 Developer 단계로 계속 이동할지 결정이 필요하다.
|
- 실제 구현 issue를 별도 Redmine 하위 이슈로 나눌지, #424를 Developer 단계로 계속 이동할지 결정이 필요하다.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> **상태**: Approved
|
> **상태**: Approved
|
||||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
|
||||||
> **추적성** — Redmine: #456 · 관련 ADR: 없음
|
> **추적성** — Redmine: #456 · 관련 ADR: 없음
|
||||||
> · 구현 파일: `database/adb/26_agent_ords_security_dynamic_vpd_filter.sql`, `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`, `mvn test`
|
> · 구현 파일: `sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`, `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`, `mvn test`
|
||||||
|
|
||||||
## 1. 목적 (Why)
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
@@ -78,8 +78,8 @@ I/O와 순수 로직 경계:
|
|||||||
|
|
||||||
## 10. 테스트 계획
|
## 10. 테스트 계획
|
||||||
|
|
||||||
- SQLcl: `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
|
- SQLcl: `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
|
||||||
- SQLcl: `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
- SQLcl: `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
||||||
- Maven: `mvn test`
|
- Maven: `mvn test`
|
||||||
- ORDS smoke: 기존 HR=3, SELF=1, ALL=6 결과 유지 확인.
|
- ORDS smoke: 기존 HR=3, SELF=1, ALL=6 결과 유지 확인.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 함수 설계서: `cb_agent_doc_vpd_filter` (#456)
|
# 함수 설계서: `cb_agent_doc_vpd_filter` (#456)
|
||||||
|
|
||||||
> **부모 설계서**: ./README.md · **상태**: Approved
|
> **부모 설계서**: ./README.md · **상태**: Approved
|
||||||
> **작성**: [AI] Architect · **구현**: `database/adb/26_agent_ords_security_dynamic_vpd_filter.sql:cb_agent_doc_vpd_filter` · **테스트**: `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
> **작성**: [AI] Architect · **구현**: `sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql:cb_agent_doc_vpd_filter` · **테스트**: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
||||||
|
|
||||||
## 1. 시그니처
|
## 1. 시그니처
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> **상태**: Approved
|
> **상태**: Approved
|
||||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-25
|
||||||
> **추적성** — Redmine: #462 · 관련 ADR: 없음
|
> **추적성** — Redmine: #462 · 관련 ADR: 없음
|
||||||
> · 구현 파일: `database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: SQLcl `27`, `./run.sh backoffice-vpd-ords-test`, `mvn test`
|
> · 구현 파일: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: SQLcl `27`, `./run.sh backoffice-vpd-ords-test`, `mvn test`
|
||||||
|
|
||||||
## 1. 목적 (Why)
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
@@ -67,8 +67,8 @@ malicious cb_permission_rule rows
|
|||||||
|
|
||||||
## 10. 테스트 계획
|
## 10. 테스트 계획
|
||||||
|
|
||||||
- `@database/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
|
- `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql`
|
||||||
- `@database/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
- `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql`
|
||||||
- `./run.sh backoffice-vpd-ords-test`
|
- `./run.sh backoffice-vpd-ords-test`
|
||||||
- `mvn test`
|
- `mvn test`
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user