Compare commits
34 Commits
1917df09a2
...
hmm-backof
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce0341102 | ||
|
|
2d0b5a3c1e | ||
|
|
cbd49b6b05 | ||
|
|
84029ed633 | ||
|
|
6394bbf078 | ||
|
|
2f5fc2bfbd | ||
|
|
66fcb5d149 | ||
|
|
b13b913939 | ||
|
|
c0c5a36ba0 | ||
|
|
cc2c3e3e25 | ||
|
|
c519435266 | ||
|
|
ad1e4573a9 | ||
|
|
2bd373bcb3 | ||
|
|
7d53b5c75c | ||
|
|
fbf4c19c72 | ||
|
|
1c8ad8647f | ||
|
|
90c771af57 | ||
|
|
de0956ef03 | ||
|
|
2c790f76b7 | ||
|
|
91c96d7919 | ||
|
|
89a6f4e4bc | ||
|
|
e6432fa943 | ||
|
|
cbc645466b | ||
|
|
35782b8a50 | ||
|
|
7dd47d39b9 | ||
|
|
d868d7bbb8 | ||
|
|
9f46205057 | ||
|
|
e9d50e6a32 | ||
|
|
2e44ed0b97 | ||
|
|
af6add5a44 | ||
|
|
9bed75f0c0 | ||
|
|
cdb2c37ae3 | ||
|
|
65a2b27fcc | ||
|
|
0d9028ef13 |
42
.env.example
42
.env.example
@@ -52,7 +52,41 @@ 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=""
|
||||||
|
|
||||||
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다.
|
||||||
|
export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
|
||||||
|
export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}"
|
||||||
|
export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}"
|
||||||
|
export BACKOFFICE_SELECT_AI_PROFILE=""
|
||||||
|
# 생성 SQL은 반드시 EXEMPT ACCESS POLICY가 없는 별도 계정으로 실행합니다.
|
||||||
|
# 런타임 비밀번호는 Git에 저장하지 말고 배포 서버 secret 환경 파일에만 넣으세요.
|
||||||
|
export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}"
|
||||||
|
export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS"
|
||||||
|
export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=""
|
||||||
|
# Optional deployment-specific JSON contract. Keep project rules out of Java.
|
||||||
|
export BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE=""
|
||||||
|
|
||||||
|
# --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 ---
|
||||||
|
# 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다.
|
||||||
|
export BACKOFFICE_CATALOG_OWNER="APP_OWNER"
|
||||||
|
export BACKOFFICE_CATALOG_OBJECTS='[{"key":"employees","tableName":"EMPLOYEES","objectType":"TABLE","businessName":"직원","description":"직원 기본 정보"}]'
|
||||||
|
export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
|
||||||
|
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
|
||||||
|
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
|
||||||
|
|
||||||
|
# 단일 Select AI 도구 호환 설정. 여러 Agent Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
|
||||||
|
export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp"
|
||||||
|
export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice"
|
||||||
|
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_TOOLS=''
|
||||||
|
|
||||||
|
# Data Redaction 관리 대상과 보안 SQL 화면 allowlist. 빈 값이면 관리/노출하지 않습니다.
|
||||||
|
export BACKOFFICE_MASKING_POLICIES=''
|
||||||
|
export BACKOFFICE_SECURITY_SQL_SCRIPTS=''
|
||||||
|
|
||||||
|
# --- (2d) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||||
export BACKOFFICE_AI_ENABLED="false"
|
export BACKOFFICE_AI_ENABLED="false"
|
||||||
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
||||||
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
|
||||||
@@ -71,7 +105,7 @@ export BACKOFFICE_AI_OCI_COMPARTMENT_ID=""
|
|||||||
export VPDTEST1_API_KEY=""
|
export VPDTEST1_API_KEY=""
|
||||||
export VPDTEST2_API_KEY=""
|
export VPDTEST2_API_KEY=""
|
||||||
|
|
||||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (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 가능
|
||||||
@@ -84,7 +118,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"
|
||||||
@@ -105,7 +139,7 @@ export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
|
|||||||
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
|
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
|
||||||
|
|
||||||
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
||||||
# 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
@@ -1,5 +1,6 @@
|
|||||||
# 환경/비밀 — 절대 commit 금지
|
# 환경/비밀 — 절대 commit 금지
|
||||||
.env
|
.env
|
||||||
|
.runtime/
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
@@ -17,6 +18,12 @@ logs/
|
|||||||
# Java / Maven
|
# Java / Maven
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
data/
|
||||||
|
|
||||||
# Locally downloaded development tools (for example SQLcl)
|
# Locally downloaded development tools (for example SQLcl)
|
||||||
.tools/
|
.tools/
|
||||||
|
|
||||||
|
|||||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# 문서 작성 공통 규칙
|
||||||
|
|
||||||
|
이 저장소에서 새 문서를 만들거나 기존 문서를 크게 고칠 때는 아래 구조를 기본으로 한다.
|
||||||
|
|
||||||
|
1. 첫 문서(`README.md`)는 독자가 2~3분 안에 목적, 범위, 결정사항, 전체 구성, 현재 상태를 파악하는 **개요 문서**로 작성한다.
|
||||||
|
2. README에는 복잡한 절차와 모든 오류 사례를 누적하지 않는다. 아래와 같이 역할별 상세 문서로 분리하고, 개요에서 명확한 링크를 제공한다.
|
||||||
|
- `architecture.md`: 신뢰 경계, 컴포넌트 책임, 데이터·인증 흐름, 설계 결정
|
||||||
|
- `cookbook.md`: 준비물, 단계별 적용 명령/화면값, 검증, 롤백
|
||||||
|
- `troubleshooting.md`: 증상 → 원인 → 확인 방법 → 해결 → 재발 방지
|
||||||
|
- 필요하면 `operations.md`, `security.md`, `adr/` 등 목적이 드러나는 파일을 추가한다.
|
||||||
|
3. 그림은 한 장에 모든 세부사항을 넣지 않는다.
|
||||||
|
- README에는 시스템 경계와 핵심 흐름만 보이는 **개요도**를 둔다.
|
||||||
|
- 상세 연결·claim·포트·예외 흐름은 architecture 또는 cookbook의 **상세도**로 분리한다.
|
||||||
|
- 각 그림 아래에는 독자가 알아야 할 결론을 한두 문장으로 적는다.
|
||||||
|
4. Cookbook은 실제 적용 순서를 따르며, 각 단계에 입력값의 출처, 성공 판정, 실패 시 연결할 troubleshooting 항목을 포함한다.
|
||||||
|
5. 비밀번호, client secret, access token, 개인 식별 정보는 어떤 문서·그림·명령 출력에도 기록하지 않는다. 위치와 안전한 조회/rotation 방법만 기록한다.
|
||||||
|
6. README의 문서 지도와 각 상세 문서의 상호 링크를 변경 후 반드시 확인한다.
|
||||||
|
|
||||||
|
문서의 독자는 운영자·개발자·검토자다. 제품명과 전문 용어는 필요할 때만 쓰고, 처음 한 번은 평이한 말로 역할을 정의한다.
|
||||||
|
|
||||||
|
## HMM Select AI·HTML 리포트 보호 기준
|
||||||
|
|
||||||
|
1. 사용자 권한이 적용되는 HMM MCP의 기준 endpoint는 `https://hmm-backoffice.cloud-handson.com/mcp`다.
|
||||||
|
2. MCP route `search_carrier_performance`는 기존 DB Agent Tool `HMM_CARRIER_FEDERATION_SEARCH`를 호출한다. 이 Tool은 자연어를 Select AI Federation으로 처리하며, 고정 SQL·고정 데이터·대체 package로 바꾸지 않는다.
|
||||||
|
3. `hmm-mcp.cloud-handson.com`은 별도 호환성·무 VPD 시험 환경이다. 기준 endpoint나 운영 Select AI 경로의 대체재로 사용하지 않는다.
|
||||||
|
4. HTML 기능 추가 범위는 후속 표현 Tool `HMM_CARRIER_REPORT_RENDERER`와 포털의 범용 이전 결과 전달뿐이다. 렌더러는 사용자·권한·업무 데이터를 다시 조회하거나 보충하지 않는다.
|
||||||
|
5. 조회 Tool, target, package 또는 endpoint를 생성·삭제·교체하기 전에는 현재 MCP discovery, `BACKOFFICE_MCP_TOOLS`, DB Agent Tool metadata를 먼저 대조한다. 기존 조회 경로 변경은 사용자가 명시적으로 요청한 경우에만 한다.
|
||||||
|
6. 회귀 검증은 같은 Bearer 문맥에서 `search_carrier_performance`의 원본 행 수와 renderer에 전달된 `rows` 수가 같은지 확인한다. 대표 시나리오의 현재 기준은 E1001 팀장 질문에 대한 8건이지만, 코드는 E1001이나 8을 조건으로 사용하지 않는다.
|
||||||
54
README.md
54
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
|
||||||
@@ -59,19 +59,21 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
|
|||||||
|
|
||||||
## 소스 위치와 Git 저장 기준
|
## 소스 위치와 Git 저장 기준
|
||||||
|
|
||||||
현재 이 저장소에는 **VPD 관리 백오피스**와 **PoC4 MCP AI Console 외피**가 함께 들어 있다.
|
현재 이 저장소에는 서로 독립적으로 빌드·실행되는 세 애플리케이션이 있다. 각 제품은
|
||||||
두 소스는 별도 Git repository가 아니라 같은 repository의 서로 다른 경로로 관리한다.
|
최상위 폴더를 하나씩 사용하며 저장소 루트에 프레임워크 `src`를 두지 않는다.
|
||||||
|
|
||||||
| 구분 | 저장 위치 | 역할 |
|
| 구분 | 저장 위치 | 역할 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| VPD/ASO/FGA 관리 백오피스 | `src/main/java/com/cloudhandson/vpdbackoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
| VPD/ASO/FGA 관리 백오피스 | `vpd-backoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
||||||
| VPD 백오피스 화면 | `src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
| VPD 백오피스 화면 | `vpd-backoffice/src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
||||||
| VPD 백오피스 CSS/JS | `src/main/resources/static/` | 화면 동작과 스타일 |
|
| VPD 백오피스 CSS/JS | `vpd-backoffice/src/main/resources/static/` | 화면 동작과 스타일 |
|
||||||
| VPD/ASO/FGA/ORDS/Select AI SQL | `sql/adb/` | ADB 적용·검증 SQL 스크립트 |
|
| 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 데모 |
|
| DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 |
|
||||||
| Streamlit 외피 / PoC4 MCP AI Console | `poc4_active_source_20260714/` | ReAct-style로 MCP tool을 호출하는 PoC4 데모 소스 snapshot |
|
| AI Web Agent Console | `ai-web-agent-console/` | Streamlit 기반 MCP 업무 에이전트 콘솔 |
|
||||||
| Streamlit 외피 메인 화면 | `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py` | MCP discovery, tool routing, ReAct-style agent loop, FGA evidence 표시 |
|
| AI Web Agent Console 진입점 | `ai-web-agent-console/app.py` | MCP discovery, 도구 호출, 답변 합성, 감사 증적 표시 |
|
||||||
| Streamlit 외피 MCP 설정 | `poc4_active_source_20260714/config/mcp_servers.json` | `kb_mcp`, `kb_vector_mcp` endpoint와 allowlist |
|
| AI Web Agent Console 패키지 | `ai-web-agent-console/ai_web_agent_console/` | 인증, 화면, MCP, 모델과 질의 계약 모듈 |
|
||||||
|
| AI Web Agent Console 설정 | `ai-web-agent-console/config/` | 제품, MCP, 모델, 데모 사용자와 질문 JSON |
|
||||||
|
|
||||||
Git 기준은 다음과 같다.
|
Git 기준은 다음과 같다.
|
||||||
|
|
||||||
@@ -80,14 +82,12 @@ Git 기준은 다음과 같다.
|
|||||||
| 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` |
|
| 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` |
|
||||||
| 운영 기준 remote | `gitea` |
|
| 운영 기준 remote | `gitea` |
|
||||||
| 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` |
|
| 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` |
|
||||||
| 기준 branch | `main` |
|
| HMM 기준 branch | `hmm-backoffice` |
|
||||||
| 보조 remote | `origin` = `https://github.com/devmrko/vpd-permission-poc.git` |
|
|
||||||
|
|
||||||
주의:
|
주의:
|
||||||
|
|
||||||
- 현재 운영·현행화 기준은 `gitea/main`이다.
|
- HMM 운영·현행화 기준은 Gitea의 `hmm-backoffice` 브랜치다.
|
||||||
- `origin/main`은 보조 remote이며, 이 작업공간의 `main`이 `origin/main`보다 앞서 있을 수 있다.
|
- 날짜 스냅샷과 PoC 번호는 현재 소스 경로로 사용하지 않는다.
|
||||||
- `poc4_active_source_20260714/`는 배포 VM의 PoC4 활성 소스를 가져온 snapshot이다. 원본은 배포 VM의 `/home/opc/poc_4/` 계열 경로에서 운영되었고, 저장소에는 비밀정보 없이 소스와 설정 sample만 보관한다.
|
|
||||||
- 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다.
|
- 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -97,6 +97,7 @@ 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` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
|
||||||
@@ -109,7 +110,7 @@ Git 기준은 다음과 같다.
|
|||||||
## 빠른 시작 (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) 환경값 채우기
|
||||||
@@ -167,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 결과 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
@@ -185,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 행 수를 가볍게 확인합니다.
|
||||||
|
|
||||||
@@ -197,7 +198,7 @@ $EDITOR .env
|
|||||||
|
|
||||||
## DDS 설정 핵심
|
## DDS 설정 핵심
|
||||||
|
|
||||||
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이
|
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이
|
||||||
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
|
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
|
||||||
|
|
||||||
### 사전 조건
|
### 사전 조건
|
||||||
@@ -257,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
|
||||||
|
|||||||
53
ai-web-agent-console/.env.example
Normal file
53
ai-web-agent-console/.env.example
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# AI Web Agent Console local/VM configuration example.
|
||||||
|
# Copy to .env and keep real credentials out of Git.
|
||||||
|
|
||||||
|
# MCP
|
||||||
|
HMM_MCP_BEARER_TOKEN=
|
||||||
|
HMM_MCP_BEARER_TOKEN_E1001=
|
||||||
|
HMM_MCP_BEARER_TOKEN_E1002=
|
||||||
|
HMM_MCP_BEARER_TOKEN_E1003=
|
||||||
|
HMM_MCP_BEARER_TOKEN_E1005=
|
||||||
|
HMM_MCP_BEARER_TOKEN_E1007=
|
||||||
|
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
|
||||||
59
ai-web-agent-console/README.md
Normal file
59
ai-web-agent-console/README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 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_E1001`처럼 사용자별 환경변수 이름만 참조한다. token 원문은 설정 JSON이나
|
||||||
|
문서에 기록하지 않는다. 사용자 preset들이 같은 환경변수를 공유하면 화면의 사용자만 바뀌고 DB
|
||||||
|
권한 문맥은 바뀌지 않으므로 허용하지 않는다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
```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 패키지와 실행 명령은 이 문서의 새 이름만
|
||||||
|
사용한다.
|
||||||
@@ -28,6 +28,14 @@ from typing import Deque
|
|||||||
from urllib.parse import parse_qs, urlsplit
|
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")
|
LOG = logging.getLogger("hmm_portal_auth")
|
||||||
SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION"
|
SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION"
|
||||||
CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF"
|
CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF"
|
||||||
@@ -54,13 +62,17 @@ class AuthConfig:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_environment(cls) -> "AuthConfig":
|
def from_environment(cls) -> "AuthConfig":
|
||||||
config = cls(
|
config = cls(
|
||||||
username=os.environ.get("POC4_LOGIN_USER", "").strip(),
|
username=_environment_value(
|
||||||
password_pbkdf2=os.environ.get(
|
"AI_WEB_AGENT_CONSOLE_LOGIN_USER", "POC4_LOGIN_USER"
|
||||||
"POC4_LOGIN_PASSWORD_PBKDF2", ""
|
),
|
||||||
).strip(),
|
password_pbkdf2=_environment_value(
|
||||||
cookie_secret=os.environ.get(
|
"AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2",
|
||||||
"POC4_LOGIN_COOKIE_SECRET", ""
|
"POC4_LOGIN_PASSWORD_PBKDF2",
|
||||||
).strip(),
|
),
|
||||||
|
cookie_secret=_environment_value(
|
||||||
|
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET",
|
||||||
|
"POC4_LOGIN_COOKIE_SECRET",
|
||||||
|
),
|
||||||
bind_address=os.environ.get(
|
bind_address=os.environ.get(
|
||||||
"PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1"
|
"PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1"
|
||||||
).strip(),
|
).strip(),
|
||||||
@@ -96,9 +108,11 @@ class AuthConfig:
|
|||||||
|
|
||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
if not self.username or not self.password_pbkdf2:
|
if not self.username or not self.password_pbkdf2:
|
||||||
raise ValueError("POC4 portal login credentials are not configured")
|
raise ValueError("AI web agent console login credentials are not configured")
|
||||||
if len(self.cookie_secret.encode("utf-8")) < 32:
|
if len(self.cookie_secret.encode("utf-8")) < 32:
|
||||||
raise ValueError("POC4_LOGIN_COOKIE_SECRET must be at least 32 bytes")
|
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"}:
|
if self.bind_address not in {"127.0.0.1", "::1"}:
|
||||||
raise ValueError("Portal authentication service must bind to loopback")
|
raise ValueError("Portal authentication service must bind to loopback")
|
||||||
if not 1 <= self.port <= 65535:
|
if not 1 <= self.port <= 65535:
|
||||||
@@ -10,11 +10,11 @@ from dataclasses import dataclass
|
|||||||
import json
|
import json
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from src.oci_genai_sdk import (
|
from ai_web_agent_console.oci_genai_sdk import (
|
||||||
build_oci_genai_completion_client,
|
build_oci_genai_completion_client,
|
||||||
temperature_for_model_profile,
|
temperature_for_model_profile,
|
||||||
)
|
)
|
||||||
from src.poc3.model_registry import resolve_model_profile
|
from ai_web_agent_console.model_registry import resolve_model_profile
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""8512/8513 전용 PoC_3 model profile registry.
|
"""8512/8513 전용 AI Web Agent Console model profile registry.
|
||||||
|
|
||||||
이 registry는 모델 metadata만 관리한다. ``provider=oci``는 모델의 출처를 뜻하며
|
이 registry는 모델 metadata만 관리한다. ``provider=oci``는 모델의 출처를 뜻하며
|
||||||
``POC3_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재
|
``AI_WEB_AGENT_CONSOLE_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재
|
||||||
MCP 실행 경로는 계속 ``mock``일 수 있다.
|
MCP 실행 경로는 계속 ``mock``일 수 있다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -15,11 +15,11 @@ import re
|
|||||||
from typing import Any, Mapping, Optional
|
from typing import Any, Mapping, Optional
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
REGISTRY_PATH = ROOT / "config" / "poc3_model_profiles.json"
|
REGISTRY_PATH = ROOT / "config" / "model_profiles.json"
|
||||||
DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci"
|
DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci"
|
||||||
MODEL_PROFILE_ENV = "POC3_MODEL_PROFILE"
|
MODEL_PROFILE_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE"
|
||||||
MODEL_PROFILE_DEFAULT_ENV = "POC3_MODEL_PROFILE_DEFAULT"
|
MODEL_PROFILE_DEFAULT_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE_DEFAULT"
|
||||||
EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b")
|
EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b")
|
||||||
MODEL_PROFILE_ALIASES = {
|
MODEL_PROFILE_ALIASES = {
|
||||||
"gpt54_mini": "gpt54_mini_oci",
|
"gpt54_mini": "gpt54_mini_oci",
|
||||||
@@ -84,36 +84,36 @@ _REQUIRED_PROFILE_FIELDS = (
|
|||||||
"poc2_select_ai_profile",
|
"poc2_select_ai_profile",
|
||||||
"poc2_native_agent_team",
|
"poc2_native_agent_team",
|
||||||
"verification_status",
|
"verification_status",
|
||||||
"default_for_poc3",
|
"default_for_console",
|
||||||
"source_tag",
|
"source_tag",
|
||||||
"notes",
|
"notes",
|
||||||
)
|
)
|
||||||
|
|
||||||
_PROFILE_ROUTE_ENV_KEYS = {
|
_PROFILE_ROUTE_ENV_KEYS = {
|
||||||
"gpt55_oci": (
|
"gpt55_oci": (
|
||||||
"POC3_LLM_GPT55_OCI_MODEL_ID",
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_MODEL_ID",
|
||||||
"POC3_LLM_GPT55_OCI_REGION",
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_REGION",
|
||||||
"POC3_LLM_GPT55_OCI_ENDPOINT",
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_ENDPOINT",
|
||||||
),
|
),
|
||||||
"gpt54_mini_oci": (
|
"gpt54_mini_oci": (
|
||||||
"POC3_LLM_GPT54_MINI_OCI_MODEL_ID",
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID",
|
||||||
"POC3_LLM_GPT54_MINI_OCI_REGION",
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION",
|
||||||
"POC3_LLM_GPT54_MINI_OCI_ENDPOINT",
|
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT",
|
||||||
),
|
),
|
||||||
"grok43": (
|
"grok43": (
|
||||||
"POC3_LLM_GROK43_MODEL_ID",
|
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_MODEL_ID",
|
||||||
"POC3_LLM_GROK43_REGION",
|
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_REGION",
|
||||||
"POC3_LLM_GROK43_ENDPOINT",
|
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_ENDPOINT",
|
||||||
),
|
),
|
||||||
"llama4_maverick": (
|
"llama4_maverick": (
|
||||||
"POC3_LLM_LLAMA4_MAVERICK_MODEL_ID",
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_MODEL_ID",
|
||||||
"POC3_LLM_LLAMA4_MAVERICK_REGION",
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_REGION",
|
||||||
"POC3_LLM_LLAMA4_MAVERICK_ENDPOINT",
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_ENDPOINT",
|
||||||
),
|
),
|
||||||
"llama33_70b": (
|
"llama33_70b": (
|
||||||
"POC3_LLM_LLAMA33_70B_MODEL_ID",
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_MODEL_ID",
|
||||||
"POC3_LLM_LLAMA33_70B_REGION",
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_REGION",
|
||||||
"POC3_LLM_LLAMA33_70B_ENDPOINT",
|
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_ENDPOINT",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ class ModelProfile:
|
|||||||
poc2_select_ai_profile: str
|
poc2_select_ai_profile: str
|
||||||
poc2_native_agent_team: str
|
poc2_native_agent_team: str
|
||||||
verification_status: str
|
verification_status: str
|
||||||
default_for_poc3: bool
|
default_for_console: bool
|
||||||
source_tag: str
|
source_tag: str
|
||||||
notes: str
|
notes: str
|
||||||
display_order: int = 999
|
display_order: int = 999
|
||||||
@@ -163,9 +163,9 @@ class ModelProfile:
|
|||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile":
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile":
|
||||||
missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value]
|
missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value]
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError("PoC_3 model profile fields are missing")
|
raise ValueError("AI Web Agent Console model profile fields are missing")
|
||||||
if not isinstance(value.get("default_for_poc3"), bool):
|
if not isinstance(value.get("default_for_console"), bool):
|
||||||
raise ValueError("default_for_poc3 must be boolean")
|
raise ValueError("default_for_console must be boolean")
|
||||||
order = value.get("display_order", 999)
|
order = value.get("display_order", 999)
|
||||||
if isinstance(order, bool) or not isinstance(order, int) or order < 0:
|
if isinstance(order, bool) or not isinstance(order, int) or order < 0:
|
||||||
raise ValueError("model profile display_order is invalid")
|
raise ValueError("model profile display_order is invalid")
|
||||||
@@ -191,7 +191,7 @@ class ModelProfile:
|
|||||||
.strip()
|
.strip()
|
||||||
.upper(),
|
.upper(),
|
||||||
verification_status=str(value["verification_status"]).strip().upper(),
|
verification_status=str(value["verification_status"]).strip().upper(),
|
||||||
default_for_poc3=value["default_for_poc3"],
|
default_for_console=value["default_for_console"],
|
||||||
source_tag=str(value["source_tag"]).strip(),
|
source_tag=str(value["source_tag"]).strip(),
|
||||||
notes=str(value["notes"]).strip(),
|
notes=str(value["notes"]).strip(),
|
||||||
display_order=order,
|
display_order=order,
|
||||||
@@ -276,7 +276,7 @@ class ModelProfile:
|
|||||||
"poc2_select_ai_profile": self.poc2_select_ai_profile,
|
"poc2_select_ai_profile": self.poc2_select_ai_profile,
|
||||||
"poc2_native_agent_team": self.poc2_native_agent_team,
|
"poc2_native_agent_team": self.poc2_native_agent_team,
|
||||||
"verification_status": self.verification_status,
|
"verification_status": self.verification_status,
|
||||||
"default_for_poc3": self.default_for_poc3,
|
"default_for_console": self.default_for_console,
|
||||||
"source_tag": self.source_tag,
|
"source_tag": self.source_tag,
|
||||||
"notes": self.notes,
|
"notes": self.notes,
|
||||||
}
|
}
|
||||||
@@ -284,7 +284,7 @@ class ModelProfile:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ModelProfileRegistry:
|
class ModelProfileRegistry:
|
||||||
"""검증된 PoC_3 model profile 집합."""
|
"""검증된 AI Web Agent Console model profile 집합."""
|
||||||
|
|
||||||
profiles: tuple[ModelProfile, ...]
|
profiles: tuple[ModelProfile, ...]
|
||||||
default_model_profile: str
|
default_model_profile: str
|
||||||
@@ -338,32 +338,32 @@ def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry:
|
|||||||
try:
|
try:
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
raise ValueError("PoC_3 model profile registry cannot be loaded") from exc
|
raise ValueError("AI Web Agent Console model profile registry cannot be loaded") from exc
|
||||||
if not isinstance(payload, Mapping):
|
if not isinstance(payload, Mapping):
|
||||||
raise ValueError("PoC_3 model profile registry must be an object")
|
raise ValueError("AI Web Agent Console model profile registry must be an object")
|
||||||
raw_profiles = payload.get("profiles")
|
raw_profiles = payload.get("profiles")
|
||||||
if not isinstance(raw_profiles, list) or not raw_profiles:
|
if not isinstance(raw_profiles, list) or not raw_profiles:
|
||||||
raise ValueError("PoC_3 model profile registry has no profiles")
|
raise ValueError("AI Web Agent Console model profile registry has no profiles")
|
||||||
profiles = tuple(
|
profiles = tuple(
|
||||||
ModelProfile.from_mapping(item)
|
ModelProfile.from_mapping(item)
|
||||||
for item in raw_profiles
|
for item in raw_profiles
|
||||||
if isinstance(item, Mapping)
|
if isinstance(item, Mapping)
|
||||||
)
|
)
|
||||||
if len(profiles) != len(raw_profiles):
|
if len(profiles) != len(raw_profiles):
|
||||||
raise ValueError("PoC_3 model profile registry contains an invalid profile")
|
raise ValueError("AI Web Agent Console model profile registry contains an invalid profile")
|
||||||
keys = tuple(item.model_key for item in profiles)
|
keys = tuple(item.model_key for item in profiles)
|
||||||
if len(set(keys)) != len(keys):
|
if len(set(keys)) != len(keys):
|
||||||
raise ValueError("PoC_3 model profile keys must be unique")
|
raise ValueError("AI Web Agent Console model profile keys must be unique")
|
||||||
if len({item.display_name for item in profiles}) != len(profiles):
|
if len({item.display_name for item in profiles}) != len(profiles):
|
||||||
raise ValueError("PoC_3 model profile display names must be unique")
|
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_poc3)
|
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()
|
configured_default = str(payload.get("default_model_profile") or "").strip().lower()
|
||||||
if defaults != (configured_default,):
|
if defaults != (configured_default,):
|
||||||
raise ValueError("PoC_3 model profile default is inconsistent")
|
raise ValueError("AI Web Agent Console model profile default is inconsistent")
|
||||||
if configured_default != DEFAULT_MODEL_PROFILE_KEY:
|
if configured_default != DEFAULT_MODEL_PROFILE_KEY:
|
||||||
raise ValueError("PoC_3 GPT-5.5 default contract is not satisfied")
|
raise ValueError("AI Web Agent Console GPT-5.5 default contract is not satisfied")
|
||||||
if not set(EXISTING_MODEL_PROFILE_KEYS).issubset(keys):
|
if not set(EXISTING_MODEL_PROFILE_KEYS).issubset(keys):
|
||||||
raise ValueError("existing PoC_3 selector models are missing")
|
raise ValueError("existing AI Web Agent Console selector models are missing")
|
||||||
actual_answer_routes = {
|
actual_answer_routes = {
|
||||||
item.model_key: (
|
item.model_key: (
|
||||||
item.model_id,
|
item.model_id,
|
||||||
@@ -374,16 +374,16 @@ def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry:
|
|||||||
for item in profiles
|
for item in profiles
|
||||||
}
|
}
|
||||||
if actual_answer_routes != _EXPECTED_ANSWER_MODEL_ROUTES:
|
if actual_answer_routes != _EXPECTED_ANSWER_MODEL_ROUTES:
|
||||||
raise ValueError("PoC_3 answer model route mapping is inconsistent")
|
raise ValueError("AI Web Agent Console answer model route mapping is inconsistent")
|
||||||
if str(payload.get("source_commit") or "").strip() != EXPECTED_SOURCE_COMMIT:
|
if str(payload.get("source_commit") or "").strip() != EXPECTED_SOURCE_COMMIT:
|
||||||
raise ValueError("PoC_2 source commit is inconsistent")
|
raise ValueError("PoC_2 source commit is inconsistent")
|
||||||
if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles):
|
if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles):
|
||||||
raise ValueError("PoC_2 source tag is inconsistent")
|
raise ValueError("PoC_2 source tag is inconsistent")
|
||||||
if payload.get("schema_version") != 1:
|
if payload.get("schema_version") != 1:
|
||||||
raise ValueError("unsupported PoC_3 model profile registry schema")
|
raise ValueError("unsupported AI Web Agent Console model profile registry schema")
|
||||||
registry_name = str(payload.get("registry_name") or "").strip()
|
registry_name = str(payload.get("registry_name") or "").strip()
|
||||||
if not registry_name:
|
if not registry_name:
|
||||||
raise ValueError("PoC_3 model profile registry name is missing")
|
raise ValueError("AI Web Agent Console model profile registry name is missing")
|
||||||
return ModelProfileRegistry(
|
return ModelProfileRegistry(
|
||||||
profiles=profiles,
|
profiles=profiles,
|
||||||
default_model_profile=configured_default,
|
default_model_profile=configured_default,
|
||||||
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",
|
||||||
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""PoC_3 preset catalog와 현재 질문 기반의 결정적 intent router."""
|
"""AI Web Agent Console preset catalog와 현재 질문 기반의 결정적 intent router."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ COMMON_DEMO_QUESTIONS = (
|
|||||||
|
|
||||||
_QUESTION_BY_ID = {item.question_id: item for item in COMMON_DEMO_QUESTIONS}
|
_QUESTION_BY_ID = {item.question_id: item for item in COMMON_DEMO_QUESTIONS}
|
||||||
if len(_QUESTION_BY_ID) != len(COMMON_DEMO_QUESTIONS):
|
if len(_QUESTION_BY_ID) != len(COMMON_DEMO_QUESTIONS):
|
||||||
raise RuntimeError("duplicate PoC_3 demo question id")
|
raise RuntimeError("duplicate AI Web Agent Console demo question id")
|
||||||
|
|
||||||
|
|
||||||
def question_by_id(question_id: str) -> DemoQuestion:
|
def question_by_id(question_id: str) -> DemoQuestion:
|
||||||
@@ -100,7 +100,7 @@ def question_by_id(question_id: str) -> DemoQuestion:
|
|||||||
try:
|
try:
|
||||||
return _QUESTION_BY_ID[normalized]
|
return _QUESTION_BY_ID[normalized]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ValueError("unknown PoC_3 demo question id") from None
|
raise ValueError("unknown AI Web Agent Console demo question id") from None
|
||||||
|
|
||||||
|
|
||||||
def normalize_scenario_id(value: object) -> str | None:
|
def normalize_scenario_id(value: object) -> str | None:
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>HMM | 팀 포트폴리오 선사 실적</title>
|
||||||
|
<style>
|
||||||
|
:root { --hmm:#0065ad; --hmm-deep:#004a84; --ink:#102c43; --copy:#3d5363; --muted:#71818c; --line:#0d78c4; --pale:#edf7fc; --canvas:#f4f5f6; --green:#16815d; --amber:#b66b00; --red:#c5413c; font-family:"Noto Sans KR","Malgun Gothic",Arial,sans-serif; color:var(--ink); }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; min-width:320px; background:var(--canvas); }
|
||||||
|
.page { min-height:100vh; max-width:1480px; margin:0 auto; background:#fff; box-shadow:0 0 40px rgba(12,38,60,.08); }
|
||||||
|
.wordmark { display:flex; align-items:flex-end; gap:9px; font-size:18px; font-weight:900; letter-spacing:-.03em; }
|
||||||
|
.wordmark::before { content:""; width:24px; height:21px; display:inline-block; background:linear-gradient(155deg,transparent 37%,#0571bc 38% 47%,transparent 48%),linear-gradient(26deg,transparent 42%,#0571bc 43% 52%,transparent 53%); border-left:2px solid #0571bc; border-bottom:2px solid #0571bc; }
|
||||||
|
.report-name { margin:24px 0 31px; font-weight:800; font-size:13px; line-height:1.3; }
|
||||||
|
.report-name span { display:block; color:var(--hmm); }
|
||||||
|
.side-tools { padding:12px 0 22px; border-bottom:1px solid #8f969a; color:#056bad; font-size:16px; letter-spacing:6px; }
|
||||||
|
nav { margin-top:22px; } .nav-group { padding:15px 0; border-bottom:1px solid #8f969a; }
|
||||||
|
.nav-group-title { margin:0 0 11px; font-size:12px; font-weight:850; text-transform:uppercase; }
|
||||||
|
.nav-item { display:block; padding:5px 9px; color:#69747b; font-size:11px; line-height:1.3; text-decoration:none; }
|
||||||
|
.nav-item.active { color:#fff; background:linear-gradient(90deg,#0066ae,#52a9d9); font-weight:800; }
|
||||||
|
.side-foot { margin-top:28px; color:#748087; font-size:10px; line-height:1.6; }
|
||||||
|
.content { padding:42px min(6vw,92px) 45px; }
|
||||||
|
.brand-banner { display:flex; align-items:center; justify-content:space-between; gap:18px; padding-bottom:18px; }
|
||||||
|
.brand-banner img { display:block; width:128px; height:auto; }
|
||||||
|
.brand-banner span { color:#627887; font-size:10px; font-weight:750; letter-spacing:.12em; text-transform:uppercase; }
|
||||||
|
.top-line { height:4px; background:#045fa5; margin-bottom:32px; }
|
||||||
|
.report-head { display:flex; justify-content:space-between; align-items:flex-start; gap:28px; padding-bottom:35px; border-bottom:1px solid var(--line); }
|
||||||
|
.kicker { margin:0 0 10px; color:#0070ba; font-size:12px; font-weight:850; letter-spacing:.025em; }
|
||||||
|
h1 { margin:0; color:#075b9f; font-size:clamp(30px,3.25vw,50px); line-height:1.16; letter-spacing:-.07em; font-weight:700; }
|
||||||
|
h1 b { font-weight:850; }
|
||||||
|
.head-meta { min-width:195px; padding-left:22px; border-left:1px solid #96c8e7; color:#647682; font-size:11px; line-height:1.65; }
|
||||||
|
.head-meta strong { display:block; color:#075b9f; font-size:12px; }
|
||||||
|
.intro { padding:18px 0 23px; color:var(--copy); font-size:15px; line-height:1.75; border-bottom:1px solid #8ac5e8; }
|
||||||
|
.section-heading { margin:0 0 16px; color:#005fa9; font-size:21px; letter-spacing:-.05em; }
|
||||||
|
.section-heading small { margin-left:8px; color:#71818c; font-size:11px; letter-spacing:0; font-weight:500; }
|
||||||
|
.executive { display:grid; grid-template-columns:1.45fr 1fr; gap:42px; padding:29px 0 30px; border-bottom:1px solid var(--line); }
|
||||||
|
.metrics { display:grid; grid-template-columns:repeat(2,1fr); border-top:2px solid #1478bb; border-left:1px solid #c8dce9; }
|
||||||
|
.metric { min-height:111px; padding:17px 18px; border-right:1px solid #c8dce9; border-bottom:1px solid #c8dce9; }
|
||||||
|
.metric-label { color:#5b6f7e; font-size:11px; font-weight:700; }.metric-value { margin:8px 0 5px; color:#075b9f; font-size:27px; letter-spacing:-.055em; font-weight:850; font-variant-numeric:tabular-nums; }.metric-value.money { font-size:23px; }.metric-note { color:#75848c; font-size:10px; }
|
||||||
|
.risk-panel { padding:2px 0 0 25px; border-left:1px dotted #1478bb; }.risk-panel h2 { margin:0 0 15px; color:#005fa9; font-size:20px; letter-spacing:-.05em; }
|
||||||
|
.risk-message { margin:0 0 17px; color:var(--copy); font-size:12px; line-height:1.65; }.risk-list { display:grid; gap:9px; }.risk-item { display:flex; align-items:center; gap:10px; padding-bottom:8px; border-bottom:1px solid #d7e8f3; font-size:11px; }.risk-item:last-child { border-bottom:0; }.risk-item-name { flex:1; font-weight:750; }.pill { padding:3px 7px; border-radius:2px; font-size:10px; font-weight:850; }.pill.red { color:var(--red); background:#fff1f0; }.pill.amber { color:var(--amber); background:#fff6e7; }.pill.green { color:var(--green); background:#eaf7f0; }
|
||||||
|
.portfolio { padding:31px 0 28px; border-bottom:1px solid var(--line); }.portfolio-grid { display:grid; grid-template-columns:minmax(0,1.65fr) minmax(260px,.8fr); gap:43px; }.chart-note { margin:-8px 0 22px; color:#73838e; font-size:11px; }.bar-chart { display:grid; gap:12px; }.bar-row { display:grid; grid-template-columns:146px minmax(100px,1fr) 112px; align-items:center; gap:12px; }.bar-person { font-size:11px; }.bar-person b { display:block; color:#134f7f; font-size:12px; }.bar-person span { color:#788791; }.bar-lane { height:24px; background:#ebf2f6; overflow:hidden; }.bar { height:100%; min-width:4px; background:linear-gradient(90deg,#0073bb,#1aa2d8); position:relative; }.bar.critical { background:linear-gradient(90deg,#0073bb 0 84%,#d75349 84%); }.bar-value { color:#15486f; text-align:right; font-size:11px; font-weight:800; font-variant-numeric:tabular-nums; }.bar-value span { display:block; color:#72828c; font-size:9px; font-weight:500; }
|
||||||
|
.chart-aside { padding:14px 0 0 22px; border-left:1px dotted #1478bb; }.chart-aside h3 { margin:0 0 14px; color:#005fa9; font-size:16px; }.legend { display:flex; justify-content:space-between; align-items:end; padding:10px 0; border-bottom:1px solid #d5e4ed; }.legend:last-child { border-bottom:0; }.legend-label { display:flex; align-items:center; gap:8px; font-size:11px; }.dot { width:10px; height:10px; border-radius:50%; }.dot.green { background:var(--green); }.dot.amber { background:var(--amber); }.dot.red { background:var(--red); }.legend strong { color:#075b9f; font-size:20px; }
|
||||||
|
.detail { padding:31px 0 28px; }.detail-head { display:flex; justify-content:space-between; align-items:end; gap:16px; }.detail-head p { margin:0 0 16px; color:#75848c; font-size:11px; }.detail-table { width:100%; border-collapse:collapse; border-top:2px solid #1478bb; }.detail-table th { padding:11px 10px; background:#eff8fd; color:#176aa4; text-align:left; font-size:10px; font-weight:800; }.detail-table td { padding:12px 10px; border-bottom:1px solid #d4e3eb; color:#334f62; font-size:11px; }.detail-table .code { display:block; margin-bottom:2px; color:#75848c; font-size:9px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.detail-table .name { font-weight:750; }.number { text-align:right; font-variant-numeric:tabular-nums; }.negative { color:var(--red)!important; }.empty { padding:35px; color:#77868f; text-align:center; border:1px solid #d5e4ed; }
|
||||||
|
.footnotes { display:grid; grid-template-columns:1.4fr 1fr; gap:25px; padding-top:19px; border-top:1px solid var(--line); color:#697d89; font-size:10px; line-height:1.7; }.footnotes h3 { margin:0 0 5px; color:#075b9f; font-size:11px; }.footnotes p,.footnotes ul { margin:0; padding-left:15px; }.footnotes p { padding-left:0; }
|
||||||
|
@media(max-width:1000px){.content{padding:32px 35px 42px}.executive,.portfolio-grid{gap:25px}.bar-row{grid-template-columns:118px minmax(80px,1fr) 95px}}
|
||||||
|
@media(max-width:760px){.content{padding:24px 18px 35px}.top-line{margin-bottom:20px}.report-head,.executive,.portfolio-grid,.footnotes{grid-template-columns:1fr;display:grid}.report-head{gap:17px;padding-bottom:24px}.head-meta{padding-left:0;border-left:0;border-top:1px solid #96c8e7;padding-top:10px}.intro{font-size:13px}.risk-panel,.chart-aside{padding:24px 0 0;border-left:0;border-top:1px dotted #1478bb}.bar-row{grid-template-columns:105px minmax(30px,1fr) 80px;gap:7px}.detail{overflow-x:auto}.detail-table{min-width:690px}.footnotes{gap:14px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="report-root" class="page" aria-live="polite"></main>
|
||||||
|
<script>
|
||||||
|
// 서버가 이미 권한 검사를 마친 조회 결과 JSON을 이 위치에 주입한다.
|
||||||
|
const reportData = __REPORT_DATA__;
|
||||||
|
const esc=(v)=>String(v??"").replace(/[&<>'"]/g,c=>({"&":"&","<":"<",">":">","'":"'","\"":"""}[c]));
|
||||||
|
const n=(v,d=2)=>new Intl.NumberFormat("en-US",{maximumFractionDigits:d}).format(Number(v||0));
|
||||||
|
const usd=(v)=>`${n(v)} USD`; const pct=(v)=>`${n(v)}%`; const when=(v)=>new Intl.DateTimeFormat("ko-KR",{dateStyle:"medium",timeStyle:"short",timeZone:"Asia/Seoul"}).format(new Date(v));
|
||||||
|
const riskClass=(v)=>String(v||"").toLowerCase(); const riskLabel=(v)=>({GREEN:"정상",AMBER:"주의",RED:"위험"}[v]||v||"미분류");
|
||||||
|
function aggregate(rows){ const risks={GREEN:0,AMBER:0,RED:0}; const people=new Map(); rows.forEach(row=>{const revenue=Number(row.latestRevenueUsd||0); const margin=Number(row.latestGrossMarginUsd||0); risks[row.latestRiskLevel]=(risks[row.latestRiskLevel]||0)+1; if(!people.has(row.employeeCode)) people.set(row.employeeCode,{code:row.employeeCode,name:row.employeeName,revenue:0,carriers:0,hasRisk:false}); const p=people.get(row.employeeCode);p.revenue+=revenue;p.carriers+=1;p.hasRisk||=row.latestRiskLevel==="RED"||row.latestRiskLevel==="AMBER";}); const totalRevenue=rows.reduce((s,r)=>s+Number(r.latestRevenueUsd||0),0); const totalMargin=rows.reduce((s,r)=>s+Number(r.latestGrossMarginUsd||0),0); return {risks,people:[...people.values()].sort((a,b)=>b.revenue-a.revenue),totalRevenue,totalMargin,avgReliability:rows.length?rows.reduce((s,r)=>s+Number(r.latestScheduleReliabilityPct||0),0)/rows.length:0}; }
|
||||||
|
function badge(value){return `<span class="pill ${riskClass(value)}">${esc(riskLabel(value))}</span>`;}
|
||||||
|
function detailRow(row){return `<tr><td><span class="code">${esc(row.employeeCode)}</span><span class="name">${esc(row.employeeName)}</span></td><td><span class="code">${esc(row.carrierCode)}</span><span class="name">${esc(row.carrierName)}</span></td><td class="number">${usd(row.latestRevenueUsd)}</td><td class="number ${Number(row.latestGrossMarginUsd)<0?"negative":""}">${usd(row.latestGrossMarginUsd)}</td><td class="number">${pct(row.latestScheduleReliabilityPct)}</td><td>${badge(row.latestRiskLevel)}</td></tr>`;}
|
||||||
|
function renderHmmCarrierPerformanceReport(payload,target=document.getElementById("report-root")){const report=payload?.report||{};const rows=Array.isArray(payload?.rows)?payload.rows:[];const s=aggregate(rows);const max=Math.max(...s.people.map(p=>p.revenue),1);const watch=rows.filter(r=>r.latestRiskLevel!=="GREEN").sort((a,b)=>a.latestRiskLevel.localeCompare(b.latestRiskLevel));target.innerHTML=`
|
||||||
|
<section class="content"><div class="brand-banner"><img src="https://eu-images.contentstack.com/v3/assets/bltdcfe6aab5515629e/bltaf50776e73f3149f/668ea7b97dc26754645e1830/hmmci.png?width=1400&auto=webp&quality=80&disable=upscale" alt="HMM"><span>HMM Management Report · Internal Demo</span></div><div class="top-line"></div><header class="report-head"><div><p class="kicker">${esc(report.id||"FEDERATION")} · ${esc(report.category||"HMM Business Intelligence")}</p><h1><b>${esc(report.title||"팀 포트폴리오")}</b> — 최신 선사 실적</h1></div><div class="head-meta"><strong>${esc(report.requestedBy||"-")} 팀장 조회</strong>생성 ${esc(when(report.generatedAt))}<br>MCP ${esc(report.execution?.calls||0)}회 호출<br>${esc(report.execution?.tool||"-")}</div></header>
|
||||||
|
<p class="intro">${esc(report.answer||report.question||"조회된 선사 실적입니다.")} 전체 ${n(rows.length,0)}개 담당 선사의 매출·수익성·운항 지표를 담당자별 포트폴리오 관점에서 요약했습니다.</p>
|
||||||
|
<section id="summary" class="executive"><div><h2 class="section-heading">핵심 요약 <small>Latest performance snapshot</small></h2><div class="metrics"><article class="metric"><div class="metric-label">담당 선사</div><div class="metric-value">${n(rows.length,0)}<small>개</small></div><div class="metric-note">팀원 ${n(s.people.length,0)}명 기준</div></article><article class="metric"><div class="metric-label">최신 매출 합계</div><div class="metric-value money">${usd(s.totalRevenue)}</div><div class="metric-note">담당 선사별 최신 기준월 합산</div></article><article class="metric"><div class="metric-label">매출총이익 합계</div><div class="metric-value money ${s.totalMargin<0?"negative":""}">${usd(s.totalMargin)}</div><div class="metric-note">음수 마진 선사 포함</div></article><article class="metric"><div class="metric-label">평균 정시 운항률</div><div class="metric-value">${pct(s.avgReliability)}</div><div class="metric-note">담당 선사 단순 평균</div></article></div></div><aside id="risk" class="risk-panel"><h2>위험 신호</h2><p class="risk-message">주의·위험 등급 선사 ${n(s.risks.AMBER+s.risks.RED,0)}개를 우선 점검 대상으로 표시합니다.</p><div class="risk-list">${watch.length?watch.map(r=>`<div class="risk-item">${badge(r.latestRiskLevel)}<span class="risk-item-name">${esc(r.carrierName)}</span><span>${esc(r.employeeName)}</span></div>`).join(""):'<div class="risk-item">현재 주의·위험 선사가 없습니다.</div>'}</div></aside></section>
|
||||||
|
<section id="portfolio" class="portfolio"><h2 class="section-heading">담당자별 포트폴리오 매출 <small>Latest revenue by employee</small></h2><div class="portfolio-grid"><div><p class="chart-note">각 막대는 담당 선사 최신 매출의 합계입니다. 막대 끝의 색상은 해당 담당자 포트폴리오에 주의·위험 선사가 있는 경우를 나타냅니다.</p><div class="bar-chart">${s.people.map(p=>`<div class="bar-row"><div class="bar-person"><b>${esc(p.name)}</b><span>${esc(p.code)} · ${n(p.carriers,0)}개 선사</span></div><div class="bar-lane"><div class="bar ${p.hasRisk?"critical":""}" style="width:${(p.revenue/max*100).toFixed(2)}%"></div></div><div class="bar-value">${usd(p.revenue)}<span>팀 매출 ${(p.revenue/s.totalRevenue*100).toFixed(1)}%</span></div></div>`).join("")||'<div class="empty">차트 데이터가 없습니다.</div>'}</div></div><aside class="chart-aside"><h3>위험 등급 분포</h3><div class="legend"><span class="legend-label"><i class="dot green"></i>정상</span><strong>${n(s.risks.GREEN,0)}</strong></div><div class="legend"><span class="legend-label"><i class="dot amber"></i>주의</span><strong>${n(s.risks.AMBER,0)}</strong></div><div class="legend"><span class="legend-label"><i class="dot red"></i>위험</span><strong>${n(s.risks.RED,0)}</strong></div></aside></div></section>
|
||||||
|
<section id="detail" class="detail"><div class="detail-head"><h2 class="section-heading">선사별 최신 지표 <small>Carrier detail</small></h2><p>총 ${n(rows.length,0)}건 · 최신순 1~${n(rows.length,0)}건 표시</p></div>${rows.length?`<table class="detail-table"><thead><tr><th>담당자</th><th>선사</th><th class="number">최신 매출</th><th class="number">매출총이익</th><th class="number">정시 운항률</th><th>위험 등급</th></tr></thead><tbody>${rows.map(detailRow).join("")}</tbody></table>`:'<div class="empty">표시할 상세 데이터가 없습니다.</div>'}</section>
|
||||||
|
<footer id="notes" class="footnotes"><section><h3>답변 근거</h3><ul>${(report.evidence||[]).map(e=>`<li>${esc(e)}</li>`).join("")||'<li>제공된 근거가 없습니다.</li>'}</ul></section><section><h3>제약 및 주의</h3><p>${esc(report.limitation||"제약 정보가 제공되지 않았습니다.")}</p></section></footer></section>`;}
|
||||||
|
window.renderHmmCarrierPerformanceReport=renderHmmCarrierPerformanceReport;renderHmmCarrierPerformanceReport(reportData);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
76
ai-web-agent-console/config/hmm_demo_scenarios.json
Normal file
76
ai-web-agent-console/config/hmm_demo_scenarios.json
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"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": "선사 실적",
|
||||||
|
"title": "우리 팀 담당 선사 최신 실적",
|
||||||
|
"question": "우리 팀원별 담당 선사와 각 선사의 최신 매출, 매출총이익, 정시 운항률, 위험 등급을 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FED-02",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "선사 실적",
|
||||||
|
"title": "주의가 필요한 선사",
|
||||||
|
"question": "우리 팀이 담당하는 선사 중 최신 위험 등급이 RED인 선사의 담당자, 선사명, 매출, 정시 운항률과 클레임 발생률을 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FED-03",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "선사 실적",
|
||||||
|
"title": "내 담당 선사 월별 추이",
|
||||||
|
"question": "내가 담당하는 선사의 월별 매출, 운송 물동량, 매출총이익과 위험 등급을 기준월 순서로 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FED-04",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "선사 실적 리포트",
|
||||||
|
"title": "우리 팀 선사 실적 리포트",
|
||||||
|
"question": "우리 팀원별 담당 선사의 최신 매출, 매출총이익, 정시 운항률과 위험 등급을 HMM 리포트로 보여줘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FED-05",
|
||||||
|
"enabled": true,
|
||||||
|
"category": "선사 실적 리포트",
|
||||||
|
"title": "내 담당 선사 실적 리포트",
|
||||||
|
"question": "내 담당 선사의 최신 매출, 매출총이익, 정시 운항률과 위험 등급을 HMM 리포트로 보여줘"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,17 +6,19 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "hmm_compat_mcp",
|
"provider": "hmm_compat_mcp",
|
||||||
"transport": "http",
|
"transport": "http",
|
||||||
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
|
"endpoint_url": "https://hmm-backoffice.cloud-handson.com/mcp",
|
||||||
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
|
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||||
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
|
"timeout_seconds_env": "AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS",
|
||||||
"default_tool": "search_hr_data",
|
"default_tool": "search_hr_data",
|
||||||
"router_model_profile": "gpt54_mini_oci",
|
"router_model_profile": "gpt54_mini_oci",
|
||||||
"tool_allowlist": [
|
"tool_allowlist": [
|
||||||
"search_hr_data",
|
"search_hr_data",
|
||||||
"resolve_hr_term",
|
"resolve_hr_term",
|
||||||
"search_hr_policy"
|
"search_hr_policy",
|
||||||
|
"search_carrier_performance",
|
||||||
|
"render_hmm_carrier_report"
|
||||||
],
|
],
|
||||||
"description": "HMM HR knowledge and data MCP server"
|
"description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"registry_name": "POC3_8512_8513_MODEL_PROFILES",
|
"registry_name": "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES",
|
||||||
"default_model_profile": "gpt55_oci",
|
"default_model_profile": "gpt55_oci",
|
||||||
"source_commit": "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f",
|
"source_commit": "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f",
|
||||||
"profiles": [
|
"profiles": [
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2",
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2",
|
||||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT55_OCI_V2",
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT55_OCI_V2",
|
||||||
"verification_status": "VERIFIED_WITH_WARNINGS",
|
"verification_status": "VERIFIED_WITH_WARNINGS",
|
||||||
"default_for_poc3": true,
|
"default_for_console": true,
|
||||||
"source_tag": "poc_2-gpt55-oci-partial",
|
"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이다.",
|
"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
|
"display_order": 0
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT54_MINI_OCI_PROFILE_V1",
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT54_MINI_OCI_PROFILE_V1",
|
||||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT54_MINI_OCI_V1",
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT54_MINI_OCI_V1",
|
||||||
"verification_status": "PARTIAL_VERIFIED",
|
"verification_status": "PARTIAL_VERIFIED",
|
||||||
"default_for_poc3": false,
|
"default_for_console": false,
|
||||||
"source_tag": "poc_2-gpt55-oci-partial",
|
"source_tag": "poc_2-gpt55-oci-partial",
|
||||||
"notes": "PoC4 MCP 실행 방식(single/agent) 판단용 경량 planner profile. Select AI/Agent Team 본 처리 기본값은 gpt55_oci를 유지한다.",
|
"notes": "PoC4 MCP 실행 방식(single/agent) 판단용 경량 planner profile. Select AI/Agent Team 본 처리 기본값은 gpt55_oci를 유지한다.",
|
||||||
"display_order": 1
|
"display_order": 1
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GROK43_PROFILE_V2",
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GROK43_PROFILE_V2",
|
||||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GROK43_V3",
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GROK43_V3",
|
||||||
"verification_status": "VERIFIED",
|
"verification_status": "VERIFIED",
|
||||||
"default_for_poc3": false,
|
"default_for_console": false,
|
||||||
"source_tag": "poc_2-gpt55-oci-partial",
|
"source_tag": "poc_2-gpt55-oci-partial",
|
||||||
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||||
"display_order": 2
|
"display_order": 2
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA4_MAVERICK_PROFILE_V2",
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA4_MAVERICK_PROFILE_V2",
|
||||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA4_MAVERICK_V3",
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA4_MAVERICK_V3",
|
||||||
"verification_status": "VERIFIED",
|
"verification_status": "VERIFIED",
|
||||||
"default_for_poc3": false,
|
"default_for_console": false,
|
||||||
"source_tag": "poc_2-gpt55-oci-partial",
|
"source_tag": "poc_2-gpt55-oci-partial",
|
||||||
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||||
"display_order": 3
|
"display_order": 3
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA33_PROFILE_V2",
|
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA33_PROFILE_V2",
|
||||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA33_V3",
|
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA33_V3",
|
||||||
"verification_status": "VERIFIED",
|
"verification_status": "VERIFIED",
|
||||||
"default_for_poc3": false,
|
"default_for_console": false,
|
||||||
"source_tag": "poc_2-gpt55-oci-partial",
|
"source_tag": "poc_2-gpt55-oci-partial",
|
||||||
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||||
"display_order": 4
|
"display_order": 4
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"default": true,
|
"default": true,
|
||||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1001",
|
||||||
"user_id": "E1001",
|
"user_id": "E1001",
|
||||||
"name": "Kim Minseo",
|
"name": "Kim Minseo",
|
||||||
"role": "HR Team Manager",
|
"role": "HR Team Manager",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1002",
|
||||||
"user_id": "E1002",
|
"user_id": "E1002",
|
||||||
"name": "Lee Jiwon",
|
"name": "Lee Jiwon",
|
||||||
"role": "HR Operations Specialist",
|
"role": "HR Operations Specialist",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1003",
|
||||||
"user_id": "E1003",
|
"user_id": "E1003",
|
||||||
"name": "Park Dohyun",
|
"name": "Park Dohyun",
|
||||||
"role": "People Analytics Analyst",
|
"role": "People Analytics Analyst",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1005",
|
||||||
"user_id": "E1005",
|
"user_id": "E1005",
|
||||||
"name": "Han Seojun",
|
"name": "Han Seojun",
|
||||||
"role": "Recruiting Specialist",
|
"role": "Recruiting Specialist",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN_E1007",
|
||||||
"user_id": "E1007",
|
"user_id": "E1007",
|
||||||
"name": "Kang Minho",
|
"name": "Kang Minho",
|
||||||
"role": "HR Coordinator",
|
"role": "HR Coordinator",
|
||||||
@@ -13,7 +13,7 @@ from urllib.parse import urlencode
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from src.agent_console.auth_gateway import ( # noqa: E402
|
from ai_web_agent_console.auth_gateway import ( # noqa: E402
|
||||||
AuthConfig,
|
AuthConfig,
|
||||||
LoginAttemptLimiter,
|
LoginAttemptLimiter,
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
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
|
||||||
|
)
|
||||||
538
ai-web-agent-console/tests/test_scenarios.py
Normal file
538
ai-web-agent-console/tests/test_scenarios.py
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, Mapping
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios
|
||||||
|
from ai_web_agent_console.profile import load_app_profile
|
||||||
|
from ai_web_agent_console.mcp_tool_router import McpTool, build_mcp_tool_arguments
|
||||||
|
from ai_web_agent_console.mcp_result import (
|
||||||
|
has_actionable_text_result,
|
||||||
|
status_result_evidence,
|
||||||
|
status_result_summary,
|
||||||
|
)
|
||||||
|
from ai_web_agent_console.model_registry import load_model_registry
|
||||||
|
|
||||||
|
|
||||||
|
def _load_console_query_helpers():
|
||||||
|
"""Load the Streamlit entrypoint only when its optional runtime is installed."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app import _prepare_hmm_hr_tool_query
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
return None
|
||||||
|
return _prepare_hmm_hr_tool_query
|
||||||
|
|
||||||
|
|
||||||
|
class DemoScenarioConfigTest(unittest.TestCase):
|
||||||
|
def test_model_registry_uses_console_names(self) -> None:
|
||||||
|
registry = load_model_registry()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
registry.registry_name,
|
||||||
|
"AI_WEB_AGENT_CONSOLE_MODEL_PROFILES",
|
||||||
|
)
|
||||||
|
self.assertTrue(registry.default_profile.default_for_console)
|
||||||
|
|
||||||
|
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": "HMM",
|
||||||
|
"AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트",
|
||||||
|
"AGENT_CONSOLE_PRIMARY_COLOR": "#003b70",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
profile = load_app_profile(path)
|
||||||
|
|
||||||
|
self.assertEqual(profile.short_name, "HMM")
|
||||||
|
self.assertEqual(profile.page_title, "HMM AI 업무 에이전트")
|
||||||
|
self.assertEqual(profile.primary_color, "#003b70")
|
||||||
|
|
||||||
|
def test_profile_reads_dotenv_values(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
env_file = Path(temp_dir) / ".env"
|
||||||
|
env_file.write_text("AGENT_CONSOLE_SHORT_NAME=HMM\n", encoding="utf-8")
|
||||||
|
profile = load_app_profile(path, env_file)
|
||||||
|
|
||||||
|
self.assertEqual(profile.short_name, "HMM")
|
||||||
|
|
||||||
|
def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "ai_web_agent_console" / "presentation.py"
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('[data-testid="stAppViewContainer"] li', source)
|
||||||
|
self.assertIn('[data-testid="stExpander"] summary', source)
|
||||||
|
self.assertIn('div[data-testid="stButton"] > button', source)
|
||||||
|
self.assertIn('[data-baseweb="tab-list"] [role="tab"]', source)
|
||||||
|
self.assertIn('[data-testid="stTab"]', source)
|
||||||
|
self.assertIn('[role="tab"][aria-selected="true"]', source)
|
||||||
|
|
||||||
|
def test_audit_tab_uses_hmm_access_audit_loaders(self) -> None:
|
||||||
|
root = Path(__file__).parents[1]
|
||||||
|
entrypoint = (root / "app.py").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
renderer = (root / "ai_web_agent_console" / "audit.py").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("FROM ADMIN.HMM_ACCESS_AUDIT", entrypoint)
|
||||||
|
self.assertIn("_load_hmm_audit_inventory", entrypoint)
|
||||||
|
self.assertIn("(protocol=tcps)(port=1521)", entrypoint)
|
||||||
|
self.assertIn(
|
||||||
|
"AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD",
|
||||||
|
entrypoint,
|
||||||
|
)
|
||||||
|
self.assertIn("HMM 접근 관리", renderer)
|
||||||
|
self.assertNotIn('AUDIT_SCHEMA = "POC_2"', entrypoint)
|
||||||
|
|
||||||
|
def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "config" / "hmm_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))
|
||||||
|
by_id = {item.scenario_id: item for item in scenarios}
|
||||||
|
self.assertEqual(
|
||||||
|
{"FED-01", "FED-02", "FED-03", "FED-04", "FED-05"},
|
||||||
|
{"FED-01", "FED-02", "FED-03", "FED-04", "FED-05"} & set(by_id),
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all("선사" in by_id[scenario_id].question for scenario_id in (
|
||||||
|
"FED-01", "FED-02", "FED-03", "FED-04", "FED-05"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
carrier_scenarios = [
|
||||||
|
item for item in scenarios if item.scenario_id.startswith("FED-")
|
||||||
|
]
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
re.search(r"\bE\d{4,}\b", item.question, flags=re.IGNORECASE) is None
|
||||||
|
for item in carrier_scenarios
|
||||||
|
)
|
||||||
|
)
|
||||||
|
report_scenarios = [
|
||||||
|
item for item in carrier_scenarios if "리포트로 보여줘" in item.question
|
||||||
|
]
|
||||||
|
self.assertGreaterEqual(len(report_scenarios), 2)
|
||||||
|
self.assertTrue(
|
||||||
|
all(item.category == "선사 실적 리포트" for item in report_scenarios)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hmm_mcp_allows_carrier_federation_tool(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "config" / "mcp_servers.json"
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
server = next(
|
||||||
|
item for item in payload["servers"] if item["id"] == "hmm_hr_mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"search_carrier_performance",
|
||||||
|
server["tool_allowlist"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hmm_mcp_allows_dynamic_html_renderer(self) -> None:
|
||||||
|
root = Path(__file__).parents[1]
|
||||||
|
payload = json.loads(
|
||||||
|
(root / "config" / "mcp_servers.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
server = next(
|
||||||
|
item for item in payload["servers"] if item["id"] == "hmm_hr_mcp"
|
||||||
|
)
|
||||||
|
source = (root / "app.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
server["endpoint_url"],
|
||||||
|
"https://hmm-backoffice.cloud-handson.com/mcp",
|
||||||
|
)
|
||||||
|
self.assertIn("render_hmm_carrier_report", server["tool_allowlist"])
|
||||||
|
self.assertNotIn('tool.name == "render_hmm_carrier_report"', source)
|
||||||
|
|
||||||
|
def test_hmm_report_rows_accept_nested_select_ai_json_array(self) -> None:
|
||||||
|
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
helper = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.FunctionDef)
|
||||||
|
and node.name == "_mcp_structured_rows"
|
||||||
|
)
|
||||||
|
namespace: dict[str, Any] = {
|
||||||
|
"Any": Any,
|
||||||
|
"Mapping": Mapping,
|
||||||
|
"json": json,
|
||||||
|
}
|
||||||
|
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||||
|
|
||||||
|
rows = namespace["_mcp_structured_rows"](
|
||||||
|
{
|
||||||
|
"response": {
|
||||||
|
"result": json.dumps(
|
||||||
|
[
|
||||||
|
{"EMPLOYEE_CODE": "E9001", "CARRIER_CODE": "C901"},
|
||||||
|
{"EMPLOYEE_CODE": "E9002", "CARRIER_CODE": "C902"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[0]["CARRIER_CODE"], "C901")
|
||||||
|
|
||||||
|
def test_hmm_report_normalizes_repeated_select_ai_column_labels(self) -> None:
|
||||||
|
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
helpers = [
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.FunctionDef)
|
||||||
|
and node.name in {"_camel_case_key", "_normalize_presentation_value"}
|
||||||
|
]
|
||||||
|
namespace: dict[str, Any] = {
|
||||||
|
"Any": Any,
|
||||||
|
"Mapping": Mapping,
|
||||||
|
"re": re,
|
||||||
|
}
|
||||||
|
exec(compile(ast.Module(body=helpers, type_ignores=[]), "app.py", "exec"), namespace)
|
||||||
|
|
||||||
|
first = namespace["_normalize_presentation_value"](
|
||||||
|
{"CARRIER_CODE": "C001", "LATEST_REVENUE_USD": 100}
|
||||||
|
)
|
||||||
|
repeated = namespace["_normalize_presentation_value"](
|
||||||
|
{"carrier Code": "C002", "LATEST REVENUE USD": 200}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(first, {"carrierCode": "C001", "latestRevenueUsd": 100})
|
||||||
|
self.assertEqual(repeated, {"carrierCode": "C002", "latestRevenueUsd": 200})
|
||||||
|
|
||||||
|
def test_hmm_report_title_and_answer_follow_presentation_contract(self) -> None:
|
||||||
|
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
helper = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.FunctionDef)
|
||||||
|
and node.name == "_clean_presentation_title"
|
||||||
|
)
|
||||||
|
namespace: dict[str, Any] = {
|
||||||
|
"Any": Any,
|
||||||
|
"html": html,
|
||||||
|
"re": re,
|
||||||
|
}
|
||||||
|
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||||
|
|
||||||
|
title = namespace["_clean_presentation_title"](
|
||||||
|
"<b>E1001 팀 포트폴리오</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(title, "E1001 팀 포트폴리오")
|
||||||
|
self.assertIn('"title": _clean_presentation_title(title)', source)
|
||||||
|
self.assertIn(
|
||||||
|
'assistant_message["content"] = presentation_answer',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"presentation_answer = _presentation_completion_answer(",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"do not reproduce HTML tags, Markdown tables",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_chat_context_is_scoped_to_current_selected_user(self) -> None:
|
||||||
|
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
helper = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.FunctionDef)
|
||||||
|
and node.name == "load_chat_context"
|
||||||
|
)
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params):
|
||||||
|
captured["sql"] = sql
|
||||||
|
captured["params"] = params
|
||||||
|
return self
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return [{"question": "내 담당 선사", "answer": "2건"}]
|
||||||
|
|
||||||
|
namespace: dict[str, Any] = {
|
||||||
|
"CHAT_CONTEXT_TURNS": 8,
|
||||||
|
"MAX_CONVERSATION_MESSAGES": 16,
|
||||||
|
"_chat_db_connect": FakeConnection,
|
||||||
|
"_is_failed_synthesis_answer": lambda _value: False,
|
||||||
|
}
|
||||||
|
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||||
|
|
||||||
|
messages = namespace["load_chat_context"](
|
||||||
|
"conversation-1",
|
||||||
|
selected_user_id="E1002",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("selected_user_id = ?", captured["sql"])
|
||||||
|
self.assertEqual(
|
||||||
|
captured["params"],
|
||||||
|
("conversation-1", "E1002", "E1002", 8),
|
||||||
|
)
|
||||||
|
self.assertEqual(messages[0]["content"], "내 담당 선사")
|
||||||
|
|
||||||
|
def test_standalone_question_uses_current_user_without_prior_context(self) -> None:
|
||||||
|
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
helpers = [
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.FunctionDef)
|
||||||
|
and node.name in {"_conversation_context", "resolve_standalone_question"}
|
||||||
|
]
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def complete(self, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"standalone_question": (
|
||||||
|
"E1002 사용자의 담당 선사 최신 실적을 조회해줘"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
namespace: dict[str, Any] = {
|
||||||
|
"Any": Any,
|
||||||
|
"Mapping": Mapping,
|
||||||
|
"MAX_CONVERSATION_MESSAGES": 16,
|
||||||
|
"json": json,
|
||||||
|
"resolve_model_profile": lambda _key: SimpleNamespace(
|
||||||
|
model_id="model",
|
||||||
|
answer_model_region="region",
|
||||||
|
answer_model_endpoint="endpoint",
|
||||||
|
),
|
||||||
|
"build_oci_genai_completion_client": lambda *_args: FakeClient(),
|
||||||
|
"temperature_for_model_profile": lambda _profile: 0.0,
|
||||||
|
}
|
||||||
|
exec(compile(ast.Module(body=helpers, type_ignores=[]), "app.py", "exec"), namespace)
|
||||||
|
|
||||||
|
rewritten = namespace["resolve_standalone_question"](
|
||||||
|
question="내 담당 선사 최신 실적을 리포트로 보여줘",
|
||||||
|
messages=[],
|
||||||
|
model_profile_key="test",
|
||||||
|
selected_user_id="E1002",
|
||||||
|
)
|
||||||
|
prompt_payload = json.loads(captured["user_prompt"])
|
||||||
|
|
||||||
|
self.assertTrue(rewritten.startswith("E1002"))
|
||||||
|
self.assertEqual(prompt_payload["current_selected_user_id"], "E1002")
|
||||||
|
self.assertIn("authoritative", captured["system_prompt"])
|
||||||
|
|
||||||
|
def test_report_payload_requester_prefers_current_selected_user(self) -> None:
|
||||||
|
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
helper = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.FunctionDef)
|
||||||
|
and node.name == "_presentation_payload"
|
||||||
|
)
|
||||||
|
namespace: dict[str, Any] = {
|
||||||
|
"Any": Any,
|
||||||
|
"Mapping": Mapping,
|
||||||
|
"datetime": __import__("datetime").datetime,
|
||||||
|
"timezone": __import__("datetime").timezone,
|
||||||
|
"re": re,
|
||||||
|
"_mcp_structured_rows": lambda _value: [],
|
||||||
|
"_normalize_presentation_value": lambda value: value,
|
||||||
|
"_clean_presentation_title": lambda value: str(value),
|
||||||
|
}
|
||||||
|
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||||
|
|
||||||
|
payload = namespace["_presentation_payload"](
|
||||||
|
"E1001 팀장 문맥이 남은 질문",
|
||||||
|
[],
|
||||||
|
title="담당 선사 실적",
|
||||||
|
selected_user_id="E1002",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["report"]["requestedBy"], "E1002")
|
||||||
|
|
||||||
|
def test_hmm_report_data_query_drops_html_format_request(self) -> None:
|
||||||
|
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
helper = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.FunctionDef)
|
||||||
|
and node.name == "_fallback_presentation_data_query"
|
||||||
|
)
|
||||||
|
namespace: dict[str, Any] = {"re": re}
|
||||||
|
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
|
||||||
|
|
||||||
|
query = namespace["_fallback_presentation_data_query"](
|
||||||
|
"E1001 팀장의 담당 선사 최신 매출, 매출총이익, 정시 운항률, "
|
||||||
|
"위험 등급을 HTML로 보여줘"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn("HTML", query.upper())
|
||||||
|
self.assertIn("E1001", query)
|
||||||
|
self.assertIn("매출총이익", query)
|
||||||
|
self.assertIn("정시 운항률", query)
|
||||||
|
self.assertIn("위험 등급", query)
|
||||||
|
self.assertIn("_mcp_rows_contain_presentation_markup(mcp_result)", source)
|
||||||
|
|
||||||
|
personal_query = namespace["_fallback_presentation_data_query"](
|
||||||
|
"내 담당 선사와 최신 매출을 리포트로 보여줘"
|
||||||
|
)
|
||||||
|
self.assertEqual(personal_query, "내 담당 선사와 최신 매출을 보여줘")
|
||||||
|
|
||||||
|
def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
|
||||||
|
template = (
|
||||||
|
Path(__file__).parents[1]
|
||||||
|
/ "assets"
|
||||||
|
/ "hmm-carrier-performance-report.html"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(template.count("__REPORT_DATA__"), 1)
|
||||||
|
self.assertNotIn("Bluewave Maritime", template)
|
||||||
|
self.assertNotIn("Southern Cross Marine", template)
|
||||||
|
|
||||||
|
def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json"
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
presets = payload["presets"]
|
||||||
|
|
||||||
|
self.assertEqual(payload["version"], 2)
|
||||||
|
self.assertEqual({item["user_id"] for item in presets}, {
|
||||||
|
"E1001", "E1002", "E1003", "E1005", "E1007"
|
||||||
|
})
|
||||||
|
self.assertEqual(
|
||||||
|
{item["mcp_token_env"] for item in presets},
|
||||||
|
{f"HMM_MCP_BEARER_TOKEN_{item['user_id']}" for item in presets},
|
||||||
|
)
|
||||||
|
self.assertTrue(all("token" not in item for item in presets))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_default_mcp_tool_arguments_follow_discovered_query_schema(self) -> None:
|
||||||
|
tool = McpTool(
|
||||||
|
name="search_hr_data",
|
||||||
|
description="",
|
||||||
|
schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
arguments = build_mcp_tool_arguments(
|
||||||
|
tool, "직원 E1005의 휴가 신청 내역", 50, preferred_tool="search_hr_data"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(arguments, {"query": "직원 E1005의 휴가 신청 내역"})
|
||||||
|
|
||||||
|
def test_term_tool_arguments_follow_discovered_term_schema(self) -> None:
|
||||||
|
tool = McpTool(
|
||||||
|
name="resolve_hr_term",
|
||||||
|
description="",
|
||||||
|
schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"term": {"type": "string"}},
|
||||||
|
"required": ["term"],
|
||||||
|
},
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
arguments = build_mcp_tool_arguments(
|
||||||
|
tool, "반차", 50, preferred_tool="search_hr_data"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(arguments, {"term": "반차"})
|
||||||
|
|
||||||
|
def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None:
|
||||||
|
result = {
|
||||||
|
"status": "success",
|
||||||
|
"result": (
|
||||||
|
"HR_POLICY_SEARCH_RESULT\n"
|
||||||
|
"EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = status_result_summary(result, excerpt_chars=40)
|
||||||
|
evidence = status_result_evidence(result)
|
||||||
|
|
||||||
|
self.assertEqual(summary["status"], "success")
|
||||||
|
self.assertGreater(summary["result_chars"], 40)
|
||||||
|
self.assertIn("KR_Leave_Policy.pdf", evidence["result"])
|
||||||
|
self.assertTrue(has_actionable_text_result(result))
|
||||||
|
|
||||||
|
def test_no_data_text_is_not_actionable(self) -> None:
|
||||||
|
self.assertFalse(
|
||||||
|
has_actionable_text_result({"status": "success", "result": "No data found"})
|
||||||
|
)
|
||||||
|
|
||||||
|
@unittest.skipIf(_load_console_query_helpers() is None, "Streamlit runtime is optional")
|
||||||
|
def test_policy_query_does_not_include_demo_user_context(self) -> None:
|
||||||
|
prepare = _load_console_query_helpers()
|
||||||
|
assert prepare is not None
|
||||||
|
tool = McpTool(
|
||||||
|
name="search_hr_policy",
|
||||||
|
description="Search policy documents",
|
||||||
|
schema={"properties": {"query": {"type": "string"}}},
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
query = prepare(
|
||||||
|
question="연차 휴가 이월 기준과 제한을 알려줘",
|
||||||
|
tool=tool,
|
||||||
|
model_profile_key="gpt54_mini_oci",
|
||||||
|
selected_user_id="E1001",
|
||||||
|
selected_user_role="HR Team Manager",
|
||||||
|
selected_user_team="HMM HR Demo Team",
|
||||||
|
selected_user_scope="팀원 6명 관리",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘")
|
||||||
|
|
||||||
|
|
||||||
|
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 ===
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
-- Unit-style checks for cb_agent_doc_vpd_filter.
|
-- Unit-style checks for cb_agent_doc_vpd_filter.
|
||||||
--
|
--
|
||||||
-- Run after:
|
-- Run after:
|
||||||
-- @sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
|
-- @database/adb/26_agent_ords_security_dynamic_vpd_filter.sql
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||||
SET ECHO OFF
|
SET ECHO OFF
|
||||||
159
database/adb/47_dds_ad_identity_test_setup.sql
Normal file
159
database/adb/47_dds_ad_identity_test_setup.sql
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 47_dds_ad_identity_test_setup.sql
|
||||||
|
--
|
||||||
|
-- Test-only bridge for the isolated dds.test Active Directory domain.
|
||||||
|
-- Maps immutable AD objectGUID values to the existing HMM application users
|
||||||
|
-- 1 and 2, then publishes only their passwordless local DDS END USERs.
|
||||||
|
--
|
||||||
|
-- This does NOT validate an AD/OIDC JWT. A verified issuer + subject must be
|
||||||
|
-- resolved by the MCP application before it uses this bridge.
|
||||||
|
-- ============================================================
|
||||||
|
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||||
|
SET ECHO OFF
|
||||||
|
SET FEEDBACK ON
|
||||||
|
SET DEFINE OFF
|
||||||
|
|
||||||
|
PROMPT === 1. Creating external identity bridge ===
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
CREATE TABLE cb_external_identity_binding (
|
||||||
|
issuer VARCHAR2(512) NOT NULL,
|
||||||
|
subject VARCHAR2(512) NOT NULL,
|
||||||
|
application_user_id NUMBER NOT NULL,
|
||||||
|
display_name VARCHAR2(256),
|
||||||
|
active CHAR(1) DEFAULT 'Y' NOT NULL
|
||||||
|
CHECK (active IN ('Y', 'N')),
|
||||||
|
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT cb_external_identity_binding_pk PRIMARY KEY (issuer, subject)
|
||||||
|
)]';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'CREATE INDEX cb_external_identity_binding_user_ix '
|
||||||
|
|| 'ON cb_external_identity_binding (application_user_id)';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === 2. Creating DDS END USER map ===
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
CREATE TABLE cb_dds_end_user_map (
|
||||||
|
application_user_id NUMBER PRIMARY KEY,
|
||||||
|
end_user_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||||
|
data_role_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||||
|
lookup_key_ref VARCHAR2(128) NOT NULL,
|
||||||
|
grant_name VARCHAR2(128) NOT NULL,
|
||||||
|
publish_status VARCHAR2(20) NOT NULL
|
||||||
|
CHECK (publish_status IN ('PENDING', 'PUBLISHED', 'REVOKED', 'FAILED')),
|
||||||
|
published_at TIMESTAMP,
|
||||||
|
last_error VARCHAR2(1000)
|
||||||
|
)]';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_active_users NUMBER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO v_active_users
|
||||||
|
FROM cb_app_user
|
||||||
|
WHERE user_id IN (1, 2) AND active = 'Y';
|
||||||
|
IF v_active_users <> 2 THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20947, 'Expected active HMM application users 1 and 2.');
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === 3. Binding dds.test immutable subjects to HMM users ===
|
||||||
|
MERGE INTO cb_external_identity_binding target
|
||||||
|
USING (
|
||||||
|
SELECT 'urn:dds-ad:test' AS issuer,
|
||||||
|
'fa7ebe4b-aca8-4049-a436-7f3da2d27a9f' AS subject,
|
||||||
|
1 AS application_user_id,
|
||||||
|
'dds-alice' AS display_name
|
||||||
|
FROM dual
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'urn:dds-ad:test',
|
||||||
|
'128546cf-ec0a-4e99-8f51-dfce29280c91',
|
||||||
|
2,
|
||||||
|
'dds-bob'
|
||||||
|
FROM dual
|
||||||
|
) source
|
||||||
|
ON (target.issuer = source.issuer AND target.subject = source.subject)
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
target.application_user_id = source.application_user_id,
|
||||||
|
target.display_name = source.display_name,
|
||||||
|
target.active = 'Y',
|
||||||
|
target.updated_at = SYSTIMESTAMP
|
||||||
|
WHEN NOT MATCHED THEN INSERT (
|
||||||
|
issuer, subject, application_user_id, display_name, active
|
||||||
|
) VALUES (
|
||||||
|
source.issuer, source.subject, source.application_user_id, source.display_name, 'Y'
|
||||||
|
);
|
||||||
|
|
||||||
|
MERGE INTO cb_dds_end_user_map target
|
||||||
|
USING (
|
||||||
|
SELECT user_id AS application_user_id,
|
||||||
|
'DDS_U_' || TO_CHAR(user_id) AS end_user_name,
|
||||||
|
'DDS_U_' || TO_CHAR(user_id) || '_ROLE' AS data_role_name,
|
||||||
|
'DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1' AS lookup_key_ref,
|
||||||
|
'DDS_MCP_U_' || TO_CHAR(user_id) || '_VECTOR_GRANT' AS grant_name
|
||||||
|
FROM cb_app_user
|
||||||
|
WHERE user_id IN (1, 2)
|
||||||
|
) source
|
||||||
|
ON (target.application_user_id = source.application_user_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
target.end_user_name = source.end_user_name,
|
||||||
|
target.data_role_name = source.data_role_name,
|
||||||
|
target.lookup_key_ref = source.lookup_key_ref,
|
||||||
|
target.grant_name = source.grant_name,
|
||||||
|
target.publish_status = 'PENDING',
|
||||||
|
target.last_error = NULL
|
||||||
|
WHEN NOT MATCHED THEN INSERT (
|
||||||
|
application_user_id, end_user_name, data_role_name, lookup_key_ref,
|
||||||
|
grant_name, publish_status
|
||||||
|
) VALUES (
|
||||||
|
source.application_user_id, source.end_user_name, source.data_role_name,
|
||||||
|
source.lookup_key_ref, source.grant_name, 'PENDING'
|
||||||
|
);
|
||||||
|
|
||||||
|
PROMPT === 4. Publishing DDS identities only (default deny until data grants exist) ===
|
||||||
|
BEGIN
|
||||||
|
FOR mapped_user IN (
|
||||||
|
SELECT application_user_id, end_user_name, data_role_name
|
||||||
|
FROM cb_dds_end_user_map
|
||||||
|
WHERE application_user_id IN (1, 2)
|
||||||
|
ORDER BY application_user_id
|
||||||
|
) LOOP
|
||||||
|
EXECUTE IMMEDIATE 'CREATE END USER IF NOT EXISTS "' || mapped_user.end_user_name || '"';
|
||||||
|
EXECUTE IMMEDIATE 'CREATE DATA ROLE IF NOT EXISTS ' || mapped_user.data_role_name;
|
||||||
|
EXECUTE IMMEDIATE 'GRANT DATA ROLE ' || mapped_user.data_role_name
|
||||||
|
|| ' TO "' || mapped_user.end_user_name || '"';
|
||||||
|
UPDATE cb_dds_end_user_map
|
||||||
|
SET publish_status = 'PUBLISHED', published_at = SYSTIMESTAMP, last_error = NULL
|
||||||
|
WHERE application_user_id = mapped_user.application_user_id;
|
||||||
|
END LOOP;
|
||||||
|
COMMIT;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === 5. Identity bridge inventory ===
|
||||||
|
SELECT b.issuer, b.subject, b.display_name, b.application_user_id,
|
||||||
|
m.end_user_name, m.data_role_name, m.publish_status
|
||||||
|
FROM cb_external_identity_binding b
|
||||||
|
JOIN cb_dds_end_user_map m ON m.application_user_id = b.application_user_id
|
||||||
|
WHERE b.issuer = 'urn:dds-ad:test'
|
||||||
|
ORDER BY b.application_user_id;
|
||||||
|
|
||||||
|
PROMPT === AD identity to DDS END USER test 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