Compare commits
157 Commits
ebcce66bf7
...
smilegate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b88c43fc | ||
|
|
03bf0e096c | ||
|
|
4d2964b5c6 | ||
|
|
022ae7f9d2 | ||
|
|
7bf8199343 | ||
|
|
ccc7d9e7ec | ||
|
|
e0ec0c9340 | ||
|
|
e1fd486b01 | ||
|
|
5ca02e646a | ||
|
|
0a27ce9cd5 | ||
|
|
5ac9f5d591 | ||
|
|
ae37235258 | ||
|
|
d932782c9f | ||
|
|
7bc13446bc | ||
|
|
9f15ef6be1 | ||
|
|
8298ecb511 | ||
|
|
5a3b03060d | ||
|
|
8ee7fe262b | ||
|
|
471c50a40f | ||
|
|
9816088453 | ||
|
|
5dc9572210 | ||
|
|
af311f60f8 | ||
|
|
698baee583 | ||
|
|
c4643eca8b | ||
|
|
eee87b364d | ||
|
|
f16d3e5798 | ||
|
|
4afa4b219e | ||
|
|
9b2c54d27f | ||
|
|
caa2df3e5d | ||
|
|
5d4af888f3 | ||
|
|
9089897d88 | ||
|
|
a1685f533e | ||
|
|
b69abe0f3a | ||
|
|
e77b3e0543 | ||
|
|
859840e9bd | ||
|
|
593b1750f5 | ||
|
|
0662c4140c | ||
|
|
0cbac8d23b | ||
|
|
caa7d55085 | ||
|
|
b738708528 | ||
|
|
48476961ed | ||
|
|
745c091113 | ||
|
|
7175460314 | ||
|
|
c90c43facf | ||
|
|
2407c1bcc8 | ||
|
|
e7213eabb5 | ||
|
|
88a292d711 | ||
|
|
2efd1559aa | ||
|
|
f452b05209 | ||
|
|
a579501d6e | ||
|
|
14b237a734 | ||
|
|
c3aca16a09 | ||
|
|
ddd487c96a | ||
|
|
983bd5d4cb | ||
|
|
a66fdb2413 | ||
|
|
ec6a0304b2 | ||
|
|
87b26225b6 | ||
|
|
94c7bc2e07 | ||
|
|
a7ea010f5c | ||
|
|
b21d7ad01b | ||
|
|
e843bc9b31 | ||
|
|
aa9ea48b24 | ||
|
|
5d6754df05 | ||
|
|
68ad2f81e9 | ||
|
|
de4a2c4be7 | ||
|
|
820f026e17 | ||
|
|
55b5b7fd9d | ||
|
|
068b8fba2d | ||
|
|
3304b22bc4 | ||
|
|
750bfbab5b | ||
|
|
22c0b571e2 | ||
|
|
e9d50e6a32 | ||
|
|
2e44ed0b97 | ||
|
|
af6add5a44 | ||
|
|
9bed75f0c0 | ||
|
|
cdb2c37ae3 | ||
|
|
65a2b27fcc | ||
|
|
0d9028ef13 | ||
|
|
1917df09a2 | ||
|
|
ce4b418603 | ||
|
|
da80041bcb | ||
|
|
a495e7a962 | ||
|
|
9e3840a4d7 | ||
|
|
eb5105fe72 | ||
|
|
62c5fb518c | ||
|
|
d6a558e45a | ||
|
|
ee355a9282 | ||
|
|
536938895a | ||
|
|
adfd50d1fd | ||
|
|
20c6a82338 | ||
|
|
b3f2422b63 | ||
|
|
1538aadb14 | ||
|
|
404244534a | ||
|
|
ea4fc1de8b | ||
|
|
0eb68664f1 | ||
|
|
4eec33b2ed | ||
|
|
b4ed8649f1 | ||
|
|
f7295277f1 | ||
|
|
075eb46ef8 | ||
|
|
2b66e27bfb | ||
|
|
572dcd9a67 | ||
|
|
64965c294b | ||
|
|
0b55e5b16b | ||
|
|
ea5a116203 | ||
|
|
56b9b511c1 | ||
|
|
b5d3913de3 | ||
|
|
79ed5a158e | ||
|
|
323f746d8a | ||
|
|
a2112e07dc | ||
|
|
97ed5d38ab | ||
|
|
c4e158f639 | ||
|
|
8cd4fc6ade | ||
|
|
6bc39c68b8 | ||
|
|
aa99a7d672 | ||
|
|
7befdd0148 | ||
|
|
72b2e084ac | ||
|
|
8b86d58d95 | ||
|
|
8e44a59d0a | ||
|
|
f84ec7c7a0 | ||
|
|
e45956c7c6 | ||
|
|
d8c4482dc4 | ||
|
|
add62a5b40 | ||
|
|
12d6d8f013 | ||
|
|
50b23aa4ed | ||
|
|
fe78b89452 | ||
|
|
ba0530cafb | ||
|
|
8eb8c566c6 | ||
|
|
f860f0279c | ||
|
|
a90da6c7ff | ||
|
|
c2e6ff2cc9 | ||
|
|
7968ae73a0 | ||
|
|
1f6d97440f | ||
|
|
a3ba76f2f4 | ||
|
|
33399f93f5 | ||
|
|
5bdd24299e | ||
|
|
b48d9a0792 | ||
|
|
a40102a79a | ||
|
|
e18b30feab | ||
|
|
403298d474 | ||
|
|
fbeb18ccec | ||
|
|
61f7e4c775 | ||
|
|
1cb8052045 | ||
|
|
f3eddd692c | ||
|
|
ac57932472 | ||
|
|
9d3e384203 | ||
|
|
6921682bcd | ||
|
|
2ef04e6ab2 | ||
|
|
ef944a6758 | ||
|
|
2cdc738eb9 | ||
|
|
66b57da356 | ||
|
|
6b60757721 | ||
|
|
8e1e521a70 | ||
|
|
43f6e6e1af | ||
|
|
b77966abaf | ||
|
|
ef1331be4b | ||
|
|
fd09622c82 | ||
|
|
b7100cc62a |
83
.env.example
83
.env.example
@@ -26,12 +26,25 @@ export BACKOFFICE_DB_PASSWORD="${ADB_PASSWORD}"
|
|||||||
# --- (2b) Spring Boot 백오피스 ---
|
# --- (2b) Spring Boot 백오피스 ---
|
||||||
export BACKOFFICE_ADMIN_USER="admin"
|
export BACKOFFICE_ADMIN_USER="admin"
|
||||||
export BACKOFFICE_ADMIN_PASSWORD="admin"
|
export BACKOFFICE_ADMIN_PASSWORD="admin"
|
||||||
|
# 운영에서는 최초 한 번 생성한 {bcrypt} 해시를 .env에 보관합니다. 이를 사용하면
|
||||||
|
# 지속 로그인 쿠키가 앱 재기동 후에도 유효합니다. 원문 비밀번호 값은 Git에 넣지 마세요.
|
||||||
|
export BACKOFFICE_ADMIN_PASSWORD_HASH=""
|
||||||
|
# 읽기 전용 조회 계정. 활성화하면 ROLE_VIEWER로 로그인 가능하며 POST/PUT/PATCH/DELETE는 차단됩니다.
|
||||||
|
export BACKOFFICE_GUEST_ENABLED="false"
|
||||||
|
export BACKOFFICE_GUEST_USER="guest"
|
||||||
|
export BACKOFFICE_GUEST_PASSWORD=""
|
||||||
|
export BACKOFFICE_GUEST_PASSWORD_HASH=""
|
||||||
# 로컬 HTTP 개발 기본값. 외부 VM 배포 스크립트는 loopback/HTTPS 안전값으로 덮어씁니다.
|
# 로컬 HTTP 개발 기본값. 외부 VM 배포 스크립트는 loopback/HTTPS 안전값으로 덮어씁니다.
|
||||||
export BACKOFFICE_BIND_ADDRESS="0.0.0.0"
|
export BACKOFFICE_BIND_ADDRESS="0.0.0.0"
|
||||||
export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none"
|
export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none"
|
||||||
export BACKOFFICE_REQUIRE_HTTPS="false"
|
export BACKOFFICE_REQUIRE_HTTPS="false"
|
||||||
export BACKOFFICE_SESSION_COOKIE_SECURE="false"
|
export BACKOFFICE_SESSION_COOKIE_SECURE="false"
|
||||||
export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
|
# HTTPS에서만 사용. key는 배포 환경의 secret으로 32자 이상 임의값을 넣고 Git에 저장하지 않습니다.
|
||||||
|
export BACKOFFICE_REMEMBER_ME_ENABLED="false"
|
||||||
|
export BACKOFFICE_REMEMBER_ME_KEY=""
|
||||||
|
export BACKOFFICE_REMEMBER_ME_DAYS="14"
|
||||||
|
# HMM HR 질의는 HMM MCP/DBMS_CLOUD_AI_AGENT를 사용합니다. 기존 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 생성은 막히고 소스 보기만 사용합니다.
|
||||||
@@ -39,18 +52,68 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
|
|||||||
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
||||||
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
||||||
|
|
||||||
|
# Select AI는 프로파일 소유 스키마로 별도 접속합니다.
|
||||||
|
# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
|
||||||
|
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
|
||||||
|
export BACKOFFICE_SELECT_AI_DB_USERNAME=""
|
||||||
|
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
|
||||||
|
export BACKOFFICE_SELECT_AI_PROFILE=""
|
||||||
|
export BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED="true"
|
||||||
|
export BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K="3"
|
||||||
|
# Customer-owned DB view: game aliases, approved profile objects, and valid DB objects.
|
||||||
|
export BACKOFFICE_GAME_SCOPE_ENABLED="false"
|
||||||
|
export BACKOFFICE_GAME_SCOPE_VIEW=""
|
||||||
|
export BACKOFFICE_GAME_SCOPE_MAX_SCOPES="8"
|
||||||
|
|
||||||
|
# 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다.
|
||||||
|
# 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다.
|
||||||
|
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
|
||||||
|
export BACKOFFICE_CATALOG_OBJECTS='[{"key":"customers","tableName":"CUSTOMER","objectType":"TABLE","businessName":"고객","description":"고객 기본 정보"}]'
|
||||||
|
export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
|
||||||
|
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
|
||||||
|
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
|
||||||
|
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
|
||||||
|
export BACKOFFICE_MCP_TOOL_LABEL="업무 데이터 Text2SQL"
|
||||||
|
export BACKOFFICE_MCP_TOOL_DESCRIPTION="승인된 업무 데이터에 대해 읽기 전용 SQL을 생성하고 실행합니다."
|
||||||
|
export BACKOFFICE_MCP_PROMPT_DESCRIPTION="업무 데이터에서 조회할 내용을 자연어로 입력합니다."
|
||||||
|
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME="oracle.select_ai.data_showprompt"
|
||||||
|
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL="업무 데이터 SHOWPROMPT"
|
||||||
|
export BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION="Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다."
|
||||||
|
# Select AI few-shot 예제 SQL 조회·저장 MCP. 운영 환경은 고객별 도구명과 안내문만 변경합니다.
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_NAME="oracle.select_ai.qa_vector_search"
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_LABEL="Select AI 예제 SQL 조회"
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION="현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다."
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME="oracle.select_ai.qa_vector_store"
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL="Select AI 예제 SQL 저장"
|
||||||
|
export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION="검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다."
|
||||||
|
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_NAME="oracle.select_ai.game_scope_resolve"
|
||||||
|
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_LABEL="게임 조회 범위 확인"
|
||||||
|
export BACKOFFICE_MCP_GAME_SCOPE_TOOL_DESCRIPTION="질문의 게임 별칭을 DB 범위 view로 확인하고, SUPPORTED 결과에만 Few-shot NL2SQL을 호출하도록 안내합니다."
|
||||||
|
# 마스킹 관리 대상. objectName/policyName JSON 배열이며, 비우면 어떤 DB 정책도 관리하지 않습니다.
|
||||||
|
export BACKOFFICE_MASKING_POLICIES=''
|
||||||
|
# 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다.
|
||||||
|
export BACKOFFICE_SECURITY_SQL_SCRIPTS=''
|
||||||
|
|
||||||
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||||
export BACKOFFICE_AI_ENABLED="false"
|
export BACKOFFICE_AI_ENABLED="false"
|
||||||
|
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
||||||
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
||||||
export BACKOFFICE_AI_MODEL="" # 예: openai.gpt-5.4-mini
|
export BACKOFFICE_AI_MODEL="" # 예: openai.gpt-5.4-mini
|
||||||
export BACKOFFICE_AI_API_KEY=""
|
export BACKOFFICE_AI_API_KEY=""
|
||||||
export BACKOFFICE_AI_TIMEOUT_SECONDS="30"
|
export BACKOFFICE_AI_TIMEOUT_SECONDS="30"
|
||||||
|
# provider=oci이면 API key를 환경 변수로 복제하지 않고 OCI config profile로 요청에
|
||||||
|
# 서명합니다. POC3_LLM_GPT55_OCI_*/OCI_GENAI_*가 있으면 아래 BACKOFFICE_* 값보다
|
||||||
|
# fallback으로 사용됩니다.
|
||||||
|
export BACKOFFICE_AI_OCI_CONFIG_FILE=""
|
||||||
|
export BACKOFFICE_AI_OCI_PROFILE="DEFAULT"
|
||||||
|
export BACKOFFICE_AI_OCI_REGION=""
|
||||||
|
export BACKOFFICE_AI_OCI_COMPARTMENT_ID=""
|
||||||
|
|
||||||
# 로컬 키 별칭은 .env에만 저장하세요. .env.example에는 원문 키를 넣지 않습니다.
|
# 로컬 키 별칭은 .env에만 저장하세요. .env.example에는 원문 키를 넣지 않습니다.
|
||||||
export VPDTEST1_API_KEY=""
|
export VPDTEST1_API_KEY=""
|
||||||
export VPDTEST2_API_KEY=""
|
export VPDTEST2_API_KEY=""
|
||||||
|
|
||||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) ---
|
# --- (3) 데모용 ADB 엔드유저 비밀번호 (database/adb/07_end_users.sql 에서 사용) ---
|
||||||
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
|
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
|
||||||
# 4명의 데모 유저:
|
# 4명의 데모 유저:
|
||||||
# vpduser_my → MySQL view 만 SELECT 가능
|
# vpduser_my → MySQL view 만 SELECT 가능
|
||||||
@@ -63,7 +126,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 변형 데모용 엔드유저 비밀번호 ---
|
||||||
# sql/adb/13_dds_variant.sql (Oracle 26ai Deep Data Security 재구현) 에서만 사용.
|
# database/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"
|
||||||
@@ -71,8 +134,20 @@ export DDSUSER_BOTH_PASSWORD="DdsGrant#Both26"
|
|||||||
export DDSUSER_NONE_PASSWORD="DdsGrant#None26"
|
export DDSUSER_NONE_PASSWORD="DdsGrant#None26"
|
||||||
export DDSUSER_TOKEN_PASSWORD="DdsGrant#Token26"
|
export DDSUSER_TOKEN_PASSWORD="DdsGrant#Token26"
|
||||||
|
|
||||||
|
# --- (3c) DDS MCP SSE · OCI IAM Confidential Application ---
|
||||||
|
# 실제 값은 .env 또는 배포 환경의 secret store에만 둔다.
|
||||||
|
export DDS_OCI_IAM_CLIENT_ID=""
|
||||||
|
export DDS_OCI_IAM_CLIENT_SECRET=""
|
||||||
|
export DDS_OCI_IAM_DOMAIN_URL="" # 예: https://idcs-<unique>.identity.oraclecloud.com
|
||||||
|
export DDS_OCI_IAM_DATABASE_SCOPE="" # OCI IAM의 DB resource scope
|
||||||
|
export DDS_OCI_IAM_TOKEN_URI="" # 비우면 ${DDS_OCI_IAM_DOMAIN_URL}/oauth2/v1/token
|
||||||
|
export DDS_OCI_IAM_TIMEOUT_SECONDS="10s"
|
||||||
|
export DDS_OCI_IAM_REFRESH_SKEW_SECONDS="60s"
|
||||||
|
export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
|
||||||
|
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
|
||||||
|
|
||||||
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
||||||
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
# database/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"
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -17,6 +17,13 @@ logs/
|
|||||||
# Java / Maven
|
# Java / Maven
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# Python / Streamlit
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Locally downloaded development tools (for example SQLcl)
|
||||||
|
.tools/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
61
README.md
61
README.md
@@ -32,7 +32,7 @@ End-to-End 데모입니다.
|
|||||||
행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만
|
행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만
|
||||||
`email` 컬럼은 항상 `NULL` 로 반환.
|
`email` 컬럼은 항상 `NULL` 로 반환.
|
||||||
|
|
||||||
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`sql/adb/06a_redaction.sql`).
|
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`database/adb/06a_redaction.sql`).
|
||||||
* DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수.
|
* DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수.
|
||||||
|
|
||||||
### 3. 두 가지 구현 — VPD 와 DDS
|
### 3. 두 가지 구현 — VPD 와 DDS
|
||||||
@@ -57,11 +57,47 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 소스 위치와 Git 저장 기준
|
||||||
|
|
||||||
|
현재 이 저장소에는 서로 독립적으로 빌드·실행되는 세 애플리케이션이 있다. 각 제품은
|
||||||
|
최상위 폴더를 하나씩 사용하며 저장소 루트에 프레임워크 `src`를 두지 않는다.
|
||||||
|
|
||||||
|
| 구분 | 저장 위치 | 역할 |
|
||||||
|
|---|---|---|
|
||||||
|
| VPD/ASO/FGA 관리 백오피스 | `vpd-backoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
||||||
|
| VPD 백오피스 화면 | `vpd-backoffice/src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
||||||
|
| VPD 백오피스 CSS/JS | `vpd-backoffice/src/main/resources/static/` | 화면 동작과 스타일 |
|
||||||
|
| VPD/ASO/FGA/ORDS/Select AI SQL | `database/adb/` | ADB 적용·검증 SQL 스크립트 |
|
||||||
|
| 원격 원장 seed/검증 SQL | `database/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 |
|
||||||
|
| DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 |
|
||||||
|
| AI Web Agent Console | `ai-web-agent-console/` | Streamlit 기반 MCP 업무 에이전트 콘솔 |
|
||||||
|
| AI Web Agent Console 진입점 | `ai-web-agent-console/app.py` | MCP discovery, 도구 호출, 답변 합성, 감사 증적 표시 |
|
||||||
|
| AI Web Agent Console 패키지 | `ai-web-agent-console/ai_web_agent_console/` | 인증, 화면, MCP, 모델과 질의 계약 모듈 |
|
||||||
|
| AI Web Agent Console 설정 | `ai-web-agent-console/config/` | 제품, MCP, 모델, 데모 사용자와 질문 JSON |
|
||||||
|
|
||||||
|
Git 기준은 다음과 같다.
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` |
|
||||||
|
| 운영 기준 remote | `gitea` |
|
||||||
|
| 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` |
|
||||||
|
| HMM 기준 branch | `hmm-backoffice` |
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
- HMM 운영·현행화 기준은 Gitea의 `hmm-backoffice` 브랜치다.
|
||||||
|
- 날짜 스냅샷과 PoC 번호는 현재 소스 경로로 사용하지 않는다.
|
||||||
|
- 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 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` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
|
||||||
@@ -74,7 +110,7 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
|
|||||||
## 빠른 시작 (One-Click)
|
## 빠른 시작 (One-Click)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/<you>/vpd-permission-poc.git
|
git clone --branch hmm-backoffice https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git
|
||||||
cd vpd-permission-poc
|
cd vpd-permission-poc
|
||||||
|
|
||||||
# 1) 환경값 채우기
|
# 1) 환경값 채우기
|
||||||
@@ -132,7 +168,7 @@ $EDITOR .env
|
|||||||
|
|
||||||
## 데모 시나리오 — 2×2 source access matrix
|
## 데모 시나리오 — 2×2 source access matrix
|
||||||
|
|
||||||
`sql/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
`database/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
||||||
|
|
||||||
| DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 |
|
| DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
@@ -150,7 +186,7 @@ $EDITOR .env
|
|||||||
(`1=0` predicate) — **deny by default**.
|
(`1=0` predicate) — **deny by default**.
|
||||||
* 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음.
|
* 누구든 원본 테이블 직접 접근 시도 (`@RDS_POSTGRES_LINK` 등) → 권한 없음.
|
||||||
|
|
||||||
`sql/adb/08_tests_user_my.sql` 가 우회 시도 5개 (원격 직접 SELECT, 컨텍스트
|
`database/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 행 수를 가볍게 확인합니다.
|
||||||
|
|
||||||
@@ -162,7 +198,7 @@ $EDITOR .env
|
|||||||
|
|
||||||
## DDS 설정 핵심
|
## DDS 설정 핵심
|
||||||
|
|
||||||
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이
|
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이
|
||||||
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
|
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
|
||||||
|
|
||||||
### 사전 조건
|
### 사전 조건
|
||||||
@@ -222,10 +258,23 @@ 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 검증 헬퍼
|
||||||
├── sql/
|
├── database/
|
||||||
│ ├── 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
|
||||||
|
|||||||
48
ai-web-agent-console/.env.example
Normal file
48
ai-web-agent-console/.env.example
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 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
|
||||||
57
ai-web-agent-console/README.md
Normal file
57
ai-web-agent-console/README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 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
ai-web-agent-console/ai_web_agent_console/__init__.py
Normal file
1
ai-web-agent-console/ai_web_agent_console/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Reusable presentation shell for MCP-backed Streamlit agent consoles."""
|
||||||
190
ai-web-agent-console/ai_web_agent_console/audit.py
Normal file
190
ai-web-agent-console/ai_web_agent_console/audit.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""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)),
|
||||||
|
)
|
||||||
563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
"""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()
|
||||||
80
ai-web-agent-console/ai_web_agent_console/mcp_result.py
Normal file
80
ai-web-agent-console/ai_web_agent_console/mcp_result.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
196
ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py
Normal file
196
ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""LLM-based MCP tool routing.
|
||||||
|
|
||||||
|
The router receives only the user question and the discovered MCP tool
|
||||||
|
descriptors. It never receives MCP bearer tokens or provider credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ai_web_agent_console.oci_genai_sdk import (
|
||||||
|
build_oci_genai_completion_client,
|
||||||
|
temperature_for_model_profile,
|
||||||
|
)
|
||||||
|
from ai_web_agent_console.model_registry import resolve_model_profile
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class McpTool:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
schema: Mapping[str, Any]
|
||||||
|
read_only: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RoutedMcpTool:
|
||||||
|
server_id: str
|
||||||
|
tool: McpTool
|
||||||
|
|
||||||
|
|
||||||
|
class McpToolRouterError(RuntimeError):
|
||||||
|
"""Safe routing error. Must not contain secrets or provider traces."""
|
||||||
|
|
||||||
|
|
||||||
|
def route_mcp_tool_across_servers_with_llm(
|
||||||
|
tools: list[RoutedMcpTool],
|
||||||
|
question: str,
|
||||||
|
*,
|
||||||
|
router_model_profile: str,
|
||||||
|
) -> RoutedMcpTool:
|
||||||
|
"""Select one discovered MCP server/tool pair with OCI GenAI."""
|
||||||
|
|
||||||
|
candidates = list(tools)
|
||||||
|
if not candidates:
|
||||||
|
raise McpToolRouterError("라우팅 가능한 MCP tool이 없습니다.")
|
||||||
|
|
||||||
|
by_key = {
|
||||||
|
"{}::{}".format(candidate.server_id, candidate.tool.name): candidate
|
||||||
|
for candidate in candidates
|
||||||
|
}
|
||||||
|
route_keys = list(by_key)
|
||||||
|
try:
|
||||||
|
profile = resolve_model_profile(router_model_profile)
|
||||||
|
client = build_oci_genai_completion_client(
|
||||||
|
profile.model_id,
|
||||||
|
profile.answer_model_region,
|
||||||
|
profile.answer_model_endpoint,
|
||||||
|
)
|
||||||
|
tool_catalog = [
|
||||||
|
{
|
||||||
|
"route_key": "{}::{}".format(candidate.server_id, candidate.tool.name),
|
||||||
|
"server_id": candidate.server_id,
|
||||||
|
"tool_name": candidate.tool.name,
|
||||||
|
"description": candidate.tool.description[:1000],
|
||||||
|
"input_properties": sorted(
|
||||||
|
(
|
||||||
|
candidate.tool.schema.get("properties", {})
|
||||||
|
if isinstance(
|
||||||
|
candidate.tool.schema.get("properties"), Mapping
|
||||||
|
)
|
||||||
|
else {}
|
||||||
|
).keys()
|
||||||
|
),
|
||||||
|
"read_only": candidate.tool.read_only,
|
||||||
|
}
|
||||||
|
for candidate in candidates
|
||||||
|
]
|
||||||
|
text = client.complete(
|
||||||
|
system_prompt=(
|
||||||
|
"You are an MCP server and tool router. Choose exactly one "
|
||||||
|
"server/tool route for the user question from the discovered "
|
||||||
|
"routes. Return only JSON that matches the schema. Never "
|
||||||
|
"request or expose bearer tokens. Do not invent server ids or "
|
||||||
|
"tool names."
|
||||||
|
),
|
||||||
|
user_prompt=json.dumps(
|
||||||
|
{
|
||||||
|
"question": question,
|
||||||
|
"routes": tool_catalog,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
response_schema={
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"required": ["route_key"],
|
||||||
|
"properties": {
|
||||||
|
"route_key": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": route_keys,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
max_tokens=256,
|
||||||
|
temperature=temperature_for_model_profile(profile),
|
||||||
|
)
|
||||||
|
routed = json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
raise McpToolRouterError("LLM tool router 호출에 실패했습니다.") from None
|
||||||
|
|
||||||
|
if not isinstance(routed, Mapping):
|
||||||
|
raise McpToolRouterError("LLM tool router 응답 형식이 올바르지 않습니다.")
|
||||||
|
selected_key = str(routed.get("route_key") or "").strip()
|
||||||
|
selected = by_key.get(selected_key)
|
||||||
|
if selected is None:
|
||||||
|
raise McpToolRouterError("LLM tool router가 허용되지 않은 route를 선택했습니다.")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def route_mcp_tool_with_llm(
|
||||||
|
tools: list[McpTool],
|
||||||
|
question: str,
|
||||||
|
*,
|
||||||
|
preferred_tool: str,
|
||||||
|
tool_allowlist: tuple[str, ...],
|
||||||
|
router_model_profile: str,
|
||||||
|
) -> McpTool:
|
||||||
|
"""Backward-compatible single-server routing helper."""
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
tool for tool in tools if not tool_allowlist or tool.name in tool_allowlist
|
||||||
|
]
|
||||||
|
routed = route_mcp_tool_across_servers_with_llm(
|
||||||
|
[RoutedMcpTool(server_id="default", tool=tool) for tool in candidates],
|
||||||
|
question,
|
||||||
|
router_model_profile=router_model_profile,
|
||||||
|
)
|
||||||
|
return routed.tool
|
||||||
|
|
||||||
|
|
||||||
|
def build_mcp_tool_arguments(
|
||||||
|
tool: McpTool,
|
||||||
|
question: str,
|
||||||
|
limit: int,
|
||||||
|
*,
|
||||||
|
preferred_tool: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build bounded tool arguments from the selected tool schema."""
|
||||||
|
|
||||||
|
properties = tool.schema.get("properties")
|
||||||
|
if not isinstance(properties, Mapping):
|
||||||
|
properties = {}
|
||||||
|
|
||||||
|
# A server's default/preferred tool still has to obey its discovered schema.
|
||||||
|
# HMM tools use `query` and `term`; forcing the legacy `prompt`/`limit` shape
|
||||||
|
# makes an otherwise valid tool fail argument validation.
|
||||||
|
del preferred_tool
|
||||||
|
input_name = next(
|
||||||
|
(name for name in ("prompt", "question", "query", "term", "text") if name in properties),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if not input_name:
|
||||||
|
required = tool.schema.get("required")
|
||||||
|
if isinstance(required, list):
|
||||||
|
input_name = next(
|
||||||
|
(
|
||||||
|
str(name)
|
||||||
|
for name in required
|
||||||
|
if isinstance(properties.get(str(name)), Mapping)
|
||||||
|
and properties[str(name)].get("type") == "string"
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if input_name:
|
||||||
|
args: dict[str, Any] = {input_name: question}
|
||||||
|
if "max_evidence" in properties:
|
||||||
|
args["max_evidence"] = min(limit, 10)
|
||||||
|
elif "max_rows" in properties:
|
||||||
|
args["max_rows"] = limit
|
||||||
|
elif "limit" in properties:
|
||||||
|
args["limit"] = limit
|
||||||
|
return args
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"McpTool",
|
||||||
|
"McpToolRouterError",
|
||||||
|
"RoutedMcpTool",
|
||||||
|
"build_mcp_tool_arguments",
|
||||||
|
"route_mcp_tool_across_servers_with_llm",
|
||||||
|
"route_mcp_tool_with_llm",
|
||||||
|
]
|
||||||
434
ai-web-agent-console/ai_web_agent_console/model_registry.py
Normal file
434
ai-web-agent-console/ai_web_agent_console/model_registry.py
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
"""8512/8513 전용 AI Web Agent Console model profile registry.
|
||||||
|
|
||||||
|
이 registry는 모델 metadata만 관리한다. ``provider=oci``는 모델의 출처를 뜻하며
|
||||||
|
``AI_WEB_AGENT_CONSOLE_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재
|
||||||
|
MCP 실행 경로는 계속 ``mock``일 수 있다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping, Optional
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REGISTRY_PATH = ROOT / "config" / "model_profiles.json"
|
||||||
|
DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci"
|
||||||
|
MODEL_PROFILE_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE"
|
||||||
|
MODEL_PROFILE_DEFAULT_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE_DEFAULT"
|
||||||
|
EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b")
|
||||||
|
MODEL_PROFILE_ALIASES = {
|
||||||
|
"gpt54_mini": "gpt54_mini_oci",
|
||||||
|
"llama33": "llama33_70b",
|
||||||
|
}
|
||||||
|
EXPECTED_SOURCE_TAG = "poc_2-gpt55-oci-partial"
|
||||||
|
EXPECTED_SOURCE_COMMIT = "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f"
|
||||||
|
_EXPECTED_ANSWER_MODEL_ROUTES = {
|
||||||
|
"gpt55_oci": (
|
||||||
|
"openai.gpt-5.5",
|
||||||
|
"us-chicago-1",
|
||||||
|
"OPENAI_GPT_5_5_CHAT",
|
||||||
|
"OCI_REGIONAL_DEFAULT",
|
||||||
|
),
|
||||||
|
"gpt54_mini_oci": (
|
||||||
|
"openai.gpt-5.4-mini",
|
||||||
|
"us-chicago-1",
|
||||||
|
"OPENAI_GPT_5_4_MINI_CHAT",
|
||||||
|
"OCI_REGIONAL_DEFAULT",
|
||||||
|
),
|
||||||
|
"grok43": (
|
||||||
|
"xai.grok-4.3",
|
||||||
|
"us-chicago-1",
|
||||||
|
"XAI_GROK_4_3_CHAT",
|
||||||
|
"OCI_REGIONAL_DEFAULT",
|
||||||
|
),
|
||||||
|
"llama4_maverick": (
|
||||||
|
"meta.llama-4-maverick-17b-128e-instruct-fp8",
|
||||||
|
"us-chicago-1",
|
||||||
|
"META_LLAMA_4_MAVERICK_CHAT",
|
||||||
|
"OCI_REGIONAL_DEFAULT",
|
||||||
|
),
|
||||||
|
"llama33_70b": (
|
||||||
|
"meta.llama-3.3-70b-instruct",
|
||||||
|
"us-chicago-1",
|
||||||
|
"META_LLAMA_3_3_70B_CHAT",
|
||||||
|
"OCI_REGIONAL_DEFAULT",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
_MODEL_KEY = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
|
||||||
|
_MODEL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||||
|
_ANSWER_MODEL_ID_ALIAS = re.compile(r"^[A-Z][A-Z0-9_]{1,127}$")
|
||||||
|
_OCI_REGION = re.compile(r"^[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*$")
|
||||||
|
_OCI_REGIONAL_ENDPOINT = re.compile(
|
||||||
|
r"^https://inference\.generativeai\."
|
||||||
|
r"(?P<region>[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*)\.oci\.oraclecloud\.com$"
|
||||||
|
)
|
||||||
|
_ORACLE_IDENTIFIER = re.compile(r"^[A-Z][A-Z0-9_$#]{0,127}$")
|
||||||
|
_SOURCE_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$")
|
||||||
|
_VERIFICATION_STATUSES = frozenset(
|
||||||
|
{"VERIFIED", "PARTIAL_VERIFIED", "VERIFIED_WITH_WARNINGS"}
|
||||||
|
)
|
||||||
|
_REQUIRED_PROFILE_FIELDS = (
|
||||||
|
"model_key",
|
||||||
|
"display_name",
|
||||||
|
"provider",
|
||||||
|
"model_id",
|
||||||
|
"answer_model_id_alias",
|
||||||
|
"answer_model_region",
|
||||||
|
"answer_model_endpoint_mode",
|
||||||
|
"poc2_select_ai_profile",
|
||||||
|
"poc2_native_agent_team",
|
||||||
|
"verification_status",
|
||||||
|
"default_for_console",
|
||||||
|
"source_tag",
|
||||||
|
"notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROFILE_ROUTE_ENV_KEYS = {
|
||||||
|
"gpt55_oci": (
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_MODEL_ID",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_REGION",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_ENDPOINT",
|
||||||
|
),
|
||||||
|
"gpt54_mini_oci": (
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT",
|
||||||
|
),
|
||||||
|
"grok43": (
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_MODEL_ID",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_REGION",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_ENDPOINT",
|
||||||
|
),
|
||||||
|
"llama4_maverick": (
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_MODEL_ID",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_REGION",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_ENDPOINT",
|
||||||
|
),
|
||||||
|
"llama33_70b": (
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_MODEL_ID",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_REGION",
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_ENDPOINT",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _regional_endpoint(region: str) -> str:
|
||||||
|
return "https://inference.generativeai.%s.oci.oraclecloud.com" % region
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_host_alias(region: str) -> str:
|
||||||
|
return "OCI_GENAI_INFERENCE_%s" % region.upper().replace("-", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _env_override(
|
||||||
|
environ: Mapping[str, str],
|
||||||
|
key: str,
|
||||||
|
default: str,
|
||||||
|
) -> str:
|
||||||
|
if key not in environ:
|
||||||
|
return default
|
||||||
|
value = environ.get(key)
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ValueError("answer model route override is invalid")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ModelProfile:
|
||||||
|
"""UI와 workflow가 공유하는 비밀값 없는 model metadata."""
|
||||||
|
|
||||||
|
model_key: str
|
||||||
|
display_name: str
|
||||||
|
provider: str
|
||||||
|
model_id: str
|
||||||
|
answer_model_id_alias: str
|
||||||
|
answer_model_region: str
|
||||||
|
answer_model_endpoint_mode: str
|
||||||
|
answer_model_endpoint: str = field(repr=False)
|
||||||
|
poc2_select_ai_profile: str
|
||||||
|
poc2_native_agent_team: str
|
||||||
|
verification_status: str
|
||||||
|
default_for_console: bool
|
||||||
|
source_tag: str
|
||||||
|
notes: str
|
||||||
|
display_order: int = 999
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile":
|
||||||
|
missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value]
|
||||||
|
if missing:
|
||||||
|
raise ValueError("AI Web Agent Console model profile fields are missing")
|
||||||
|
if not isinstance(value.get("default_for_console"), bool):
|
||||||
|
raise ValueError("default_for_console must be boolean")
|
||||||
|
order = value.get("display_order", 999)
|
||||||
|
if isinstance(order, bool) or not isinstance(order, int) or order < 0:
|
||||||
|
raise ValueError("model profile display_order is invalid")
|
||||||
|
|
||||||
|
answer_model_region = str(value["answer_model_region"]).strip().lower()
|
||||||
|
profile = cls(
|
||||||
|
model_key=str(value["model_key"]).strip().lower(),
|
||||||
|
display_name=str(value["display_name"]).strip(),
|
||||||
|
provider=str(value["provider"]).strip().lower(),
|
||||||
|
model_id=str(value["model_id"]).strip(),
|
||||||
|
answer_model_id_alias=str(value["answer_model_id_alias"])
|
||||||
|
.strip()
|
||||||
|
.upper(),
|
||||||
|
answer_model_region=answer_model_region,
|
||||||
|
answer_model_endpoint_mode=str(
|
||||||
|
value["answer_model_endpoint_mode"]
|
||||||
|
).strip().upper(),
|
||||||
|
answer_model_endpoint=_regional_endpoint(answer_model_region),
|
||||||
|
poc2_select_ai_profile=str(value["poc2_select_ai_profile"])
|
||||||
|
.strip()
|
||||||
|
.upper(),
|
||||||
|
poc2_native_agent_team=str(value["poc2_native_agent_team"])
|
||||||
|
.strip()
|
||||||
|
.upper(),
|
||||||
|
verification_status=str(value["verification_status"]).strip().upper(),
|
||||||
|
default_for_console=value["default_for_console"],
|
||||||
|
source_tag=str(value["source_tag"]).strip(),
|
||||||
|
notes=str(value["notes"]).strip(),
|
||||||
|
display_order=order,
|
||||||
|
)
|
||||||
|
if not _MODEL_KEY.fullmatch(profile.model_key):
|
||||||
|
raise ValueError("model profile key is invalid")
|
||||||
|
if not profile.display_name or len(profile.display_name) > 128:
|
||||||
|
raise ValueError("model profile display name is invalid")
|
||||||
|
if profile.provider != "oci":
|
||||||
|
raise ValueError("unsupported model provider")
|
||||||
|
if not _MODEL_ID.fullmatch(profile.model_id):
|
||||||
|
raise ValueError("model id is invalid")
|
||||||
|
if not _ANSWER_MODEL_ID_ALIAS.fullmatch(profile.answer_model_id_alias):
|
||||||
|
raise ValueError("answer model id alias is invalid")
|
||||||
|
if not _OCI_REGION.fullmatch(profile.answer_model_region):
|
||||||
|
raise ValueError("answer model region is invalid")
|
||||||
|
if profile.answer_model_endpoint_mode != "OCI_REGIONAL_DEFAULT":
|
||||||
|
raise ValueError("answer model endpoint mode is invalid")
|
||||||
|
endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch(
|
||||||
|
profile.answer_model_endpoint
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
endpoint_match is None
|
||||||
|
or endpoint_match.group("region") != profile.answer_model_region
|
||||||
|
):
|
||||||
|
raise ValueError("answer model endpoint is invalid")
|
||||||
|
if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_select_ai_profile):
|
||||||
|
raise ValueError("PoC_2 Select AI profile mapping is invalid")
|
||||||
|
if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_native_agent_team):
|
||||||
|
raise ValueError("PoC_2 Native Agent team mapping is invalid")
|
||||||
|
if profile.verification_status not in _VERIFICATION_STATUSES:
|
||||||
|
raise ValueError("model verification status is invalid")
|
||||||
|
if not _SOURCE_TAG.fullmatch(profile.source_tag):
|
||||||
|
raise ValueError("model profile source tag is invalid")
|
||||||
|
if not profile.notes or len(profile.notes) > 1_000:
|
||||||
|
raise ValueError("model profile notes are invalid")
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def with_answer_route_overrides(
|
||||||
|
self,
|
||||||
|
environ: Mapping[str, str],
|
||||||
|
) -> "ModelProfile":
|
||||||
|
"""Apply only this profile's validated, non-secret OCI route settings."""
|
||||||
|
|
||||||
|
keys = _PROFILE_ROUTE_ENV_KEYS.get(self.model_key)
|
||||||
|
if keys is None:
|
||||||
|
raise ValueError("answer model route is not registered")
|
||||||
|
model_id = _env_override(environ, keys[0], self.model_id)
|
||||||
|
region = _env_override(environ, keys[1], self.answer_model_region).lower()
|
||||||
|
endpoint = _env_override(
|
||||||
|
environ,
|
||||||
|
keys[2],
|
||||||
|
_regional_endpoint(region),
|
||||||
|
)
|
||||||
|
if not _MODEL_ID.fullmatch(model_id):
|
||||||
|
raise ValueError("answer model route override is invalid")
|
||||||
|
if not _OCI_REGION.fullmatch(region):
|
||||||
|
raise ValueError("answer model route override is invalid")
|
||||||
|
endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch(endpoint)
|
||||||
|
if endpoint_match is None or endpoint_match.group("region") != region:
|
||||||
|
raise ValueError("answer model route override is invalid")
|
||||||
|
return replace(
|
||||||
|
self,
|
||||||
|
model_id=model_id,
|
||||||
|
answer_model_region=region,
|
||||||
|
answer_model_endpoint=endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
def public_metadata(self) -> dict[str, object]:
|
||||||
|
"""System Details에 투영 가능한 비밀값 없는 metadata를 반환한다."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"model_key": self.model_key,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"provider": self.provider,
|
||||||
|
"answer_model_id_alias": self.answer_model_id_alias,
|
||||||
|
"answer_model_region": self.answer_model_region,
|
||||||
|
"answer_model_endpoint_mode": self.answer_model_endpoint_mode,
|
||||||
|
"answer_model_endpoint_host_alias": _endpoint_host_alias(
|
||||||
|
self.answer_model_region
|
||||||
|
),
|
||||||
|
"poc2_select_ai_profile": self.poc2_select_ai_profile,
|
||||||
|
"poc2_native_agent_team": self.poc2_native_agent_team,
|
||||||
|
"verification_status": self.verification_status,
|
||||||
|
"default_for_console": self.default_for_console,
|
||||||
|
"source_tag": self.source_tag,
|
||||||
|
"notes": self.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ModelProfileRegistry:
|
||||||
|
"""검증된 AI Web Agent Console model profile 집합."""
|
||||||
|
|
||||||
|
profiles: tuple[ModelProfile, ...]
|
||||||
|
default_model_profile: str
|
||||||
|
registry_name: str
|
||||||
|
source_commit: str
|
||||||
|
schema_version: int = 1
|
||||||
|
|
||||||
|
def by_key(self, model_key: object) -> ModelProfile:
|
||||||
|
candidate = str(model_key or "").strip().lower()
|
||||||
|
candidate = MODEL_PROFILE_ALIASES.get(candidate, candidate)
|
||||||
|
for profile in self.profiles:
|
||||||
|
if profile.model_key == candidate:
|
||||||
|
return profile
|
||||||
|
# 사용자 입력이나 환경변수 원문을 오류에 반사하지 않는다.
|
||||||
|
raise ValueError("model profile is not registered")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_profile(self) -> ModelProfile:
|
||||||
|
return self.by_key(self.default_model_profile)
|
||||||
|
|
||||||
|
def selector_options(self) -> tuple[ModelProfile, ...]:
|
||||||
|
return tuple(sorted(self.profiles, key=lambda item: item.display_order))
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
requested: object = None,
|
||||||
|
*,
|
||||||
|
environ: Optional[Mapping[str, str]] = None,
|
||||||
|
) -> ModelProfile:
|
||||||
|
"""명시 요청은 엄격히 검증하고 환경 기본값은 안전하게 fallback한다."""
|
||||||
|
|
||||||
|
source = os.environ if environ is None else environ
|
||||||
|
if requested is not None and str(requested).strip():
|
||||||
|
return self.by_key(requested).with_answer_route_overrides(source)
|
||||||
|
|
||||||
|
for name in (MODEL_PROFILE_ENV, MODEL_PROFILE_DEFAULT_ENV):
|
||||||
|
candidate = source.get(name)
|
||||||
|
if not candidate or not candidate.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
profile = self.by_key(candidate)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return profile.with_answer_route_overrides(source)
|
||||||
|
return self.default_profile.with_answer_route_overrides(source)
|
||||||
|
|
||||||
|
|
||||||
|
def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry:
|
||||||
|
"""JSON registry를 매 호출마다 검증해 반환한다."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise ValueError("AI Web Agent Console model profile registry cannot be loaded") from exc
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise ValueError("AI Web Agent Console model profile registry must be an object")
|
||||||
|
raw_profiles = payload.get("profiles")
|
||||||
|
if not isinstance(raw_profiles, list) or not raw_profiles:
|
||||||
|
raise ValueError("AI Web Agent Console model profile registry has no profiles")
|
||||||
|
profiles = tuple(
|
||||||
|
ModelProfile.from_mapping(item)
|
||||||
|
for item in raw_profiles
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
)
|
||||||
|
if len(profiles) != len(raw_profiles):
|
||||||
|
raise ValueError("AI Web Agent Console model profile registry contains an invalid profile")
|
||||||
|
keys = tuple(item.model_key for item in profiles)
|
||||||
|
if len(set(keys)) != len(keys):
|
||||||
|
raise ValueError("AI Web Agent Console model profile keys must be unique")
|
||||||
|
if len({item.display_name for item in profiles}) != len(profiles):
|
||||||
|
raise ValueError("AI Web Agent Console model profile display names must be unique")
|
||||||
|
defaults = tuple(item.model_key for item in profiles if item.default_for_console)
|
||||||
|
configured_default = str(payload.get("default_model_profile") or "").strip().lower()
|
||||||
|
if defaults != (configured_default,):
|
||||||
|
raise ValueError("AI Web Agent Console model profile default is inconsistent")
|
||||||
|
if configured_default != DEFAULT_MODEL_PROFILE_KEY:
|
||||||
|
raise ValueError("AI Web Agent Console GPT-5.5 default contract is not satisfied")
|
||||||
|
if not set(EXISTING_MODEL_PROFILE_KEYS).issubset(keys):
|
||||||
|
raise ValueError("existing AI Web Agent Console selector models are missing")
|
||||||
|
actual_answer_routes = {
|
||||||
|
item.model_key: (
|
||||||
|
item.model_id,
|
||||||
|
item.answer_model_region,
|
||||||
|
item.answer_model_id_alias,
|
||||||
|
item.answer_model_endpoint_mode,
|
||||||
|
)
|
||||||
|
for item in profiles
|
||||||
|
}
|
||||||
|
if actual_answer_routes != _EXPECTED_ANSWER_MODEL_ROUTES:
|
||||||
|
raise ValueError("AI Web Agent Console answer model route mapping is inconsistent")
|
||||||
|
if str(payload.get("source_commit") or "").strip() != EXPECTED_SOURCE_COMMIT:
|
||||||
|
raise ValueError("PoC_2 source commit is inconsistent")
|
||||||
|
if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles):
|
||||||
|
raise ValueError("PoC_2 source tag is inconsistent")
|
||||||
|
if payload.get("schema_version") != 1:
|
||||||
|
raise ValueError("unsupported AI Web Agent Console model profile registry schema")
|
||||||
|
registry_name = str(payload.get("registry_name") or "").strip()
|
||||||
|
if not registry_name:
|
||||||
|
raise ValueError("AI Web Agent Console model profile registry name is missing")
|
||||||
|
return ModelProfileRegistry(
|
||||||
|
profiles=profiles,
|
||||||
|
default_model_profile=configured_default,
|
||||||
|
registry_name=registry_name,
|
||||||
|
source_commit=EXPECTED_SOURCE_COMMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model_profile(
|
||||||
|
requested: object = None,
|
||||||
|
*,
|
||||||
|
environ: Optional[Mapping[str, str]] = None,
|
||||||
|
) -> ModelProfile:
|
||||||
|
return load_model_registry().resolve(requested, environ=environ)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model_profile_key(
|
||||||
|
requested: object = None,
|
||||||
|
*,
|
||||||
|
environ: Optional[Mapping[str, str]] = None,
|
||||||
|
) -> str:
|
||||||
|
return resolve_model_profile(requested, environ=environ).model_key
|
||||||
|
|
||||||
|
|
||||||
|
def is_registered_model_profile(value: object) -> bool:
|
||||||
|
try:
|
||||||
|
load_model_registry().by_key(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_MODEL_PROFILE_KEY",
|
||||||
|
"EXISTING_MODEL_PROFILE_KEYS",
|
||||||
|
"EXPECTED_SOURCE_COMMIT",
|
||||||
|
"EXPECTED_SOURCE_TAG",
|
||||||
|
"MODEL_PROFILE_DEFAULT_ENV",
|
||||||
|
"MODEL_PROFILE_ENV",
|
||||||
|
"MODEL_PROFILE_ALIASES",
|
||||||
|
"ModelProfile",
|
||||||
|
"ModelProfileRegistry",
|
||||||
|
"REGISTRY_PATH",
|
||||||
|
"is_registered_model_profile",
|
||||||
|
"load_model_registry",
|
||||||
|
"resolve_model_profile",
|
||||||
|
"resolve_model_profile_key",
|
||||||
|
]
|
||||||
277
ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py
Normal file
277
ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""Common OCI Generative AI chat-completion SDK boundary.
|
||||||
|
|
||||||
|
This module is intentionally small: callers provide a validated model route
|
||||||
|
and a JSON schema, and this boundary performs one OCI GenAI chat call. It
|
||||||
|
does not know about MCP, Streamlit, business payloads, or bearer tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Dict, Optional, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DOTENV_PATH = ROOT / ".env"
|
||||||
|
ALLOWED_OCI_SETTINGS = frozenset(
|
||||||
|
{
|
||||||
|
"OCI_AUTH_TYPE",
|
||||||
|
"OCI_CONFIG_FILE",
|
||||||
|
"OCI_GENAI_COMPARTMENT_ID",
|
||||||
|
"OCI_PROFILE",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# OCI permits the tenancy OCID when the root compartment is selected.
|
||||||
|
_COMPARTMENT_OR_ROOT_ID = re.compile(
|
||||||
|
r"^ocid1\.(?:compartment|tenancy)\.[A-Za-z0-9._-]+$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompletionClient(Protocol):
|
||||||
|
"""Minimal completion client contract shared by app layers."""
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
system_prompt: str,
|
||||||
|
user_prompt: str,
|
||||||
|
response_schema: Mapping[str, object],
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: Optional[float],
|
||||||
|
) -> str:
|
||||||
|
"""Return the assistant message text."""
|
||||||
|
|
||||||
|
|
||||||
|
def read_allowed_dotenv(path: Optional[Path] = None) -> Dict[str, str]:
|
||||||
|
"""Read only non-secret OCI routing/auth-mode settings from .env."""
|
||||||
|
|
||||||
|
selected_path = DOTENV_PATH if path is None else path
|
||||||
|
try:
|
||||||
|
lines = selected_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
values: Dict[str, str] = {}
|
||||||
|
for raw_line in lines:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.startswith("export "):
|
||||||
|
line = line[7:].lstrip()
|
||||||
|
key, separator, raw_value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
if not separator or key not in ALLOWED_OCI_SETTINGS:
|
||||||
|
continue
|
||||||
|
value = raw_value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||||
|
value = value[1:-1]
|
||||||
|
if "\x00" not in value and "\n" not in value and "\r" not in value:
|
||||||
|
values[key] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, repr=False)
|
||||||
|
class OCISettings:
|
||||||
|
auth_type: str
|
||||||
|
config_file: str
|
||||||
|
profile: str
|
||||||
|
compartment_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_oci_settings() -> OCISettings:
|
||||||
|
"""Resolve OCI GenAI settings from safe .env keys and environment."""
|
||||||
|
|
||||||
|
values = read_allowed_dotenv()
|
||||||
|
for key in ALLOWED_OCI_SETTINGS:
|
||||||
|
value = os.environ.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
values[key] = value.strip()
|
||||||
|
|
||||||
|
auth_type = values.get("OCI_AUTH_TYPE", "config_file").strip().casefold()
|
||||||
|
auth_type = auth_type.replace("-", "_")
|
||||||
|
if auth_type in {"api_key", "config", "config_file"}:
|
||||||
|
auth_type = "config_file"
|
||||||
|
elif auth_type not in {"instance_principal", "resource_principal"}:
|
||||||
|
raise ValueError("unsupported OCI authentication mode")
|
||||||
|
|
||||||
|
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
|
||||||
|
if not _COMPARTMENT_OR_ROOT_ID.fullmatch(compartment_id):
|
||||||
|
raise ValueError("OCI Generative AI compartment is not configured")
|
||||||
|
return OCISettings(
|
||||||
|
auth_type=auth_type,
|
||||||
|
config_file=values.get("OCI_CONFIG_FILE", "~/.oci/config").strip(),
|
||||||
|
profile=values.get("OCI_PROFILE", "DEFAULT").strip() or "DEFAULT",
|
||||||
|
compartment_id=compartment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OCICompletionClient:
|
||||||
|
"""Minimal OCI GenericChatRequest adapter."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: OCISettings,
|
||||||
|
model_id: str,
|
||||||
|
region: str,
|
||||||
|
endpoint: str,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
import oci
|
||||||
|
from oci.generative_ai_inference import GenerativeAiInferenceClient
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
raise RuntimeError("OCI SDK is unavailable") from None
|
||||||
|
|
||||||
|
kwargs: Dict[str, object] = {}
|
||||||
|
if settings.auth_type == "config_file":
|
||||||
|
try:
|
||||||
|
config = oci.config.from_file(
|
||||||
|
file_location=os.path.expandvars(
|
||||||
|
os.path.expanduser(settings.config_file)
|
||||||
|
),
|
||||||
|
profile_name=settings.profile,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError("OCI SDK configuration is unavailable") from None
|
||||||
|
config["region"] = region
|
||||||
|
elif settings.auth_type == "instance_principal":
|
||||||
|
try:
|
||||||
|
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError("OCI signer is unavailable") from None
|
||||||
|
config = {"region": region}
|
||||||
|
kwargs["signer"] = signer
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
signer = oci.auth.signers.get_resource_principals_signer()
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError("OCI signer is unavailable") from None
|
||||||
|
config = {"region": region}
|
||||||
|
kwargs["signer"] = signer
|
||||||
|
if not config.get("region"):
|
||||||
|
raise RuntimeError("OCI region is unavailable")
|
||||||
|
kwargs["service_endpoint"] = endpoint
|
||||||
|
try:
|
||||||
|
self._client = GenerativeAiInferenceClient(config, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError("OCI Generative AI client is unavailable") from None
|
||||||
|
self._compartment_id = settings.compartment_id
|
||||||
|
self._model_id = model_id
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
system_prompt: str,
|
||||||
|
user_prompt: str,
|
||||||
|
response_schema: Mapping[str, object],
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: Optional[float],
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
from oci.generative_ai_inference.models import (
|
||||||
|
ChatDetails,
|
||||||
|
GenericChatRequest,
|
||||||
|
JsonSchemaResponseFormat,
|
||||||
|
OnDemandServingMode,
|
||||||
|
ResponseJsonSchema,
|
||||||
|
SystemMessage,
|
||||||
|
TextContent,
|
||||||
|
UserMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = ResponseJsonSchema(
|
||||||
|
name="oci_genai_json_response",
|
||||||
|
description="Strict JSON response",
|
||||||
|
schema=dict(response_schema),
|
||||||
|
is_strict=True,
|
||||||
|
)
|
||||||
|
request_options: Dict[str, object] = {
|
||||||
|
"api_format": "GENERIC",
|
||||||
|
"messages": [
|
||||||
|
SystemMessage(content=[TextContent(text=system_prompt)]),
|
||||||
|
UserMessage(content=[TextContent(text=user_prompt)]),
|
||||||
|
],
|
||||||
|
"max_completion_tokens": max_tokens,
|
||||||
|
"is_stream": False,
|
||||||
|
"response_format": JsonSchemaResponseFormat(json_schema=schema),
|
||||||
|
}
|
||||||
|
if temperature is not None:
|
||||||
|
request_options["temperature"] = temperature
|
||||||
|
request = GenericChatRequest(**request_options)
|
||||||
|
details = ChatDetails(
|
||||||
|
compartment_id=self._compartment_id,
|
||||||
|
serving_mode=OnDemandServingMode(model_id=self._model_id),
|
||||||
|
chat_request=request,
|
||||||
|
)
|
||||||
|
response = self._client.chat(details)
|
||||||
|
data = getattr(response, "data", None)
|
||||||
|
chat_response = getattr(data, "chat_response", None)
|
||||||
|
choices = getattr(chat_response, "choices", None)
|
||||||
|
if not isinstance(choices, Sequence) or not choices:
|
||||||
|
raise RuntimeError("OCI response has no choice")
|
||||||
|
message = getattr(choices[0], "message", None)
|
||||||
|
content = getattr(message, "content", None)
|
||||||
|
if not isinstance(content, Sequence) or isinstance(
|
||||||
|
content, (str, bytes, bytearray)
|
||||||
|
) or not content:
|
||||||
|
raise RuntimeError("OCI response has no content")
|
||||||
|
text = getattr(content[0], "text", None)
|
||||||
|
if not isinstance(text, str):
|
||||||
|
raise RuntimeError("OCI response content is invalid")
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError("OCI GenAI completion call failed") from None
|
||||||
|
|
||||||
|
|
||||||
|
def build_oci_genai_completion_client(
|
||||||
|
model_id: str,
|
||||||
|
region: str,
|
||||||
|
endpoint: str,
|
||||||
|
) -> CompletionClient:
|
||||||
|
"""Build a completion client for one validated model route."""
|
||||||
|
|
||||||
|
return _cached_oci_genai_completion_client(
|
||||||
|
load_oci_settings(),
|
||||||
|
model_id,
|
||||||
|
region,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=16)
|
||||||
|
def _cached_oci_genai_completion_client(
|
||||||
|
settings: OCISettings,
|
||||||
|
model_id: str,
|
||||||
|
region: str,
|
||||||
|
endpoint: str,
|
||||||
|
) -> CompletionClient:
|
||||||
|
"""Reuse OCI GenAI clients within one Python process."""
|
||||||
|
|
||||||
|
return OCICompletionClient(settings, model_id, region, endpoint)
|
||||||
|
|
||||||
|
|
||||||
|
def temperature_for_model_key(model_key: object) -> Optional[float]:
|
||||||
|
"""Return provider-compatible temperature for one registered model key."""
|
||||||
|
|
||||||
|
key = str(model_key or "").strip().lower()
|
||||||
|
if key.startswith("gpt"):
|
||||||
|
return None
|
||||||
|
return 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def temperature_for_model_profile(profile: object) -> Optional[float]:
|
||||||
|
return temperature_for_model_key(getattr(profile, "model_key", profile))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ALLOWED_OCI_SETTINGS",
|
||||||
|
"CompletionClient",
|
||||||
|
"OCICompletionClient",
|
||||||
|
"OCISettings",
|
||||||
|
"build_oci_genai_completion_client",
|
||||||
|
"load_oci_settings",
|
||||||
|
"read_allowed_dotenv",
|
||||||
|
"temperature_for_model_key",
|
||||||
|
"temperature_for_model_profile",
|
||||||
|
]
|
||||||
176
ai-web-agent-console/ai_web_agent_console/presentation.py
Normal file
176
ai-web-agent-console/ai_web_agent_console/presentation.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Shared, intentionally small Streamlit presentation primitives."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .profile import AppProfile
|
||||||
|
|
||||||
|
|
||||||
|
def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
||||||
|
"""Apply one predictable light theme from the product profile."""
|
||||||
|
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<style>
|
||||||
|
:root {{ color-scheme: light !important; --console-primary: {escape(profile.primary_color)};
|
||||||
|
--console-text: {escape(profile.text_color)}; --console-muted: {escape(profile.muted_color)};
|
||||||
|
--console-border: {escape(profile.border_color)}; }}
|
||||||
|
html, body, [data-testid="stAppViewContainer"], .stApp {{ background:#fff !important;
|
||||||
|
color:var(--console-text) !important; color-scheme:light !important;
|
||||||
|
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
|
||||||
|
header[data-testid="stHeader"] {{ display:none !important; }}
|
||||||
|
.block-container {{ max-width:1180px; padding:2rem 3rem 4rem; }}
|
||||||
|
section[data-testid="stSidebar"], section[data-testid="stSidebar"] > div {{ background:#fff !important; }}
|
||||||
|
section[data-testid="stSidebar"] {{ border-right:1px solid var(--console-border); }}
|
||||||
|
[data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span,
|
||||||
|
[data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1,
|
||||||
|
[data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3,
|
||||||
|
[data-testid="stAppViewContainer"] li, [data-testid="stAppViewContainer"] dt,
|
||||||
|
[data-testid="stAppViewContainer"] dd, [data-testid="stAppViewContainer"] blockquote,
|
||||||
|
[data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea,
|
||||||
|
section[data-testid="stSidebar"] * {{ color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
|
||||||
|
background:#fff !important; border:1px solid var(--console-border) !important;
|
||||||
|
border-radius:4px !important; box-shadow:none !important; }}
|
||||||
|
/* Streamlit JSON uses an independently styled code surface. Keep the
|
||||||
|
MCP detail payload readable even when the browser/system prefers a
|
||||||
|
dark code theme. */
|
||||||
|
[data-testid="stJson"], [data-testid="stJson"] > div,
|
||||||
|
[data-testid="stJson"] .react-json-view, [data-testid="stJson"] pre {{
|
||||||
|
background:#f8fafc !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||||
|
[data-testid="stJson"] *, [data-testid="stJson"] pre *,
|
||||||
|
[data-testid="stJson"] code {{
|
||||||
|
background:transparent !important; color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
/* Baseline answers and generated SQL use Streamlit's separate code
|
||||||
|
surface. Keep it readable when the browser prefers dark mode. */
|
||||||
|
[data-testid="stCode"], [data-testid="stCode"] pre,
|
||||||
|
[data-testid="stCode"] code, [data-testid="stCodeBlock"],
|
||||||
|
[data-testid="stCodeBlock"] pre, [data-testid="stCodeBlock"] code {{
|
||||||
|
background:#f8fafc !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
[data-testid="stCode"] *, [data-testid="stCodeBlock"] * {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
/* Chat responses are rendered in a separate Streamlit surface. Without
|
||||||
|
these rules a dark browser theme can leave the answer card dark while
|
||||||
|
its Markdown keeps the light-theme text color. */
|
||||||
|
div[data-testid="stChatMessage"], div[data-testid="stChatMessageContent"] {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"],
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] *,
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"],
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"] * {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
/* Streamlit expanders use a dark summary bar in dark browser themes. */
|
||||||
|
details, details > summary {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||||
|
details > summary *, details > summary::marker {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
[data-testid="stExpander"] > details,
|
||||||
|
[data-testid="stExpander"] > details > summary,
|
||||||
|
[data-testid="stExpander"] > details > summary > div,
|
||||||
|
[data-testid="stExpander"] > details > summary > div > div {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; }}
|
||||||
|
[data-testid="stExpander"] > details > summary *,
|
||||||
|
[data-testid="stExpander"] > details > summary svg {{
|
||||||
|
color:var(--console-text) !important; fill:var(--console-text) !important;
|
||||||
|
stroke:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important;
|
||||||
|
border:1px solid var(--console-border) !important;
|
||||||
|
border-radius:4px !important; box-shadow:none !important; color-scheme:light !important; }}
|
||||||
|
div[data-testid="stButton"] > button *, div[data-testid="stFormSubmitButton"] > button * {{
|
||||||
|
color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
div[data-testid="stButton"] > button[kind="primary"],
|
||||||
|
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
||||||
|
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
||||||
|
div[data-testid="stButton"] > button[kind="primary"] *,
|
||||||
|
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {{
|
||||||
|
color:#fff !important; -webkit-text-fill-color:#fff !important; }}
|
||||||
|
/* Streamlit JSON/code components otherwise retain a dark-surface theme while
|
||||||
|
the console text is forced dark, making MCP details unreadable. */
|
||||||
|
[data-testid="stJson"], [data-testid="stJson"] > div,
|
||||||
|
[data-testid="stCodeBlock"], [data-testid="stCodeBlock"] > div,
|
||||||
|
[data-testid="stCode"], [data-testid="stCode"] > div {{
|
||||||
|
background:#f6f8fa !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; }}
|
||||||
|
[data-testid="stJson"] *, [data-testid="stCodeBlock"] *, [data-testid="stCode"] *,
|
||||||
|
[data-testid="stJson"] pre, [data-testid="stCodeBlock"] pre, [data-testid="stCode"] pre {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
[data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{
|
||||||
|
background:#fff !important; border-color:var(--console-border) !important; }}
|
||||||
|
[data-testid="stExpander"] summary {{
|
||||||
|
background:#f6f8fa !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; }}
|
||||||
|
[data-testid="stExpander"] summary * {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
[data-baseweb="tab-list"], [data-testid="stTabs"] [role="tablist"] {{
|
||||||
|
border-bottom:1px solid var(--console-border) !important; }}
|
||||||
|
[data-baseweb="tab-list"] [role="tab"], [data-testid="stTab"] {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important;
|
||||||
|
border-radius:4px 4px 0 0 !important; }}
|
||||||
|
[data-baseweb="tab-list"] [role="tab"] *, [data-testid="stTab"] * {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
[data-baseweb="tab-list"] [role="tab"]:hover, [data-testid="stTab"]:hover {{
|
||||||
|
background:#f6f8fa !important; }}
|
||||||
|
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"],
|
||||||
|
[data-testid="stTab"][aria-selected="true"] {{
|
||||||
|
color:var(--console-primary) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-primary) !important;
|
||||||
|
border-bottom:3px solid var(--console-primary) !important; }}
|
||||||
|
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"] *,
|
||||||
|
[data-testid="stTab"][aria-selected="true"] * {{
|
||||||
|
color:var(--console-primary) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-primary) !important; }}
|
||||||
|
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
|
||||||
|
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
|
||||||
|
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
|
||||||
|
.console-muted {{ color:var(--console-muted) !important; }}
|
||||||
|
.st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }}
|
||||||
|
.console-login {{ text-align:left; }}
|
||||||
|
.console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }}
|
||||||
|
a.console-logout-button {{ display:block; width:100%; padding:.55rem .8rem;
|
||||||
|
margin:.25rem 0 .75rem; background:#fff; color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important;
|
||||||
|
border:1px solid var(--console-border); border-radius:4px;
|
||||||
|
text-align:center; text-decoration:none; font-weight:700; }}
|
||||||
|
a.console-logout-button:hover {{ background:#f6f8fa; }}
|
||||||
|
@media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }}
|
||||||
|
</style>
|
||||||
|
""",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_console_header(st: Any, profile: AppProfile) -> None:
|
||||||
|
st.markdown(
|
||||||
|
f"""<section class="console-header"><div class="console-wordmark">{escape(profile.short_name)}</div>
|
||||||
|
<h1>{escape(profile.header_title)}</h1><p class="console-muted">{escape(profile.header_description)}</p></section>""",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_login_brand(st: Any, profile: AppProfile) -> None:
|
||||||
|
st.markdown(
|
||||||
|
f"""<section class="console-login"><div class="console-wordmark">{escape(profile.short_name)}</div>
|
||||||
|
<p class="console-muted">{escape(profile.login_kicker)}</p><h1>{escape(profile.login_title)}</h1>
|
||||||
|
<p class="console-muted">{escape(profile.login_description)}</p></section>""",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
139
ai-web-agent-console/ai_web_agent_console/profile.py
Normal file
139
ai-web-agent-console/ai_web_agent_console/profile.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Configuration-backed product profile for a reusable agent console."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
class AppProfileError(RuntimeError):
|
||||||
|
"""Safe error for an invalid product profile."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AppProfile:
|
||||||
|
product_name: str
|
||||||
|
short_name: str
|
||||||
|
page_title: str
|
||||||
|
page_icon: str
|
||||||
|
header_title: str
|
||||||
|
header_description: str
|
||||||
|
login_kicker: str
|
||||||
|
login_title: str
|
||||||
|
login_description: str
|
||||||
|
login_footer: str
|
||||||
|
primary_color: str
|
||||||
|
text_color: str
|
||||||
|
muted_color: str
|
||||||
|
border_color: str
|
||||||
|
|
||||||
|
|
||||||
|
_ENV_FIELD_NAMES = {
|
||||||
|
"product_name": "AGENT_CONSOLE_NAME",
|
||||||
|
"short_name": "AGENT_CONSOLE_SHORT_NAME",
|
||||||
|
"page_title": "AGENT_CONSOLE_PAGE_TITLE",
|
||||||
|
"page_icon": "AGENT_CONSOLE_PAGE_ICON",
|
||||||
|
"header_title": "AGENT_CONSOLE_HEADER_TITLE",
|
||||||
|
"header_description": "AGENT_CONSOLE_HEADER_DESCRIPTION",
|
||||||
|
"login_kicker": "AGENT_CONSOLE_LOGIN_KICKER",
|
||||||
|
"login_title": "AGENT_CONSOLE_LOGIN_TITLE",
|
||||||
|
"login_description": "AGENT_CONSOLE_LOGIN_DESCRIPTION",
|
||||||
|
"login_footer": "AGENT_CONSOLE_LOGIN_FOOTER",
|
||||||
|
"primary_color": "AGENT_CONSOLE_PRIMARY_COLOR",
|
||||||
|
"text_color": "AGENT_CONSOLE_TEXT_COLOR",
|
||||||
|
"muted_color": "AGENT_CONSOLE_MUTED_COLOR",
|
||||||
|
"border_color": "AGENT_CONSOLE_BORDER_COLOR",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _string(section: Mapping[str, Any], key: str, fallback: str = "") -> str:
|
||||||
|
return str(section.get(key) or fallback).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile_path(default_path: Path) -> Path:
|
||||||
|
configured = os.environ.get("AGENT_CONSOLE_PROFILE_PATH", "").strip()
|
||||||
|
if not configured:
|
||||||
|
return default_path
|
||||||
|
path = Path(configured).expanduser()
|
||||||
|
return path if path.is_absolute() else default_path.parent / path
|
||||||
|
|
||||||
|
|
||||||
|
def _dotenv_value(name: str, path: Path | None) -> str:
|
||||||
|
"""Read one simple KEY=value runtime setting without importing a dotenv lib."""
|
||||||
|
|
||||||
|
if path is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
return ""
|
||||||
|
prefix = f"{name}="
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith(prefix):
|
||||||
|
return stripped[len(prefix) :].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_environment_overrides(profile: AppProfile, env_file: Path | None) -> AppProfile:
|
||||||
|
"""Apply deployment-specific presentation values without a code change."""
|
||||||
|
|
||||||
|
values = {
|
||||||
|
field_name: (
|
||||||
|
os.environ.get(env_name, "").strip()
|
||||||
|
or _dotenv_value(env_name, env_file)
|
||||||
|
or getattr(profile, field_name)
|
||||||
|
)
|
||||||
|
for field_name, env_name in _ENV_FIELD_NAMES.items()
|
||||||
|
}
|
||||||
|
return AppProfile(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def load_app_profile(default_path: Path, env_file: Path | None = None) -> AppProfile:
|
||||||
|
"""Load the selectable product skin without coupling it to a PoC name."""
|
||||||
|
|
||||||
|
path = resolve_profile_path(default_path)
|
||||||
|
try:
|
||||||
|
payload: Any = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, ValueError):
|
||||||
|
raise AppProfileError(f"애플리케이션 프로필을 읽지 못했습니다: {path}") from None
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise AppProfileError("애플리케이션 프로필 형식이 올바르지 않습니다.")
|
||||||
|
product = payload.get("product")
|
||||||
|
theme = payload.get("theme")
|
||||||
|
if not isinstance(product, Mapping) or not isinstance(theme, Mapping):
|
||||||
|
raise AppProfileError("애플리케이션 프로필에 product와 theme 객체가 필요합니다.")
|
||||||
|
|
||||||
|
profile = _apply_environment_overrides(AppProfile(
|
||||||
|
product_name=_string(product, "name"),
|
||||||
|
short_name=_string(product, "short_name"),
|
||||||
|
page_title=_string(product, "page_title"),
|
||||||
|
page_icon=_string(product, "page_icon", "🤖"),
|
||||||
|
header_title=_string(product, "header_title"),
|
||||||
|
header_description=_string(product, "header_description"),
|
||||||
|
login_kicker=_string(product, "login_kicker"),
|
||||||
|
login_title=_string(product, "login_title"),
|
||||||
|
login_description=_string(product, "login_description"),
|
||||||
|
login_footer=_string(product, "login_footer"),
|
||||||
|
primary_color=_string(theme, "primary_color"),
|
||||||
|
text_color=_string(theme, "text_color"),
|
||||||
|
muted_color=_string(theme, "muted_color"),
|
||||||
|
border_color=_string(theme, "border_color"),
|
||||||
|
), env_file)
|
||||||
|
required = (
|
||||||
|
profile.product_name,
|
||||||
|
profile.short_name,
|
||||||
|
profile.page_title,
|
||||||
|
profile.header_title,
|
||||||
|
profile.login_title,
|
||||||
|
profile.primary_color,
|
||||||
|
profile.text_color,
|
||||||
|
profile.muted_color,
|
||||||
|
profile.border_color,
|
||||||
|
)
|
||||||
|
if not all(required):
|
||||||
|
raise AppProfileError("애플리케이션 프로필의 필수 표시값 또는 색상이 비어 있습니다.")
|
||||||
|
return profile
|
||||||
242
ai-web-agent-console/ai_web_agent_console/qa_history.py
Normal file
242
ai-web-agent-console/ai_web_agent_console/qa_history.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"""Customer QA benchmark parsing and deterministic SQL evaluation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
class QaBenchmarkError(RuntimeError):
|
||||||
|
"""Raised when the QA benchmark source cannot be used safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QaQuestion:
|
||||||
|
question_id: int | None
|
||||||
|
question_code: str
|
||||||
|
category: str
|
||||||
|
title: str
|
||||||
|
question_text: str
|
||||||
|
source_document: str
|
||||||
|
source_sheet: str
|
||||||
|
source_row: int | None
|
||||||
|
source_scenario: str
|
||||||
|
sample_sql: str
|
||||||
|
expected_focus: str
|
||||||
|
baseline_sql: str
|
||||||
|
baseline_answer: str
|
||||||
|
support_level: str
|
||||||
|
evaluation_rule: Mapping[str, Any]
|
||||||
|
last_judgment_status: str = ""
|
||||||
|
last_evaluated_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QaJudgment:
|
||||||
|
status: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def question_fingerprint(question_text: str) -> str:
|
||||||
|
normalized = " ".join(str(question_text or "").split()).casefold()
|
||||||
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_text(value: object) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: object) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
return tuple(_compact_text(item) for item in value if _compact_text(item))
|
||||||
|
|
||||||
|
|
||||||
|
def load_benchmark_questions(path: Path) -> tuple[QaQuestion, ...]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, ValueError) as exc:
|
||||||
|
raise QaBenchmarkError(f"질답 기준 파일을 읽지 못했습니다: {path}") from exc
|
||||||
|
rows = payload.get("scenarios") if isinstance(payload, Mapping) else None
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise QaBenchmarkError("질답 기준 파일에 scenarios 배열이 필요합니다.")
|
||||||
|
|
||||||
|
questions: list[QaQuestion] = []
|
||||||
|
seen_codes: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, Mapping):
|
||||||
|
raise QaBenchmarkError("질답 기준의 각 시나리오는 객체여야 합니다.")
|
||||||
|
source = row.get("source") if isinstance(row.get("source"), Mapping) else {}
|
||||||
|
history = (
|
||||||
|
row.get("historical_answer")
|
||||||
|
if isinstance(row.get("historical_answer"), Mapping)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
code = _compact_text(row.get("case_id")).upper()
|
||||||
|
question_text = _compact_text(row.get("question"))
|
||||||
|
if not code or not question_text:
|
||||||
|
raise QaBenchmarkError("각 질답 기준에는 case_id와 question이 필요합니다.")
|
||||||
|
if code in seen_codes:
|
||||||
|
raise QaBenchmarkError(f"중복된 질답 case_id입니다: {code}")
|
||||||
|
evaluation_rule = row.get("evaluation_rule")
|
||||||
|
if not isinstance(evaluation_rule, Mapping):
|
||||||
|
evaluation_rule = {}
|
||||||
|
questions.append(
|
||||||
|
QaQuestion(
|
||||||
|
question_id=None,
|
||||||
|
question_code=code,
|
||||||
|
category=_compact_text(row.get("category")) or "GENERAL",
|
||||||
|
title=_compact_text(row.get("title")) or code,
|
||||||
|
question_text=question_text,
|
||||||
|
source_document=_compact_text(source.get("workbook")),
|
||||||
|
source_sheet=_compact_text(source.get("sheet")),
|
||||||
|
source_row=_number_or_none(source.get("excel_row")),
|
||||||
|
source_scenario=_compact_text(source.get("scenario")),
|
||||||
|
sample_sql=_compact_text(source.get("sample_query")),
|
||||||
|
expected_focus=_compact_text(row.get("expected_focus")),
|
||||||
|
baseline_sql=_compact_text(history.get("generated_sql")),
|
||||||
|
baseline_answer=_compact_text(history.get("answer_text")),
|
||||||
|
support_level=_compact_text(row.get("support_level")).upper() or "UNKNOWN",
|
||||||
|
evaluation_rule={
|
||||||
|
"required_sql_terms": list(
|
||||||
|
_string_list(evaluation_rule.get("required_sql_terms"))
|
||||||
|
),
|
||||||
|
"recommended_sql_terms": list(
|
||||||
|
_string_list(evaluation_rule.get("recommended_sql_terms"))
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen_codes.add(code)
|
||||||
|
return tuple(questions)
|
||||||
|
|
||||||
|
|
||||||
|
def question_from_record(record: Mapping[str, Any]) -> QaQuestion:
|
||||||
|
rule = record.get("evaluation_rule")
|
||||||
|
if isinstance(rule, str):
|
||||||
|
try:
|
||||||
|
rule = json.loads(rule)
|
||||||
|
except ValueError:
|
||||||
|
rule = {}
|
||||||
|
if not isinstance(rule, Mapping):
|
||||||
|
rule = {}
|
||||||
|
return QaQuestion(
|
||||||
|
question_id=_number_or_none(record.get("question_id")),
|
||||||
|
question_code=_compact_text(record.get("question_code")),
|
||||||
|
category=_compact_text(record.get("category")) or "GENERAL",
|
||||||
|
title=_compact_text(record.get("title")) or _compact_text(record.get("question_code")),
|
||||||
|
question_text=_compact_text(record.get("question_text")),
|
||||||
|
source_document=_compact_text(record.get("source_document")),
|
||||||
|
source_sheet=_compact_text(record.get("source_sheet")),
|
||||||
|
source_row=_number_or_none(record.get("source_row")),
|
||||||
|
source_scenario=_compact_text(record.get("source_scenario")),
|
||||||
|
sample_sql=_compact_text(record.get("sample_sql")),
|
||||||
|
expected_focus=_compact_text(record.get("expected_focus")),
|
||||||
|
baseline_sql=_compact_text(record.get("baseline_sql")),
|
||||||
|
baseline_answer=_compact_text(record.get("baseline_answer")),
|
||||||
|
support_level=_compact_text(record.get("support_level")).upper() or "UNKNOWN",
|
||||||
|
evaluation_rule={
|
||||||
|
"required_sql_terms": list(
|
||||||
|
_string_list(rule.get("required_sql_terms"))
|
||||||
|
),
|
||||||
|
"recommended_sql_terms": list(
|
||||||
|
_string_list(rule.get("recommended_sql_terms"))
|
||||||
|
),
|
||||||
|
},
|
||||||
|
last_judgment_status=_compact_text(record.get("last_judgment_status")),
|
||||||
|
last_evaluated_at=_compact_text(record.get("last_evaluated_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _number_or_none(value: object) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _append_issue(issues: list[str], condition: bool, message: str) -> None:
|
||||||
|
if condition:
|
||||||
|
issues.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_sql(
|
||||||
|
question: QaQuestion | None,
|
||||||
|
generated_sql: str,
|
||||||
|
*,
|
||||||
|
execution_succeeded: bool,
|
||||||
|
error_text: str = "",
|
||||||
|
game_plan_status: str = "",
|
||||||
|
) -> QaJudgment:
|
||||||
|
"""Evaluate the generated SQL against the customer-approved benchmark rule."""
|
||||||
|
if question is None or not question.question_code:
|
||||||
|
return QaJudgment(
|
||||||
|
status="REVIEW",
|
||||||
|
reason="자유 질의입니다. 고객 기준 정답 시나리오와 연결되지 않아 수동 검토가 필요합니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = _compact_text(generated_sql)
|
||||||
|
upper_sql = sql.upper()
|
||||||
|
lower_sql = sql.lower()
|
||||||
|
execution_status = "PASS" if execution_succeeded else "FAIL_EXECUTION"
|
||||||
|
issues: list[str] = []
|
||||||
|
failure_markers = ("could not be generated", "exception encountered", "invalid identifier", "ora-")
|
||||||
|
has_failure_text = any(marker in lower_sql for marker in failure_markers)
|
||||||
|
required = _string_list(question.evaluation_rule.get("required_sql_terms"))
|
||||||
|
recommended = _string_list(question.evaluation_rule.get("recommended_sql_terms"))
|
||||||
|
missing_required = [term for term in required if term.upper() not in upper_sql]
|
||||||
|
missing_recommended = [term for term in recommended if term.upper() not in upper_sql]
|
||||||
|
|
||||||
|
if not execution_succeeded:
|
||||||
|
issues.append(f"실행 상태가 {execution_status}입니다.")
|
||||||
|
if not sql:
|
||||||
|
issues.append("생성 SQL이 없습니다.")
|
||||||
|
if has_failure_text:
|
||||||
|
issues.append("생성 SQL에 오류 또는 생성 실패 문구가 포함되어 있습니다.")
|
||||||
|
if missing_required:
|
||||||
|
issues.append("필수 SQL 요소 누락: " + ", ".join(missing_required))
|
||||||
|
if missing_recommended:
|
||||||
|
issues.append("권장 SQL 요소 누락: " + ", ".join(missing_recommended))
|
||||||
|
_append_issue(
|
||||||
|
issues,
|
||||||
|
bool(re.search(r'_[A-Z0-9]*YN"\s*=\s*\'1\'', sql, flags=re.IGNORECASE)),
|
||||||
|
"*_YN 컬럼은 샘플 메타데이터의 Y/N 값으로 비교해야 합니다.",
|
||||||
|
)
|
||||||
|
_append_issue(
|
||||||
|
issues,
|
||||||
|
bool(re.search(r'_[A-Z0-9]*FLAG"\s*=\s*\'Y\'', sql, flags=re.IGNORECASE)),
|
||||||
|
"*_FLAG 컬럼은 샘플 메타데이터의 0/1 값으로 비교해야 합니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
support = question.support_level
|
||||||
|
if support == "UNSUPPORTED":
|
||||||
|
plan_status = _compact_text(game_plan_status).upper()
|
||||||
|
safe_empty_result = bool(
|
||||||
|
re.search(r"\bFROM\s+DUAL\b", upper_sql)
|
||||||
|
and re.search(r"\bWHERE\s+1\s*=\s*0\b", upper_sql)
|
||||||
|
)
|
||||||
|
if plan_status in {"UNAVAILABLE", "UNMATCHED"} and execution_succeeded and safe_empty_result:
|
||||||
|
return QaJudgment(
|
||||||
|
"PASS",
|
||||||
|
"게임 계획이 데이터 미지원 또는 미매칭으로 판정됐고, 임의 객체 선택 없이 빈 결과를 반환했습니다.",
|
||||||
|
)
|
||||||
|
if not sql and any(marker in error_text.lower() for marker in failure_markers):
|
||||||
|
return QaJudgment("PASS", "미지원 게임 질문이 실행 가능한 SQL로 변환되지 않았습니다. 기대한 안전 차단입니다.")
|
||||||
|
return QaJudgment("FAIL", "미지원 게임이 게임 계획의 안전한 빈 결과로 처리되지 않았거나 실행에 실패했습니다.")
|
||||||
|
|
||||||
|
if not execution_succeeded or not sql or has_failure_text or missing_required:
|
||||||
|
return QaJudgment("FAIL", "\n".join(issues) or "필수 SQL 또는 실행 검증에 실패했습니다.")
|
||||||
|
|
||||||
|
if any(issue.startswith("필수") for issue in issues):
|
||||||
|
return QaJudgment("FAIL", "\n".join(issues))
|
||||||
|
if support == "PARTIAL":
|
||||||
|
issues.append("지원 범위가 일부인 질문이므로 결과 범위를 함께 검토해야 합니다.")
|
||||||
|
if issues:
|
||||||
|
return QaJudgment("WARN", "\n".join(issues))
|
||||||
|
return QaJudgment("PASS", "고객 기준의 필수 SQL 요소와 실행 결과를 확인했습니다.")
|
||||||
583
ai-web-agent-console/ai_web_agent_console/qa_history_store.py
Normal file
583
ai-web-agent-console/ai_web_agent_console/qa_history_store.py
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
"""Oracle ADB persistence for the Smilegate customer QA benchmark."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator, Mapping
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
import oracledb
|
||||||
|
|
||||||
|
from src.poc4.qa_history import QaQuestion, load_benchmark_questions, question_fingerprint, question_from_record
|
||||||
|
|
||||||
|
|
||||||
|
class QaHistoryStoreError(RuntimeError):
|
||||||
|
"""A safe user-facing persistence error."""
|
||||||
|
|
||||||
|
|
||||||
|
QUESTION_TABLE = "SG_AI_QA_QUESTION"
|
||||||
|
ANSWER_TABLE = "SG_AI_QA_ANSWER"
|
||||||
|
HISTORICAL_RUN_KEY = "HISTORICAL:2026-07-21:term-dict-final-v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _env_value(name: str, env_file: Path | None = None) -> str:
|
||||||
|
value = os.environ.get(name, "").strip()
|
||||||
|
if value or env_file is None or not env_file.is_file():
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
lines = env_file.read_text(encoding="utf-8").splitlines()
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
return ""
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
if line.startswith("export "):
|
||||||
|
line = line[7:].lstrip()
|
||||||
|
key, raw = line.split("=", 1)
|
||||||
|
if key.strip() != name:
|
||||||
|
continue
|
||||||
|
raw = raw.strip()
|
||||||
|
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {"'", '"'}:
|
||||||
|
raw = raw[1:-1]
|
||||||
|
return raw.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_oracle_dsn(raw_dsn: str) -> tuple[str, str]:
|
||||||
|
value = str(raw_dsn or "").strip()
|
||||||
|
if value.startswith("jdbc:oracle:thin:@"):
|
||||||
|
value = value[len("jdbc:oracle:thin:@"):]
|
||||||
|
if "?" not in value:
|
||||||
|
return value, ""
|
||||||
|
dsn, query = value.split("?", 1)
|
||||||
|
parsed = parse_qs(query, keep_blank_values=False)
|
||||||
|
wallet_dir = (parsed.get("TNS_ADMIN") or parsed.get("tns_admin") or [""])[0]
|
||||||
|
return dsn.strip(), wallet_dir.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_lob(value: Any) -> Any:
|
||||||
|
return value.read() if hasattr(value, "read") else value
|
||||||
|
|
||||||
|
|
||||||
|
def _record_from_cursor(cursor: Any, row: Any) -> dict[str, Any]:
|
||||||
|
names = [column[0].lower() for column in cursor.description]
|
||||||
|
return {name: _read_lob(value) for name, value in zip(names, row)}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_json(value: Mapping[str, Any] | None) -> str:
|
||||||
|
payload = dict(value or {})
|
||||||
|
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||||
|
if len(text) <= 120_000:
|
||||||
|
return text
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"truncated": True,
|
||||||
|
"preview": text[:119_800],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_record(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
result_json = str(row.get("result_json") or "").strip()
|
||||||
|
try:
|
||||||
|
result = json.loads(result_json) if result_json else {}
|
||||||
|
except ValueError:
|
||||||
|
result = {"raw": result_json}
|
||||||
|
return {
|
||||||
|
"answer_seq": row.get("answer_seq"),
|
||||||
|
"question_id": row.get("question_id"),
|
||||||
|
"answer_kind": str(row.get("answer_kind") or ""),
|
||||||
|
"run_key": str(row.get("run_key") or ""),
|
||||||
|
"conversation_id": str(row.get("conversation_id") or ""),
|
||||||
|
"requested_by": str(row.get("requested_by") or ""),
|
||||||
|
"requested_at": str(row.get("requested_at") or ""),
|
||||||
|
"model_profile": str(row.get("model_profile") or ""),
|
||||||
|
"generated_sql": str(row.get("generated_sql") or ""),
|
||||||
|
"answer_text": str(row.get("answer_text") or ""),
|
||||||
|
"result": result,
|
||||||
|
"execution_output": str(row.get("execution_output") or ""),
|
||||||
|
"execution_status": str(row.get("execution_status") or ""),
|
||||||
|
"judgment_status": str(row.get("judgment_status") or ""),
|
||||||
|
"judgment_reason": str(row.get("judgment_reason") or ""),
|
||||||
|
"duration_ms": row.get("duration_ms"),
|
||||||
|
"created_at": str(row.get("created_at") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QaHistoryStore:
|
||||||
|
def __init__(self, *, env_file: Path | None = None) -> None:
|
||||||
|
self._env_file = env_file
|
||||||
|
self._pool: Any | None = None
|
||||||
|
|
||||||
|
def _config(self) -> dict[str, str]:
|
||||||
|
username = (
|
||||||
|
_env_value("POC4_QA_DB_USERNAME", self._env_file)
|
||||||
|
or _env_value("BACKOFFICE_SELECT_AI_DB_USERNAME", self._env_file)
|
||||||
|
or _env_value("BACKOFFICE_DB_USERNAME", self._env_file)
|
||||||
|
)
|
||||||
|
password = (
|
||||||
|
_env_value("POC4_QA_DB_PASSWORD", self._env_file)
|
||||||
|
or _env_value("BACKOFFICE_SELECT_AI_DB_PASSWORD", self._env_file)
|
||||||
|
or _env_value("BACKOFFICE_DB_PASSWORD", self._env_file)
|
||||||
|
)
|
||||||
|
raw_dsn = (
|
||||||
|
_env_value("POC4_QA_DB_DSN", self._env_file)
|
||||||
|
or _env_value("BACKOFFICE_SELECT_AI_DB_URL", self._env_file)
|
||||||
|
or _env_value("BACKOFFICE_DB_URL", self._env_file)
|
||||||
|
)
|
||||||
|
dsn, wallet_from_dsn = _normalize_oracle_dsn(raw_dsn)
|
||||||
|
wallet_dir = (
|
||||||
|
_env_value("POC4_QA_DB_WALLET_DIR", self._env_file)
|
||||||
|
or wallet_from_dsn
|
||||||
|
or _env_value("ORACLE_WALLET_DIR", self._env_file)
|
||||||
|
)
|
||||||
|
if not username or not password or not dsn:
|
||||||
|
raise QaHistoryStoreError("질답 이력 DB 접속 설정을 확인해 주세요.")
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"dsn": dsn,
|
||||||
|
"wallet_dir": wallet_dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_pool(self) -> Any:
|
||||||
|
if self._pool is not None:
|
||||||
|
return self._pool
|
||||||
|
config = self._config()
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"user": config["username"],
|
||||||
|
"password": config["password"],
|
||||||
|
"dsn": config["dsn"],
|
||||||
|
"min": 1,
|
||||||
|
"max": 3,
|
||||||
|
"increment": 1,
|
||||||
|
"getmode": oracledb.POOL_GETMODE_WAIT,
|
||||||
|
}
|
||||||
|
wallet_dir = Path(config["wallet_dir"]).expanduser()
|
||||||
|
if config["wallet_dir"]:
|
||||||
|
if not wallet_dir.is_dir():
|
||||||
|
raise QaHistoryStoreError("질답 이력 DB Wallet 경로를 확인해 주세요.")
|
||||||
|
kwargs["config_dir"] = str(wallet_dir)
|
||||||
|
try:
|
||||||
|
self._pool = oracledb.create_pool(**kwargs)
|
||||||
|
return self._pool
|
||||||
|
except (oracledb.Error, OSError, ValueError) as exc:
|
||||||
|
raise QaHistoryStoreError("질답 이력 DB에 연결하지 못했습니다.") from exc
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection(self) -> Iterator[Any]:
|
||||||
|
try:
|
||||||
|
with self._get_pool().acquire() as connection:
|
||||||
|
yield connection
|
||||||
|
except QaHistoryStoreError:
|
||||||
|
raise
|
||||||
|
except (oracledb.Error, OSError, ValueError) as exc:
|
||||||
|
raise QaHistoryStoreError("질답 이력 DB 작업에 실패했습니다.") from exc
|
||||||
|
|
||||||
|
def list_questions(self, *, limit: int = 200) -> list[QaQuestion]:
|
||||||
|
sql = f"""
|
||||||
|
SELECT q.question_id, q.question_code, q.category, q.title,
|
||||||
|
q.question_text, q.source_document, q.source_sheet,
|
||||||
|
q.source_row, q.source_scenario, q.sample_sql,
|
||||||
|
q.expected_focus, q.baseline_sql, q.baseline_answer,
|
||||||
|
q.support_level, q.evaluation_rule_json,
|
||||||
|
latest.judgment_status AS last_judgment_status,
|
||||||
|
TO_CHAR(latest.evaluated_at AT TIME ZONE 'Asia/Seoul',
|
||||||
|
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS last_evaluated_at
|
||||||
|
FROM {QUESTION_TABLE} q
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT question_id, judgment_status, evaluated_at
|
||||||
|
FROM (
|
||||||
|
SELECT question_id, judgment_status, evaluated_at,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY question_id ORDER BY answer_seq DESC
|
||||||
|
) AS row_no
|
||||||
|
FROM {ANSWER_TABLE}
|
||||||
|
)
|
||||||
|
WHERE row_no = 1
|
||||||
|
) latest ON latest.question_id = q.question_id
|
||||||
|
WHERE q.active_yn = 'Y'
|
||||||
|
ORDER BY q.category, q.question_code
|
||||||
|
FETCH FIRST :row_limit ROWS ONLY
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, {"row_limit": int(limit)})
|
||||||
|
rows = [_record_from_cursor(cursor, row) for row in cursor]
|
||||||
|
return [question_from_record(row) for row in rows]
|
||||||
|
|
||||||
|
def get_question(self, question_id: int) -> QaQuestion | None:
|
||||||
|
sql = f"""
|
||||||
|
SELECT question_id, question_code, category, title, question_text,
|
||||||
|
source_document, source_sheet, source_row, source_scenario,
|
||||||
|
sample_sql, expected_focus, baseline_sql, baseline_answer,
|
||||||
|
support_level, evaluation_rule_json
|
||||||
|
FROM {QUESTION_TABLE}
|
||||||
|
WHERE question_id = :question_id AND active_yn = 'Y'
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, {"question_id": int(question_id)})
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return question_from_record(_record_from_cursor(cursor, row)) if row else None
|
||||||
|
|
||||||
|
def get_question_by_code(self, question_code: str) -> QaQuestion | None:
|
||||||
|
sql = f"""
|
||||||
|
SELECT question_id, question_code, category, title, question_text,
|
||||||
|
source_document, source_sheet, source_row, source_scenario,
|
||||||
|
sample_sql, expected_focus, baseline_sql, baseline_answer,
|
||||||
|
support_level, evaluation_rule_json
|
||||||
|
FROM {QUESTION_TABLE}
|
||||||
|
WHERE question_code = :question_code AND active_yn = 'Y'
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, {"question_code": str(question_code).upper()})
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return question_from_record(_record_from_cursor(cursor, row)) if row else None
|
||||||
|
|
||||||
|
def list_answers(self, question_id: int, *, limit: int = 30) -> list[dict[str, Any]]:
|
||||||
|
sql = f"""
|
||||||
|
SELECT answer_seq, question_id, answer_kind, run_key, conversation_id,
|
||||||
|
requested_by,
|
||||||
|
TO_CHAR(requested_at AT TIME ZONE 'Asia/Seoul',
|
||||||
|
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS requested_at,
|
||||||
|
model_profile, generated_sql, answer_text, result_json,
|
||||||
|
execution_output, execution_status, judgment_status,
|
||||||
|
judgment_reason, duration_ms,
|
||||||
|
TO_CHAR(created_at AT TIME ZONE 'Asia/Seoul',
|
||||||
|
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS created_at
|
||||||
|
FROM {ANSWER_TABLE}
|
||||||
|
WHERE question_id = :question_id
|
||||||
|
ORDER BY answer_seq DESC
|
||||||
|
FETCH FIRST :row_limit ROWS ONLY
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, {"question_id": int(question_id), "row_limit": int(limit)})
|
||||||
|
rows = [_record_from_cursor(cursor, row) for row in cursor]
|
||||||
|
return [_answer_record(row) for row in rows]
|
||||||
|
|
||||||
|
def find_or_create_free_text_question(self, question_text: str) -> QaQuestion:
|
||||||
|
normalized = str(question_text or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise QaHistoryStoreError("자유 질의가 비어 있습니다.")
|
||||||
|
fingerprint = question_fingerprint(normalized)
|
||||||
|
code = f"ADHOC-{fingerprint[:12].upper()}"
|
||||||
|
merge_sql = f"""
|
||||||
|
MERGE INTO {QUESTION_TABLE} target
|
||||||
|
USING (SELECT :question_hash AS question_hash FROM dual) source
|
||||||
|
ON (target.question_hash = source.question_hash)
|
||||||
|
WHEN NOT MATCHED THEN INSERT (
|
||||||
|
question_code, question_source, question_hash, category, title,
|
||||||
|
question_text, support_level, evaluation_rule_json, active_yn
|
||||||
|
) VALUES (
|
||||||
|
:question_code, 'FREE_TEXT', :question_hash, 'FREE_TEXT',
|
||||||
|
:title, :question_text, 'REVIEW', '{{}}', 'Y'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
merge_sql,
|
||||||
|
{
|
||||||
|
"question_hash": fingerprint,
|
||||||
|
"question_code": code,
|
||||||
|
"title": normalized[:180],
|
||||||
|
"question_text": normalized,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
cursor.execute(
|
||||||
|
f"""SELECT question_id FROM {QUESTION_TABLE}
|
||||||
|
WHERE question_hash = :question_hash""",
|
||||||
|
{"question_hash": fingerprint},
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise QaHistoryStoreError("자유 질의 마스터를 저장하지 못했습니다.")
|
||||||
|
question = self.get_question(int(row[0]))
|
||||||
|
if question is None:
|
||||||
|
raise QaHistoryStoreError("자유 질의 마스터를 다시 읽지 못했습니다.")
|
||||||
|
return question
|
||||||
|
|
||||||
|
def record_answer(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
question_id: int,
|
||||||
|
answer_kind: str,
|
||||||
|
conversation_id: str,
|
||||||
|
requested_by: str,
|
||||||
|
model_profile: str,
|
||||||
|
generated_sql: str,
|
||||||
|
answer_text: str,
|
||||||
|
result: Mapping[str, Any] | None,
|
||||||
|
execution_output: str,
|
||||||
|
execution_status: str,
|
||||||
|
judgment_status: str,
|
||||||
|
judgment_reason: str,
|
||||||
|
duration_ms: int | None,
|
||||||
|
run_key: str = "",
|
||||||
|
) -> None:
|
||||||
|
sql = f"""
|
||||||
|
INSERT INTO {ANSWER_TABLE} (
|
||||||
|
question_id, answer_kind, run_key, conversation_id, requested_by,
|
||||||
|
requested_at, model_profile, generated_sql, answer_text, result_json,
|
||||||
|
execution_output, execution_status, judgment_status,
|
||||||
|
judgment_reason, duration_ms
|
||||||
|
) VALUES (
|
||||||
|
:question_id, :answer_kind, :run_key, :conversation_id,
|
||||||
|
:requested_by, SYSTIMESTAMP, :model_profile, :generated_sql,
|
||||||
|
:answer_text, :result_json, :execution_output, :execution_status,
|
||||||
|
:judgment_status, :judgment_reason, :duration_ms
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
sql,
|
||||||
|
{
|
||||||
|
"question_id": int(question_id),
|
||||||
|
"answer_kind": str(answer_kind)[:20],
|
||||||
|
"run_key": str(run_key)[:100] or None,
|
||||||
|
"conversation_id": str(conversation_id)[:100] or None,
|
||||||
|
"requested_by": str(requested_by)[:100] or None,
|
||||||
|
"model_profile": str(model_profile)[:100] or None,
|
||||||
|
"generated_sql": str(generated_sql or ""),
|
||||||
|
"answer_text": str(answer_text or ""),
|
||||||
|
"result_json": _to_json(result),
|
||||||
|
"execution_output": str(execution_output or ""),
|
||||||
|
"execution_status": str(execution_status)[:40] or None,
|
||||||
|
"judgment_status": str(judgment_status)[:20],
|
||||||
|
"judgment_reason": str(judgment_reason or ""),
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def seed_benchmark(self, benchmark_file: Path) -> tuple[int, int]:
|
||||||
|
questions = load_benchmark_questions(benchmark_file)
|
||||||
|
raw = json.loads(benchmark_file.read_text(encoding="utf-8"))
|
||||||
|
raw_by_code = {
|
||||||
|
str(item.get("case_id") or "").upper(): item
|
||||||
|
for item in raw.get("scenarios", [])
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
}
|
||||||
|
seeded_questions = 0
|
||||||
|
seeded_answers = 0
|
||||||
|
for question in questions:
|
||||||
|
question_id = self._upsert_benchmark_question(question)
|
||||||
|
seeded_questions += 1
|
||||||
|
raw_item = raw_by_code[question.question_code]
|
||||||
|
history = raw_item.get("historical_answer") if isinstance(raw_item.get("historical_answer"), Mapping) else {}
|
||||||
|
inserted = self._seed_historical_answer(question_id, history, raw)
|
||||||
|
seeded_answers += 1 if inserted else 0
|
||||||
|
return seeded_questions, seeded_answers
|
||||||
|
|
||||||
|
def _upsert_benchmark_question(self, question: QaQuestion) -> int:
|
||||||
|
sql = f"""
|
||||||
|
MERGE INTO {QUESTION_TABLE} target
|
||||||
|
USING (SELECT :question_code AS question_code FROM dual) source
|
||||||
|
ON (target.question_code = source.question_code)
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
question_source = 'CUSTOMER_EXCEL',
|
||||||
|
question_hash = :question_hash,
|
||||||
|
category = :category,
|
||||||
|
title = :title,
|
||||||
|
question_text = :question_text,
|
||||||
|
source_document = :source_document,
|
||||||
|
source_sheet = :source_sheet,
|
||||||
|
source_row = :source_row,
|
||||||
|
source_scenario = :source_scenario,
|
||||||
|
sample_sql = :sample_sql,
|
||||||
|
expected_focus = :expected_focus,
|
||||||
|
baseline_sql = :baseline_sql,
|
||||||
|
baseline_answer = :baseline_answer,
|
||||||
|
support_level = :support_level,
|
||||||
|
evaluation_rule_json = :evaluation_rule_json,
|
||||||
|
active_yn = 'Y',
|
||||||
|
updated_at = SYSTIMESTAMP
|
||||||
|
WHEN NOT MATCHED THEN INSERT (
|
||||||
|
question_code, question_source, question_hash, category, title,
|
||||||
|
question_text, source_document, source_sheet, source_row,
|
||||||
|
source_scenario, sample_sql, expected_focus, baseline_sql,
|
||||||
|
baseline_answer, support_level, evaluation_rule_json, active_yn
|
||||||
|
) VALUES (
|
||||||
|
:question_code, 'CUSTOMER_EXCEL', :question_hash, :category,
|
||||||
|
:title, :question_text, :source_document, :source_sheet,
|
||||||
|
:source_row, :source_scenario, :sample_sql, :expected_focus,
|
||||||
|
:baseline_sql, :baseline_answer, :support_level,
|
||||||
|
:evaluation_rule_json, 'Y'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
binds = {
|
||||||
|
"question_code": question.question_code,
|
||||||
|
"question_hash": question_fingerprint(question.question_text),
|
||||||
|
"category": question.category[:30],
|
||||||
|
"title": question.title[:200],
|
||||||
|
"question_text": question.question_text,
|
||||||
|
"source_document": question.source_document[:255] or None,
|
||||||
|
"source_sheet": question.source_sheet[:255] or None,
|
||||||
|
"source_row": question.source_row,
|
||||||
|
"source_scenario": question.source_scenario,
|
||||||
|
"sample_sql": question.sample_sql,
|
||||||
|
"expected_focus": question.expected_focus,
|
||||||
|
"baseline_sql": question.baseline_sql,
|
||||||
|
"baseline_answer": question.baseline_answer,
|
||||||
|
"support_level": question.support_level[:20],
|
||||||
|
"evaluation_rule_json": json.dumps(question.evaluation_rule, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, binds)
|
||||||
|
connection.commit()
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT question_id FROM {QUESTION_TABLE} WHERE question_code = :question_code",
|
||||||
|
{"question_code": question.question_code},
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise QaHistoryStoreError(f"질문 마스터를 적재하지 못했습니다: {question.question_code}")
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
def _seed_historical_answer(
|
||||||
|
self,
|
||||||
|
question_id: int,
|
||||||
|
history: Mapping[str, Any],
|
||||||
|
benchmark: Mapping[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
exists_sql = f"""
|
||||||
|
SELECT COUNT(*) FROM {ANSWER_TABLE}
|
||||||
|
WHERE question_id = :question_id AND run_key = :run_key
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(exists_sql, {"question_id": question_id, "run_key": HISTORICAL_RUN_KEY})
|
||||||
|
if int(cursor.fetchone()[0]) > 0:
|
||||||
|
return False
|
||||||
|
result = {
|
||||||
|
"source_report": str(benchmark.get("source_report") or ""),
|
||||||
|
"source_redmine": benchmark.get("source_redmine"),
|
||||||
|
"historical_execution_output": str(history.get("execution_output") or ""),
|
||||||
|
}
|
||||||
|
self.record_answer(
|
||||||
|
question_id=question_id,
|
||||||
|
answer_kind="HISTORICAL",
|
||||||
|
run_key=HISTORICAL_RUN_KEY,
|
||||||
|
conversation_id="",
|
||||||
|
requested_by="customer-excel-baseline",
|
||||||
|
model_profile=str(history.get("profile") or ""),
|
||||||
|
generated_sql=str(history.get("generated_sql") or ""),
|
||||||
|
answer_text=str(history.get("answer_text") or ""),
|
||||||
|
result=result,
|
||||||
|
execution_output=str(history.get("execution_output") or ""),
|
||||||
|
execution_status=str(history.get("execution_status") or ""),
|
||||||
|
judgment_status=str(history.get("judgment_status") or "REVIEW"),
|
||||||
|
judgment_reason=str(history.get("judgment_reason") or ""),
|
||||||
|
duration_ms=int(history.get("duration_ms") or 0),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def schema_statements() -> tuple[str, ...]:
|
||||||
|
return (
|
||||||
|
f"""
|
||||||
|
CREATE TABLE {QUESTION_TABLE} (
|
||||||
|
question_id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY,
|
||||||
|
question_code VARCHAR2(30) UNIQUE,
|
||||||
|
question_source VARCHAR2(30) NOT NULL,
|
||||||
|
question_hash VARCHAR2(64) NOT NULL UNIQUE,
|
||||||
|
category VARCHAR2(30) NOT NULL,
|
||||||
|
title VARCHAR2(200) NOT NULL,
|
||||||
|
question_text CLOB NOT NULL,
|
||||||
|
source_document VARCHAR2(255),
|
||||||
|
source_sheet VARCHAR2(255),
|
||||||
|
source_row NUMBER,
|
||||||
|
source_scenario CLOB,
|
||||||
|
sample_sql CLOB,
|
||||||
|
expected_focus CLOB,
|
||||||
|
baseline_sql CLOB,
|
||||||
|
baseline_answer CLOB,
|
||||||
|
support_level VARCHAR2(20) NOT NULL,
|
||||||
|
evaluation_rule_json CLOB CHECK (evaluation_rule_json IS JSON),
|
||||||
|
active_yn CHAR(1) DEFAULT 'Y' NOT NULL CHECK (active_yn IN ('Y', 'N')),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT sg_ai_qa_question_source_ck
|
||||||
|
CHECK (question_source IN ('CUSTOMER_EXCEL', 'FREE_TEXT'))
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE {ANSWER_TABLE} (
|
||||||
|
answer_seq NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
question_id NUMBER NOT NULL,
|
||||||
|
answer_kind VARCHAR2(20) NOT NULL,
|
||||||
|
run_key VARCHAR2(100),
|
||||||
|
conversation_id VARCHAR2(100),
|
||||||
|
requested_by VARCHAR2(100),
|
||||||
|
requested_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
model_profile VARCHAR2(100),
|
||||||
|
generated_sql CLOB,
|
||||||
|
answer_text CLOB,
|
||||||
|
result_json CLOB CHECK (result_json IS JSON),
|
||||||
|
execution_output CLOB,
|
||||||
|
execution_status VARCHAR2(40),
|
||||||
|
judgment_status VARCHAR2(20) NOT NULL,
|
||||||
|
judgment_reason CLOB,
|
||||||
|
duration_ms NUMBER,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT sg_ai_qa_answer_question_fk
|
||||||
|
FOREIGN KEY (question_id)
|
||||||
|
REFERENCES {QUESTION_TABLE} (question_id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT sg_ai_qa_answer_kind_ck
|
||||||
|
CHECK (answer_kind IN ('HISTORICAL', 'LIVE')),
|
||||||
|
CONSTRAINT sg_ai_qa_answer_judgment_ck
|
||||||
|
CHECK (judgment_status IN ('PASS', 'WARN', 'FAIL', 'REVIEW'))
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE INDEX sg_ai_qa_answer_question_ix
|
||||||
|
ON {ANSWER_TABLE} (question_id, answer_seq DESC)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE UNIQUE INDEX sg_ai_qa_answer_run_uk
|
||||||
|
ON {ANSWER_TABLE} (question_id, run_key)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema(store: QaHistoryStore) -> None:
|
||||||
|
objects = (QUESTION_TABLE, ANSWER_TABLE)
|
||||||
|
with store._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT table_name FROM user_tables WHERE table_name IN (:q, :a)",
|
||||||
|
{"q": objects[0], "a": objects[1]},
|
||||||
|
)
|
||||||
|
existing = {str(row[0]) for row in cursor}
|
||||||
|
statements = schema_statements()
|
||||||
|
if QUESTION_TABLE not in existing:
|
||||||
|
cursor.execute(statements[0])
|
||||||
|
if ANSWER_TABLE not in existing:
|
||||||
|
cursor.execute(statements[1])
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT index_name FROM user_indexes WHERE index_name IN (:ix1, :ix2)",
|
||||||
|
{"ix1": "SG_AI_QA_ANSWER_QUESTION_IX", "ix2": "SG_AI_QA_ANSWER_RUN_UK"},
|
||||||
|
)
|
||||||
|
indexes = {str(row[0]) for row in cursor}
|
||||||
|
if "SG_AI_QA_ANSWER_QUESTION_IX" not in indexes:
|
||||||
|
cursor.execute(statements[2])
|
||||||
|
if "SG_AI_QA_ANSWER_RUN_UK" not in indexes:
|
||||||
|
cursor.execute(statements[3])
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
407
ai-web-agent-console/ai_web_agent_console/query_contracts.py
Normal file
407
ai-web-agent-console/ai_web_agent_console/query_contracts.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
264
ai-web-agent-console/ai_web_agent_console/questions.py
Normal file
264
ai-web-agent-console/ai_web_agent_console/questions.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""AI Web Agent Console preset catalog와 현재 질문 기반의 결정적 intent router."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
QUESTION_CATEGORIES = ("STRUCTURED", "RAG", "HYBRID")
|
||||||
|
GENERIC_RAG_QUESTION_ID = "R0"
|
||||||
|
MAX_QUESTION_CHARS = 2_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DemoQuestion:
|
||||||
|
"""ID와 분류가 고정된 데모 질문."""
|
||||||
|
|
||||||
|
question_id: str
|
||||||
|
category: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.category not in QUESTION_CATEGORIES:
|
||||||
|
raise ValueError("unsupported demo question category")
|
||||||
|
if not self.question_id or not self.text.strip():
|
||||||
|
raise ValueError("demo question id/text is required")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResolvedQuestionIntent:
|
||||||
|
"""현재 질문만으로 결정된 실행 intent.
|
||||||
|
|
||||||
|
``question_id``는 local route/fixture를 설명하는 분류 label이다. 8500 provider
|
||||||
|
입력으로 전달되지 않으며 UI에서 선택한 scenario ID도 이 모델에 들어오지
|
||||||
|
않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
question_id: str
|
||||||
|
category: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.category not in QUESTION_CATEGORIES:
|
||||||
|
raise ValueError("unsupported resolved question category")
|
||||||
|
if self.question_id not in {
|
||||||
|
GENERIC_RAG_QUESTION_ID,
|
||||||
|
"S1",
|
||||||
|
"S2",
|
||||||
|
"S3",
|
||||||
|
"S4",
|
||||||
|
"S5",
|
||||||
|
"R1",
|
||||||
|
"R2",
|
||||||
|
"H1",
|
||||||
|
"H2",
|
||||||
|
"H3",
|
||||||
|
"H4",
|
||||||
|
}:
|
||||||
|
raise ValueError("unsupported resolved question id")
|
||||||
|
|
||||||
|
|
||||||
|
COMMON_DEMO_QUESTIONS = (
|
||||||
|
DemoQuestion("S1", "STRUCTURED", "상품별 계약 건수를 보여줘."),
|
||||||
|
DemoQuestion("S2", "STRUCTURED", "총 지급보험금이 가장 큰 상품은?"),
|
||||||
|
DemoQuestion(
|
||||||
|
"S3",
|
||||||
|
"STRUCTURED",
|
||||||
|
"숫자로 계산 가능한 평균 보장금액이 높은 상품 10개를 보여줘.",
|
||||||
|
),
|
||||||
|
DemoQuestion("S4", "STRUCTURED", "고객 등급별 평균 보험료를 보여줘."),
|
||||||
|
DemoQuestion("S5", "STRUCTURED", "이해관계자 역할별 인원 수를 보여줘."),
|
||||||
|
DemoQuestion("R1", "RAG", "자동차보험 약관의 면책 사항은?"),
|
||||||
|
DemoQuestion("R2", "RAG", "보험금 청구 시 필요한 서류는?"),
|
||||||
|
DemoQuestion(
|
||||||
|
"H1",
|
||||||
|
"HYBRID",
|
||||||
|
"보험금이 가장 큰 상품의 주요 면책 조항을 알려줘.",
|
||||||
|
),
|
||||||
|
DemoQuestion(
|
||||||
|
"H2",
|
||||||
|
"HYBRID",
|
||||||
|
"청구가 많은 상품군의 보장 제외 조건을 알려줘.",
|
||||||
|
),
|
||||||
|
DemoQuestion(
|
||||||
|
"H3",
|
||||||
|
"HYBRID",
|
||||||
|
"고객 등급별 보험료 수준을 보고, 관련 약관상 유의해야 할 보장 제외 조건도 함께 알려줘.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_QUESTION_BY_ID = {item.question_id: item for item in COMMON_DEMO_QUESTIONS}
|
||||||
|
if len(_QUESTION_BY_ID) != len(COMMON_DEMO_QUESTIONS):
|
||||||
|
raise RuntimeError("duplicate AI Web Agent Console demo question id")
|
||||||
|
|
||||||
|
|
||||||
|
def question_by_id(question_id: str) -> DemoQuestion:
|
||||||
|
"""정규화된 ID로 질문을 찾되, 알 수 없는 ID는 거부한다."""
|
||||||
|
|
||||||
|
normalized = str(question_id).strip().upper()
|
||||||
|
try:
|
||||||
|
return _QUESTION_BY_ID[normalized]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError("unknown AI Web Agent Console demo question id") from None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_scenario_id(value: object) -> str | None:
|
||||||
|
"""선택적인 preset ID를 정규화하되 실행 routing에는 관여하지 않는다."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("scenario id must be a string")
|
||||||
|
normalized = value.strip().upper()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
question_by_id(normalized)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_question(question: object) -> tuple[str, str]:
|
||||||
|
if not isinstance(question, str):
|
||||||
|
raise ValueError("question must be a non-empty string")
|
||||||
|
if len(question) > MAX_QUESTION_CHARS:
|
||||||
|
raise ValueError("question exceeds the supported length")
|
||||||
|
normalized = re.sub(
|
||||||
|
r"\s+", " ", unicodedata.normalize("NFKC", question).strip().lower()
|
||||||
|
)
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("question must be a non-empty string")
|
||||||
|
return normalized, normalized.replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_any(text: str, candidates: tuple[str, ...]) -> bool:
|
||||||
|
return any(candidate in text for candidate in candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_question_intent(question: object) -> ResolvedQuestionIntent:
|
||||||
|
"""현재 질문 텍스트만으로 S/R/H route intent를 결정한다.
|
||||||
|
|
||||||
|
명확한 structured/hybrid intent에 해당하지 않는 질문은 임의로 추측하지 않고
|
||||||
|
``R0`` generic RAG 검색으로 보낸다. 이 함수는 scenario 또는
|
||||||
|
question ID hint를 받지 않으므로 preset metadata가 실행을 바꿀 수 없다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized, compact = _normalized_question(question)
|
||||||
|
|
||||||
|
# Canonical preset은 기존 10문항 동작을 byte-for-byte 보존한다.
|
||||||
|
for item in COMMON_DEMO_QUESTIONS:
|
||||||
|
candidate, _ = _normalized_question(item.text)
|
||||||
|
if normalized == candidate:
|
||||||
|
return ResolvedQuestionIntent(item.question_id, item.category)
|
||||||
|
|
||||||
|
has_product = _contains_any(compact, ("상품", "상품군"))
|
||||||
|
has_exclusion = _contains_any(
|
||||||
|
compact,
|
||||||
|
("면책", "보장제외", "제외조건", "보상하지않", "약관상유의"),
|
||||||
|
)
|
||||||
|
has_top = _contains_any(
|
||||||
|
compact, ("가장큰", "최대", "최고", "1위", "상위", "제일많", "높은")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 자유 질문에서 자동차보험 보유 규모와 타사 대비 강점을 함께 요구하면
|
||||||
|
# generic structured 조회와 자유 evidence 검색을 로컬에서 합성하는 H4로
|
||||||
|
# 보낸다. Label은 routing metadata일 뿐 provider query ID가 아니다.
|
||||||
|
has_auto_insurance = "자동차보험" in compact
|
||||||
|
has_count = _contains_any(
|
||||||
|
compact,
|
||||||
|
(
|
||||||
|
"갯수",
|
||||||
|
"개수",
|
||||||
|
"건수",
|
||||||
|
"계약수",
|
||||||
|
"몇개",
|
||||||
|
"몇건",
|
||||||
|
"보유수",
|
||||||
|
"상품수",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
has_competitor_comparison = _contains_any(
|
||||||
|
compact, ("타사", "경쟁사", "다른회사", "타보험사")
|
||||||
|
) and _contains_any(compact, ("강점", "장점", "차별", "우위", "비교"))
|
||||||
|
if has_auto_insurance and has_count and has_competitor_comparison:
|
||||||
|
return ResolvedQuestionIntent("H4", "HYBRID")
|
||||||
|
|
||||||
|
# Hybrid를 먼저 판별해 정형 키워드가 포함된 복합 질문이 S/R 단일 route로
|
||||||
|
# 축소되지 않도록 한다.
|
||||||
|
if (
|
||||||
|
_contains_any(compact, ("고객등급", "등급별"))
|
||||||
|
and "보험료" in compact
|
||||||
|
and has_exclusion
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("H3", "HYBRID")
|
||||||
|
if (
|
||||||
|
"청구" in compact
|
||||||
|
and _contains_any(compact, ("많은", "빈도", "건수", "상위"))
|
||||||
|
and has_product
|
||||||
|
and has_exclusion
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("H2", "HYBRID")
|
||||||
|
if (
|
||||||
|
has_product
|
||||||
|
and _contains_any(compact, ("지급보험금", "보험금"))
|
||||||
|
and has_top
|
||||||
|
and has_exclusion
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("H1", "HYBRID")
|
||||||
|
|
||||||
|
if (
|
||||||
|
has_product
|
||||||
|
and "계약" in compact
|
||||||
|
and _contains_any(compact, ("건수", "계약수", "몇건", "집계"))
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("S1", "STRUCTURED")
|
||||||
|
if (
|
||||||
|
has_product
|
||||||
|
and _contains_any(compact, ("지급보험금", "보험금총액", "총보험금"))
|
||||||
|
and has_top
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("S2", "STRUCTURED")
|
||||||
|
if (
|
||||||
|
has_product
|
||||||
|
and _contains_any(compact, ("보장금액", "가입금액"))
|
||||||
|
and "평균" in compact
|
||||||
|
and has_top
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("S3", "STRUCTURED")
|
||||||
|
if (
|
||||||
|
_contains_any(compact, ("고객등급", "등급별"))
|
||||||
|
and "보험료" in compact
|
||||||
|
and "평균" in compact
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("S4", "STRUCTURED")
|
||||||
|
if (
|
||||||
|
"이해관계자" in compact
|
||||||
|
and "역할" in compact
|
||||||
|
and _contains_any(compact, ("인원", "사람수", "몇명", "명수", "수"))
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("S5", "STRUCTURED")
|
||||||
|
|
||||||
|
if (
|
||||||
|
"자동차보험" in compact
|
||||||
|
and _contains_any(compact, ("면책", "보상하지않", "보장제외", "제외사항"))
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("R1", "RAG")
|
||||||
|
if (
|
||||||
|
_contains_any(compact, ("보험금", "청구"))
|
||||||
|
and _contains_any(compact, ("서류", "문서", "증빙", "제출자료"))
|
||||||
|
):
|
||||||
|
return ResolvedQuestionIntent("R2", "RAG")
|
||||||
|
|
||||||
|
return ResolvedQuestionIntent(GENERIC_RAG_QUESTION_ID, "RAG")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"COMMON_DEMO_QUESTIONS",
|
||||||
|
"DemoQuestion",
|
||||||
|
"GENERIC_RAG_QUESTION_ID",
|
||||||
|
"MAX_QUESTION_CHARS",
|
||||||
|
"QUESTION_CATEGORIES",
|
||||||
|
"ResolvedQuestionIntent",
|
||||||
|
"normalize_scenario_id",
|
||||||
|
"question_by_id",
|
||||||
|
"resolve_question_intent",
|
||||||
|
]
|
||||||
71
ai-web-agent-console/ai_web_agent_console/scenarios.py
Normal file
71
ai-web-agent-console/ai_web_agent_console/scenarios.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""File-backed demo scenarios used by the PoC4 Streamlit screen.
|
||||||
|
|
||||||
|
Scenario content is deliberately configuration, not executable routing policy.
|
||||||
|
Changing this JSON changes only the menu shown to a demo user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioConfigError(RuntimeError):
|
||||||
|
"""A safe, user-facing error for invalid scenario configuration."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DemoScenario:
|
||||||
|
scenario_id: str
|
||||||
|
category: str
|
||||||
|
title: str
|
||||||
|
question: str
|
||||||
|
|
||||||
|
# Compatibility aliases keep the Streamlit rendering independent from the
|
||||||
|
# storage field names and make a future scenario source interchangeable.
|
||||||
|
@property
|
||||||
|
def question_id(self) -> str:
|
||||||
|
return self.scenario_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return self.question
|
||||||
|
|
||||||
|
|
||||||
|
def load_demo_scenarios(path: Path) -> tuple[DemoScenario, ...]:
|
||||||
|
"""Read enabled scenarios and reject malformed or duplicated entries."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload: Any = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, ValueError):
|
||||||
|
raise ScenarioConfigError(f"질문 시나리오 설정을 읽지 못했습니다: {path}") from None
|
||||||
|
|
||||||
|
raw_scenarios = payload.get("scenarios") if isinstance(payload, Mapping) else None
|
||||||
|
if not isinstance(raw_scenarios, list):
|
||||||
|
raise ScenarioConfigError("질문 시나리오 설정에 scenarios 배열이 필요합니다.")
|
||||||
|
|
||||||
|
scenarios: list[DemoScenario] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for raw in raw_scenarios:
|
||||||
|
if not isinstance(raw, Mapping) or raw.get("enabled", True) is not True:
|
||||||
|
continue
|
||||||
|
scenario_id = str(raw.get("id") or "").strip().upper()
|
||||||
|
category = str(raw.get("category") or "일반").strip()
|
||||||
|
title = str(raw.get("title") or "").strip()
|
||||||
|
question = str(raw.get("question") or "").strip()
|
||||||
|
if not scenario_id or not title or not question:
|
||||||
|
raise ScenarioConfigError("각 질문 시나리오에는 id, title, question이 필요합니다.")
|
||||||
|
if scenario_id in seen_ids:
|
||||||
|
raise ScenarioConfigError(f"중복된 질문 시나리오 ID입니다: {scenario_id}")
|
||||||
|
scenarios.append(
|
||||||
|
DemoScenario(
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
category=category,
|
||||||
|
title=title,
|
||||||
|
question=question,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen_ids.add(scenario_id)
|
||||||
|
return tuple(scenarios)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Smilegate demo modules.
|
||||||
|
|
||||||
|
The portal is assembled from small modules so each feature can be reviewed and
|
||||||
|
released independently.
|
||||||
|
"""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation modules for the Smilegate demo."""
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
6530
ai-web-agent-console/app.py
Normal file
6530
ai-web-agent-console/app.py
Normal file
File diff suppressed because it is too large
Load Diff
21
ai-web-agent-console/config/app_profile.json
Normal file
21
ai-web-agent-console/config/app_profile.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"product": {
|
||||||
|
"name": "SMILEGATE DATA & AI POC",
|
||||||
|
"short_name": "SMILEGATE",
|
||||||
|
"page_title": "SMILEGATE DATA & AI POC",
|
||||||
|
"page_icon": "🤖",
|
||||||
|
"header_title": "스마일게이트 게임 데이터 AI 에이전트",
|
||||||
|
"header_description": "게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.",
|
||||||
|
"login_kicker": "SMILEGATE DATA & AI POC",
|
||||||
|
"login_title": "스마일게이트 게임 데이터 AI 에이전트",
|
||||||
|
"login_description": "사용자 인증 후 게임 데이터 AI 질의와 보안 관리 기능을 이용할 수 있습니다.",
|
||||||
|
"login_footer": "승인된 Data & AI PoC 사용자만 접근할 수 있습니다."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"primary_color": "#113F67",
|
||||||
|
"text_color": "#15283B",
|
||||||
|
"muted_color": "#5D6C7C",
|
||||||
|
"border_color": "#D7E0E8"
|
||||||
|
}
|
||||||
|
}
|
||||||
62
ai-web-agent-console/config/hmm_demo_scenarios.json
Normal file
62
ai-web-agent-console/config/hmm_demo_scenarios.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"description": "HMM AI 업무 에이전트 화면에 표시할 데모 질문 목록입니다. 이 파일을 수정하면 재배포 후 질문 메뉴에 반영됩니다.",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"id": "HR-01",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "휴가 규정",
|
||||||
|
"title": "연차 이월 기준",
|
||||||
|
"question": "연차 휴가 이월 기준과 제한을 알려줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "HR-02",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "휴가 규정",
|
||||||
|
"title": "입사일 기준 연차",
|
||||||
|
"question": "입사일 기준으로 연차가 언제 발생하는지 알려줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "HR-03",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "근태 용어",
|
||||||
|
"title": "휴가 명칭 표준화",
|
||||||
|
"question": "반차와 반일 휴가의 표준 근태 용어를 알려줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "HR-04",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "조직·인력",
|
||||||
|
"title": "팀 구성 조회",
|
||||||
|
"question": "팀별 인원과 매니저를 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "HR-05",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "근태 현황",
|
||||||
|
"title": "휴가 현황 조회",
|
||||||
|
"question": "이번 달 팀원별 휴가 사용 현황을 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FED-01",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "선사 실적 Federation",
|
||||||
|
"title": "팀원별 담당 선사 최신 실적",
|
||||||
|
"question": "E1001 팀장의 팀원별 담당 선사와 해당 선사의 최신 매출, 매출총이익, 정시 운항률, 위험 등급을 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FED-02",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "선사 실적 Federation",
|
||||||
|
"title": "위험 선사와 담당자",
|
||||||
|
"question": "E1001 팀에서 최신 위험 등급이 RED인 선사를 담당하는 직원, 선사명, 매출, 정시 운항률과 클레임 발생률을 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FED-03",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "선사 실적 Federation",
|
||||||
|
"title": "담당 선사 월별 추이",
|
||||||
|
"question": "E1006 직원이 담당하는 선사의 월별 매출, 운송 물동량, 매출총이익과 위험 등급을 기준월 순서로 보여줘"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
84
ai-web-agent-console/config/hmm_hr_query_contracts.json
Normal file
84
ai-web-agent-console/config/hmm_hr_query_contracts.json
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"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": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
58
ai-web-agent-console/config/mcp_servers.json
Normal file
58
ai-web-agent-console/config/mcp_servers.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
88
ai-web-agent-console/config/model_profiles.json
Normal file
88
ai-web-agent-console/config/model_profiles.json
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"registry_name": "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES",
|
||||||
|
"default_model_profile": "gpt55_oci",
|
||||||
|
"source_commit": "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"model_key": "gpt55_oci",
|
||||||
|
"display_name": "GPT-5.5 (OCI GenAI)",
|
||||||
|
"provider": "oci",
|
||||||
|
"model_id": "openai.gpt-5.5",
|
||||||
|
"answer_model_id_alias": "OPENAI_GPT_5_5_CHAT",
|
||||||
|
"answer_model_region": "us-chicago-1",
|
||||||
|
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||||
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2",
|
||||||
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT55_OCI_V2",
|
||||||
|
"verification_status": "VERIFIED_WITH_WARNINGS",
|
||||||
|
"default_for_console": true,
|
||||||
|
"source_tag": "poc_2-gpt55-oci-partial",
|
||||||
|
"notes": "PoC_2 Select AI S1~S5와 승인된 read-only 실행은 5/5 PASS. 8503 Native Agent는 S1/S2/R1/H1 PASS이나 S3 safe-conversion marker 미확인 WARN으로 PARTIAL이다.",
|
||||||
|
"display_order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_key": "gpt54_mini_oci",
|
||||||
|
"display_name": "GPT-5.4 Mini (OCI GenAI)",
|
||||||
|
"provider": "oci",
|
||||||
|
"model_id": "openai.gpt-5.4-mini",
|
||||||
|
"answer_model_id_alias": "OPENAI_GPT_5_4_MINI_CHAT",
|
||||||
|
"answer_model_region": "us-chicago-1",
|
||||||
|
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||||
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT54_MINI_OCI_PROFILE_V1",
|
||||||
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT54_MINI_OCI_V1",
|
||||||
|
"verification_status": "PARTIAL_VERIFIED",
|
||||||
|
"default_for_console": false,
|
||||||
|
"source_tag": "poc_2-gpt55-oci-partial",
|
||||||
|
"notes": "PoC4 MCP 실행 방식(single/agent) 판단용 경량 planner profile. Select AI/Agent Team 본 처리 기본값은 gpt55_oci를 유지한다.",
|
||||||
|
"display_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_key": "grok43",
|
||||||
|
"display_name": "Grok 4.3",
|
||||||
|
"provider": "oci",
|
||||||
|
"model_id": "xai.grok-4.3",
|
||||||
|
"answer_model_id_alias": "XAI_GROK_4_3_CHAT",
|
||||||
|
"answer_model_region": "us-chicago-1",
|
||||||
|
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||||
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GROK43_PROFILE_V2",
|
||||||
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GROK43_V3",
|
||||||
|
"verification_status": "VERIFIED",
|
||||||
|
"default_for_console": false,
|
||||||
|
"source_tag": "poc_2-gpt55-oci-partial",
|
||||||
|
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||||
|
"display_order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_key": "llama4_maverick",
|
||||||
|
"display_name": "Llama 4 Maverick",
|
||||||
|
"provider": "oci",
|
||||||
|
"model_id": "meta.llama-4-maverick-17b-128e-instruct-fp8",
|
||||||
|
"answer_model_id_alias": "META_LLAMA_4_MAVERICK_CHAT",
|
||||||
|
"answer_model_region": "us-chicago-1",
|
||||||
|
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||||
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA4_MAVERICK_PROFILE_V2",
|
||||||
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA4_MAVERICK_V3",
|
||||||
|
"verification_status": "VERIFIED",
|
||||||
|
"default_for_console": false,
|
||||||
|
"source_tag": "poc_2-gpt55-oci-partial",
|
||||||
|
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||||
|
"display_order": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_key": "llama33_70b",
|
||||||
|
"display_name": "Llama 3.3 70B",
|
||||||
|
"provider": "oci",
|
||||||
|
"model_id": "meta.llama-3.3-70b-instruct",
|
||||||
|
"answer_model_id_alias": "META_LLAMA_3_3_70B_CHAT",
|
||||||
|
"answer_model_region": "us-chicago-1",
|
||||||
|
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
|
||||||
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA33_PROFILE_V2",
|
||||||
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA33_V3",
|
||||||
|
"verification_status": "VERIFIED",
|
||||||
|
"default_for_console": false,
|
||||||
|
"source_tag": "poc_2-gpt55-oci-partial",
|
||||||
|
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||||
|
"display_order": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
41
ai-web-agent-console/config/smilegate_demo_scenarios.json
Normal file
41
ai-web-agent-console/config/smilegate_demo_scenarios.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"description": "Smilegate Data & AI PoC 화면에 표시할 게임 데이터 질의 샘플입니다.",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"id": "GAME-01",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "활성 사용자",
|
||||||
|
"title": "카제나 최신 AU",
|
||||||
|
"question": "카제나 최신 기준 활성 사용자 수(AU)를 알려줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "GAME-02",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "매출",
|
||||||
|
"title": "게임별 판매 현황",
|
||||||
|
"question": "최신 기준 게임별 판매 건수와 판매 금액을 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "GAME-03",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "환불",
|
||||||
|
"title": "최근 환불 현황",
|
||||||
|
"question": "최신 기준 게임별 환불 건수와 환불 금액을 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "GAME-04",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "게임·서버",
|
||||||
|
"title": "게임 서버 구성",
|
||||||
|
"question": "등록된 게임과 게임 서버 정보를 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "GAME-05",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "사용자 분석",
|
||||||
|
"title": "신규 사용자 현황",
|
||||||
|
"question": "최신 월 기준 게임별 신규 사용자 수를 보여줘"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1832
ai-web-agent-console/config/smilegate_qa_benchmark.json
Normal file
1832
ai-web-agent-console/config/smilegate_qa_benchmark.json
Normal file
File diff suppressed because one or more lines are too long
24
ai-web-agent-console/config/vpd_token_presets.json
Normal file
24
ai-web-agent-console/config/vpd_token_presets.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"default": true,
|
||||||
|
"user_id": "1001",
|
||||||
|
"name": "Data & AI TF 팀장",
|
||||||
|
"role": "DATA_AI_POC_ADMIN",
|
||||||
|
"channel": "DATA_AI_TF",
|
||||||
|
"scope": "SGMP_POC 게임 데이터 전체",
|
||||||
|
"token_env": "SMILEGATE_TEAMLEAD_BEARER_TOKEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"default": false,
|
||||||
|
"user_id": "1002",
|
||||||
|
"name": "Data & AI TF 팀원",
|
||||||
|
"role": "DATA_AI_POC_ADMIN",
|
||||||
|
"channel": "DATA_AI_TF",
|
||||||
|
"scope": "SGMP_POC 게임 데이터 전체",
|
||||||
|
"token_env": "SMILEGATE_TEAMMEMBER_BEARER_TOKEN"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
128
ai-web-agent-console/portal_auth_gateway.py
Normal file
128
ai-web-agent-console/portal_auth_gateway.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""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()
|
||||||
4
ai-web-agent-console/requirements-langgraph.txt
Normal file
4
ai-web-agent-console/requirements-langgraph.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
fastmcp==3.4.3
|
||||||
|
langgraph>=1.2,<2
|
||||||
|
oci>=2.180,<3
|
||||||
5
ai-web-agent-console/requirements.txt
Normal file
5
ai-web-agent-console/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
openpyxl>=3.1,<4
|
||||||
|
oci>=2.180,<3
|
||||||
|
oracledb>=2,<4
|
||||||
|
pandas>=2,<3
|
||||||
|
streamlit>=1.35,<2
|
||||||
47
ai-web-agent-console/scripts/sync_smilegate_qa_history.py
Normal file
47
ai-web-agent-console/scripts/sync_smilegate_qa_history.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create and seed the Smilegate customer QA benchmark history tables."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from src.poc4.qa_history_store import QaHistoryStore, ensure_schema
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--benchmark",
|
||||||
|
type=Path,
|
||||||
|
default=ROOT / "config" / "smilegate_qa_benchmark.json",
|
||||||
|
help="Customer Excel benchmark JSON generated from the approved QA report.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--env-file",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Optional environment file containing the QA DB connection settings.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.benchmark.is_file():
|
||||||
|
raise SystemExit(f"Benchmark file not found: {args.benchmark}")
|
||||||
|
store = QaHistoryStore(env_file=args.env_file)
|
||||||
|
ensure_schema(store)
|
||||||
|
question_count, historical_insert_count = store.seed_benchmark(args.benchmark)
|
||||||
|
print(
|
||||||
|
"qa_history_sync"
|
||||||
|
f" questions={question_count}"
|
||||||
|
f" historical_answers_inserted={historical_insert_count}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
20
ai-web-agent-console/smilegate_demo.py
Normal file
20
ai-web-agent-console/smilegate_demo.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""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()
|
||||||
217
ai-web-agent-console/tests/test_auth_gateway.py
Normal file
217
ai-web-agent-console/tests/test_auth_gateway.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
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()
|
||||||
51
ai-web-agent-console/tests/test_oci_genai_settings.py
Normal file
51
ai-web-agent-console/tests/test_oci_genai_settings.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""OCI GenAI configuration validation tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from src.oci_genai_sdk import ALLOWED_OCI_SETTINGS, load_oci_settings
|
||||||
|
|
||||||
|
|
||||||
|
class OCISettingsTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._previous = {key: os.environ.get(key) for key in ALLOWED_OCI_SETTINGS}
|
||||||
|
os.environ.update(
|
||||||
|
{
|
||||||
|
"OCI_AUTH_TYPE": "config_file",
|
||||||
|
"OCI_CONFIG_FILE": "/home/opc/.oci/config",
|
||||||
|
"OCI_PROFILE": "DEFAULT",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
for key, value in self._previous.items():
|
||||||
|
if value is None:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
else:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
def test_accepts_a_child_compartment_ocid(self) -> None:
|
||||||
|
os.environ["OCI_GENAI_COMPARTMENT_ID"] = "ocid1.compartment.oc1..example"
|
||||||
|
|
||||||
|
settings = load_oci_settings()
|
||||||
|
|
||||||
|
self.assertEqual("ocid1.compartment.oc1..example", settings.compartment_id)
|
||||||
|
|
||||||
|
def test_accepts_a_tenancy_ocid_for_the_root_compartment(self) -> None:
|
||||||
|
os.environ["OCI_GENAI_COMPARTMENT_ID"] = "ocid1.tenancy.oc1..example"
|
||||||
|
|
||||||
|
settings = load_oci_settings()
|
||||||
|
|
||||||
|
self.assertEqual("ocid1.tenancy.oc1..example", settings.compartment_id)
|
||||||
|
|
||||||
|
def test_rejects_an_invalid_compartment_identifier(self) -> None:
|
||||||
|
os.environ["OCI_GENAI_COMPARTMENT_ID"] = "not-an-ocid"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "compartment is not configured"):
|
||||||
|
load_oci_settings()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
80
ai-web-agent-console/tests/test_qa_history.py
Normal file
80
ai-web-agent-console/tests/test_qa_history.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from src.poc4.qa_history import evaluate_sql, load_benchmark_questions
|
||||||
|
from src.poc4.qa_history_store import _normalize_oracle_dsn, schema_statements
|
||||||
|
|
||||||
|
|
||||||
|
class QaHistoryTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
benchmark = Path(__file__).parents[1] / "config" / "smilegate_qa_benchmark.json"
|
||||||
|
cls.questions = {item.question_code: item for item in load_benchmark_questions(benchmark)}
|
||||||
|
|
||||||
|
def test_customer_excel_benchmark_contains_all_47_cases(self) -> None:
|
||||||
|
self.assertEqual(47, len(self.questions))
|
||||||
|
self.assertIn("STD-01", self.questions)
|
||||||
|
self.assertIn("CZN-19", self.questions)
|
||||||
|
|
||||||
|
def test_supported_query_passes_when_required_terms_are_present(self) -> None:
|
||||||
|
judgment = evaluate_sql(
|
||||||
|
self.questions["STD-13"],
|
||||||
|
"SELECT SUM(PAYMT_AMT) FROM COMN_SALES_TXN",
|
||||||
|
execution_succeeded=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("PASS", judgment.status)
|
||||||
|
|
||||||
|
def test_monthly_au_with_au_flag_fails(self) -> None:
|
||||||
|
judgment = evaluate_sql(
|
||||||
|
self.questions["STD-27"],
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM CZN_COMN_USER_MST
|
||||||
|
WHERE AU_FLAG = 1
|
||||||
|
AND BASE_DT = (SELECT MAX(BASE_DT) FROM CZN_COMN_USER_MST)
|
||||||
|
AND LAST_CONN_DT >= ADD_MONTHS(BASE_DT, -1)
|
||||||
|
AND STD_USER_YN = 'Y'
|
||||||
|
AND EXPT_USER_YN = 'N'
|
||||||
|
""",
|
||||||
|
execution_succeeded=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("FAIL", judgment.status)
|
||||||
|
self.assertIn("AU_FLAG", judgment.reason)
|
||||||
|
|
||||||
|
def test_unsupported_game_requires_safe_alias_lookup(self) -> None:
|
||||||
|
safe = evaluate_sql(
|
||||||
|
self.questions["STD-02"],
|
||||||
|
"SELECT GAME_ID FROM COMN_GAME_ALIAS_BAS WHERE GAME_NM LIKE '%버블리즈%'",
|
||||||
|
execution_succeeded=True,
|
||||||
|
)
|
||||||
|
unsafe = evaluate_sql(
|
||||||
|
self.questions["STD-02"],
|
||||||
|
"SELECT COUNT(*) FROM CZN_COMN_USER_MST WHERE GAME_ID = 'STOVE_CHAOSZERO'",
|
||||||
|
execution_succeeded=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("PASS", safe.status)
|
||||||
|
self.assertEqual("FAIL", unsafe.status)
|
||||||
|
|
||||||
|
def test_free_text_is_review_not_automatic_pass(self) -> None:
|
||||||
|
judgment = evaluate_sql(None, "SELECT 1 FROM DUAL", execution_succeeded=True)
|
||||||
|
self.assertEqual("REVIEW", judgment.status)
|
||||||
|
|
||||||
|
def test_jdbc_url_wallet_is_normalized_for_python_driver(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
("sgmpaipoc_medium", "/home/opc/wallet/sgmpaipoc"),
|
||||||
|
_normalize_oracle_dsn(
|
||||||
|
"jdbc:oracle:thin:@sgmpaipoc_medium?TNS_ADMIN=/home/opc/wallet/sgmpaipoc"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schema_defines_two_history_tables_and_indexes(self) -> None:
|
||||||
|
statements = "\n".join(schema_statements())
|
||||||
|
self.assertIn("CREATE TABLE SG_AI_QA_QUESTION", statements)
|
||||||
|
self.assertIn("CREATE TABLE SG_AI_QA_ANSWER", statements)
|
||||||
|
self.assertIn("answer_seq NUMBER GENERATED ALWAYS AS IDENTITY", statements)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
181
ai-web-agent-console/tests/test_query_contracts.py
Normal file
181
ai-web-agent-console/tests/test_query_contracts.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
67
ai-web-agent-console/tests/test_scenarios.py
Normal file
67
ai-web-agent-console/tests/test_scenarios.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
|
||||||
|
from src.agent_console.profile import load_app_profile
|
||||||
|
|
||||||
|
|
||||||
|
class DemoScenarioConfigTest(unittest.TestCase):
|
||||||
|
def test_profile_environment_overrides_json_defaults(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"AGENT_CONSOLE_SHORT_NAME": "SMILEGATE",
|
||||||
|
"AGENT_CONSOLE_PAGE_TITLE": "SMILEGATE DATA & AI POC",
|
||||||
|
"AGENT_CONSOLE_PRIMARY_COLOR": "#113F67",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
profile = load_app_profile(path)
|
||||||
|
|
||||||
|
self.assertEqual(profile.short_name, "SMILEGATE")
|
||||||
|
self.assertEqual(profile.page_title, "SMILEGATE DATA & AI POC")
|
||||||
|
self.assertEqual(profile.primary_color, "#113F67")
|
||||||
|
|
||||||
|
def test_profile_reads_dotenv_values(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
env_file = Path(temp_dir) / ".env"
|
||||||
|
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=SMILEGATE\n", encoding="utf-8")
|
||||||
|
profile = load_app_profile(path, env_file)
|
||||||
|
|
||||||
|
self.assertEqual(profile.short_name, "SMILEGATE")
|
||||||
|
|
||||||
|
def test_smilegate_scenarios_are_enabled_and_unique(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "config" / "smilegate_demo_scenarios.json"
|
||||||
|
scenarios = load_demo_scenarios(path)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(len(scenarios), 3)
|
||||||
|
self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios}))
|
||||||
|
self.assertTrue(all(item.question.strip() for item in scenarios))
|
||||||
|
|
||||||
|
def test_duplicate_id_is_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
path = Path(temp_dir) / "scenarios.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"scenarios": [
|
||||||
|
{"id": "HR-01", "title": "one", "question": "q1"},
|
||||||
|
{"id": "HR-01", "title": "two", "question": "q2"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ScenarioConfigError):
|
||||||
|
load_demo_scenarios(path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -80,7 +80,7 @@ COMMIT;
|
|||||||
-- COMMIT;
|
-- COMMIT;
|
||||||
--
|
--
|
||||||
-- The policy function vpd_region_filter handles CSV → IN-list
|
-- The policy function vpd_region_filter handles CSV → IN-list
|
||||||
-- conversion automatically. See sql/adb/06_policy.sql.
|
-- conversion automatically. See database/adb/06_policy.sql.
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
PROMPT === Seed complete ===
|
PROMPT === Seed complete ===
|
||||||
22
database/adb/100_sgmp_czn05_country_business_au_fewshot.sql
Normal file
22
database/adb/100_sgmp_czn05_country_business_au_fewshot.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- 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';
|
||||||
16
database/adb/101_sgmp_czn06_country_standard_au_fewshot.sql
Normal file
16
database/adb/101_sgmp_czn06_country_standard_au_fewshot.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- 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;
|
||||||
15
database/adb/102_sgmp_czn07_crystal_holdings_fewshot.sql
Normal file
15
database/adb/102_sgmp_czn07_crystal_holdings_fewshot.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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;
|
||||||
15
database/adb/103_sgmp_czn08_crystal_average_fewshot.sql
Normal file
15
database/adb/103_sgmp_czn08_crystal_average_fewshot.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 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;
|
||||||
11
database/adb/105_sgmp_czn13_zero_aggregate_fewshot.sql
Normal file
11
database/adb/105_sgmp_czn13_zero_aggregate_fewshot.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 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;
|
||||||
64
database/adb/107_sgmp_separate_customer_qa_from_fewshot.sql
Normal file
64
database/adb/107_sgmp_separate_customer_qa_from_fewshot.sql
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
-- 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;
|
||||||
|
/
|
||||||
9
database/adb/110_sg_game_query_plan_admin_grants.sql
Normal file
9
database/adb/110_sg_game_query_plan_admin_grants.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- 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;
|
||||||
|
/
|
||||||
148
database/adb/111_sg_game_daily_au_lookup.sql
Normal file
148
database/adb/111_sg_game_daily_au_lookup.sql
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
-- 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;
|
||||||
|
/
|
||||||
81
database/adb/112_sgmp_select_ai_oci_llama4scout_profile.sql
Normal file
81
database/adb/112_sgmp_select_ai_oci_llama4scout_profile.sql
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
-- Creates a non-operational comparison profile for Smilegate game-scope chat.
|
||||||
|
-- The active GPT profile remains unchanged. Provider credentials and profile
|
||||||
|
-- metadata are copied from it so the only comparison variable is the model.
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_exists PLS_INTEGER;
|
||||||
|
v_credential_name VARCHAR2(128);
|
||||||
|
v_region VARCHAR2(128);
|
||||||
|
v_compartment_id VARCHAR2(4000);
|
||||||
|
v_attributes CLOB;
|
||||||
|
v_attribute_json JSON_OBJECT_T := JSON_OBJECT_T();
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_exists
|
||||||
|
FROM user_cloud_ai_profiles
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_LLAMA4SCOUT';
|
||||||
|
|
||||||
|
IF v_exists > 0 THEN
|
||||||
|
DBMS_CLOUD_AI.DROP_PROFILE(
|
||||||
|
profile_name => 'SGMP_POC_OCI_LLAMA4SCOUT',
|
||||||
|
force => TRUE
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||||
|
INTO v_credential_name
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name = 'credential_name';
|
||||||
|
|
||||||
|
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||||
|
INTO v_region
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name = 'region';
|
||||||
|
|
||||||
|
SELECT DBMS_LOB.SUBSTR(attribute_value, 4000, 1)
|
||||||
|
INTO v_compartment_id
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name = 'oci_compartment_id';
|
||||||
|
|
||||||
|
v_attribute_json.put('provider', 'oci');
|
||||||
|
v_attribute_json.put('credential_name', v_credential_name);
|
||||||
|
v_attribute_json.put('model', 'meta.llama-4-scout-17b-16e-instruct');
|
||||||
|
v_attribute_json.put('region', v_region);
|
||||||
|
v_attribute_json.put('oci_compartment_id', v_compartment_id);
|
||||||
|
v_attributes := v_attribute_json.to_clob;
|
||||||
|
|
||||||
|
DBMS_CLOUD_AI.CREATE_PROFILE(
|
||||||
|
profile_name => 'SGMP_POC_OCI_LLAMA4SCOUT',
|
||||||
|
attributes => v_attributes,
|
||||||
|
description => 'Non-operational Smilegate game-scope latency comparison'
|
||||||
|
);
|
||||||
|
|
||||||
|
FOR source_attribute IN (
|
||||||
|
SELECT attribute_name, attribute_value
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name NOT IN (
|
||||||
|
'credential_name', 'model', 'provider', 'provider_endpoint',
|
||||||
|
'region', 'oci_compartment_id', 'oci_endpoint_id',
|
||||||
|
'oci_apiformat', 'oci_runtimetype'
|
||||||
|
)
|
||||||
|
) LOOP
|
||||||
|
DBMS_CLOUD_AI.SET_ATTRIBUTE(
|
||||||
|
profile_name => 'SGMP_POC_OCI_LLAMA4SCOUT',
|
||||||
|
attribute_name => source_attribute.attribute_name,
|
||||||
|
attribute_value => source_attribute.attribute_value
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
SELECT attribute_name, attribute_value
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_LLAMA4SCOUT'
|
||||||
|
AND attribute_name IN (
|
||||||
|
'provider', 'model', 'credential_name', 'region', 'oci_compartment_id'
|
||||||
|
)
|
||||||
|
ORDER BY attribute_name;
|
||||||
60
database/adb/113_sgmp_game_scope_chat_profile_benchmark.sql
Normal file
60
database/adb/113_sgmp_game_scope_chat_profile_benchmark.sql
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
-- Read-only latency and JSON-shape comparison for the game-mention extraction
|
||||||
|
-- stage. Korean input is reconstructed from UTF-8 base64 for SQLcl safety.
|
||||||
|
|
||||||
|
set serveroutput on size unlimited
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_question CLOB := utl_i18n.raw_to_char(
|
||||||
|
utl_encode.base64_decode(utl_raw.cast_to_raw(
|
||||||
|
'66Gc65Oc64KY7J247J20656RIOy5tOygnOuCmOydmCAyMDI264WEIDfsm5QgMTXsnbwgQVXrpbwg6rCB6rCBIOyVjOugpOykmC4='
|
||||||
|
)),
|
||||||
|
'AL32UTF8'
|
||||||
|
);
|
||||||
|
v_prompt CLOB;
|
||||||
|
v_result CLOB;
|
||||||
|
v_json JSON_OBJECT_T;
|
||||||
|
v_started PLS_INTEGER;
|
||||||
|
v_elapsed_seconds NUMBER;
|
||||||
|
|
||||||
|
PROCEDURE run_profile(p_profile_name IN VARCHAR2) IS
|
||||||
|
BEGIN
|
||||||
|
v_started := DBMS_UTILITY.GET_TIME;
|
||||||
|
v_result := DBMS_CLOUD_AI.GENERATE(
|
||||||
|
prompt => v_prompt,
|
||||||
|
profile_name => p_profile_name,
|
||||||
|
action => 'chat'
|
||||||
|
);
|
||||||
|
v_elapsed_seconds := (DBMS_UTILITY.GET_TIME - v_started) / 100;
|
||||||
|
v_json := JSON_OBJECT_T.parse(v_result);
|
||||||
|
DBMS_OUTPUT.PUT_LINE(
|
||||||
|
p_profile_name
|
||||||
|
|| '|elapsed_seconds=' || TO_CHAR(v_elapsed_seconds, 'FM9990D00')
|
||||||
|
|| '|scope_hint=' || NVL(v_json.get_string('scope_hint'), 'NULL')
|
||||||
|
|| '|mention_count=' || v_json.get_array('game_mentions').get_size
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
DBMS_OUTPUT.PUT_LINE(
|
||||||
|
p_profile_name || '|elapsed_seconds='
|
||||||
|
|| TO_CHAR(v_elapsed_seconds, 'FM9990D00')
|
||||||
|
|| '|ERROR|' || SQLCODE || '|' || SUBSTR(SQLERRM, 1, 300)
|
||||||
|
);
|
||||||
|
DBMS_OUTPUT.PUT_LINE(
|
||||||
|
p_profile_name || '|raw_response=' || DBMS_LOB.SUBSTR(v_result, 1000, 1)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
v_prompt := 'Extract only game-name mentions from the user question. '
|
||||||
|
|| 'Metrics, acronyms, dates, filters, and database object or column names are not game names unless they are themselves an explicit game title. '
|
||||||
|
|| 'When a title-like noun directly qualifies a game data request such as user master, character, sales, AU, NRU, server, or game log, preserve that noun as a game-name mention even when it is not in a catalog. '
|
||||||
|
|| 'Do not discard an unknown title merely because it cannot be resolved. General scope words such as common, overall, all, total, or every are not game-name mentions unless they are part of an explicit title. '
|
||||||
|
|| 'Return exactly one JSON object with keys game_mentions (array of strings) '
|
||||||
|
|| 'and scope_hint (GLOBAL, SINGLE_GAME, MULTI_GAME, ALL_GAMES, UNKNOWN). '
|
||||||
|
|| 'Do not resolve names to IDs and do not generate SQL. '
|
||||||
|
|| 'Return raw JSON only: no prose, no Markdown, and no code fence. Question: '
|
||||||
|
|| v_question;
|
||||||
|
|
||||||
|
run_profile('SGMP_POC_OCI_GPT54MINI');
|
||||||
|
run_profile('SGMP_POC_OCI_LLAMA4SCOUT');
|
||||||
|
END;
|
||||||
|
/
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
-- Creates non-operational OCI profiles for game-scope extraction benchmarks.
|
||||||
|
-- Every profile inherits the active GPT profile's OCI credential, region,
|
||||||
|
-- object list, and metadata. Only model is varied.
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_credential_name VARCHAR2(128);
|
||||||
|
v_region VARCHAR2(128);
|
||||||
|
v_compartment_id VARCHAR2(4000);
|
||||||
|
v_attributes CLOB;
|
||||||
|
v_attribute_json JSON_OBJECT_T;
|
||||||
|
v_exists PLS_INTEGER;
|
||||||
|
|
||||||
|
PROCEDURE create_candidate(
|
||||||
|
p_profile_name IN VARCHAR2,
|
||||||
|
p_model IN VARCHAR2
|
||||||
|
) IS
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_exists
|
||||||
|
FROM user_cloud_ai_profiles
|
||||||
|
WHERE profile_name = p_profile_name;
|
||||||
|
|
||||||
|
IF v_exists > 0 THEN
|
||||||
|
DBMS_CLOUD_AI.DROP_PROFILE(profile_name => p_profile_name, force => TRUE);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_attribute_json := JSON_OBJECT_T();
|
||||||
|
v_attribute_json.put('provider', 'oci');
|
||||||
|
v_attribute_json.put('credential_name', v_credential_name);
|
||||||
|
v_attribute_json.put('model', p_model);
|
||||||
|
v_attribute_json.put('region', v_region);
|
||||||
|
v_attribute_json.put('oci_compartment_id', v_compartment_id);
|
||||||
|
v_attributes := v_attribute_json.to_clob;
|
||||||
|
|
||||||
|
DBMS_CLOUD_AI.CREATE_PROFILE(
|
||||||
|
profile_name => p_profile_name,
|
||||||
|
attributes => v_attributes,
|
||||||
|
description => 'Non-operational Smilegate game-scope benchmark profile'
|
||||||
|
);
|
||||||
|
|
||||||
|
FOR source_attribute IN (
|
||||||
|
SELECT attribute_name, attribute_value
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name NOT IN (
|
||||||
|
'credential_name', 'model', 'provider', 'provider_endpoint',
|
||||||
|
'region', 'oci_compartment_id', 'oci_endpoint_id',
|
||||||
|
'oci_apiformat', 'oci_runtimetype'
|
||||||
|
)
|
||||||
|
) LOOP
|
||||||
|
DBMS_CLOUD_AI.SET_ATTRIBUTE(
|
||||||
|
profile_name => p_profile_name,
|
||||||
|
attribute_name => source_attribute.attribute_name,
|
||||||
|
attribute_value => source_attribute.attribute_value
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||||
|
INTO v_credential_name
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name = 'credential_name';
|
||||||
|
|
||||||
|
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||||
|
INTO v_region
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name = 'region';
|
||||||
|
|
||||||
|
SELECT DBMS_LOB.SUBSTR(attribute_value, 4000, 1)
|
||||||
|
INTO v_compartment_id
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name = 'oci_compartment_id';
|
||||||
|
|
||||||
|
create_candidate('SGMP_SCOPE_COHERE_VISION', 'cohere.command-a-vision');
|
||||||
|
create_candidate('SGMP_SCOPE_COHERE_COMMAND', 'cohere.command-latest');
|
||||||
|
create_candidate('SGMP_SCOPE_COHERE_PLUS', 'cohere.command-plus-latest');
|
||||||
|
create_candidate('SGMP_SCOPE_GEMINI_FLASH', 'google.gemini-2.5-flash-lite');
|
||||||
|
create_candidate('SGMP_SCOPE_LLAMA_MAV', 'meta.llama-4-maverick-17b-128e-instruct-fp8');
|
||||||
|
create_candidate('SGMP_SCOPE_GROK_NONR', 'xai.grok-4.20-non-reasoning');
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
SELECT profile_name, attribute_value AS model
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name LIKE 'SGMP_SCOPE_%'
|
||||||
|
AND attribute_name = 'model'
|
||||||
|
ORDER BY profile_name;
|
||||||
112
database/adb/115_sgmp_scope_chat_candidate_benchmark.sql
Normal file
112
database/adb/115_sgmp_scope_chat_candidate_benchmark.sql
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
-- Read-only benchmark for candidate game-scope extraction profiles.
|
||||||
|
-- Korean test questions use UTF-8 base64 reconstruction for SQLcl safety.
|
||||||
|
|
||||||
|
set serveroutput on size unlimited
|
||||||
|
prompt SG_SCOPE_PROFILE_BENCHMARK_LOADED
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
TYPE t_case IS RECORD (
|
||||||
|
case_name VARCHAR2(12),
|
||||||
|
question CLOB,
|
||||||
|
expected_scope VARCHAR2(20),
|
||||||
|
expected_mentions PLS_INTEGER
|
||||||
|
);
|
||||||
|
TYPE t_cases IS TABLE OF t_case INDEX BY PLS_INTEGER;
|
||||||
|
v_cases t_cases;
|
||||||
|
v_prompt_prefix CLOB :=
|
||||||
|
'Extract only game-name mentions from the user question. '
|
||||||
|
|| 'Metrics, acronyms, dates, filters, and database object or column names are not game names unless they are themselves an explicit game title. '
|
||||||
|
|| 'When a title-like noun directly qualifies a game data request such as user master, character, sales, AU, NRU, server, or game log, preserve that noun as a game-name mention even when it is not in a catalog. '
|
||||||
|
|| 'Do not discard an unknown title merely because it cannot be resolved. General scope words such as common, overall, all, total, or every are not game-name mentions unless they are part of an explicit title. '
|
||||||
|
|| 'Return exactly one JSON object with keys game_mentions (array of strings) '
|
||||||
|
|| 'and scope_hint (GLOBAL, SINGLE_GAME, MULTI_GAME, ALL_GAMES, UNKNOWN). '
|
||||||
|
|| 'Do not resolve names to IDs and do not generate SQL. '
|
||||||
|
|| 'Return raw JSON only: no prose, no Markdown, and no code fence. Question: ';
|
||||||
|
v_result CLOB;
|
||||||
|
v_json JSON_OBJECT_T;
|
||||||
|
v_started PLS_INTEGER;
|
||||||
|
v_elapsed NUMBER;
|
||||||
|
v_scope VARCHAR2(20);
|
||||||
|
v_mentions PLS_INTEGER;
|
||||||
|
v_raw_json VARCHAR2(5);
|
||||||
|
|
||||||
|
PROCEDURE run_case(
|
||||||
|
p_profile_name IN VARCHAR2,
|
||||||
|
p_case t_case
|
||||||
|
) IS
|
||||||
|
BEGIN
|
||||||
|
v_started := DBMS_UTILITY.GET_TIME;
|
||||||
|
v_result := DBMS_CLOUD_AI.GENERATE(
|
||||||
|
prompt => v_prompt_prefix || p_case.question,
|
||||||
|
profile_name => p_profile_name,
|
||||||
|
action => 'chat'
|
||||||
|
);
|
||||||
|
v_elapsed := (DBMS_UTILITY.GET_TIME - v_started) / 100;
|
||||||
|
v_json := JSON_OBJECT_T.parse(v_result);
|
||||||
|
v_raw_json := 'TRUE';
|
||||||
|
v_scope := v_json.get_string('scope_hint');
|
||||||
|
v_mentions := v_json.get_array('game_mentions').get_size;
|
||||||
|
DBMS_OUTPUT.PUT_LINE(
|
||||||
|
p_profile_name || '|' || p_case.case_name
|
||||||
|
|| '|seconds=' || TO_CHAR(v_elapsed, 'FM9990D00')
|
||||||
|
|| '|raw_json=' || v_raw_json
|
||||||
|
|| '|scope=' || NVL(v_scope, 'NULL')
|
||||||
|
|| '|mentions=' || v_mentions
|
||||||
|
|| '|expected=' || p_case.expected_scope || '/' || p_case.expected_mentions
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
DBMS_OUTPUT.PUT_LINE(
|
||||||
|
p_profile_name || '|' || p_case.case_name
|
||||||
|
|| '|seconds=' || TO_CHAR(v_elapsed, 'FM9990D00')
|
||||||
|
|| '|ERROR=' || SQLCODE || '|' || SUBSTR(SQLERRM, 1, 180)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
DBMS_OUTPUT.PUT_LINE('benchmark_started');
|
||||||
|
v_cases(1).case_name := 'NONE';
|
||||||
|
v_cases(1).question := utl_i18n.raw_to_char(utl_encode.base64_decode(
|
||||||
|
utl_raw.cast_to_raw('7KCE7LK0IOunpOy2nCDslYzroKTspJgu')), 'AL32UTF8');
|
||||||
|
v_cases(1).expected_scope := 'GLOBAL';
|
||||||
|
v_cases(1).expected_mentions := 0;
|
||||||
|
|
||||||
|
v_cases(2).case_name := 'SINGLE';
|
||||||
|
v_cases(2).question := utl_i18n.raw_to_char(utl_encode.base64_decode(
|
||||||
|
utl_raw.cast_to_raw('7Lm07KCc64KYIOy1nOyLoCBBVSDslYzroKTspJgu')), 'AL32UTF8');
|
||||||
|
v_cases(2).expected_scope := 'SINGLE_GAME';
|
||||||
|
v_cases(2).expected_mentions := 1;
|
||||||
|
|
||||||
|
v_cases(3).case_name := 'MULTI';
|
||||||
|
v_cases(3).question := utl_i18n.raw_to_char(utl_encode.base64_decode(
|
||||||
|
utl_raw.cast_to_raw('66Gc65Oc64KY7J247J20656RIOy5tOygnOuCmOydmCAyMDI264WEIDfsm5QgMTXsnbwgQVXrpbwg6rCB6rCBIOyVjOugpOykmC4=')), 'AL32UTF8');
|
||||||
|
v_cases(3).expected_scope := 'MULTI_GAME';
|
||||||
|
v_cases(3).expected_mentions := 2;
|
||||||
|
|
||||||
|
v_cases(4).case_name := 'ALL';
|
||||||
|
v_cases(4).question := utl_i18n.raw_to_char(utl_encode.base64_decode(
|
||||||
|
utl_raw.cast_to_raw('7KCE7LK0IOqyjOyehOydmCDrp6Tstpwg7JWM66Ck7KSYLg==')), 'AL32UTF8');
|
||||||
|
v_cases(4).expected_scope := 'ALL_GAMES';
|
||||||
|
v_cases(4).expected_mentions := 0;
|
||||||
|
|
||||||
|
FOR profile_row IN (
|
||||||
|
SELECT profile_name
|
||||||
|
FROM user_cloud_ai_profiles
|
||||||
|
WHERE profile_name IN (
|
||||||
|
'SGMP_POC_OCI_GPT54MINI',
|
||||||
|
'SGMP_SCOPE_COHERE_VISION',
|
||||||
|
'SGMP_SCOPE_COHERE_COMMAND',
|
||||||
|
'SGMP_SCOPE_COHERE_PLUS',
|
||||||
|
'SGMP_SCOPE_GEMINI_FLASH',
|
||||||
|
'SGMP_SCOPE_LLAMA_MAV',
|
||||||
|
'SGMP_SCOPE_GROK_NONR'
|
||||||
|
)
|
||||||
|
ORDER BY profile_name
|
||||||
|
) LOOP
|
||||||
|
DBMS_OUTPUT.PUT_LINE('profile=' || profile_row.profile_name);
|
||||||
|
FOR i IN 1 .. 4 LOOP
|
||||||
|
run_case(profile_row.profile_name, v_cases(i));
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
prompt SG_SCOPE_PROFILE_BENCHMARK_COMPLETED
|
||||||
122
database/adb/116_sg_game_catalog_alias_embeddings.sql
Normal file
122
database/adb/116_sg_game_catalog_alias_embeddings.sql
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
-- Store all game-name variants as one JSON array per game and embed that JSON
|
||||||
|
-- as the canonical game-search vector. No customer game name is hardcoded.
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_column_count PLS_INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_column_count
|
||||||
|
FROM user_tab_columns
|
||||||
|
WHERE table_name = 'SG_GAME_CATALOG'
|
||||||
|
AND column_name = 'ALIASES_JSON';
|
||||||
|
|
||||||
|
IF v_column_count = 0 THEN
|
||||||
|
EXECUTE IMMEDIATE 'ALTER TABLE sg_game_catalog ADD (aliases_json CLOB)';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
UPDATE sg_game_catalog
|
||||||
|
SET aliases_json = '[]'
|
||||||
|
WHERE aliases_json IS NULL;
|
||||||
|
/
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_constraint_count PLS_INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_constraint_count
|
||||||
|
FROM user_constraints
|
||||||
|
WHERE table_name = 'SG_GAME_CATALOG'
|
||||||
|
AND constraint_name = 'SG_GAME_CATALOG_ALIASES_JS_CK';
|
||||||
|
|
||||||
|
IF v_constraint_count = 0 THEN
|
||||||
|
EXECUTE IMMEDIATE
|
||||||
|
'ALTER TABLE sg_game_catalog ADD CONSTRAINT sg_game_catalog_aliases_js_ck '
|
||||||
|
|| 'CHECK (aliases_json IS JSON)';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
MERGE INTO sg_game_catalog c
|
||||||
|
USING (
|
||||||
|
WITH source_alias AS (
|
||||||
|
SELECT game_id AS game_key, game_nm AS alias_name
|
||||||
|
FROM comn_game_alias_bas
|
||||||
|
WHERE use_yn = 'Y' AND game_nm IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT game_id, game_alias_nm
|
||||||
|
FROM comn_game_alias_bas
|
||||||
|
WHERE use_yn = 'Y' AND game_alias_nm IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT game_id, game_id
|
||||||
|
FROM comn_game_alias_bas
|
||||||
|
WHERE use_yn = 'Y' AND game_id IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT game_id, game_prefix
|
||||||
|
FROM comn_game_alias_bas
|
||||||
|
WHERE use_yn = 'Y' AND game_prefix IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT game_key, display_name
|
||||||
|
FROM sg_game_scope_registry
|
||||||
|
WHERE active_yn = 'Y' AND display_name IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT game_key, game_alias
|
||||||
|
FROM sg_game_scope_registry
|
||||||
|
WHERE active_yn = 'Y' AND game_alias IS NOT NULL
|
||||||
|
),
|
||||||
|
deduplicated_alias AS (
|
||||||
|
SELECT game_key, alias_name
|
||||||
|
FROM source_alias
|
||||||
|
WHERE TRIM(alias_name) IS NOT NULL
|
||||||
|
GROUP BY game_key, alias_name
|
||||||
|
)
|
||||||
|
SELECT game_key,
|
||||||
|
JSON_ARRAYAGG(alias_name ORDER BY alias_name RETURNING CLOB) AS aliases_json
|
||||||
|
FROM deduplicated_alias
|
||||||
|
GROUP BY game_key
|
||||||
|
) s
|
||||||
|
ON (c.game_key = s.game_key)
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
c.aliases_json = s.aliases_json,
|
||||||
|
c.updated_at = SYSTIMESTAMP;
|
||||||
|
/
|
||||||
|
|
||||||
|
-- A game has one canonical vector made from its complete JSON alias array.
|
||||||
|
UPDATE sg_game_catalog c
|
||||||
|
SET c.embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||||
|
c.aliases_json,
|
||||||
|
JSON(sg_qa_vector_params('search_document'))
|
||||||
|
),
|
||||||
|
c.updated_at = SYSTIMESTAMP
|
||||||
|
WHERE c.active_yn = 'Y';
|
||||||
|
/
|
||||||
|
|
||||||
|
COMMENT ON COLUMN sg_game_catalog.aliases_json IS
|
||||||
|
'Canonical JSON string array of every game-name variant used as the embedding input.';
|
||||||
|
COMMENT ON COLUMN sg_game_catalog.embedding IS
|
||||||
|
'One vector per game, generated from the complete aliases_json array.';
|
||||||
|
/
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION sg_game_catalog_search(
|
||||||
|
p_question IN CLOB,
|
||||||
|
p_top_k IN PLS_INTEGER DEFAULT 5
|
||||||
|
) RETURN SYS_REFCURSOR AUTHID DEFINER IS
|
||||||
|
v_query VECTOR;
|
||||||
|
v_result SYS_REFCURSOR;
|
||||||
|
BEGIN
|
||||||
|
v_query := DBMS_VECTOR.UTL_TO_EMBEDDING(
|
||||||
|
p_question,
|
||||||
|
JSON(sg_qa_vector_params('search_query'))
|
||||||
|
);
|
||||||
|
OPEN v_result FOR
|
||||||
|
SELECT game_key, game_id, game_prefix, game_nm, game_alias_nm,
|
||||||
|
user_master_object_name,
|
||||||
|
VECTOR_DISTANCE(embedding, v_query, COSINE) AS cosine_distance
|
||||||
|
FROM sg_game_catalog
|
||||||
|
WHERE active_yn = 'Y' AND embedding IS NOT NULL
|
||||||
|
ORDER BY VECTOR_DISTANCE(embedding, v_query, COSINE), priority, game_key
|
||||||
|
FETCH FIRST LEAST(GREATEST(NVL(p_top_k, 5), 1), 20) ROWS ONLY;
|
||||||
|
RETURN v_result;
|
||||||
|
END;
|
||||||
|
/
|
||||||
68
database/adb/117_sg_game_scope_policy.sql
Normal file
68
database/adb/117_sg_game_scope_policy.sql
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
-- 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.';
|
||||||
|
/
|
||||||
82
database/adb/118_sgmp_qa_vector_quality_threshold.sql
Normal file
82
database/adb/118_sgmp_qa_vector_quality_threshold.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
-- 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.';
|
||||||
|
/
|
||||||
78
database/adb/118_sgmp_scope_command_profile.sql
Normal file
78
database/adb/118_sgmp_scope_command_profile.sql
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
-- Operational OCI Cohere profile for short game-name and scope extraction.
|
||||||
|
-- It inherits the active GPT profile's OCI credential, region, and metadata.
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_credential_name VARCHAR2(128);
|
||||||
|
v_region VARCHAR2(128);
|
||||||
|
v_compartment_id VARCHAR2(4000);
|
||||||
|
v_attributes CLOB;
|
||||||
|
v_attribute_json JSON_OBJECT_T;
|
||||||
|
v_exists PLS_INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_exists
|
||||||
|
FROM user_cloud_ai_profiles
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_COHERE_COMMAND';
|
||||||
|
|
||||||
|
IF v_exists > 0 THEN
|
||||||
|
DBMS_CLOUD_AI.DROP_PROFILE(
|
||||||
|
profile_name => 'SGMP_POC_OCI_COHERE_COMMAND',
|
||||||
|
force => TRUE
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||||
|
INTO v_credential_name
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name = 'credential_name';
|
||||||
|
|
||||||
|
SELECT DBMS_LOB.SUBSTR(attribute_value, 128, 1)
|
||||||
|
INTO v_region
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name = 'region';
|
||||||
|
|
||||||
|
SELECT DBMS_LOB.SUBSTR(attribute_value, 4000, 1)
|
||||||
|
INTO v_compartment_id
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name = 'oci_compartment_id';
|
||||||
|
|
||||||
|
v_attribute_json := JSON_OBJECT_T();
|
||||||
|
v_attribute_json.put('provider', 'oci');
|
||||||
|
v_attribute_json.put('credential_name', v_credential_name);
|
||||||
|
v_attribute_json.put('model', 'cohere.command-latest');
|
||||||
|
v_attribute_json.put('region', v_region);
|
||||||
|
v_attribute_json.put('oci_compartment_id', v_compartment_id);
|
||||||
|
v_attributes := v_attribute_json.to_clob;
|
||||||
|
|
||||||
|
DBMS_CLOUD_AI.CREATE_PROFILE(
|
||||||
|
profile_name => 'SGMP_POC_OCI_COHERE_COMMAND',
|
||||||
|
attributes => v_attributes,
|
||||||
|
description => 'Smilegate operational OCI Cohere Command profile for game scope extraction'
|
||||||
|
);
|
||||||
|
|
||||||
|
FOR source_attribute IN (
|
||||||
|
SELECT attribute_name, attribute_value
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_GPT54MINI'
|
||||||
|
AND attribute_name NOT IN (
|
||||||
|
'credential_name', 'model', 'provider', 'provider_endpoint',
|
||||||
|
'region', 'oci_compartment_id', 'oci_endpoint_id',
|
||||||
|
'oci_apiformat', 'oci_runtimetype'
|
||||||
|
)
|
||||||
|
) LOOP
|
||||||
|
DBMS_CLOUD_AI.SET_ATTRIBUTE(
|
||||||
|
profile_name => 'SGMP_POC_OCI_COHERE_COMMAND',
|
||||||
|
attribute_name => source_attribute.attribute_name,
|
||||||
|
attribute_value => source_attribute.attribute_value
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
SELECT profile_name, attribute_value AS model
|
||||||
|
FROM user_cloud_ai_profile_attributes
|
||||||
|
WHERE profile_name = 'SGMP_POC_OCI_COHERE_COMMAND'
|
||||||
|
AND attribute_name = 'model';
|
||||||
38
database/adb/119_sgmp_remove_invalid_std12_fewshot.sql
Normal file
38
database/adb/119_sgmp_remove_invalid_std12_fewshot.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- 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';
|
||||||
84
database/adb/121_sg_game_catalog_identity_duality_view.sql
Normal file
84
database/adb/121_sg_game_catalog_identity_duality_view.sql
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
-- 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.';
|
||||||
|
/
|
||||||
144
database/adb/122_sgmp_std18_filtered_sales_aggregate_fewshot.sql
Normal file
144
database/adb/122_sgmp_std18_filtered_sales_aggregate_fewshot.sql
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
-- 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;
|
||||||
33
database/adb/123_sgmp_filtered_sales_aggregate_any_scope.sql
Normal file
33
database/adb/123_sgmp_filtered_sales_aggregate_any_scope.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- 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;
|
||||||
|
/
|
||||||
23
database/adb/124_sgmp_std18_expected_answer_fewshot.sql
Normal file
23
database/adb/124_sgmp_std18_expected_answer_fewshot.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- 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;
|
||||||
|
/
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- 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';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- 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';
|
||||||
90
database/adb/131_sgmp_independent_au_comparison_pattern.sql
Normal file
90
database/adb/131_sgmp_independent_au_comparison_pattern.sql
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
-- 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;
|
||||||
|
|
||||||
|
/
|
||||||
58
database/adb/132_sgmp_country_daily_au_pattern.sql
Normal file
58
database/adb/132_sgmp_country_daily_au_pattern.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- 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;
|
||||||
|
|
||||||
|
/
|
||||||
17
database/adb/133_sgmp_restore_czn06_exact_fewshot.sql
Normal file
17
database/adb/133_sgmp_restore_czn06_exact_fewshot.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- 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
|
||||||
69
database/adb/135_sgmp_semantic_fewshot_retrieval.sql
Normal file
69
database/adb/135_sgmp_semantic_fewshot_retrieval.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
-- 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
|
||||||
87
database/adb/136_sgmp_fewshot_neighbor_margin.sql
Normal file
87
database/adb/136_sgmp_fewshot_neighbor_margin.sql
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
-- 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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 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
|
||||||
11
database/adb/138_sgmp_tighten_fewshot_neighbor_margin.sql
Normal file
11
database/adb/138_sgmp_tighten_fewshot_neighbor_margin.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- 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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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
|
||||||
95
database/adb/140_sgmp_hybrid_fewshot_reranking.sql
Normal file
95
database/adb/140_sgmp_hybrid_fewshot_reranking.sql
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
-- 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
|
||||||
15
database/adb/141_sgmp_restore_czn13_ether_usage_fewshot.sql
Normal file
15
database/adb/141_sgmp_restore_czn13_ether_usage_fewshot.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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
|
||||||
94
database/adb/16_agent_ords_security_local_cleanup.sql
Normal file
94
database/adb/16_agent_ords_security_local_cleanup.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 16_agent_ords_security_local_cleanup.sql
|
||||||
|
-- Agent ORDS security local-only cleanup.
|
||||||
|
--
|
||||||
|
-- This removes only CB_* objects used by the local ADB example.
|
||||||
|
-- It does not touch the existing RDS DB Link POC objects.
|
||||||
|
-- ============================================================
|
||||||
|
WHENEVER SQLERROR CONTINUE
|
||||||
|
SET ECHO OFF
|
||||||
|
SET FEEDBACK ON
|
||||||
|
|
||||||
|
PROMPT === Cleaning VPD policy ===
|
||||||
|
BEGIN
|
||||||
|
DBMS_REDACT.DROP_POLICY(
|
||||||
|
object_schema => USER,
|
||||||
|
object_name => 'CB_V_SEARCH_DOCUMENTS',
|
||||||
|
policy_name => 'CB_CONTENTS_REDACT'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN NULL;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
DBMS_RLS.DROP_POLICY(
|
||||||
|
object_schema => USER,
|
||||||
|
object_name => 'CB_V_SEARCH_DOCUMENTS',
|
||||||
|
policy_name => 'CB_AGENT_DOC_POLICY'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN NULL;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === Cleaning DDS data grants ===
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP DATA GRANT admin.cb_dg_hr_docs'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP DATA GRANT admin.cb_dg_fin_docs'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP DATA GRANT admin.cb_dg_all_docs'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === Cleaning DDS end users and roles ===
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_hr"'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_fin"'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_all"'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP END USER "cb_dds_none"'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_hr_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_fin_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_all_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP DATA ROLE cb_dds_connect_only_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP ROLE cb_dds_connect_role'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === Cleaning DB user and local objects ===
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP USER cb_ords CASCADE'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP VIEW cb_dds_v_search_documents'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP VIEW cb_v_search_documents'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP FUNCTION cb_agent_doc_vpd_filter'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP CONTEXT cb_agent_ctx'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP PACKAGE cb_agent_ctx_pkg'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_agent_bearer_key PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_permission_rule PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_permission PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_user_role PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_app_role PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_app_user PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_dds_documents PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
BEGIN EXECUTE IMMEDIATE 'DROP TABLE cb_search_documents PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === Agent ORDS security local cleanup complete ===
|
||||||
|
EXIT;
|
||||||
@@ -46,6 +46,7 @@ CREATE TABLE cb_app_user (
|
|||||||
user_name VARCHAR2(50) NOT NULL,
|
user_name VARCHAR2(50) NOT NULL,
|
||||||
employee_no VARCHAR2(20) NOT NULL,
|
employee_no VARCHAR2(20) NOT NULL,
|
||||||
dept_code VARCHAR2(20) NOT NULL,
|
dept_code VARCHAR2(20) NOT NULL,
|
||||||
|
stakeholder_user_id VARCHAR2(30),
|
||||||
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
|
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
|
||||||
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
|
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
|
||||||
);
|
);
|
||||||
@@ -75,7 +76,7 @@ CREATE TABLE cb_permission_rule (
|
|||||||
perm_id NUMBER NOT NULL REFERENCES cb_permission(perm_id),
|
perm_id NUMBER NOT NULL REFERENCES cb_permission(perm_id),
|
||||||
rule_column VARCHAR2(128),
|
rule_column VARCHAR2(128),
|
||||||
rule_type VARCHAR2(30) NOT NULL,
|
rule_type VARCHAR2(30) NOT NULL,
|
||||||
rule_value VARCHAR2(100)
|
rule_value VARCHAR2(1000 CHAR)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE cb_permission_column (
|
CREATE TABLE cb_permission_column (
|
||||||
@@ -87,6 +88,7 @@ CREATE TABLE cb_permission_column (
|
|||||||
CREATE TABLE cb_agent_bearer_key (
|
CREATE TABLE cb_agent_bearer_key (
|
||||||
key_id NUMBER PRIMARY KEY,
|
key_id NUMBER PRIMARY KEY,
|
||||||
user_id NUMBER NOT NULL REFERENCES cb_app_user(user_id),
|
user_id NUMBER NOT NULL REFERENCES cb_app_user(user_id),
|
||||||
|
stakeholder_user_id VARCHAR2(30),
|
||||||
key_hash VARCHAR2(128) NOT NULL UNIQUE,
|
key_hash VARCHAR2(128) NOT NULL UNIQUE,
|
||||||
key_prefix VARCHAR2(16),
|
key_prefix VARCHAR2(16),
|
||||||
issued_at DATE DEFAULT SYSDATE NOT NULL,
|
issued_at DATE DEFAULT SYSDATE NOT NULL,
|
||||||
@@ -96,9 +98,9 @@ CREATE TABLE cb_agent_bearer_key (
|
|||||||
);
|
);
|
||||||
|
|
||||||
PROMPT === 3. Seeding permissions ===
|
PROMPT === 3. Seeding permissions ===
|
||||||
INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', 'N', 'Y');
|
INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', NULL, 'N', 'Y');
|
||||||
INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', 'N', 'Y');
|
INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', NULL, 'N', 'Y');
|
||||||
INSERT INTO cb_app_user VALUES (103, '김어드민', 'E99999', 'HQ', 'Y', 'Y');
|
INSERT INTO cb_app_user VALUES (103, '김어드민', 'E99999', 'HQ', NULL, 'Y', 'Y');
|
||||||
|
|
||||||
INSERT INTO cb_app_role VALUES (10, 'HR_DEPT_ROLE', 'INTERNAL');
|
INSERT INTO cb_app_role VALUES (10, 'HR_DEPT_ROLE', 'INTERNAL');
|
||||||
INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_ROLE', 'INTERNAL');
|
INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_ROLE', 'INTERNAL');
|
||||||
@@ -112,9 +114,12 @@ INSERT INTO cb_permission VALUES (100, 10, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'A
|
|||||||
INSERT INTO cb_permission VALUES (200, 20, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
|
INSERT INTO cb_permission VALUES (200, 20, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
|
||||||
INSERT INTO cb_permission VALUES (300, 30, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
|
INSERT INTO cb_permission VALUES (300, 30, 'CB_V_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW');
|
||||||
|
|
||||||
INSERT INTO cb_permission_rule VALUES (1000, 100, 'MY_DEPT', NULL);
|
INSERT INTO cb_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||||
INSERT INTO cb_permission_rule VALUES (2000, 200, 'SELF', NULL);
|
VALUES (1000, 100, NULL, 'MY_DEPT', NULL);
|
||||||
INSERT INTO cb_permission_rule VALUES (3000, 300, 'ALL', NULL);
|
INSERT INTO cb_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||||
|
VALUES (2000, 200, NULL, 'SELF', NULL);
|
||||||
|
INSERT INTO cb_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||||
|
VALUES (3000, 300, NULL, 'ALL', NULL);
|
||||||
|
|
||||||
INSERT INTO cb_permission_column VALUES (300, 'CONTENTS');
|
INSERT INTO cb_permission_column VALUES (300, 'CONTENTS');
|
||||||
|
|
||||||
115
database/adb/18_agent_ords_security_local_vpd_test.sql
Normal file
115
database/adb/18_agent_ords_security_local_vpd_test.sql
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 18_agent_ords_security_local_vpd_test.sql
|
||||||
|
-- Run as CB_ORDS.
|
||||||
|
--
|
||||||
|
-- Expected:
|
||||||
|
-- no context -> 0 rows
|
||||||
|
-- cb_hr_key -> HR rows only, 3 rows
|
||||||
|
-- cb_fin_key -> owner_emp_no = E2001 only, 1 row
|
||||||
|
-- cb_all_key -> all rows, 6 rows
|
||||||
|
-- contents column -> NULL unless CAN_READ_CONTENTS = Y
|
||||||
|
-- invalid key -> ORA-20002, then 0 rows
|
||||||
|
-- direct base table -> ORA-00942
|
||||||
|
-- ============================================================
|
||||||
|
WHENEVER SQLERROR CONTINUE
|
||||||
|
SET ECHO OFF
|
||||||
|
SET FEEDBACK ON
|
||||||
|
SET LINESIZE 220
|
||||||
|
SET PAGESIZE 100
|
||||||
|
COLUMN ctx_user_id FORMAT A12
|
||||||
|
COLUMN ctx_emp_no FORMAT A12
|
||||||
|
COLUMN ctx_dept_code FORMAT A12
|
||||||
|
COLUMN ctx_can_read_contents FORMAT A22
|
||||||
|
COLUMN title FORMAT A28
|
||||||
|
COLUMN owner_emp_no FORMAT A12
|
||||||
|
COLUMN dept_code FORMAT A10
|
||||||
|
COLUMN contents FORMAT A35
|
||||||
|
|
||||||
|
PROMPT
|
||||||
|
PROMPT === VPD 1. No Bearer key / context: fail closed ===
|
||||||
|
BEGIN
|
||||||
|
admin.cb_agent_ctx_pkg.clear_user;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
SELECT SYS_CONTEXT('CB_AGENT_CTX','USER_ID') AS ctx_user_id,
|
||||||
|
SYS_CONTEXT('CB_AGENT_CTX','EMP_NO') AS ctx_emp_no,
|
||||||
|
SYS_CONTEXT('CB_AGENT_CTX','DEPT_CODE') AS ctx_dept_code,
|
||||||
|
SYS_CONTEXT('CB_AGENT_CTX','CAN_READ_CONTENTS') AS ctx_can_read_contents
|
||||||
|
FROM dual;
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS rows_visible
|
||||||
|
FROM admin.cb_v_search_documents;
|
||||||
|
|
||||||
|
PROMPT
|
||||||
|
PROMPT === VPD 2. Authorization: Bearer cb_hr_key -> HR department rows ===
|
||||||
|
BEGIN
|
||||||
|
admin.cb_agent_ctx_pkg.set_user_by_bearer('cb_hr_key');
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
SELECT SYS_CONTEXT('CB_AGENT_CTX','USER_ID') AS ctx_user_id,
|
||||||
|
SYS_CONTEXT('CB_AGENT_CTX','EMP_NO') AS ctx_emp_no,
|
||||||
|
SYS_CONTEXT('CB_AGENT_CTX','DEPT_CODE') AS ctx_dept_code,
|
||||||
|
SYS_CONTEXT('CB_AGENT_CTX','CAN_READ_CONTENTS') AS ctx_can_read_contents
|
||||||
|
FROM dual;
|
||||||
|
|
||||||
|
SELECT doc_id, title, owner_emp_no, dept_code, contents
|
||||||
|
FROM admin.cb_v_search_documents
|
||||||
|
ORDER BY doc_id;
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS rows_visible
|
||||||
|
FROM admin.cb_v_search_documents;
|
||||||
|
|
||||||
|
PROMPT
|
||||||
|
PROMPT === VPD 3. Authorization: Bearer cb_fin_key -> self row only ===
|
||||||
|
BEGIN
|
||||||
|
admin.cb_agent_ctx_pkg.set_user_by_bearer('cb_fin_key');
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
SELECT SYS_CONTEXT('CB_AGENT_CTX','USER_ID') AS ctx_user_id,
|
||||||
|
SYS_CONTEXT('CB_AGENT_CTX','EMP_NO') AS ctx_emp_no,
|
||||||
|
SYS_CONTEXT('CB_AGENT_CTX','DEPT_CODE') AS ctx_dept_code,
|
||||||
|
SYS_CONTEXT('CB_AGENT_CTX','CAN_READ_CONTENTS') AS ctx_can_read_contents
|
||||||
|
FROM dual;
|
||||||
|
|
||||||
|
SELECT doc_id, title, owner_emp_no, dept_code, contents
|
||||||
|
FROM admin.cb_v_search_documents
|
||||||
|
ORDER BY doc_id;
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS rows_visible
|
||||||
|
FROM admin.cb_v_search_documents;
|
||||||
|
|
||||||
|
SELECT doc_id, title, owner_emp_no, dept_code, contents
|
||||||
|
FROM admin.cb_v_search_documents
|
||||||
|
ORDER BY doc_id
|
||||||
|
FETCH FIRST 3 ROWS ONLY;
|
||||||
|
|
||||||
|
PROMPT
|
||||||
|
PROMPT === VPD 4. Authorization: Bearer cb_all_key -> all rows ===
|
||||||
|
BEGIN
|
||||||
|
admin.cb_agent_ctx_pkg.set_user_by_bearer('cb_all_key');
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS rows_visible
|
||||||
|
FROM admin.cb_v_search_documents;
|
||||||
|
|
||||||
|
PROMPT
|
||||||
|
PROMPT === VPD 5. Invalid Bearer key -> ORA-20002 and context cleared ===
|
||||||
|
BEGIN
|
||||||
|
admin.cb_agent_ctx_pkg.set_user_by_bearer('wrong_key');
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS rows_visible_after_invalid_key
|
||||||
|
FROM admin.cb_v_search_documents;
|
||||||
|
|
||||||
|
PROMPT
|
||||||
|
PROMPT === VPD 6. Bypass attempts: direct base/permission tables are not granted ===
|
||||||
|
SELECT COUNT(*) FROM admin.cb_search_documents;
|
||||||
|
SELECT COUNT(*) FROM admin.cb_app_user;
|
||||||
|
SELECT COUNT(*) FROM admin.cb_agent_bearer_key;
|
||||||
|
|
||||||
|
EXIT;
|
||||||
89
database/adb/19_agent_ords_security_local_dds_setup.sql
Normal file
89
database/adb/19_agent_ords_security_local_dds_setup.sql
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 19_agent_ords_security_local_dds_setup.sql
|
||||||
|
-- Local-only executable DDS example for Agent ORDS security.
|
||||||
|
--
|
||||||
|
-- Scenario:
|
||||||
|
-- * DDS uses END USER -> DATA ROLE -> DATA GRANT.
|
||||||
|
-- * No permission table lookup is used.
|
||||||
|
-- * Each DATA GRANT declares the protected object, row filter,
|
||||||
|
-- and column restriction.
|
||||||
|
-- ============================================================
|
||||||
|
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||||
|
SET ECHO OFF
|
||||||
|
SET FEEDBACK ON
|
||||||
|
SET DEFINE OFF
|
||||||
|
|
||||||
|
PROMPT === 1. Creating DDS-only local table and view ===
|
||||||
|
CREATE TABLE cb_dds_documents (
|
||||||
|
doc_id NUMBER PRIMARY KEY,
|
||||||
|
title VARCHAR2(100) NOT NULL,
|
||||||
|
owner_emp_no VARCHAR2(20) NOT NULL,
|
||||||
|
dept_code VARCHAR2(20) NOT NULL,
|
||||||
|
contents VARCHAR2(4000),
|
||||||
|
created_at DATE DEFAULT SYSDATE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO cb_dds_documents VALUES (1, 'HR payroll guide', 'E1001', 'HR', 'Payroll policy and HR guide', SYSDATE);
|
||||||
|
INSERT INTO cb_dds_documents VALUES (2, 'HR recruiting plan', 'E1002', 'HR', 'Recruiting plan for HR team', SYSDATE);
|
||||||
|
INSERT INTO cb_dds_documents VALUES (3, 'Finance close checklist','E2001', 'FIN', 'Monthly close checklist', SYSDATE);
|
||||||
|
INSERT INTO cb_dds_documents VALUES (4, 'Finance audit memo', 'E2002', 'FIN', 'Audit memo for finance team', SYSDATE);
|
||||||
|
INSERT INTO cb_dds_documents VALUES (5, 'Sales forecast', 'E3001', 'SALES', 'Quarterly sales forecast', SYSDATE);
|
||||||
|
INSERT INTO cb_dds_documents VALUES (6, 'HR benefits notice', 'E1003', 'HR', 'Benefits notice for employees', SYSDATE);
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW cb_dds_v_search_documents AS
|
||||||
|
SELECT doc_id,
|
||||||
|
title,
|
||||||
|
owner_emp_no,
|
||||||
|
dept_code,
|
||||||
|
contents,
|
||||||
|
created_at
|
||||||
|
FROM cb_dds_documents;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
PROMPT === 2. Creating DDS end users ===
|
||||||
|
CREATE END USER "cb_dds_hr" IDENTIFIED BY "CbDds#Hr2026Local1";
|
||||||
|
CREATE END USER "cb_dds_fin" IDENTIFIED BY "CbDds#Fin2026Local1";
|
||||||
|
CREATE END USER "cb_dds_all" IDENTIFIED BY "CbDds#All2026Local1";
|
||||||
|
CREATE END USER "cb_dds_none" IDENTIFIED BY "CbDds#None2026Local1";
|
||||||
|
|
||||||
|
PROMPT === 3. Creating DATA ROLEs and connection carrier role ===
|
||||||
|
CREATE ROLE cb_dds_connect_role;
|
||||||
|
GRANT CREATE SESSION TO cb_dds_connect_role;
|
||||||
|
|
||||||
|
CREATE DATA ROLE cb_dds_hr_role;
|
||||||
|
CREATE DATA ROLE cb_dds_fin_role;
|
||||||
|
CREATE DATA ROLE cb_dds_all_role;
|
||||||
|
CREATE DATA ROLE cb_dds_connect_only_role;
|
||||||
|
|
||||||
|
GRANT cb_dds_connect_role TO cb_dds_hr_role;
|
||||||
|
GRANT cb_dds_connect_role TO cb_dds_fin_role;
|
||||||
|
GRANT cb_dds_connect_role TO cb_dds_all_role;
|
||||||
|
GRANT cb_dds_connect_role TO cb_dds_connect_only_role;
|
||||||
|
|
||||||
|
PROMPT === 4. Mapping END USERs to DATA ROLEs ===
|
||||||
|
GRANT DATA ROLE cb_dds_hr_role TO "cb_dds_hr";
|
||||||
|
GRANT DATA ROLE cb_dds_fin_role TO "cb_dds_fin";
|
||||||
|
GRANT DATA ROLE cb_dds_all_role TO "cb_dds_all";
|
||||||
|
GRANT DATA ROLE cb_dds_connect_only_role TO "cb_dds_none";
|
||||||
|
|
||||||
|
PROMPT === 5. Creating DATA GRANTs ===
|
||||||
|
CREATE DATA GRANT admin.cb_dg_hr_docs
|
||||||
|
AS SELECT (ALL COLUMNS EXCEPT contents)
|
||||||
|
ON admin.cb_dds_v_search_documents
|
||||||
|
WHERE dept_code = 'HR'
|
||||||
|
TO cb_dds_hr_role;
|
||||||
|
|
||||||
|
CREATE DATA GRANT admin.cb_dg_fin_docs
|
||||||
|
AS SELECT (ALL COLUMNS EXCEPT contents)
|
||||||
|
ON admin.cb_dds_v_search_documents
|
||||||
|
WHERE dept_code = 'FIN'
|
||||||
|
TO cb_dds_fin_role;
|
||||||
|
|
||||||
|
CREATE DATA GRANT admin.cb_dg_all_docs
|
||||||
|
AS SELECT
|
||||||
|
ON admin.cb_dds_v_search_documents
|
||||||
|
TO cb_dds_all_role;
|
||||||
|
|
||||||
|
PROMPT === DDS local setup complete ===
|
||||||
|
EXIT;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user