Compare commits

41 Commits

Author SHA1 Message Date
devmrko
9ce0341102 refs #749: compare HMM Select AI annotation profiles 2026-08-11 11:01:25 +09:00
devmrko
2d0b5a3c1e refs #732: polish portal report question samples 2026-08-11 10:38:07 +09:00
devmrko
cbd49b6b05 refs #732: scope report titles to selected user 2026-08-10 18:02:49 +09:00
devmrko
84029ed633 refs #732: stabilize carrier report result schema 2026-08-10 17:37:32 +09:00
devmrko
6394bbf078 refs #740: enforce carrier VPD by MCP user 2026-08-10 17:26:01 +09:00
devmrko
2f5fc2bfbd refs #732: separate report data and rendering requests 2026-08-10 16:15:47 +09:00
devmrko
66fcb5d149 refs #732: polish HMM report title and response 2026-08-10 16:06:17 +09:00
devmrko
b13b913939 refs #732: preserve Select AI results in HMM reports 2026-08-10 15:51:29 +09:00
devmrko
c0c5a36ba0 refs #732: add dynamic HMM HTML report MCP tool 2026-08-10 15:23:36 +09:00
devmrko
cc2c3e3e25 refs #744: record delegated authentication capability blocker 2026-08-05 15:07:12 +09:00
devmrko
c519435266 refs #744: document verified AD Bridge sync recovery 2026-08-05 14:50:14 +09:00
devmrko
ad1e4573a9 refs #744: record AD Bridge installer execution constraints 2026-08-05 14:01:11 +09:00
devmrko
2bd373bcb3 refs #744: document AD Bridge installer artifact 2026-08-05 13:53:16 +09:00
devmrko
7d53b5c75c refs #744: document WinRM HTTPS access diagnosis 2026-08-05 13:29:25 +09:00
devmrko
fbf4c19c72 refs #744: standardize overview and cookbook documentation 2026-08-05 12:55:05 +09:00
devmrko
1c8ad8647f refs #744: document AD Bridge remote execution blocker 2026-08-05 11:08:56 +09:00
devmrko
90c771af57 refs #744: add OCI AD Bridge migration runbook 2026-08-05 11:06:39 +09:00
devmrko
de0956ef03 refs #744: clarify AD login source 2026-08-04 17:01:41 +09:00
devmrko
2c790f76b7 refs #744: document AD test user credentials 2026-08-04 16:54:35 +09:00
devmrko
91c96d7919 refs #744: expand AD OIDC DDS architecture guide 2026-08-04 16:49:33 +09:00
devmrko
89a6f4e4bc refs #744: document AIPF deny behavior 2026-08-04 16:44:52 +09:00
devmrko
e6432fa943 refs #744: document OAuth secret handling and test data 2026-08-04 16:33:41 +09:00
devmrko
cbc645466b refs #744: configure OCI IAM DDS context test 2026-08-04 16:18:22 +09:00
devmrko
35782b8a50 refs #744: document AIPF OAuth MCP test flow 2026-08-04 15:49:36 +09:00
devmrko
7dd47d39b9 refs #744: expose DDS MCP under public context path 2026-08-04 14:55:24 +09:00
devmrko
d868d7bbb8 refs #744: add AD OIDC MCP identity bridge 2026-08-04 14:24:07 +09:00
devmrko
9f46205057 docs: add HMM MCP VPD operations guide 2026-08-03 13:32:59 +09:00
devmrko
e9d50e6a32 refs #741 #742: reorganize repository by application 2026-08-03 10:20:36 +09:00
devmrko
2e44ed0b97 refs #740: enforce HMM MCP VPD runtime boundary 2026-07-31 13:57:04 +09:00
devmrko
af6add5a44 refs #732: expose carrier federation portal scenarios 2026-07-24 15:48:28 +09:00
devmrko
9bed75f0c0 refs #730: map carrier access groups to RDS performance 2026-07-24 14:43:45 +09:00
devmrko
cdb2c37ae3 refs #730: add ADB PostgreSQL federation recording scripts 2026-07-24 14:18:19 +09:00
devmrko
65a2b27fcc refs #729: seed RDS carrier performance demo 2026-07-24 14:04:42 +09:00
devmrko
0d9028ef13 refs #723: externalize backoffice catalogs and MCP tools 2026-07-23 19:41:54 +09:00
devmrko
1917df09a2 refs #712: authenticate backoffice MCP user tokens 2026-07-23 14:45:18 +09:00
devmrko
ce4b418603 refs #709: replace portal query token with HttpOnly auth 2026-07-23 14:40:47 +09:00
devmrko
da80041bcb refs #707: enforce HMM leave team VPD 2026-07-23 13:36:21 +09:00
devmrko
a495e7a962 refs #699: align HMM portal audit experience 2026-07-23 12:51:40 +09:00
devmrko
9e3840a4d7 refs #702: externalize HMM structured data catalog 2026-07-23 10:43:54 +09:00
devmrko
eb5105fe72 refs #699: preserve HMM policy MCP evidence 2026-07-23 09:40:09 +09:00
devmrko
62c5fb518c refs #699: record HMM deployment verification 2026-07-22 20:16:57 +09:00
505 changed files with 13146 additions and 3392 deletions

View File

@@ -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
View File

@@ -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
View 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을 조건으로 사용하지 않는다.

View File

@@ -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

View 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

View 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 패키지와 실행 명령은 이 문서의 새 이름만
사용한다.

View File

@@ -0,0 +1,190 @@
"""Reusable audit-tab renderer with data loaders supplied by the application."""
from __future__ import annotations
from typing import Any, Callable
AuditInventoryLoader = Callable[[], list[dict[str, Any]]]
AuditEventsLoader = Callable[[int, int, str, str], list[dict[str, Any]]]
def render_hmm_audit_tab(
st: Any,
inventory_loader: AuditInventoryLoader,
events_loader: AuditEventsLoader,
error_type: type[Exception],
) -> None:
"""Render HMM access audit data without owning DB connection details."""
st.markdown(
'<div class="kb-section-title input" role="heading" aria-level="3">'
'감사로그 ( <strong>HMM 접근 관리</strong> )'
'</div>',
unsafe_allow_html=True,
)
st.markdown(
'<div class="kb-audit-lead">'
'HMM 백오피스의 사용자·그룹·역할·토큰·접근 정책 변경 이력을 시간순으로 확인합니다. '
'이벤트 유형과 처리 상태로 필터링해 운영 변경의 성공·실패를 추적할 수 있습니다.'
'</div>',
unsafe_allow_html=True,
)
try:
inventory = inventory_loader()
except error_type as exc:
st.error(str(exc))
return
event_types = tuple(
str(item.get("event_type") or "").strip()
for item in inventory
if str(item.get("event_type") or "").strip()
)
st.markdown('<div class="kb-audit-heading">조회 조건</div>', unsafe_allow_html=True)
with st.container(key="poc4_hmm_audit_filters"):
event_column, status_column = st.columns(2)
with event_column:
selected_event_type = st.selectbox(
"이벤트 유형",
options=("", *event_types),
format_func=lambda value: "전체 이벤트" if not value else value,
key="poc4_hmm_audit_event_filter",
)
with status_column:
selected_status = st.selectbox(
"처리 상태",
options=("", "SUCCESS", "FAILURE", "DENIED"),
format_func=lambda value: "전체 상태" if not value else value,
key="poc4_hmm_audit_status_filter",
)
days_column, limit_column, refresh_column = st.columns([1.5, 1, 0.8])
with days_column:
days = st.slider(
"조회 기간",
min_value=1,
max_value=90,
value=7,
format="%d",
key="poc4_hmm_audit_days",
)
with limit_column:
row_limit = st.number_input(
"최대 건수",
min_value=10,
max_value=500,
value=100,
step=10,
key="poc4_hmm_audit_row_limit",
)
with refresh_column:
st.markdown('<div style="height: 28px"></div>', unsafe_allow_html=True)
if st.button(
"새로고침",
icon=":material/refresh:",
width="stretch",
key="poc4_hmm_audit_refresh",
):
inventory_loader.clear()
events_loader.clear()
st.rerun()
try:
events = events_loader(
int(days), int(row_limit), selected_event_type, selected_status
)
except error_type as exc:
st.error(str(exc))
return
success_count = sum(
1 for item in events if str(item.get("status") or "").upper() == "SUCCESS"
)
with st.container(key="poc4_hmm_audit_metrics"):
type_metric, event_metric, success_metric, failure_metric = st.columns(4)
type_metric.metric("이벤트 유형", len(inventory))
event_metric.metric("조회 이벤트", len(events))
success_metric.metric("성공", success_count)
failure_metric.metric("실패·거부", len(events) - success_count)
st.markdown(
'<div class="kb-audit-heading">이벤트 유형 현황</div>'
f'<div class="kb-audit-caption">현재 기록된 이벤트 유형 {len(inventory)}개 · '
'유형별 누적 건수와 최근 발생 시각을 확인합니다.</div>',
unsafe_allow_html=True,
)
with st.container(key="poc4_hmm_audit_inventory_panel"):
with st.expander("감사 이벤트 유형", expanded=True):
if inventory:
st.dataframe(
[
{
"이벤트 유형": str(item.get("event_type") or ""),
"누적 건수": int(item.get("event_count") or 0),
"최근 발생(KST)": str(item.get("latest_event_time") or ""),
}
for item in inventory
],
column_config={
"이벤트 유형": st.column_config.TextColumn(width="large"),
"누적 건수": st.column_config.NumberColumn(width="small"),
"최근 발생(KST)": st.column_config.TextColumn(width="medium"),
},
hide_index=True,
width="stretch",
height=min(360, 72 + 36 * len(inventory)),
)
else:
st.caption("아직 기록된 HMM 접근 관리 이벤트가 없습니다.")
st.markdown(
'<div class="kb-audit-heading">감사 이벤트</div>'
'<div class="kb-audit-caption">최신 이벤트부터 표시합니다. '
'처리 상태와 대상 식별자, 오류 메시지를 먼저 확인하세요.</div>',
unsafe_allow_html=True,
)
show_details = st.toggle(
"상세 메시지 표시",
value=True,
key="poc4_hmm_audit_show_details",
)
if not events:
st.info("선택한 조건에 해당하는 HMM 접근 관리 이벤트가 없습니다.")
return
display_rows: list[dict[str, Any]] = []
for event in events:
row: dict[str, Any] = {
"감사 ID": int(event.get("audit_id") or 0),
"발생시각(KST)": str(event.get("event_time") or ""),
"이벤트 유형": str(event.get("event_type") or ""),
"상태": str(event.get("status") or ""),
"토큰 Key ID": event.get("key_id"),
"대상 Object ID": event.get("object_id"),
"처리 행": event.get("row_count"),
"오류 코드": str(event.get("error_code") or ""),
}
if show_details:
row["메시지"] = str(event.get("message") or "")
display_rows.append(row)
column_config: dict[str, Any] = {
"감사 ID": st.column_config.NumberColumn(width="small"),
"발생시각(KST)": st.column_config.TextColumn(width="medium"),
"이벤트 유형": st.column_config.TextColumn(width="large"),
"상태": st.column_config.TextColumn(width="small"),
"토큰 Key ID": st.column_config.NumberColumn(width="small"),
"대상 Object ID": st.column_config.NumberColumn(width="small"),
"처리 행": st.column_config.NumberColumn(width="small"),
"오류 코드": st.column_config.TextColumn(width="medium"),
}
if show_details:
column_config["메시지"] = st.column_config.TextColumn(width="large")
with st.container(key="poc4_hmm_audit_event_panel"):
st.dataframe(
display_rows,
column_config=column_config,
hide_index=True,
width="stretch",
height=min(640, 104 + 38 * len(display_rows)),
)

View File

@@ -0,0 +1,563 @@
"""Small localhost authentication service for the HMM Streamlit portal.
Nginx owns the public security boundary. This module validates the existing
PBKDF2 login, issues a signed HttpOnly cookie, and answers Nginx auth_request
subrequests. Authentication values are never accepted from a URL.
"""
from __future__ import annotations
import base64
import binascii
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import datetime, timezone
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import hashlib
import hmac
import html
import json
import logging
import os
import secrets
import threading
import time
from typing import Deque
from urllib.parse import parse_qs, urlsplit
def _environment_value(*names: str, default: str = "") -> str:
for name in names:
value = os.environ.get(name)
if value is not None and value.strip():
return value.strip()
return default
LOG = logging.getLogger("hmm_portal_auth")
SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION"
CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF"
SESSION_TOKEN_VERSION = 2
MAX_REQUEST_BYTES = 8_192
MAX_FIELD_CHARS = 200
@dataclass(frozen=True)
class AuthConfig:
username: str
password_pbkdf2: str
cookie_secret: str
bind_address: str = "127.0.0.1"
port: int = 8621
session_seconds: int = 12 * 60 * 60
remember_seconds: int = 7 * 24 * 60 * 60
product_name: str = "HMM AI 업무 에이전트"
login_title: str = "HMM AI 업무 에이전트"
login_description: str = "사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다."
login_footer: str = "승인된 사용자만 접속할 수 있습니다."
primary_color: str = "#004b87"
@classmethod
def from_environment(cls) -> "AuthConfig":
config = cls(
username=_environment_value(
"AI_WEB_AGENT_CONSOLE_LOGIN_USER", "POC4_LOGIN_USER"
),
password_pbkdf2=_environment_value(
"AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2",
"POC4_LOGIN_PASSWORD_PBKDF2",
),
cookie_secret=_environment_value(
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET",
"POC4_LOGIN_COOKIE_SECRET",
),
bind_address=os.environ.get(
"PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1"
).strip(),
port=int(os.environ.get("PORTAL_AUTH_PORT", "8621")),
session_seconds=int(
os.environ.get("PORTAL_AUTH_SESSION_SECONDS", str(12 * 60 * 60))
),
remember_seconds=int(
os.environ.get(
"PORTAL_AUTH_REMEMBER_SECONDS", str(7 * 24 * 60 * 60)
)
),
product_name=os.environ.get(
"AGENT_CONSOLE_NAME", "HMM AI 업무 에이전트"
).strip(),
login_title=os.environ.get(
"AGENT_CONSOLE_LOGIN_TITLE", "HMM AI 업무 에이전트"
).strip(),
login_description=os.environ.get(
"AGENT_CONSOLE_LOGIN_DESCRIPTION",
"사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다.",
).strip(),
login_footer=os.environ.get(
"AGENT_CONSOLE_LOGIN_FOOTER",
"승인된 사용자만 접속할 수 있습니다.",
).strip(),
primary_color=os.environ.get(
"AGENT_CONSOLE_PRIMARY_COLOR", "#004b87"
).strip(),
)
config.validate()
return config
def validate(self) -> None:
if not self.username or not self.password_pbkdf2:
raise ValueError("AI web agent console login credentials are not configured")
if len(self.cookie_secret.encode("utf-8")) < 32:
raise ValueError(
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET must be at least 32 bytes"
)
if self.bind_address not in {"127.0.0.1", "::1"}:
raise ValueError("Portal authentication service must bind to loopback")
if not 1 <= self.port <= 65535:
raise ValueError("PORTAL_AUTH_PORT is invalid")
if not 300 <= self.session_seconds <= 24 * 60 * 60:
raise ValueError("PORTAL_AUTH_SESSION_SECONDS is outside the safe range")
if not self.session_seconds <= self.remember_seconds <= 30 * 24 * 60 * 60:
raise ValueError("PORTAL_AUTH_REMEMBER_SECONDS is outside the safe range")
@dataclass(frozen=True)
class AuthenticatedSession:
username: str
expires_at: int
class SessionTokenCodec:
def __init__(self, secret: str):
self._secret = secret.encode("utf-8")
def issue(self, username: str, lifetime_seconds: int, now: int | None = None) -> str:
issued_at = int(time.time()) if now is None else now
payload = {
"v": SESSION_TOKEN_VERSION,
"u": username,
"i": issued_at,
"e": issued_at + lifetime_seconds,
"n": secrets.token_urlsafe(18),
}
encoded = _base64url_encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
)
signature = hmac.new(
self._secret, encoded.encode("ascii"), hashlib.sha256
).hexdigest()
return f"{encoded}.{signature}"
def verify(self, token: str, expected_username: str, now: int | None = None) -> AuthenticatedSession | None:
if not token or len(token) > 2048:
return None
current_time = int(time.time()) if now is None else now
try:
encoded, supplied_signature = token.split(".", 1)
expected_signature = hmac.new(
self._secret, encoded.encode("ascii"), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(supplied_signature, expected_signature):
return None
payload = json.loads(_base64url_decode(encoded).decode("utf-8"))
version = int(payload["v"])
username = str(payload["u"])
issued_at = int(payload["i"])
expires_at = int(payload["e"])
except (
binascii.Error,
KeyError,
TypeError,
ValueError,
UnicodeDecodeError,
json.JSONDecodeError,
):
return None
if version != SESSION_TOKEN_VERSION:
return None
if issued_at > current_time + 30 or expires_at <= current_time:
return None
if expires_at - issued_at > 30 * 24 * 60 * 60:
return None
if not hmac.compare_digest(username, expected_username):
return None
return AuthenticatedSession(username=username, expires_at=expires_at)
class LoginAttemptLimiter:
def __init__(self, maximum_failures: int = 5, window_seconds: int = 300):
self._maximum_failures = maximum_failures
self._window_seconds = window_seconds
self._failures: dict[str, Deque[float]] = defaultdict(deque)
self._lock = threading.Lock()
def blocked(self, key: str, now: float | None = None) -> bool:
current_time = time.monotonic() if now is None else now
with self._lock:
failures = self._failures[key]
self._prune(failures, current_time)
return len(failures) >= self._maximum_failures
def record_failure(self, key: str, now: float | None = None) -> None:
current_time = time.monotonic() if now is None else now
with self._lock:
failures = self._failures[key]
self._prune(failures, current_time)
failures.append(current_time)
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
def _prune(self, failures: Deque[float], now: float) -> None:
cutoff = now - self._window_seconds
while failures and failures[0] < cutoff:
failures.popleft()
def password_matches(password: str, encoded_password: str) -> bool:
try:
scheme, iterations_text, salt_hex, expected_hex = encoded_password.split(
"$", 3
)
iterations = int(iterations_text)
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(expected_hex)
except (TypeError, ValueError):
return False
if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000:
return False
candidate = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, iterations
)
return hmac.compare_digest(candidate, expected)
def session_cookie_header(token: str, max_age: int | None) -> str:
attributes = [
f"{SESSION_COOKIE_NAME}={token}",
"Path=/",
"Secure",
"HttpOnly",
"SameSite=Lax",
]
if max_age is not None:
attributes.append(f"Max-Age={max_age}")
return "; ".join(attributes)
def clear_session_cookie_header() -> str:
return (
f"{SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; "
"Secure; HttpOnly; SameSite=Lax"
)
def csrf_cookie_header(value: str, max_age: int = 600) -> str:
return (
f"{CSRF_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; "
"Secure; HttpOnly; SameSite=Strict"
)
def clear_csrf_cookie_header() -> str:
return (
f"{CSRF_COOKIE_NAME}=; Path=/; Max-Age=0; "
"Secure; HttpOnly; SameSite=Strict"
)
def _base64url_encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def _base64url_decode(value: str) -> bytes:
padded = value + "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(padded)
def _cookie_value(cookie_header: str, name: str) -> str:
try:
cookies = SimpleCookie()
cookies.load(cookie_header)
morsel = cookies.get(name)
return morsel.value if morsel is not None else ""
except (KeyError, TypeError):
return ""
def _login_page(config: AuthConfig, csrf_value: str, error: str = "") -> bytes:
error_html = (
f'<div class="error" role="alert">{html.escape(error)}</div>'
if error
else ""
)
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(config.product_name)}</title>
<style>
:root {{ --primary:{html.escape(config.primary_color)}; --text:#17232d;
--muted:#60717f; --border:#d9e0e5; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#fff; color:var(--text);
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
main {{ width:min(420px,calc(100% - 40px)); margin:12vh auto 0; }}
.wordmark {{ color:var(--primary); font-size:1.25rem; font-weight:800;
letter-spacing:.08em; }}
h1 {{ margin:16px 0 8px; font-size:1.75rem; }}
.description,.footer {{ color:var(--muted); line-height:1.55; }}
form {{ margin-top:28px; }}
label {{ display:block; margin:0 0 18px; font-weight:700; }}
input[type="text"],input[type="password"] {{ width:100%; margin-top:8px;
padding:12px 13px; border:1px solid var(--border); border-radius:5px;
font:inherit; color:var(--text); background:#fff; }}
.remember {{ display:flex; align-items:center; gap:8px; font-weight:500; }}
.remember input {{ width:17px; height:17px; }}
button {{ width:100%; padding:12px; border:1px solid var(--primary);
border-radius:5px; background:var(--primary); color:#fff; font:inherit;
font-weight:800; cursor:pointer; }}
.error {{ margin:18px 0 0; padding:11px 12px; border:1px solid #d99898;
border-radius:5px; color:#8a2222; background:#fff7f7; }}
.footer {{ margin-top:22px; font-size:.9rem; }}
</style>
</head>
<body>
<main>
<div class="wordmark">HMM</div>
<h1>{html.escape(config.login_title)}</h1>
<p class="description">{html.escape(config.login_description)}</p>
{error_html}
<form action="/auth/login" method="post" autocomplete="on">
<input type="hidden" name="csrf" value="{html.escape(csrf_value)}">
<label>사용자 ID
<input name="username" type="text" maxlength="80" autocomplete="username"
required autofocus>
</label>
<label>비밀번호
<input name="password" type="password" maxlength="200"
autocomplete="current-password" required>
</label>
<label class="remember">
<input name="remember" type="checkbox" value="yes"> 로그인 유지 (7일)
</label>
<button type="submit">로그인</button>
</form>
<p class="footer">{html.escape(config.login_footer)}</p>
</main>
</body>
</html>""".encode("utf-8")
def build_handler(config: AuthConfig) -> type[BaseHTTPRequestHandler]:
codec = SessionTokenCodec(config.cookie_secret)
limiter = LoginAttemptLimiter()
class PortalAuthHandler(BaseHTTPRequestHandler):
server_version = "HMMPortalAuth/1.0"
sys_version = ""
def do_HEAD(self) -> None:
self._route(send_body=False)
def do_GET(self) -> None:
self._route(send_body=True)
def do_POST(self) -> None:
path = urlsplit(self.path).path
if path == "/auth/login":
self._login()
elif path == "/auth/logout":
self._logout()
else:
self._send_text(HTTPStatus.NOT_FOUND, "Not found")
def _route(self, send_body: bool) -> None:
path = urlsplit(self.path).path
if path == "/auth/check":
self._check()
elif path == "/auth/login":
self._show_login(send_body=send_body)
elif path == "/auth/logout":
self._logout()
elif path == "/auth/healthz":
self._send_text(HTTPStatus.OK, "ok", send_body=send_body)
else:
self._send_text(HTTPStatus.NOT_FOUND, "Not found", send_body=send_body)
def _check(self) -> None:
session = self._session()
if session is None:
self._send_empty(HTTPStatus.UNAUTHORIZED)
return
self.send_response(HTTPStatus.NO_CONTENT)
self._security_headers()
self.send_header("X-Auth-User", session.username)
self.send_header("X-Auth-Expires", str(session.expires_at))
self.end_headers()
def _show_login(self, send_body: bool = True, error: str = "") -> None:
if self._session() is not None and not error:
self._redirect("/")
return
csrf_value = secrets.token_urlsafe(32)
body = _login_page(config, csrf_value, error)
self.send_response(HTTPStatus.OK)
self._security_headers()
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Set-Cookie", csrf_cookie_header(csrf_value))
self.end_headers()
if send_body:
self.wfile.write(body)
def _login(self) -> None:
client_key = self._client_key()
if limiter.blocked(client_key):
self._show_login(error="로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해 주세요.")
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
content_length = 0
if not 1 <= content_length <= MAX_REQUEST_BYTES:
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
return
raw_body = self.rfile.read(content_length)
try:
form = parse_qs(
raw_body.decode("utf-8"),
keep_blank_values=True,
strict_parsing=False,
max_num_fields=8,
)
except (UnicodeDecodeError, ValueError):
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
return
username = _form_value(form, "username")
password = _form_value(form, "password")
csrf_form = _form_value(form, "csrf")
csrf_cookie = _cookie_value(
self.headers.get("Cookie", ""), CSRF_COOKIE_NAME
)
if (
not csrf_form
or not csrf_cookie
or not hmac.compare_digest(csrf_form, csrf_cookie)
):
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
return
valid_credentials = (
len(username) <= 80
and len(password) <= MAX_FIELD_CHARS
and hmac.compare_digest(username.strip(), config.username)
and password_matches(password, config.password_pbkdf2)
)
if not valid_credentials:
limiter.record_failure(client_key)
self._show_login(error="사용자 ID 또는 비밀번호를 확인해 주세요.")
return
limiter.reset(client_key)
remember = _form_value(form, "remember") == "yes"
lifetime = (
config.remember_seconds if remember else config.session_seconds
)
token = codec.issue(config.username, lifetime)
self.send_response(HTTPStatus.SEE_OTHER)
self._security_headers()
self.send_header("Location", "/")
self.send_header(
"Set-Cookie",
session_cookie_header(token, lifetime if remember else None),
)
self.send_header("Set-Cookie", clear_csrf_cookie_header())
self.end_headers()
def _logout(self) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self._security_headers()
self.send_header("Location", "/auth/login")
self.send_header("Set-Cookie", clear_session_cookie_header())
self.send_header("Set-Cookie", clear_csrf_cookie_header())
self.end_headers()
def _session(self) -> AuthenticatedSession | None:
token = _cookie_value(
self.headers.get("Cookie", ""), SESSION_COOKIE_NAME
)
return codec.verify(token, config.username)
def _client_key(self) -> str:
forwarded = self.headers.get("X-Real-IP", "").strip()
return forwarded or self.client_address[0]
def _redirect(self, location: str) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self._security_headers()
self.send_header("Location", location)
self.end_headers()
def _send_empty(self, status: HTTPStatus) -> None:
self.send_response(status)
self._security_headers()
self.send_header("Content-Length", "0")
self.end_headers()
def _send_text(
self,
status: HTTPStatus,
message: str,
send_body: bool = True,
) -> None:
body = message.encode("utf-8")
self.send_response(status)
self._security_headers()
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if send_body:
self.wfile.write(body)
def _security_headers(self) -> None:
self.send_header("Cache-Control", "no-store")
self.send_header("Pragma", "no-cache")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; "
"form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
)
def log_message(self, _format: str, *args: object) -> None:
# Do not log query strings, cookies, form bodies, or tokens.
LOG.info("%s %s", self.command, urlsplit(self.path).path)
return PortalAuthHandler
def _form_value(form: dict[str, list[str]], name: str) -> str:
values = form.get(name)
return values[0] if values else ""
def main() -> None:
logging.basicConfig(
level=os.environ.get("PORTAL_AUTH_LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
config = AuthConfig.from_environment()
server = ThreadingHTTPServer(
(config.bind_address, config.port), build_handler(config)
)
LOG.info("HMM portal authentication service listening on loopback port %s", config.port)
server.serve_forever()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,80 @@
"""Pure helpers for MCP result envelopes used by the Streamlit console."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
def response_payload(value: Any) -> Mapping[str, Any]:
"""Return the business payload from a direct or nested MCP response."""
if not isinstance(value, Mapping):
return {}
nested = value.get("response")
return nested if isinstance(nested, Mapping) else value
def text_result(value: Any) -> str:
"""Return a textual `result` field without stringifying other structures."""
result = response_payload(value).get("result")
return result.strip() if isinstance(result, str) else ""
def status_result_summary(value: Any, *, excerpt_chars: int = 900) -> dict[str, Any]:
"""Build a safe UI summary for status/result-style compatibility tools."""
payload = response_payload(value)
summary: dict[str, Any] = {}
for key in ("status", "success", "error", "errorCode", "errorMessage"):
item = payload.get(key)
if item not in (None, "", []):
summary[key] = item
result = text_result(value)
if result:
summary["result_chars"] = len(result)
summary["result_excerpt"] = result[:excerpt_chars] + (
"..." if len(result) > excerpt_chars else ""
)
return summary
def status_result_evidence(value: Any, *, max_chars: int = 7000) -> dict[str, Any]:
"""Preserve bounded textual policy/data evidence for final answer synthesis."""
payload = response_payload(value)
evidence: dict[str, Any] = {}
for key in ("status", "success", "error", "errorCode", "errorMessage"):
item = payload.get(key)
if item not in (None, "", []):
evidence[key] = item
result = text_result(value)
if result:
evidence["result"] = result[:max_chars] + (
"..." if len(result) > max_chars else ""
)
evidence["result_chars"] = len(result)
return evidence
def has_actionable_text_result(value: Any) -> bool:
"""Return whether a textual result contains evidence worth stopping on."""
result = text_result(value)
if not result:
return False
normalized = " ".join(result.casefold().split())
return not any(
marker in normalized
for marker in ("no data found", "no evidence found", "error:")
)
__all__ = [
"has_actionable_text_result",
"response_payload",
"status_result_evidence",
"status_result_summary",
"text_result",
]

View File

@@ -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)

View File

@@ -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,

View File

@@ -27,6 +27,8 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
[data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span, [data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span,
[data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1, [data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1,
[data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3, [data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3,
[data-testid="stAppViewContainer"] li, [data-testid="stAppViewContainer"] dt,
[data-testid="stAppViewContainer"] dd, [data-testid="stAppViewContainer"] blockquote,
[data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea, [data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea,
section[data-testid="stSidebar"] * {{ color:var(--console-text) !important; section[data-testid="stSidebar"] * {{ color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }} -webkit-text-fill-color:var(--console-text) !important; }}
@@ -34,7 +36,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
background:#fff !important; border:1px solid var(--console-border) !important; background:#fff !important; border:1px solid var(--console-border) !important;
border-radius:4px !important; box-shadow:none !important; }} border-radius:4px !important; box-shadow:none !important; }}
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{ div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
background:#fff !important; color:var(--console-text) !important;
border:1px solid var(--console-border) !important;
border-radius:4px !important; box-shadow:none !important; }} border-radius:4px !important; box-shadow:none !important; }}
div[data-testid="stButton"] > button *,
div[data-testid="stFormSubmitButton"] > button * {{
color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }}
div[data-testid="stButton"] > button[kind="primary"], div[data-testid="stButton"] > button[kind="primary"],
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{ div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }} background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
@@ -54,6 +61,32 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
-webkit-text-fill-color:var(--console-text) !important; }} -webkit-text-fill-color:var(--console-text) !important; }}
[data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{ [data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{
background:#fff !important; border-color:var(--console-border) !important; }} background:#fff !important; border-color:var(--console-border) !important; }}
[data-testid="stExpander"] summary {{
background:#f6f8fa !important; color:var(--console-text) !important;
border-color:var(--console-border) !important; }}
[data-testid="stExpander"] summary * {{
color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
[data-baseweb="tab-list"], [data-testid="stTabs"] [role="tablist"] {{
border-bottom:1px solid var(--console-border) !important; }}
[data-baseweb="tab-list"] [role="tab"], [data-testid="stTab"] {{
background:#fff !important; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important;
border-radius:4px 4px 0 0 !important; }}
[data-baseweb="tab-list"] [role="tab"] *, [data-testid="stTab"] * {{
color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
[data-baseweb="tab-list"] [role="tab"]:hover, [data-testid="stTab"]:hover {{
background:#f6f8fa !important; }}
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"],
[data-testid="stTab"][aria-selected="true"] {{
color:var(--console-primary) !important;
-webkit-text-fill-color:var(--console-primary) !important;
border-bottom:3px solid var(--console-primary) !important; }}
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"] *,
[data-testid="stTab"][aria-selected="true"] * {{
color:var(--console-primary) !important;
-webkit-text-fill-color:var(--console-primary) !important; }}
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }} .console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }} .console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }} .console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
@@ -61,6 +94,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
.st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }} .st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }}
.console-login {{ text-align:left; }} .console-login {{ text-align:left; }}
.console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }} .console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }}
a.console-logout-button {{ display:block; width:100%; padding:.55rem .8rem;
margin:.25rem 0 .75rem; background:#fff; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important;
border:1px solid var(--console-border); border-radius:4px;
text-align:center; text-decoration:none; font-weight:700; }}
a.console-logout-button:hover {{ background:#f6f8fa; }}
@media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }} @media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }}
</style> </style>
""", """,

View 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",
]

View File

@@ -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:

View File

@@ -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=>({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;","\"":"&quot;"}[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&amp;auto=webp&amp;quality=80&amp;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>

View 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 리포트로 보여줘"
}
]
}

View 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": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다."
}
}
]
}

View File

@@ -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"
} }
] ]
} }

View File

@@ -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

View File

@@ -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",

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import hashlib
import http.client
import os
from pathlib import Path
import sys
import threading
import unittest
from urllib.parse import urlencode
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from ai_web_agent_console.auth_gateway import ( # noqa: E402
AuthConfig,
LoginAttemptLimiter,
SESSION_COOKIE_NAME,
SessionTokenCodec,
build_handler,
clear_session_cookie_header,
password_matches,
session_cookie_header,
)
class AuthGatewayTest(unittest.TestCase):
def setUp(self) -> None:
self.secret = "s" * 48
self.codec = SessionTokenCodec(self.secret)
def test_session_token_round_trip_and_tamper_rejection(self) -> None:
token = self.codec.issue("demo-admin", 3600, now=1_000)
session = self.codec.verify(token, "demo-admin", now=1_001)
self.assertIsNotNone(session)
self.assertEqual("demo-admin", session.username)
self.assertEqual(4_600, session.expires_at)
self.assertIsNone(self.codec.verify(token + "x", "demo-admin", now=1_001))
self.assertIsNone(self.codec.verify(token, "other-user", now=1_001))
def test_expired_session_token_is_rejected(self) -> None:
token = self.codec.issue("demo-admin", 300, now=1_000)
self.assertIsNone(self.codec.verify(token, "demo-admin", now=1_300))
def test_remember_cookie_has_required_security_attributes(self) -> None:
header = session_cookie_header("signed-value", 604_800)
self.assertIn(f"{SESSION_COOKIE_NAME}=signed-value", header)
self.assertIn("Path=/", header)
self.assertIn("Secure", header)
self.assertIn("HttpOnly", header)
self.assertIn("SameSite=Lax", header)
self.assertIn("Max-Age=604800", header)
self.assertNotIn("Domain=", header)
def test_session_cookie_omits_persistent_max_age(self) -> None:
header = session_cookie_header("signed-value", None)
self.assertNotIn("Max-Age", header)
self.assertIn("HttpOnly", header)
def test_logout_cookie_expires_immediately(self) -> None:
header = clear_session_cookie_header()
self.assertIn("Max-Age=0", header)
self.assertIn("Secure", header)
self.assertIn("HttpOnly", header)
def test_pbkdf2_password_verification(self) -> None:
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
expected = hashlib.pbkdf2_hmac(
"sha256", b"correct-password", salt, 200_000
).hex()
encoded = f"pbkdf2_sha256$200000${salt.hex()}${expected}"
self.assertTrue(password_matches("correct-password", encoded))
self.assertFalse(password_matches("wrong-password", encoded))
def test_rate_limiter_blocks_only_after_threshold(self) -> None:
limiter = LoginAttemptLimiter(maximum_failures=2, window_seconds=10)
limiter.record_failure("client", now=1)
self.assertFalse(limiter.blocked("client", now=2))
limiter.record_failure("client", now=3)
self.assertTrue(limiter.blocked("client", now=4))
self.assertFalse(limiter.blocked("client", now=20))
def test_environment_config_requires_new_cookie_secret(self) -> None:
previous = dict(os.environ)
try:
os.environ["POC4_LOGIN_USER"] = "demo-admin"
os.environ["POC4_LOGIN_PASSWORD_PBKDF2"] = "encoded"
os.environ.pop("POC4_LOGIN_COOKIE_SECRET", None)
with self.assertRaisesRegex(ValueError, "COOKIE_SECRET"):
AuthConfig.from_environment()
finally:
os.environ.clear()
os.environ.update(previous)
def test_http_login_check_and_logout_flow_never_uses_url_token(self) -> None:
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
expected = hashlib.pbkdf2_hmac(
"sha256", b"correct-password", salt, 200_000
).hex()
config = AuthConfig(
username="demo-admin",
password_pbkdf2=(
f"pbkdf2_sha256$200000${salt.hex()}${expected}"
),
cookie_secret=self.secret,
port=8621,
)
from http.server import ThreadingHTTPServer
server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(config))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
connection = http.client.HTTPConnection(
"127.0.0.1", server.server_address[1], timeout=3
)
try:
connection.request("GET", "/auth/login")
login_page = connection.getresponse()
body = login_page.read().decode("utf-8")
self.assertEqual(200, login_page.status)
csrf_header = next(
value
for name, value in login_page.getheaders()
if name.lower() == "set-cookie"
and value.startswith("__Host-HMM_LOGIN_CSRF=")
)
csrf_value = csrf_header.split("=", 1)[1].split(";", 1)[0]
self.assertIn(
f'name="csrf" value="{csrf_value}"',
body,
)
payload = urlencode(
{
"csrf": csrf_value,
"username": "demo-admin",
"password": "correct-password",
"remember": "yes",
}
)
connection.request(
"POST",
"/auth/login",
body=payload,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": f"__Host-HMM_LOGIN_CSRF={csrf_value}",
},
)
logged_in = connection.getresponse()
logged_in.read()
self.assertEqual(303, logged_in.status)
self.assertEqual("/", logged_in.getheader("Location"))
self.assertNotRegex(logged_in.getheader("Location"), r"token|remember")
session_header = next(
value
for name, value in logged_in.getheaders()
if name.lower() == "set-cookie"
and value.startswith(f"{SESSION_COOKIE_NAME}=")
)
session_value = session_header.split("=", 1)[1].split(";", 1)[0]
self.assertIn("Secure", session_header)
self.assertIn("HttpOnly", session_header)
self.assertIn("SameSite=Lax", session_header)
connection.request(
"GET",
"/auth/check",
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
)
check = connection.getresponse()
check.read()
self.assertEqual(204, check.status)
self.assertEqual("demo-admin", check.getheader("X-Auth-User"))
connection.request(
"GET",
"/auth/check",
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}x"},
)
tampered = connection.getresponse()
tampered.read()
self.assertEqual(401, tampered.status)
connection.request(
"GET",
"/auth/logout",
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
)
logout = connection.getresponse()
logout.read()
self.assertEqual(303, logout.status)
self.assertEqual("/auth/login", logout.getheader("Location"))
self.assertTrue(
any(
name.lower() == "set-cookie" and "Max-Age=0" in value
for name, value in logout.getheaders()
)
)
finally:
connection.close()
server.shutdown()
server.server_close()
thread.join(timeout=3)
if __name__ == "__main__":
unittest.main()

View 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
)

View 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()

View File

@@ -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 ===

View File

@@ -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

View 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