refs #742: merge HMM application layout into main
# Conflicts: # src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java # vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java # vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java # vpd-backoffice/src/main/resources/mapper/MaskingRuleMapper.xml # vpd-backoffice/src/main/resources/templates/schema-metadata.html # vpd-backoffice/src/main/resources/templates/structured-data.html
This commit is contained in:
45
.env.example
45
.env.example
@@ -43,7 +43,8 @@ export BACKOFFICE_SESSION_COOKIE_SECURE="false"
|
||||
export BACKOFFICE_REMEMBER_ME_ENABLED="false"
|
||||
export BACKOFFICE_REMEMBER_ME_KEY=""
|
||||
export BACKOFFICE_REMEMBER_ME_DAYS="14"
|
||||
export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
|
||||
# HMM HR 질의는 HMM MCP/DBMS_CLOUD_AI_AGENT를 사용합니다. 기존 ORDS 운영 기능이 필요할 때만 설정합니다.
|
||||
export BACKOFFICE_ORDS_BASE_URL=""
|
||||
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
|
||||
# ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로
|
||||
# ADMIN으로 실행 중이면 Handler 생성은 막히고 소스 보기만 사용합니다.
|
||||
@@ -51,7 +52,41 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
|
||||
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
||||
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_PROVIDER="openai" # openai | oci
|
||||
export BACKOFFICE_AI_BASE_URL="" # 예: https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
||||
@@ -70,7 +105,7 @@ export BACKOFFICE_AI_OCI_COMPARTMENT_ID=""
|
||||
export VPDTEST1_API_KEY=""
|
||||
export VPDTEST2_API_KEY=""
|
||||
|
||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (sql/adb/07_end_users.sql 에서 사용) ---
|
||||
# --- (3) 데모용 ADB 엔드유저 비밀번호 (database/adb/07_end_users.sql 에서 사용) ---
|
||||
# ADB 비번 정책: 12자 이상, 대/소/숫자/특수 조합.
|
||||
# 4명의 데모 유저:
|
||||
# vpduser_my → MySQL view 만 SELECT 가능
|
||||
@@ -83,7 +118,7 @@ export VPDUSER_BOTH_PASSWORD="RowFilter#Both26"
|
||||
export VPDUSER_NONE_PASSWORD="RowFilter#None26"
|
||||
|
||||
# --- (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 데모와 공존.
|
||||
export DDSUSER_MY_PASSWORD="DdsGrant#My2026"
|
||||
export DDSUSER_PG_PASSWORD="DdsGrant#Pg2026"
|
||||
@@ -104,7 +139,7 @@ export DDS_MCP_BASE_URL="http://127.0.0.1:8083"
|
||||
export DDS_MCP_TEST_BEARER="" # 발급한 활성 CB_APP_USER Bearer만 로컬 테스트에 사용
|
||||
|
||||
# --- (4) 원격 Postgres (AWS RDS, Cloud SQL, ...) ---
|
||||
# sql/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
||||
# database/source/postgres_setup.sql 가 여기로 customers 테이블/seed 생성.
|
||||
# ADB 의 RDS_POSTGRES_LINK 가 이 인스턴스를 가리킴.
|
||||
export PG_HOST="" # 예: vpd-poc.xxxxx.ap-northeast-2.rds.amazonaws.com
|
||||
export PG_PORT="5432"
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -17,6 +17,12 @@ logs/
|
||||
# Java / Maven
|
||||
target/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
data/
|
||||
|
||||
# Locally downloaded development tools (for example SQLcl)
|
||||
.tools/
|
||||
|
||||
|
||||
54
README.md
54
README.md
@@ -32,7 +32,7 @@ End-to-End 데모입니다.
|
||||
행 자체는 보여주되 특정 컬럼만 가리는 통제입니다. 예: 영업팀은 고객 행은 다 보지만
|
||||
`email` 컬럼은 항상 `NULL` 로 반환.
|
||||
|
||||
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`sql/adb/06a_redaction.sql`).
|
||||
* VPD 경로: 별도의 `DBMS_REDACT` 정책 (`database/adb/06a_redaction.sql`).
|
||||
* DDS 경로: 데이터 그랜트 안에 `(ALL COLUMNS EXCEPT email)` 한 줄로 흡수.
|
||||
|
||||
### 3. 두 가지 구현 — VPD 와 DDS
|
||||
@@ -59,19 +59,21 @@ DDS 변형의 능력 (멀티테넌트, OAuth2 federated identity, MAC 모드,
|
||||
|
||||
## 소스 위치와 Git 저장 기준
|
||||
|
||||
현재 이 저장소에는 **VPD 관리 백오피스**와 **PoC4 MCP AI Console 외피**가 함께 들어 있다.
|
||||
두 소스는 별도 Git repository가 아니라 같은 repository의 서로 다른 경로로 관리한다.
|
||||
현재 이 저장소에는 서로 독립적으로 빌드·실행되는 세 애플리케이션이 있다. 각 제품은
|
||||
최상위 폴더를 하나씩 사용하며 저장소 루트에 프레임워크 `src`를 두지 않는다.
|
||||
|
||||
| 구분 | 저장 위치 | 역할 |
|
||||
|---|---|---|
|
||||
| VPD/ASO/FGA 관리 백오피스 | `src/main/java/com/cloudhandson/vpdbackoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
||||
| VPD 백오피스 화면 | `src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
||||
| VPD 백오피스 CSS/JS | `src/main/resources/static/` | 화면 동작과 스타일 |
|
||||
| VPD/ASO/FGA/ORDS/Select AI SQL | `sql/adb/` | ADB 적용·검증 SQL 스크립트 |
|
||||
| VPD/ASO/FGA 관리 백오피스 | `vpd-backoffice/` | Spring Boot 기반 데이터 접근 제어 관리 콘솔 |
|
||||
| VPD 백오피스 화면 | `vpd-backoffice/src/main/resources/templates/` | Thymeleaf 서버 렌더링 화면 |
|
||||
| VPD 백오피스 CSS/JS | `vpd-backoffice/src/main/resources/static/` | 화면 동작과 스타일 |
|
||||
| VPD/ASO/FGA/ORDS/Select AI SQL | `database/adb/` | ADB 적용·검증 SQL 스크립트 |
|
||||
| 원격 원장 seed/검증 SQL | `database/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 |
|
||||
| DDS 백오피스 | `dds-backoffice/` | VPD와 분리된 DDS 전용 Spring Boot 데모 |
|
||||
| Streamlit 외피 / PoC4 MCP AI Console | `poc4_active_source_20260714/` | ReAct-style로 MCP tool을 호출하는 PoC4 데모 소스 snapshot |
|
||||
| Streamlit 외피 메인 화면 | `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py` | MCP discovery, tool routing, ReAct-style agent loop, FGA evidence 표시 |
|
||||
| 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/` | Streamlit 기반 MCP 업무 에이전트 콘솔 |
|
||||
| AI Web Agent Console 진입점 | `ai-web-agent-console/app.py` | MCP discovery, 도구 호출, 답변 합성, 감사 증적 표시 |
|
||||
| AI Web Agent Console 패키지 | `ai-web-agent-console/ai_web_agent_console/` | 인증, 화면, MCP, 모델과 질의 계약 모듈 |
|
||||
| AI Web Agent Console 설정 | `ai-web-agent-console/config/` | 제품, MCP, 모델, 데모 사용자와 질문 JSON |
|
||||
|
||||
Git 기준은 다음과 같다.
|
||||
|
||||
@@ -80,14 +82,12 @@ Git 기준은 다음과 같다.
|
||||
| 로컬 작업 디렉터리 | `/home/opc/workspaces/vpd-permission-poc-20260628213409` |
|
||||
| 운영 기준 remote | `gitea` |
|
||||
| 운영 기준 repository | `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git` |
|
||||
| 기준 branch | `main` |
|
||||
| 보조 remote | `origin` = `https://github.com/devmrko/vpd-permission-poc.git` |
|
||||
| HMM 기준 branch | `hmm-backoffice` |
|
||||
|
||||
주의:
|
||||
|
||||
- 현재 운영·현행화 기준은 `gitea/main`이다.
|
||||
- `origin/main`은 보조 remote이며, 이 작업공간의 `main`이 `origin/main`보다 앞서 있을 수 있다.
|
||||
- `poc4_active_source_20260714/`는 배포 VM의 PoC4 활성 소스를 가져온 snapshot이다. 원본은 배포 VM의 `/home/opc/poc_4/` 계열 경로에서 운영되었고, 저장소에는 비밀정보 없이 소스와 설정 sample만 보관한다.
|
||||
- HMM 운영·현행화 기준은 Gitea의 `hmm-backoffice` 브랜치다.
|
||||
- 날짜 스냅샷과 PoC 번호는 현재 소스 경로로 사용하지 않는다.
|
||||
- 실제 DB password, wallet password, Bearer token, AI key, 대화 SQLite DB는 Git에 넣지 않는다.
|
||||
|
||||
---
|
||||
@@ -97,6 +97,7 @@ Git 기준은 다음과 같다.
|
||||
| 계층 | 객체 | 역할 |
|
||||
|---|---|---|
|
||||
| 원격 | `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 - 매핑 | `app_customer`, `app_user`, `app_group`, `user_group`, `db_source`, `permission` | 누가 어느 소스의 어느 region 을 볼 수 있는지 |
|
||||
| ADB - 컨텍스트 | `vpd_ctx` (Secure Application Context) + `ctx_pkg` | 로그인 시 권한을 세션 컨텍스트로 로딩 |
|
||||
@@ -109,7 +110,7 @@ Git 기준은 다음과 같다.
|
||||
## 빠른 시작 (One-Click)
|
||||
|
||||
```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
|
||||
|
||||
# 1) 환경값 채우기
|
||||
@@ -167,7 +168,7 @@ $EDITOR .env
|
||||
|
||||
## 데모 시나리오 — 2×2 source access matrix
|
||||
|
||||
`sql/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
||||
`database/adb/03_seed.sql` 의 매핑 (4 유저, 4 케이스):
|
||||
|
||||
| DB 유저 | 그룹 | PG 뷰 | MySQL 뷰 | VPD 결과 |
|
||||
|---|---|---|---|---|
|
||||
@@ -185,7 +186,7 @@ $EDITOR .env
|
||||
(`1=0` predicate) — **deny by default**.
|
||||
* 누구든 원본 테이블 직접 접근 시도 (`@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 로 실패하는 것을
|
||||
보여줍니다. 09/10/11 은 각 유저의 expected 행 수를 가볍게 확인합니다.
|
||||
|
||||
@@ -197,7 +198,7 @@ $EDITOR .env
|
||||
|
||||
## DDS 설정 핵심
|
||||
|
||||
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이
|
||||
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이
|
||||
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
|
||||
|
||||
### 사전 조건
|
||||
@@ -257,10 +258,23 @@ CREATE DATA GRANT admin.alice_apac_grant
|
||||
.
|
||||
├── run.sh # 원클릭 엔트리포인트
|
||||
├── .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 검증 헬퍼
|
||||
├── sql/
|
||||
├── database/
|
||||
│ ├── source/
|
||||
│ │ ├── 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
|
||||
│ └── adb/
|
||||
│ ├── 00_cleanup.sql # 멱등 teardown
|
||||
|
||||
48
ai-web-agent-console/.env.example
Normal file
48
ai-web-agent-console/.env.example
Normal file
@@ -0,0 +1,48 @@
|
||||
# AI Web Agent Console local/VM configuration example.
|
||||
# Copy to .env and keep real credentials out of Git.
|
||||
|
||||
# MCP
|
||||
HMM_MCP_BEARER_TOKEN=
|
||||
AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS=45
|
||||
|
||||
# OCI Generative AI SDK
|
||||
OCI_AUTH_TYPE=config_file
|
||||
OCI_CONFIG_FILE=~/.oci/config
|
||||
OCI_PROFILE=DEFAULT
|
||||
OCI_GENAI_COMPARTMENT_ID=
|
||||
AI_WEB_AGENT_CONSOLE_MODEL_PROFILE=gpt54_mini_oci
|
||||
AI_WEB_AGENT_CONSOLE_COMPLEX_REASONING_MODEL_PROFILE=gpt54_mini_oci
|
||||
|
||||
# Optional model route overrides
|
||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini
|
||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION=us-chicago-1
|
||||
# AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com
|
||||
|
||||
# Login gateway. Generate PBKDF2 hash and random cookie secret outside Git.
|
||||
AI_WEB_AGENT_CONSOLE_LOGIN_USER=demo-admin
|
||||
AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2=
|
||||
AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET=
|
||||
PORTAL_AUTH_BIND_ADDRESS=127.0.0.1
|
||||
PORTAL_AUTH_PORT=8621
|
||||
PORTAL_AUTH_SESSION_SECONDS=43200
|
||||
PORTAL_AUTH_REMEMBER_SECONDS=604800
|
||||
|
||||
# Product display
|
||||
AGENT_CONSOLE_NAME=HMM AI 업무 에이전트
|
||||
AGENT_CONSOLE_SHORT_NAME=HMM
|
||||
AGENT_CONSOLE_PAGE_TITLE=HMM AI 업무 에이전트
|
||||
AGENT_CONSOLE_PRIMARY_COLOR=#004b87
|
||||
|
||||
# Local conversation store
|
||||
AI_WEB_AGENT_CONSOLE_CHAT_DB_PATH=data/ai_web_agent_console_chat.sqlite3
|
||||
|
||||
# Optional audit DB. Password and Wallet remain outside Git.
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE=.env
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_USER=ADMIN
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_DB_PASSWORD=
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_DSN=
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_DIR=
|
||||
AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD=
|
||||
|
||||
# Optional query contract override
|
||||
AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH=config/hmm_hr_query_contracts.json
|
||||
57
ai-web-agent-console/README.md
Normal file
57
ai-web-agent-console/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# AI Web Agent Console
|
||||
|
||||
HMM 업무 데이터를 MCP 도구로 조회하고 OCI Generative AI로 답변을 구성하는 Streamlit
|
||||
애플리케이션이다. 과거 PoC 번호나 소스 추출 날짜는 애플리케이션 이름과 패키지 경계로
|
||||
사용하지 않는다.
|
||||
|
||||
## 구조
|
||||
|
||||
```text
|
||||
ai-web-agent-console/
|
||||
├── app.py Streamlit 실행 진입점
|
||||
├── ai_web_agent_console/ 인증, 화면, MCP, 모델, 질의 계약 모듈
|
||||
├── config/ 제품·모델·MCP·시나리오 JSON
|
||||
├── tests/ Python 회귀 테스트
|
||||
├── .env.example 환경변수 예시
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
cd ai-web-agent-console
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/streamlit run app.py --server.address 0.0.0.0 --server.port 8622
|
||||
```
|
||||
|
||||
인증 게이트웨이는 같은 디렉토리에서 다음과 같이 실행한다.
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m ai_web_agent_console.auth_gateway
|
||||
```
|
||||
|
||||
## 설정
|
||||
|
||||
- `config/app_profile.json`: 제품명, 설명, 아이콘, 색상
|
||||
- `config/mcp_servers.json`: MCP endpoint와 허용 도구
|
||||
- `config/model_profiles.json`: OCI Generative AI 모델 profile
|
||||
- `config/vpd_token_presets.json`: 데모 사용자와 토큰 환경변수 매핑
|
||||
- `config/hmm_demo_scenarios.json`: 화면의 예제 질문
|
||||
- `config/hmm_hr_query_contracts.json`: 질의별 필수 근거와 계산·시간 규칙
|
||||
|
||||
실제 토큰, DB 비밀번호, Wallet, OCI private key는 Git에 넣지 않는다. 데모 사용자 JSON은
|
||||
`HMM_MCP_BEARER_TOKEN` 같은 환경변수 이름만 참조한다.
|
||||
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
python3 -m compileall -q app.py ai_web_agent_console tests
|
||||
python3 -m unittest discover -s tests -p 'test_*.py'
|
||||
```
|
||||
|
||||
## 운영 호환성
|
||||
|
||||
운영 서버의 물리 경로 `/opt/hmm-poc4`와 기존 SQLite 테이블명은 대화 이력과 서비스 전환
|
||||
호환을 위해 유지할 수 있다. Git 폴더, Python 패키지와 실행 명령은 이 문서의 새 이름만
|
||||
사용한다.
|
||||
190
ai-web-agent-console/ai_web_agent_console/audit.py
Normal file
190
ai-web-agent-console/ai_web_agent_console/audit.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Reusable audit-tab renderer with data loaders supplied by the application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
AuditInventoryLoader = Callable[[], list[dict[str, Any]]]
|
||||
AuditEventsLoader = Callable[[int, int, str, str], list[dict[str, Any]]]
|
||||
|
||||
|
||||
def render_hmm_audit_tab(
|
||||
st: Any,
|
||||
inventory_loader: AuditInventoryLoader,
|
||||
events_loader: AuditEventsLoader,
|
||||
error_type: type[Exception],
|
||||
) -> None:
|
||||
"""Render HMM access audit data without owning DB connection details."""
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||
'감사로그 ( <strong>HMM 접근 관리</strong> )'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
st.markdown(
|
||||
'<div class="kb-audit-lead">'
|
||||
'HMM 백오피스의 사용자·그룹·역할·토큰·접근 정책 변경 이력을 시간순으로 확인합니다. '
|
||||
'이벤트 유형과 처리 상태로 필터링해 운영 변경의 성공·실패를 추적할 수 있습니다.'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
try:
|
||||
inventory = inventory_loader()
|
||||
except error_type as exc:
|
||||
st.error(str(exc))
|
||||
return
|
||||
|
||||
event_types = tuple(
|
||||
str(item.get("event_type") or "").strip()
|
||||
for item in inventory
|
||||
if str(item.get("event_type") or "").strip()
|
||||
)
|
||||
st.markdown('<div class="kb-audit-heading">조회 조건</div>', unsafe_allow_html=True)
|
||||
with st.container(key="poc4_hmm_audit_filters"):
|
||||
event_column, status_column = st.columns(2)
|
||||
with event_column:
|
||||
selected_event_type = st.selectbox(
|
||||
"이벤트 유형",
|
||||
options=("", *event_types),
|
||||
format_func=lambda value: "전체 이벤트" if not value else value,
|
||||
key="poc4_hmm_audit_event_filter",
|
||||
)
|
||||
with status_column:
|
||||
selected_status = st.selectbox(
|
||||
"처리 상태",
|
||||
options=("", "SUCCESS", "FAILURE", "DENIED"),
|
||||
format_func=lambda value: "전체 상태" if not value else value,
|
||||
key="poc4_hmm_audit_status_filter",
|
||||
)
|
||||
days_column, limit_column, refresh_column = st.columns([1.5, 1, 0.8])
|
||||
with days_column:
|
||||
days = st.slider(
|
||||
"조회 기간",
|
||||
min_value=1,
|
||||
max_value=90,
|
||||
value=7,
|
||||
format="%d일",
|
||||
key="poc4_hmm_audit_days",
|
||||
)
|
||||
with limit_column:
|
||||
row_limit = st.number_input(
|
||||
"최대 건수",
|
||||
min_value=10,
|
||||
max_value=500,
|
||||
value=100,
|
||||
step=10,
|
||||
key="poc4_hmm_audit_row_limit",
|
||||
)
|
||||
with refresh_column:
|
||||
st.markdown('<div style="height: 28px"></div>', unsafe_allow_html=True)
|
||||
if st.button(
|
||||
"새로고침",
|
||||
icon=":material/refresh:",
|
||||
width="stretch",
|
||||
key="poc4_hmm_audit_refresh",
|
||||
):
|
||||
inventory_loader.clear()
|
||||
events_loader.clear()
|
||||
st.rerun()
|
||||
|
||||
try:
|
||||
events = events_loader(
|
||||
int(days), int(row_limit), selected_event_type, selected_status
|
||||
)
|
||||
except error_type as exc:
|
||||
st.error(str(exc))
|
||||
return
|
||||
|
||||
success_count = sum(
|
||||
1 for item in events if str(item.get("status") or "").upper() == "SUCCESS"
|
||||
)
|
||||
with st.container(key="poc4_hmm_audit_metrics"):
|
||||
type_metric, event_metric, success_metric, failure_metric = st.columns(4)
|
||||
type_metric.metric("이벤트 유형", len(inventory))
|
||||
event_metric.metric("조회 이벤트", len(events))
|
||||
success_metric.metric("성공", success_count)
|
||||
failure_metric.metric("실패·거부", len(events) - success_count)
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-audit-heading">이벤트 유형 현황</div>'
|
||||
f'<div class="kb-audit-caption">현재 기록된 이벤트 유형 {len(inventory)}개 · '
|
||||
'유형별 누적 건수와 최근 발생 시각을 확인합니다.</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
with st.container(key="poc4_hmm_audit_inventory_panel"):
|
||||
with st.expander("감사 이벤트 유형", expanded=True):
|
||||
if inventory:
|
||||
st.dataframe(
|
||||
[
|
||||
{
|
||||
"이벤트 유형": str(item.get("event_type") or ""),
|
||||
"누적 건수": int(item.get("event_count") or 0),
|
||||
"최근 발생(KST)": str(item.get("latest_event_time") or ""),
|
||||
}
|
||||
for item in inventory
|
||||
],
|
||||
column_config={
|
||||
"이벤트 유형": st.column_config.TextColumn(width="large"),
|
||||
"누적 건수": st.column_config.NumberColumn(width="small"),
|
||||
"최근 발생(KST)": st.column_config.TextColumn(width="medium"),
|
||||
},
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
height=min(360, 72 + 36 * len(inventory)),
|
||||
)
|
||||
else:
|
||||
st.caption("아직 기록된 HMM 접근 관리 이벤트가 없습니다.")
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-audit-heading">감사 이벤트</div>'
|
||||
'<div class="kb-audit-caption">최신 이벤트부터 표시합니다. '
|
||||
'처리 상태와 대상 식별자, 오류 메시지를 먼저 확인하세요.</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
show_details = st.toggle(
|
||||
"상세 메시지 표시",
|
||||
value=True,
|
||||
key="poc4_hmm_audit_show_details",
|
||||
)
|
||||
if not events:
|
||||
st.info("선택한 조건에 해당하는 HMM 접근 관리 이벤트가 없습니다.")
|
||||
return
|
||||
|
||||
display_rows: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
row: dict[str, Any] = {
|
||||
"감사 ID": int(event.get("audit_id") or 0),
|
||||
"발생시각(KST)": str(event.get("event_time") or ""),
|
||||
"이벤트 유형": str(event.get("event_type") or ""),
|
||||
"상태": str(event.get("status") or ""),
|
||||
"토큰 Key ID": event.get("key_id"),
|
||||
"대상 Object ID": event.get("object_id"),
|
||||
"처리 행": event.get("row_count"),
|
||||
"오류 코드": str(event.get("error_code") or ""),
|
||||
}
|
||||
if show_details:
|
||||
row["메시지"] = str(event.get("message") or "")
|
||||
display_rows.append(row)
|
||||
|
||||
column_config: dict[str, Any] = {
|
||||
"감사 ID": st.column_config.NumberColumn(width="small"),
|
||||
"발생시각(KST)": st.column_config.TextColumn(width="medium"),
|
||||
"이벤트 유형": st.column_config.TextColumn(width="large"),
|
||||
"상태": st.column_config.TextColumn(width="small"),
|
||||
"토큰 Key ID": st.column_config.NumberColumn(width="small"),
|
||||
"대상 Object ID": st.column_config.NumberColumn(width="small"),
|
||||
"처리 행": st.column_config.NumberColumn(width="small"),
|
||||
"오류 코드": st.column_config.TextColumn(width="medium"),
|
||||
}
|
||||
if show_details:
|
||||
column_config["메시지"] = st.column_config.TextColumn(width="large")
|
||||
with st.container(key="poc4_hmm_audit_event_panel"):
|
||||
st.dataframe(
|
||||
display_rows,
|
||||
column_config=column_config,
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
height=min(640, 104 + 38 * len(display_rows)),
|
||||
)
|
||||
563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""Small localhost authentication service for the HMM Streamlit portal.
|
||||
|
||||
Nginx owns the public security boundary. This module validates the existing
|
||||
PBKDF2 login, issues a signed HttpOnly cookie, and answers Nginx auth_request
|
||||
subrequests. Authentication values are never accepted from a URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Deque
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
||||
def _environment_value(*names: str, default: str = "") -> str:
|
||||
for name in names:
|
||||
value = os.environ.get(name)
|
||||
if value is not None and value.strip():
|
||||
return value.strip()
|
||||
return default
|
||||
|
||||
|
||||
LOG = logging.getLogger("hmm_portal_auth")
|
||||
SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION"
|
||||
CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF"
|
||||
SESSION_TOKEN_VERSION = 2
|
||||
MAX_REQUEST_BYTES = 8_192
|
||||
MAX_FIELD_CHARS = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfig:
|
||||
username: str
|
||||
password_pbkdf2: str
|
||||
cookie_secret: str
|
||||
bind_address: str = "127.0.0.1"
|
||||
port: int = 8621
|
||||
session_seconds: int = 12 * 60 * 60
|
||||
remember_seconds: int = 7 * 24 * 60 * 60
|
||||
product_name: str = "HMM AI 업무 에이전트"
|
||||
login_title: str = "HMM AI 업무 에이전트"
|
||||
login_description: str = "사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다."
|
||||
login_footer: str = "승인된 사용자만 접속할 수 있습니다."
|
||||
primary_color: str = "#004b87"
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "AuthConfig":
|
||||
config = cls(
|
||||
username=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_USER", "POC4_LOGIN_USER"
|
||||
),
|
||||
password_pbkdf2=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2",
|
||||
"POC4_LOGIN_PASSWORD_PBKDF2",
|
||||
),
|
||||
cookie_secret=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET",
|
||||
"POC4_LOGIN_COOKIE_SECRET",
|
||||
),
|
||||
bind_address=os.environ.get(
|
||||
"PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1"
|
||||
).strip(),
|
||||
port=int(os.environ.get("PORTAL_AUTH_PORT", "8621")),
|
||||
session_seconds=int(
|
||||
os.environ.get("PORTAL_AUTH_SESSION_SECONDS", str(12 * 60 * 60))
|
||||
),
|
||||
remember_seconds=int(
|
||||
os.environ.get(
|
||||
"PORTAL_AUTH_REMEMBER_SECONDS", str(7 * 24 * 60 * 60)
|
||||
)
|
||||
),
|
||||
product_name=os.environ.get(
|
||||
"AGENT_CONSOLE_NAME", "HMM AI 업무 에이전트"
|
||||
).strip(),
|
||||
login_title=os.environ.get(
|
||||
"AGENT_CONSOLE_LOGIN_TITLE", "HMM AI 업무 에이전트"
|
||||
).strip(),
|
||||
login_description=os.environ.get(
|
||||
"AGENT_CONSOLE_LOGIN_DESCRIPTION",
|
||||
"사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다.",
|
||||
).strip(),
|
||||
login_footer=os.environ.get(
|
||||
"AGENT_CONSOLE_LOGIN_FOOTER",
|
||||
"승인된 사용자만 접속할 수 있습니다.",
|
||||
).strip(),
|
||||
primary_color=os.environ.get(
|
||||
"AGENT_CONSOLE_PRIMARY_COLOR", "#004b87"
|
||||
).strip(),
|
||||
)
|
||||
config.validate()
|
||||
return config
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.username or not self.password_pbkdf2:
|
||||
raise ValueError("AI web agent console login credentials are not configured")
|
||||
if len(self.cookie_secret.encode("utf-8")) < 32:
|
||||
raise ValueError(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET must be at least 32 bytes"
|
||||
)
|
||||
if self.bind_address not in {"127.0.0.1", "::1"}:
|
||||
raise ValueError("Portal authentication service must bind to loopback")
|
||||
if not 1 <= self.port <= 65535:
|
||||
raise ValueError("PORTAL_AUTH_PORT is invalid")
|
||||
if not 300 <= self.session_seconds <= 24 * 60 * 60:
|
||||
raise ValueError("PORTAL_AUTH_SESSION_SECONDS is outside the safe range")
|
||||
if not self.session_seconds <= self.remember_seconds <= 30 * 24 * 60 * 60:
|
||||
raise ValueError("PORTAL_AUTH_REMEMBER_SECONDS is outside the safe range")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedSession:
|
||||
username: str
|
||||
expires_at: int
|
||||
|
||||
|
||||
class SessionTokenCodec:
|
||||
def __init__(self, secret: str):
|
||||
self._secret = secret.encode("utf-8")
|
||||
|
||||
def issue(self, username: str, lifetime_seconds: int, now: int | None = None) -> str:
|
||||
issued_at = int(time.time()) if now is None else now
|
||||
payload = {
|
||||
"v": SESSION_TOKEN_VERSION,
|
||||
"u": username,
|
||||
"i": issued_at,
|
||||
"e": issued_at + lifetime_seconds,
|
||||
"n": secrets.token_urlsafe(18),
|
||||
}
|
||||
encoded = _base64url_encode(
|
||||
json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
signature = hmac.new(
|
||||
self._secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
def verify(self, token: str, expected_username: str, now: int | None = None) -> AuthenticatedSession | None:
|
||||
if not token or len(token) > 2048:
|
||||
return None
|
||||
current_time = int(time.time()) if now is None else now
|
||||
try:
|
||||
encoded, supplied_signature = token.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
self._secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied_signature, expected_signature):
|
||||
return None
|
||||
payload = json.loads(_base64url_decode(encoded).decode("utf-8"))
|
||||
version = int(payload["v"])
|
||||
username = str(payload["u"])
|
||||
issued_at = int(payload["i"])
|
||||
expires_at = int(payload["e"])
|
||||
except (
|
||||
binascii.Error,
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
UnicodeDecodeError,
|
||||
json.JSONDecodeError,
|
||||
):
|
||||
return None
|
||||
if version != SESSION_TOKEN_VERSION:
|
||||
return None
|
||||
if issued_at > current_time + 30 or expires_at <= current_time:
|
||||
return None
|
||||
if expires_at - issued_at > 30 * 24 * 60 * 60:
|
||||
return None
|
||||
if not hmac.compare_digest(username, expected_username):
|
||||
return None
|
||||
return AuthenticatedSession(username=username, expires_at=expires_at)
|
||||
|
||||
|
||||
class LoginAttemptLimiter:
|
||||
def __init__(self, maximum_failures: int = 5, window_seconds: int = 300):
|
||||
self._maximum_failures = maximum_failures
|
||||
self._window_seconds = window_seconds
|
||||
self._failures: dict[str, Deque[float]] = defaultdict(deque)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def blocked(self, key: str, now: float | None = None) -> bool:
|
||||
current_time = time.monotonic() if now is None else now
|
||||
with self._lock:
|
||||
failures = self._failures[key]
|
||||
self._prune(failures, current_time)
|
||||
return len(failures) >= self._maximum_failures
|
||||
|
||||
def record_failure(self, key: str, now: float | None = None) -> None:
|
||||
current_time = time.monotonic() if now is None else now
|
||||
with self._lock:
|
||||
failures = self._failures[key]
|
||||
self._prune(failures, current_time)
|
||||
failures.append(current_time)
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(key, None)
|
||||
|
||||
def _prune(self, failures: Deque[float], now: float) -> None:
|
||||
cutoff = now - self._window_seconds
|
||||
while failures and failures[0] < cutoff:
|
||||
failures.popleft()
|
||||
|
||||
|
||||
def password_matches(password: str, encoded_password: str) -> bool:
|
||||
try:
|
||||
scheme, iterations_text, salt_hex, expected_hex = encoded_password.split(
|
||||
"$", 3
|
||||
)
|
||||
iterations = int(iterations_text)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(expected_hex)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000:
|
||||
return False
|
||||
candidate = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, iterations
|
||||
)
|
||||
return hmac.compare_digest(candidate, expected)
|
||||
|
||||
|
||||
def session_cookie_header(token: str, max_age: int | None) -> str:
|
||||
attributes = [
|
||||
f"{SESSION_COOKIE_NAME}={token}",
|
||||
"Path=/",
|
||||
"Secure",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
]
|
||||
if max_age is not None:
|
||||
attributes.append(f"Max-Age={max_age}")
|
||||
return "; ".join(attributes)
|
||||
|
||||
|
||||
def clear_session_cookie_header() -> str:
|
||||
return (
|
||||
f"{SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; "
|
||||
"Secure; HttpOnly; SameSite=Lax"
|
||||
)
|
||||
|
||||
|
||||
def csrf_cookie_header(value: str, max_age: int = 600) -> str:
|
||||
return (
|
||||
f"{CSRF_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; "
|
||||
"Secure; HttpOnly; SameSite=Strict"
|
||||
)
|
||||
|
||||
|
||||
def clear_csrf_cookie_header() -> str:
|
||||
return (
|
||||
f"{CSRF_COOKIE_NAME}=; Path=/; Max-Age=0; "
|
||||
"Secure; HttpOnly; SameSite=Strict"
|
||||
)
|
||||
|
||||
|
||||
def _base64url_encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _base64url_decode(value: str) -> bytes:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(padded)
|
||||
|
||||
|
||||
def _cookie_value(cookie_header: str, name: str) -> str:
|
||||
try:
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(cookie_header)
|
||||
morsel = cookies.get(name)
|
||||
return morsel.value if morsel is not None else ""
|
||||
except (KeyError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _login_page(config: AuthConfig, csrf_value: str, error: str = "") -> bytes:
|
||||
error_html = (
|
||||
f'<div class="error" role="alert">{html.escape(error)}</div>'
|
||||
if error
|
||||
else ""
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{html.escape(config.product_name)}</title>
|
||||
<style>
|
||||
:root {{ --primary:{html.escape(config.primary_color)}; --text:#17232d;
|
||||
--muted:#60717f; --border:#d9e0e5; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#fff; color:var(--text);
|
||||
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
|
||||
main {{ width:min(420px,calc(100% - 40px)); margin:12vh auto 0; }}
|
||||
.wordmark {{ color:var(--primary); font-size:1.25rem; font-weight:800;
|
||||
letter-spacing:.08em; }}
|
||||
h1 {{ margin:16px 0 8px; font-size:1.75rem; }}
|
||||
.description,.footer {{ color:var(--muted); line-height:1.55; }}
|
||||
form {{ margin-top:28px; }}
|
||||
label {{ display:block; margin:0 0 18px; font-weight:700; }}
|
||||
input[type="text"],input[type="password"] {{ width:100%; margin-top:8px;
|
||||
padding:12px 13px; border:1px solid var(--border); border-radius:5px;
|
||||
font:inherit; color:var(--text); background:#fff; }}
|
||||
.remember {{ display:flex; align-items:center; gap:8px; font-weight:500; }}
|
||||
.remember input {{ width:17px; height:17px; }}
|
||||
button {{ width:100%; padding:12px; border:1px solid var(--primary);
|
||||
border-radius:5px; background:var(--primary); color:#fff; font:inherit;
|
||||
font-weight:800; cursor:pointer; }}
|
||||
.error {{ margin:18px 0 0; padding:11px 12px; border:1px solid #d99898;
|
||||
border-radius:5px; color:#8a2222; background:#fff7f7; }}
|
||||
.footer {{ margin-top:22px; font-size:.9rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="wordmark">HMM</div>
|
||||
<h1>{html.escape(config.login_title)}</h1>
|
||||
<p class="description">{html.escape(config.login_description)}</p>
|
||||
{error_html}
|
||||
<form action="/auth/login" method="post" autocomplete="on">
|
||||
<input type="hidden" name="csrf" value="{html.escape(csrf_value)}">
|
||||
<label>사용자 ID
|
||||
<input name="username" type="text" maxlength="80" autocomplete="username"
|
||||
required autofocus>
|
||||
</label>
|
||||
<label>비밀번호
|
||||
<input name="password" type="password" maxlength="200"
|
||||
autocomplete="current-password" required>
|
||||
</label>
|
||||
<label class="remember">
|
||||
<input name="remember" type="checkbox" value="yes"> 로그인 유지 (7일)
|
||||
</label>
|
||||
<button type="submit">로그인</button>
|
||||
</form>
|
||||
<p class="footer">{html.escape(config.login_footer)}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>""".encode("utf-8")
|
||||
|
||||
|
||||
def build_handler(config: AuthConfig) -> type[BaseHTTPRequestHandler]:
|
||||
codec = SessionTokenCodec(config.cookie_secret)
|
||||
limiter = LoginAttemptLimiter()
|
||||
|
||||
class PortalAuthHandler(BaseHTTPRequestHandler):
|
||||
server_version = "HMMPortalAuth/1.0"
|
||||
sys_version = ""
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
self._route(send_body=False)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._route(send_body=True)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/auth/login":
|
||||
self._login()
|
||||
elif path == "/auth/logout":
|
||||
self._logout()
|
||||
else:
|
||||
self._send_text(HTTPStatus.NOT_FOUND, "Not found")
|
||||
|
||||
def _route(self, send_body: bool) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/auth/check":
|
||||
self._check()
|
||||
elif path == "/auth/login":
|
||||
self._show_login(send_body=send_body)
|
||||
elif path == "/auth/logout":
|
||||
self._logout()
|
||||
elif path == "/auth/healthz":
|
||||
self._send_text(HTTPStatus.OK, "ok", send_body=send_body)
|
||||
else:
|
||||
self._send_text(HTTPStatus.NOT_FOUND, "Not found", send_body=send_body)
|
||||
|
||||
def _check(self) -> None:
|
||||
session = self._session()
|
||||
if session is None:
|
||||
self._send_empty(HTTPStatus.UNAUTHORIZED)
|
||||
return
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self._security_headers()
|
||||
self.send_header("X-Auth-User", session.username)
|
||||
self.send_header("X-Auth-Expires", str(session.expires_at))
|
||||
self.end_headers()
|
||||
|
||||
def _show_login(self, send_body: bool = True, error: str = "") -> None:
|
||||
if self._session() is not None and not error:
|
||||
self._redirect("/")
|
||||
return
|
||||
csrf_value = secrets.token_urlsafe(32)
|
||||
body = _login_page(config, csrf_value, error)
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self._security_headers()
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Set-Cookie", csrf_cookie_header(csrf_value))
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _login(self) -> None:
|
||||
client_key = self._client_key()
|
||||
if limiter.blocked(client_key):
|
||||
self._show_login(error="로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해 주세요.")
|
||||
return
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
content_length = 0
|
||||
if not 1 <= content_length <= MAX_REQUEST_BYTES:
|
||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
||||
return
|
||||
raw_body = self.rfile.read(content_length)
|
||||
try:
|
||||
form = parse_qs(
|
||||
raw_body.decode("utf-8"),
|
||||
keep_blank_values=True,
|
||||
strict_parsing=False,
|
||||
max_num_fields=8,
|
||||
)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
||||
return
|
||||
username = _form_value(form, "username")
|
||||
password = _form_value(form, "password")
|
||||
csrf_form = _form_value(form, "csrf")
|
||||
csrf_cookie = _cookie_value(
|
||||
self.headers.get("Cookie", ""), CSRF_COOKIE_NAME
|
||||
)
|
||||
if (
|
||||
not csrf_form
|
||||
or not csrf_cookie
|
||||
or not hmac.compare_digest(csrf_form, csrf_cookie)
|
||||
):
|
||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
||||
return
|
||||
valid_credentials = (
|
||||
len(username) <= 80
|
||||
and len(password) <= MAX_FIELD_CHARS
|
||||
and hmac.compare_digest(username.strip(), config.username)
|
||||
and password_matches(password, config.password_pbkdf2)
|
||||
)
|
||||
if not valid_credentials:
|
||||
limiter.record_failure(client_key)
|
||||
self._show_login(error="사용자 ID 또는 비밀번호를 확인해 주세요.")
|
||||
return
|
||||
limiter.reset(client_key)
|
||||
remember = _form_value(form, "remember") == "yes"
|
||||
lifetime = (
|
||||
config.remember_seconds if remember else config.session_seconds
|
||||
)
|
||||
token = codec.issue(config.username, lifetime)
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._security_headers()
|
||||
self.send_header("Location", "/")
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
session_cookie_header(token, lifetime if remember else None),
|
||||
)
|
||||
self.send_header("Set-Cookie", clear_csrf_cookie_header())
|
||||
self.end_headers()
|
||||
|
||||
def _logout(self) -> None:
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._security_headers()
|
||||
self.send_header("Location", "/auth/login")
|
||||
self.send_header("Set-Cookie", clear_session_cookie_header())
|
||||
self.send_header("Set-Cookie", clear_csrf_cookie_header())
|
||||
self.end_headers()
|
||||
|
||||
def _session(self) -> AuthenticatedSession | None:
|
||||
token = _cookie_value(
|
||||
self.headers.get("Cookie", ""), SESSION_COOKIE_NAME
|
||||
)
|
||||
return codec.verify(token, config.username)
|
||||
|
||||
def _client_key(self) -> str:
|
||||
forwarded = self.headers.get("X-Real-IP", "").strip()
|
||||
return forwarded or self.client_address[0]
|
||||
|
||||
def _redirect(self, location: str) -> None:
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._security_headers()
|
||||
self.send_header("Location", location)
|
||||
self.end_headers()
|
||||
|
||||
def _send_empty(self, status: HTTPStatus) -> None:
|
||||
self.send_response(status)
|
||||
self._security_headers()
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def _send_text(
|
||||
self,
|
||||
status: HTTPStatus,
|
||||
message: str,
|
||||
send_body: bool = True,
|
||||
) -> None:
|
||||
body = message.encode("utf-8")
|
||||
self.send_response(status)
|
||||
self._security_headers()
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _security_headers(self) -> None:
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; style-src 'unsafe-inline'; "
|
||||
"form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
||||
)
|
||||
|
||||
def log_message(self, _format: str, *args: object) -> None:
|
||||
# Do not log query strings, cookies, form bodies, or tokens.
|
||||
LOG.info("%s %s", self.command, urlsplit(self.path).path)
|
||||
|
||||
return PortalAuthHandler
|
||||
|
||||
|
||||
def _form_value(form: dict[str, list[str]], name: str) -> str:
|
||||
values = form.get(name)
|
||||
return values[0] if values else ""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("PORTAL_AUTH_LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
config = AuthConfig.from_environment()
|
||||
server = ThreadingHTTPServer(
|
||||
(config.bind_address, config.port), build_handler(config)
|
||||
)
|
||||
LOG.info("HMM portal authentication service listening on loopback port %s", config.port)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
80
ai-web-agent-console/ai_web_agent_console/mcp_result.py
Normal file
80
ai-web-agent-console/ai_web_agent_console/mcp_result.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Pure helpers for MCP result envelopes used by the Streamlit console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def response_payload(value: Any) -> Mapping[str, Any]:
|
||||
"""Return the business payload from a direct or nested MCP response."""
|
||||
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
nested = value.get("response")
|
||||
return nested if isinstance(nested, Mapping) else value
|
||||
|
||||
|
||||
def text_result(value: Any) -> str:
|
||||
"""Return a textual `result` field without stringifying other structures."""
|
||||
|
||||
result = response_payload(value).get("result")
|
||||
return result.strip() if isinstance(result, str) else ""
|
||||
|
||||
|
||||
def status_result_summary(value: Any, *, excerpt_chars: int = 900) -> dict[str, Any]:
|
||||
"""Build a safe UI summary for status/result-style compatibility tools."""
|
||||
|
||||
payload = response_payload(value)
|
||||
summary: dict[str, Any] = {}
|
||||
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
||||
item = payload.get(key)
|
||||
if item not in (None, "", []):
|
||||
summary[key] = item
|
||||
result = text_result(value)
|
||||
if result:
|
||||
summary["result_chars"] = len(result)
|
||||
summary["result_excerpt"] = result[:excerpt_chars] + (
|
||||
"..." if len(result) > excerpt_chars else ""
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def status_result_evidence(value: Any, *, max_chars: int = 7000) -> dict[str, Any]:
|
||||
"""Preserve bounded textual policy/data evidence for final answer synthesis."""
|
||||
|
||||
payload = response_payload(value)
|
||||
evidence: dict[str, Any] = {}
|
||||
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
||||
item = payload.get(key)
|
||||
if item not in (None, "", []):
|
||||
evidence[key] = item
|
||||
result = text_result(value)
|
||||
if result:
|
||||
evidence["result"] = result[:max_chars] + (
|
||||
"..." if len(result) > max_chars else ""
|
||||
)
|
||||
evidence["result_chars"] = len(result)
|
||||
return evidence
|
||||
|
||||
|
||||
def has_actionable_text_result(value: Any) -> bool:
|
||||
"""Return whether a textual result contains evidence worth stopping on."""
|
||||
|
||||
result = text_result(value)
|
||||
if not result:
|
||||
return False
|
||||
normalized = " ".join(result.casefold().split())
|
||||
return not any(
|
||||
marker in normalized
|
||||
for marker in ("no data found", "no evidence found", "error:")
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"has_actionable_text_result",
|
||||
"response_payload",
|
||||
"status_result_evidence",
|
||||
"status_result_summary",
|
||||
"text_result",
|
||||
]
|
||||
@@ -10,11 +10,11 @@ from dataclasses import dataclass
|
||||
import json
|
||||
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,
|
||||
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)
|
||||
@@ -154,26 +154,32 @@ def build_mcp_tool_arguments(
|
||||
if not isinstance(properties, Mapping):
|
||||
properties = {}
|
||||
|
||||
if tool.name == preferred_tool:
|
||||
return {"prompt": question, "limit": limit}
|
||||
if "prompt" in properties:
|
||||
args: dict[str, Any] = {"prompt": question}
|
||||
if "limit" in properties:
|
||||
args["limit"] = limit
|
||||
elif "max_rows" in properties:
|
||||
args["max_rows"] = limit
|
||||
return args
|
||||
if "question" in properties:
|
||||
args = {"question": question}
|
||||
if "max_rows" in properties:
|
||||
args["max_rows"] = limit
|
||||
elif "limit" in properties:
|
||||
args["limit"] = limit
|
||||
return args
|
||||
if "query" in properties:
|
||||
args = {"query": question}
|
||||
# A server's default/preferred tool still has to obey its discovered schema.
|
||||
# HMM tools use `query` and `term`; forcing the legacy `prompt`/`limit` shape
|
||||
# makes an otherwise valid tool fail argument validation.
|
||||
del preferred_tool
|
||||
input_name = next(
|
||||
(name for name in ("prompt", "question", "query", "term", "text") if name in properties),
|
||||
"",
|
||||
)
|
||||
if not input_name:
|
||||
required = tool.schema.get("required")
|
||||
if isinstance(required, list):
|
||||
input_name = next(
|
||||
(
|
||||
str(name)
|
||||
for name in required
|
||||
if isinstance(properties.get(str(name)), Mapping)
|
||||
and properties[str(name)].get("type") == "string"
|
||||
),
|
||||
"",
|
||||
)
|
||||
if input_name:
|
||||
args: dict[str, Any] = {input_name: question}
|
||||
if "max_evidence" in properties:
|
||||
args["max_evidence"] = min(limit, 10)
|
||||
elif "max_rows" in properties:
|
||||
args["max_rows"] = limit
|
||||
elif "limit" in properties:
|
||||
args["limit"] = limit
|
||||
return args
|
||||
@@ -1,7 +1,7 @@
|
||||
"""8512/8513 전용 PoC_3 model profile registry.
|
||||
"""8512/8513 전용 AI Web Agent Console model profile registry.
|
||||
|
||||
이 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``일 수 있다.
|
||||
"""
|
||||
|
||||
@@ -15,11 +15,11 @@ import re
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
REGISTRY_PATH = ROOT / "config" / "poc3_model_profiles.json"
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = ROOT / "config" / "model_profiles.json"
|
||||
DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci"
|
||||
MODEL_PROFILE_ENV = "POC3_MODEL_PROFILE"
|
||||
MODEL_PROFILE_DEFAULT_ENV = "POC3_MODEL_PROFILE_DEFAULT"
|
||||
MODEL_PROFILE_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE"
|
||||
MODEL_PROFILE_DEFAULT_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE_DEFAULT"
|
||||
EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b")
|
||||
MODEL_PROFILE_ALIASES = {
|
||||
"gpt54_mini": "gpt54_mini_oci",
|
||||
@@ -84,36 +84,36 @@ _REQUIRED_PROFILE_FIELDS = (
|
||||
"poc2_select_ai_profile",
|
||||
"poc2_native_agent_team",
|
||||
"verification_status",
|
||||
"default_for_poc3",
|
||||
"default_for_console",
|
||||
"source_tag",
|
||||
"notes",
|
||||
)
|
||||
|
||||
_PROFILE_ROUTE_ENV_KEYS = {
|
||||
"gpt55_oci": (
|
||||
"POC3_LLM_GPT55_OCI_MODEL_ID",
|
||||
"POC3_LLM_GPT55_OCI_REGION",
|
||||
"POC3_LLM_GPT55_OCI_ENDPOINT",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_ENDPOINT",
|
||||
),
|
||||
"gpt54_mini_oci": (
|
||||
"POC3_LLM_GPT54_MINI_OCI_MODEL_ID",
|
||||
"POC3_LLM_GPT54_MINI_OCI_REGION",
|
||||
"POC3_LLM_GPT54_MINI_OCI_ENDPOINT",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT",
|
||||
),
|
||||
"grok43": (
|
||||
"POC3_LLM_GROK43_MODEL_ID",
|
||||
"POC3_LLM_GROK43_REGION",
|
||||
"POC3_LLM_GROK43_ENDPOINT",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_ENDPOINT",
|
||||
),
|
||||
"llama4_maverick": (
|
||||
"POC3_LLM_LLAMA4_MAVERICK_MODEL_ID",
|
||||
"POC3_LLM_LLAMA4_MAVERICK_REGION",
|
||||
"POC3_LLM_LLAMA4_MAVERICK_ENDPOINT",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_ENDPOINT",
|
||||
),
|
||||
"llama33_70b": (
|
||||
"POC3_LLM_LLAMA33_70B_MODEL_ID",
|
||||
"POC3_LLM_LLAMA33_70B_REGION",
|
||||
"POC3_LLM_LLAMA33_70B_ENDPOINT",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_MODEL_ID",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_REGION",
|
||||
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_ENDPOINT",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ class ModelProfile:
|
||||
poc2_select_ai_profile: str
|
||||
poc2_native_agent_team: str
|
||||
verification_status: str
|
||||
default_for_poc3: bool
|
||||
default_for_console: bool
|
||||
source_tag: str
|
||||
notes: str
|
||||
display_order: int = 999
|
||||
@@ -163,9 +163,9 @@ class ModelProfile:
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile":
|
||||
missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value]
|
||||
if missing:
|
||||
raise ValueError("PoC_3 model profile fields are missing")
|
||||
if not isinstance(value.get("default_for_poc3"), bool):
|
||||
raise ValueError("default_for_poc3 must be boolean")
|
||||
raise ValueError("AI Web Agent Console model profile fields are missing")
|
||||
if not isinstance(value.get("default_for_console"), bool):
|
||||
raise ValueError("default_for_console must be boolean")
|
||||
order = value.get("display_order", 999)
|
||||
if isinstance(order, bool) or not isinstance(order, int) or order < 0:
|
||||
raise ValueError("model profile display_order is invalid")
|
||||
@@ -191,7 +191,7 @@ class ModelProfile:
|
||||
.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(),
|
||||
notes=str(value["notes"]).strip(),
|
||||
display_order=order,
|
||||
@@ -276,7 +276,7 @@ class ModelProfile:
|
||||
"poc2_select_ai_profile": self.poc2_select_ai_profile,
|
||||
"poc2_native_agent_team": self.poc2_native_agent_team,
|
||||
"verification_status": self.verification_status,
|
||||
"default_for_poc3": self.default_for_poc3,
|
||||
"default_for_console": self.default_for_console,
|
||||
"source_tag": self.source_tag,
|
||||
"notes": self.notes,
|
||||
}
|
||||
@@ -284,7 +284,7 @@ class ModelProfile:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelProfileRegistry:
|
||||
"""검증된 PoC_3 model profile 집합."""
|
||||
"""검증된 AI Web Agent Console model profile 집합."""
|
||||
|
||||
profiles: tuple[ModelProfile, ...]
|
||||
default_model_profile: str
|
||||
@@ -338,32 +338,32 @@ def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
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):
|
||||
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")
|
||||
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(
|
||||
ModelProfile.from_mapping(item)
|
||||
for item in raw_profiles
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
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)
|
||||
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):
|
||||
raise ValueError("PoC_3 model profile display names must be unique")
|
||||
defaults = tuple(item.model_key for item in profiles if item.default_for_poc3)
|
||||
raise ValueError("AI Web Agent Console model profile display names must be unique")
|
||||
defaults = tuple(item.model_key for item in profiles if item.default_for_console)
|
||||
configured_default = str(payload.get("default_model_profile") or "").strip().lower()
|
||||
if defaults != (configured_default,):
|
||||
raise ValueError("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:
|
||||
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):
|
||||
raise ValueError("existing PoC_3 selector models are missing")
|
||||
raise ValueError("existing AI Web Agent Console selector models are missing")
|
||||
actual_answer_routes = {
|
||||
item.model_key: (
|
||||
item.model_id,
|
||||
@@ -374,16 +374,16 @@ def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry:
|
||||
for item in profiles
|
||||
}
|
||||
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:
|
||||
raise ValueError("PoC_2 source commit is inconsistent")
|
||||
if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles):
|
||||
raise ValueError("PoC_2 source tag is inconsistent")
|
||||
if payload.get("schema_version") != 1:
|
||||
raise ValueError("unsupported 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()
|
||||
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(
|
||||
profiles=profiles,
|
||||
default_model_profile=configured_default,
|
||||
@@ -27,6 +27,8 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
||||
[data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span,
|
||||
[data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1,
|
||||
[data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3,
|
||||
[data-testid="stAppViewContainer"] li, [data-testid="stAppViewContainer"] dt,
|
||||
[data-testid="stAppViewContainer"] dd, [data-testid="stAppViewContainer"] blockquote,
|
||||
[data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea,
|
||||
section[data-testid="stSidebar"] * {{ color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
@@ -34,13 +36,57 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
||||
background:#fff !important; border:1px solid var(--console-border) !important;
|
||||
border-radius:4px !important; box-shadow:none !important; }}
|
||||
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; }}
|
||||
div[data-testid="stButton"] > button *,
|
||||
div[data-testid="stFormSubmitButton"] > button * {{
|
||||
color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }}
|
||||
div[data-testid="stButton"] > button[kind="primary"],
|
||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
||||
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
||||
div[data-testid="stButton"] > button[kind="primary"] *,
|
||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {{
|
||||
color:#fff !important; -webkit-text-fill-color:#fff !important; }}
|
||||
/* Streamlit JSON/code components otherwise retain a dark-surface theme while
|
||||
the console text is forced dark, making MCP details unreadable. */
|
||||
[data-testid="stJson"], [data-testid="stJson"] > div,
|
||||
[data-testid="stCodeBlock"], [data-testid="stCodeBlock"] > div,
|
||||
[data-testid="stCode"], [data-testid="stCode"] > div {{
|
||||
background:#f6f8fa !important; color:var(--console-text) !important;
|
||||
border-color:var(--console-border) !important; }}
|
||||
[data-testid="stJson"] *, [data-testid="stCodeBlock"] *, [data-testid="stCode"] *,
|
||||
[data-testid="stJson"] pre, [data-testid="stCodeBlock"] pre, [data-testid="stCode"] pre {{
|
||||
color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
[data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{
|
||||
background:#fff !important; border-color:var(--console-border) !important; }}
|
||||
[data-testid="stExpander"] summary {{
|
||||
background:#f6f8fa !important; color:var(--console-text) !important;
|
||||
border-color:var(--console-border) !important; }}
|
||||
[data-testid="stExpander"] summary * {{
|
||||
color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
[data-baseweb="tab-list"], [data-testid="stTabs"] [role="tablist"] {{
|
||||
border-bottom:1px solid var(--console-border) !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"], [data-testid="stTab"] {{
|
||||
background:#fff !important; color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important;
|
||||
border-radius:4px 4px 0 0 !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"] *, [data-testid="stTab"] * {{
|
||||
color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"]:hover, [data-testid="stTab"]:hover {{
|
||||
background:#f6f8fa !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"],
|
||||
[data-testid="stTab"][aria-selected="true"] {{
|
||||
color:var(--console-primary) !important;
|
||||
-webkit-text-fill-color:var(--console-primary) !important;
|
||||
border-bottom:3px solid var(--console-primary) !important; }}
|
||||
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"] *,
|
||||
[data-testid="stTab"][aria-selected="true"] * {{
|
||||
color:var(--console-primary) !important;
|
||||
-webkit-text-fill-color:var(--console-primary) !important; }}
|
||||
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
|
||||
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
|
||||
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
|
||||
@@ -48,6 +94,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
||||
.st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }}
|
||||
.console-login {{ text-align:left; }}
|
||||
.console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }}
|
||||
a.console-logout-button {{ display:block; width:100%; padding:.55rem .8rem;
|
||||
margin:.25rem 0 .75rem; background:#fff; color:var(--console-text) !important;
|
||||
-webkit-text-fill-color:var(--console-text) !important;
|
||||
border:1px solid var(--console-border); border-radius:4px;
|
||||
text-align:center; text-decoration:none; font-weight:700; }}
|
||||
a.console-logout-button:hover {{ background:#f6f8fa; }}
|
||||
@media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }}
|
||||
</style>
|
||||
""",
|
||||
407
ai-web-agent-console/ai_web_agent_console/query_contracts.py
Normal file
407
ai-web-agent-console/ai_web_agent_console/query_contracts.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Configuration-driven query and answer evidence contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_CONTRACT_FILE = ROOT / "config" / "hmm_hr_query_contracts.json"
|
||||
|
||||
|
||||
def _contract_file() -> Path:
|
||||
configured = str(
|
||||
os.getenv("AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH")
|
||||
or os.getenv("POC4_QUERY_CONTRACTS_PATH")
|
||||
or ""
|
||||
).strip()
|
||||
if not configured:
|
||||
return DEFAULT_CONTRACT_FILE
|
||||
path = Path(configured).expanduser()
|
||||
return path if path.is_absolute() else ROOT / path
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_contract_file(path_text: str) -> tuple[Mapping[str, Any], ...]:
|
||||
path = Path(path_text)
|
||||
if not path.exists():
|
||||
return ()
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
contracts = value.get("contracts") if isinstance(value, Mapping) else None
|
||||
if not isinstance(contracts, list):
|
||||
raise ValueError("query contracts must contain a contracts array")
|
||||
return tuple(item for item in contracts if isinstance(item, Mapping))
|
||||
|
||||
|
||||
def load_query_contracts() -> tuple[Mapping[str, Any], ...]:
|
||||
return _load_contract_file(str(_contract_file()))
|
||||
|
||||
|
||||
def matching_query_contracts(
|
||||
question: str,
|
||||
tool_name: str,
|
||||
) -> tuple[Mapping[str, Any], ...]:
|
||||
normalized_question = " ".join(str(question or "").casefold().split())
|
||||
normalized_tool = str(tool_name or "").strip()
|
||||
selected: list[Mapping[str, Any]] = []
|
||||
for contract in load_query_contracts():
|
||||
tools = contract.get("applies_to_tools")
|
||||
if isinstance(tools, list) and normalized_tool not in {
|
||||
str(item) for item in tools
|
||||
}:
|
||||
continue
|
||||
intent = contract.get("intent_match")
|
||||
if not isinstance(intent, Mapping):
|
||||
continue
|
||||
subject_terms = [
|
||||
str(item).casefold()
|
||||
for item in intent.get("subject_terms_any", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
action_terms = [
|
||||
str(item).casefold()
|
||||
for item in intent.get("action_terms_any", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
if subject_terms and not any(
|
||||
term in normalized_question for term in subject_terms
|
||||
):
|
||||
continue
|
||||
if action_terms and not any(
|
||||
term in normalized_question for term in action_terms
|
||||
):
|
||||
continue
|
||||
selected.append(contract)
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
def query_contract_guidance(question: str, tool_name: str) -> str:
|
||||
contracts = matching_query_contracts(question, tool_name)
|
||||
if not contracts:
|
||||
return ""
|
||||
return json.dumps(
|
||||
{"query_contracts": contracts},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def append_query_contract_guidance(
|
||||
tool_query: str,
|
||||
*,
|
||||
original_question: str,
|
||||
tool_name: str,
|
||||
) -> str:
|
||||
guidance = query_contract_guidance(original_question, tool_name)
|
||||
normalized = str(tool_query or "").strip()
|
||||
if not guidance:
|
||||
return normalized
|
||||
return (
|
||||
f"{normalized}\n"
|
||||
"다음 질의 계약의 필드·계산·시간 기준을 반드시 지켜 결과를 반환하세요. "
|
||||
f"계약: {guidance}"
|
||||
)
|
||||
|
||||
|
||||
def _json_data_result(value: str) -> Any:
|
||||
marker = "DATA_RESULT"
|
||||
position = value.find(marker)
|
||||
if position < 0:
|
||||
return None
|
||||
remainder = value[position + len(marker) :]
|
||||
object_position = remainder.find("{")
|
||||
array_position = remainder.find("[")
|
||||
positions = [
|
||||
candidate
|
||||
for candidate in (object_position, array_position)
|
||||
if candidate >= 0
|
||||
]
|
||||
if not positions:
|
||||
return None
|
||||
candidate = remainder[min(positions) :]
|
||||
try:
|
||||
parsed, _ = json.JSONDecoder().raw_decode(candidate)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _evidence_rows(value: Any) -> list[Mapping[str, Any]]:
|
||||
rows: list[Mapping[str, Any]] = []
|
||||
if isinstance(value, Mapping):
|
||||
rows.append(value)
|
||||
for item in value.values():
|
||||
rows.extend(_evidence_rows(item))
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
rows.extend(_evidence_rows(item))
|
||||
elif isinstance(value, str):
|
||||
parsed = _json_data_result(value)
|
||||
if parsed is not None:
|
||||
rows.extend(_evidence_rows(parsed))
|
||||
return rows
|
||||
|
||||
|
||||
def _decimal(value: Any) -> Decimal:
|
||||
if isinstance(value, bool) or value is None:
|
||||
raise InvalidOperation
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _evaluate_contract_expression(
|
||||
expression: str,
|
||||
row: Mapping[str, Any],
|
||||
) -> Decimal:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
|
||||
def evaluate(node: ast.AST) -> Decimal:
|
||||
if isinstance(node, ast.Expression):
|
||||
return evaluate(node.body)
|
||||
if isinstance(node, ast.Name):
|
||||
key = node.id.casefold()
|
||||
if key not in row:
|
||||
raise InvalidOperation
|
||||
return _decimal(row[key])
|
||||
if isinstance(node, ast.Constant):
|
||||
return _decimal(node.value)
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(
|
||||
node.op, (ast.UAdd, ast.USub)
|
||||
):
|
||||
value = evaluate(node.operand)
|
||||
return value if isinstance(node.op, ast.UAdd) else -value
|
||||
if isinstance(node, ast.BinOp) and isinstance(
|
||||
node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)
|
||||
):
|
||||
left = evaluate(node.left)
|
||||
right = evaluate(node.right)
|
||||
if isinstance(node.op, ast.Add):
|
||||
return left + right
|
||||
if isinstance(node.op, ast.Sub):
|
||||
return left - right
|
||||
if isinstance(node.op, ast.Mult):
|
||||
return left * right
|
||||
return left / right
|
||||
raise ValueError("unsupported contract expression")
|
||||
|
||||
return evaluate(tree)
|
||||
|
||||
|
||||
def evidence_contract_report(
|
||||
contracts: tuple[Mapping[str, Any], ...],
|
||||
evidence: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = [
|
||||
{str(key).strip().casefold(): value for key, value in row.items()}
|
||||
for row in _evidence_rows(evidence)
|
||||
]
|
||||
reports: list[dict[str, Any]] = []
|
||||
for contract in contracts:
|
||||
data_contract = contract.get("data_contract")
|
||||
if not isinstance(data_contract, Mapping):
|
||||
continue
|
||||
required = [
|
||||
str(item).strip().casefold()
|
||||
for item in data_contract.get("required_fields", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
matching_row = next(
|
||||
(row for row in rows if required and all(key in row for key in required)),
|
||||
None,
|
||||
)
|
||||
observed = sorted(
|
||||
{
|
||||
key
|
||||
for row in rows
|
||||
for key in row
|
||||
if not required or key in required
|
||||
}
|
||||
)
|
||||
missing = [
|
||||
key.upper()
|
||||
for key in required
|
||||
if matching_row is None or key not in matching_row
|
||||
]
|
||||
computed_checks: list[dict[str, Any]] = []
|
||||
computed_fields = data_contract.get("computed_fields")
|
||||
if matching_row is not None and isinstance(computed_fields, Mapping):
|
||||
for field, definition in computed_fields.items():
|
||||
normalized_field = str(field).strip().casefold()
|
||||
expression = (
|
||||
str(definition.get("expression") or "").strip()
|
||||
if isinstance(definition, Mapping)
|
||||
else ""
|
||||
)
|
||||
if not normalized_field or not expression:
|
||||
continue
|
||||
try:
|
||||
actual = _decimal(matching_row.get(normalized_field))
|
||||
expected = _evaluate_contract_expression(
|
||||
expression,
|
||||
matching_row,
|
||||
)
|
||||
matches = actual == expected
|
||||
computed_checks.append(
|
||||
{
|
||||
"field": normalized_field.upper(),
|
||||
"expression": expression,
|
||||
"actual": str(actual),
|
||||
"expected": str(expected),
|
||||
"satisfied": matches,
|
||||
}
|
||||
)
|
||||
except (InvalidOperation, ValueError, ZeroDivisionError):
|
||||
computed_checks.append(
|
||||
{
|
||||
"field": normalized_field.upper(),
|
||||
"expression": expression,
|
||||
"satisfied": False,
|
||||
}
|
||||
)
|
||||
calculations_satisfied = all(
|
||||
bool(check.get("satisfied")) for check in computed_checks
|
||||
)
|
||||
temporal_checks: list[dict[str, Any]] = []
|
||||
temporal_contract = contract.get("temporal_contract")
|
||||
if matching_row is not None and isinstance(temporal_contract, Mapping):
|
||||
status_fields = [
|
||||
str(item).strip().casefold()
|
||||
for item in temporal_contract.get("period_status_fields", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
past_status = str(
|
||||
temporal_contract.get("past_period_status") or ""
|
||||
).strip()
|
||||
observed_status = next(
|
||||
(
|
||||
str(matching_row.get(field) or "").strip()
|
||||
for field in status_fields
|
||||
if str(matching_row.get(field) or "").strip()
|
||||
),
|
||||
"",
|
||||
)
|
||||
if observed_status == past_status and past_status:
|
||||
decision_field = str(
|
||||
temporal_contract.get("past_period_decision_field") or ""
|
||||
).strip().casefold()
|
||||
decision_value = str(
|
||||
temporal_contract.get("past_period_decision_value") or ""
|
||||
).strip()
|
||||
actual_decision = str(
|
||||
matching_row.get(decision_field) or ""
|
||||
).strip()
|
||||
temporal_checks.append(
|
||||
{
|
||||
"check": "past_period_decision",
|
||||
"field": decision_field.upper(),
|
||||
"expected": decision_value,
|
||||
"actual": actual_decision,
|
||||
"satisfied": bool(
|
||||
decision_field
|
||||
and decision_value
|
||||
and actual_decision == decision_value
|
||||
),
|
||||
}
|
||||
)
|
||||
forbidden_patterns = [
|
||||
str(item).strip()
|
||||
for item in temporal_contract.get(
|
||||
"forbidden_past_period_decision_field_patterns", []
|
||||
)
|
||||
if str(item).strip()
|
||||
]
|
||||
forbidden_fields = sorted(
|
||||
key.upper()
|
||||
for key in matching_row
|
||||
if any(
|
||||
re.search(pattern, key, flags=re.IGNORECASE)
|
||||
for pattern in forbidden_patterns
|
||||
)
|
||||
)
|
||||
temporal_checks.append(
|
||||
{
|
||||
"check": "no_past_period_yes_no_decision",
|
||||
"forbidden_fields": forbidden_fields,
|
||||
"satisfied": not forbidden_fields,
|
||||
}
|
||||
)
|
||||
required_counts = [
|
||||
str(item).strip().casefold()
|
||||
for item in temporal_contract.get(
|
||||
"validate_stated_days_against", []
|
||||
)
|
||||
if str(item).strip()
|
||||
]
|
||||
temporal_checks.append(
|
||||
{
|
||||
"check": "date_range_counts",
|
||||
"required_fields": [
|
||||
field.upper() for field in required_counts
|
||||
],
|
||||
"missing_fields": [
|
||||
field.upper()
|
||||
for field in required_counts
|
||||
if field not in matching_row
|
||||
],
|
||||
"satisfied": all(
|
||||
field in matching_row for field in required_counts
|
||||
),
|
||||
}
|
||||
)
|
||||
temporal_satisfied = all(
|
||||
bool(check.get("satisfied")) for check in temporal_checks
|
||||
)
|
||||
reports.append(
|
||||
{
|
||||
"contract_id": str(contract.get("id") or ""),
|
||||
"satisfied": bool(
|
||||
matching_row is not None
|
||||
and not missing
|
||||
and calculations_satisfied
|
||||
and temporal_satisfied
|
||||
),
|
||||
"required_fields": [key.upper() for key in required],
|
||||
"observed_required_fields": [key.upper() for key in observed],
|
||||
"missing_fields": missing,
|
||||
"computed_field_checks": computed_checks,
|
||||
"temporal_contract_checks": temporal_checks,
|
||||
"validated_record": (
|
||||
{
|
||||
key.upper(): matching_row.get(key)
|
||||
for key in required
|
||||
}
|
||||
if matching_row is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
return reports
|
||||
|
||||
|
||||
def missing_evidence_message(
|
||||
contracts: tuple[Mapping[str, Any], ...],
|
||||
) -> str:
|
||||
for contract in contracts:
|
||||
answer_contract = contract.get("answer_contract")
|
||||
if not isinstance(answer_contract, Mapping):
|
||||
continue
|
||||
message = str(answer_contract.get("missing_evidence_message") or "").strip()
|
||||
if message:
|
||||
return message
|
||||
return "조회 결과가 답변 계약의 필수 근거를 충족하지 않아 결론을 제공할 수 없습니다."
|
||||
|
||||
|
||||
__all__ = [
|
||||
"append_query_contract_guidance",
|
||||
"evidence_contract_report",
|
||||
"load_query_contracts",
|
||||
"matching_query_contracts",
|
||||
"missing_evidence_message",
|
||||
"query_contract_guidance",
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""PoC_3 preset catalog와 현재 질문 기반의 결정적 intent router."""
|
||||
"""AI Web Agent Console preset catalog와 현재 질문 기반의 결정적 intent router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -90,7 +90,7 @@ 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):
|
||||
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:
|
||||
@@ -100,7 +100,7 @@ def question_by_id(question_id: str) -> DemoQuestion:
|
||||
try:
|
||||
return _QUESTION_BY_ID[normalized]
|
||||
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:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,27 @@
|
||||
"category": "근태 현황",
|
||||
"title": "휴가 현황 조회",
|
||||
"question": "이번 달 팀원별 휴가 사용 현황을 보여줘"
|
||||
},
|
||||
{
|
||||
"id": "FED-01",
|
||||
"enabled": true,
|
||||
"category": "선사 실적 Federation",
|
||||
"title": "팀원별 담당 선사 최신 실적",
|
||||
"question": "E1001 팀장의 팀원별 담당 선사와 해당 선사의 최신 매출, 매출총이익, 정시 운항률, 위험 등급을 보여줘"
|
||||
},
|
||||
{
|
||||
"id": "FED-02",
|
||||
"enabled": true,
|
||||
"category": "선사 실적 Federation",
|
||||
"title": "위험 선사와 담당자",
|
||||
"question": "E1001 팀에서 최신 위험 등급이 RED인 선사를 담당하는 직원, 선사명, 매출, 정시 운항률과 클레임 발생률을 보여줘"
|
||||
},
|
||||
{
|
||||
"id": "FED-03",
|
||||
"enabled": true,
|
||||
"category": "선사 실적 Federation",
|
||||
"title": "담당 선사 월별 추이",
|
||||
"question": "E1006 직원이 담당하는 선사의 월별 매출, 운송 물동량, 매출총이익과 위험 등급을 기준월 순서로 보여줘"
|
||||
}
|
||||
]
|
||||
}
|
||||
84
ai-web-agent-console/config/hmm_hr_query_contracts.json
Normal file
84
ai-web-agent-console/config/hmm_hr_query_contracts.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"version": 1,
|
||||
"contracts": [
|
||||
{
|
||||
"id": "leave_balance_eligibility",
|
||||
"description": "연차·휴가 잔여와 신청 가능 여부를 원장 연도 및 구성값으로 검증한다.",
|
||||
"applies_to_tools": [
|
||||
"search_hr_data"
|
||||
],
|
||||
"intent_match": {
|
||||
"subject_terms_any": [
|
||||
"연차",
|
||||
"휴가"
|
||||
],
|
||||
"action_terms_any": [
|
||||
"잔여",
|
||||
"가능",
|
||||
"승인",
|
||||
"신청",
|
||||
"사용할 수"
|
||||
]
|
||||
},
|
||||
"data_contract": {
|
||||
"source_object": "HMM_LEAVE_BALANCES",
|
||||
"identity_fields": [
|
||||
"EMPLOYEE_CODE"
|
||||
],
|
||||
"required_fields": [
|
||||
"LEAVE_YEAR",
|
||||
"ANNUAL_ENTITLEMENT",
|
||||
"CARRYOVER_DAYS",
|
||||
"USED_DAYS",
|
||||
"PENDING_DAYS",
|
||||
"AVAILABLE_REMAINING_DAYS"
|
||||
],
|
||||
"computed_fields": {
|
||||
"AVAILABLE_REMAINING_DAYS": {
|
||||
"expression": "ANNUAL_ENTITLEMENT + CARRYOVER_DAYS - USED_DAYS - PENDING_DAYS",
|
||||
"authoritative_source": "DATABASE_VIRTUAL_COLUMN"
|
||||
}
|
||||
},
|
||||
"missing_record_semantics": "NO_BALANCE_DATA_FOR_YEAR",
|
||||
"forbidden_missing_record_fallbacks": [
|
||||
"ZERO",
|
||||
"NVL",
|
||||
"COALESCE",
|
||||
"OUTER_JOIN_DEFAULT",
|
||||
"LITERAL_VALUE"
|
||||
]
|
||||
},
|
||||
"temporal_contract": {
|
||||
"current_balance_year": "LATEST_AVAILABLE_LEAVE_YEAR",
|
||||
"requested_period_and_current_balance_are_separate": true,
|
||||
"period_status_fields": [
|
||||
"REQUEST_PERIOD_LABEL",
|
||||
"PERIOD_STATUS"
|
||||
],
|
||||
"past_period_status": "HISTORICAL_REQUEST_PERIOD",
|
||||
"past_period_decision": "DO_NOT_DECIDE_NEW_APPLICATION_FROM_CURRENT_BALANCE",
|
||||
"past_period_decision_field": "APPLICATION_DECISION_STATUS",
|
||||
"past_period_decision_value": "NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",
|
||||
"forbidden_past_period_decision_field_patterns": [
|
||||
"^CAN_",
|
||||
"^APPROVAL_(ALLOWED|DENIED)$"
|
||||
],
|
||||
"validate_stated_days_against": [
|
||||
"INCLUSIVE_CALENDAR_DAYS",
|
||||
"MONDAY_TO_FRIDAY_DAYS"
|
||||
],
|
||||
"day_count_mismatch_status": "DATE_COUNT_REQUIRES_VALIDATION"
|
||||
},
|
||||
"answer_contract": {
|
||||
"separate_data_facts_and_policy_requirements": true,
|
||||
"deny_conclusions_without_required_fields": [
|
||||
"ZERO_REMAINING_DAYS",
|
||||
"LEAVE_APPROVAL_ALLOWED",
|
||||
"LEAVE_APPROVAL_DENIED"
|
||||
],
|
||||
"missing_evidence_response": "STATE_INSUFFICIENT_EVIDENCE_WITHOUT_INVENTING_VALUES",
|
||||
"missing_evidence_message": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,15 +8,16 @@
|
||||
"transport": "http",
|
||||
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
|
||||
"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",
|
||||
"router_model_profile": "gpt54_mini_oci",
|
||||
"tool_allowlist": [
|
||||
"search_hr_data",
|
||||
"resolve_hr_term",
|
||||
"search_hr_policy"
|
||||
"search_hr_policy",
|
||||
"search_carrier_performance"
|
||||
],
|
||||
"description": "HMM HR knowledge and data MCP server"
|
||||
"description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"registry_name": "POC3_8512_8513_MODEL_PROFILES",
|
||||
"registry_name": "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES",
|
||||
"default_model_profile": "gpt55_oci",
|
||||
"source_commit": "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f",
|
||||
"profiles": [
|
||||
@@ -15,7 +15,7 @@
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT55_OCI_V2",
|
||||
"verification_status": "VERIFIED_WITH_WARNINGS",
|
||||
"default_for_poc3": true,
|
||||
"default_for_console": true,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC_2 Select AI S1~S5와 승인된 read-only 실행은 5/5 PASS. 8503 Native Agent는 S1/S2/R1/H1 PASS이나 S3 safe-conversion marker 미확인 WARN으로 PARTIAL이다.",
|
||||
"display_order": 0
|
||||
@@ -31,7 +31,7 @@
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT54_MINI_OCI_PROFILE_V1",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT54_MINI_OCI_V1",
|
||||
"verification_status": "PARTIAL_VERIFIED",
|
||||
"default_for_poc3": false,
|
||||
"default_for_console": false,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC4 MCP 실행 방식(single/agent) 판단용 경량 planner profile. Select AI/Agent Team 본 처리 기본값은 gpt55_oci를 유지한다.",
|
||||
"display_order": 1
|
||||
@@ -47,7 +47,7 @@
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GROK43_PROFILE_V2",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GROK43_V3",
|
||||
"verification_status": "VERIFIED",
|
||||
"default_for_poc3": false,
|
||||
"default_for_console": false,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||
"display_order": 2
|
||||
@@ -63,7 +63,7 @@
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA4_MAVERICK_PROFILE_V2",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA4_MAVERICK_V3",
|
||||
"verification_status": "VERIFIED",
|
||||
"default_for_poc3": false,
|
||||
"default_for_console": false,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||
"display_order": 3
|
||||
@@ -79,7 +79,7 @@
|
||||
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA33_PROFILE_V2",
|
||||
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA33_V3",
|
||||
"verification_status": "VERIFIED",
|
||||
"default_for_poc3": false,
|
||||
"default_for_console": false,
|
||||
"source_tag": "poc_2-gpt55-oci-partial",
|
||||
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
|
||||
"display_order": 4
|
||||
52
ai-web-agent-console/config/vpd_token_presets.json
Normal file
52
ai-web-agent-console/config/vpd_token_presets.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"version": 2,
|
||||
"description": "HMM HR 데모 사용자 선택 목록입니다. 파일명은 기존 배포 호환성을 위해 유지합니다. token 원문은 저장하지 않고 mcp_token_env의 서버 환경변수만 참조합니다.",
|
||||
"presets": [
|
||||
{
|
||||
"enabled": true,
|
||||
"default": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||
"user_id": "E1001",
|
||||
"name": "Kim Minseo",
|
||||
"role": "HR Team Manager",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "팀원 6명의 휴가·근태 현황을 확인하는 관리자 데모"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||
"user_id": "E1002",
|
||||
"name": "Lee Jiwon",
|
||||
"role": "HR Operations Specialist",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "본인 휴가 잔여·신청·근태를 확인하는 팀원 데모"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||
"user_id": "E1003",
|
||||
"name": "Park Dohyun",
|
||||
"role": "People Analytics Analyst",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "본인 휴가·근태와 팀 인력 현황을 확인하는 분석 담당 데모"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||
"user_id": "E1005",
|
||||
"name": "Han Seojun",
|
||||
"role": "Recruiting Specialist",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "대기 중인 2일 연차 신청을 확인하는 팀원 데모"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||
"user_id": "E1007",
|
||||
"name": "Kang Minho",
|
||||
"role": "HR Coordinator",
|
||||
"team": "HMM HR Demo Team",
|
||||
"scope": "대기 중인 1일 연차 신청과 휴가 근태를 확인하는 팀원 데모"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
openpyxl>=3.1,<4
|
||||
oci>=2.180,<3
|
||||
oracledb>=2,<4
|
||||
pandas>=2,<3
|
||||
streamlit>=1.35,<2
|
||||
|
||||
217
ai-web-agent-console/tests/test_auth_gateway.py
Normal file
217
ai-web-agent-console/tests/test_auth_gateway.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from ai_web_agent_console.auth_gateway import ( # noqa: E402
|
||||
AuthConfig,
|
||||
LoginAttemptLimiter,
|
||||
SESSION_COOKIE_NAME,
|
||||
SessionTokenCodec,
|
||||
build_handler,
|
||||
clear_session_cookie_header,
|
||||
password_matches,
|
||||
session_cookie_header,
|
||||
)
|
||||
|
||||
|
||||
class AuthGatewayTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = "s" * 48
|
||||
self.codec = SessionTokenCodec(self.secret)
|
||||
|
||||
def test_session_token_round_trip_and_tamper_rejection(self) -> None:
|
||||
token = self.codec.issue("demo-admin", 3600, now=1_000)
|
||||
|
||||
session = self.codec.verify(token, "demo-admin", now=1_001)
|
||||
|
||||
self.assertIsNotNone(session)
|
||||
self.assertEqual("demo-admin", session.username)
|
||||
self.assertEqual(4_600, session.expires_at)
|
||||
self.assertIsNone(self.codec.verify(token + "x", "demo-admin", now=1_001))
|
||||
self.assertIsNone(self.codec.verify(token, "other-user", now=1_001))
|
||||
|
||||
def test_expired_session_token_is_rejected(self) -> None:
|
||||
token = self.codec.issue("demo-admin", 300, now=1_000)
|
||||
|
||||
self.assertIsNone(self.codec.verify(token, "demo-admin", now=1_300))
|
||||
|
||||
def test_remember_cookie_has_required_security_attributes(self) -> None:
|
||||
header = session_cookie_header("signed-value", 604_800)
|
||||
|
||||
self.assertIn(f"{SESSION_COOKIE_NAME}=signed-value", header)
|
||||
self.assertIn("Path=/", header)
|
||||
self.assertIn("Secure", header)
|
||||
self.assertIn("HttpOnly", header)
|
||||
self.assertIn("SameSite=Lax", header)
|
||||
self.assertIn("Max-Age=604800", header)
|
||||
self.assertNotIn("Domain=", header)
|
||||
|
||||
def test_session_cookie_omits_persistent_max_age(self) -> None:
|
||||
header = session_cookie_header("signed-value", None)
|
||||
|
||||
self.assertNotIn("Max-Age", header)
|
||||
self.assertIn("HttpOnly", header)
|
||||
|
||||
def test_logout_cookie_expires_immediately(self) -> None:
|
||||
header = clear_session_cookie_header()
|
||||
|
||||
self.assertIn("Max-Age=0", header)
|
||||
self.assertIn("Secure", header)
|
||||
self.assertIn("HttpOnly", header)
|
||||
|
||||
def test_pbkdf2_password_verification(self) -> None:
|
||||
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
|
||||
expected = hashlib.pbkdf2_hmac(
|
||||
"sha256", b"correct-password", salt, 200_000
|
||||
).hex()
|
||||
encoded = f"pbkdf2_sha256$200000${salt.hex()}${expected}"
|
||||
|
||||
self.assertTrue(password_matches("correct-password", encoded))
|
||||
self.assertFalse(password_matches("wrong-password", encoded))
|
||||
|
||||
def test_rate_limiter_blocks_only_after_threshold(self) -> None:
|
||||
limiter = LoginAttemptLimiter(maximum_failures=2, window_seconds=10)
|
||||
|
||||
limiter.record_failure("client", now=1)
|
||||
self.assertFalse(limiter.blocked("client", now=2))
|
||||
limiter.record_failure("client", now=3)
|
||||
self.assertTrue(limiter.blocked("client", now=4))
|
||||
self.assertFalse(limiter.blocked("client", now=20))
|
||||
|
||||
def test_environment_config_requires_new_cookie_secret(self) -> None:
|
||||
previous = dict(os.environ)
|
||||
try:
|
||||
os.environ["POC4_LOGIN_USER"] = "demo-admin"
|
||||
os.environ["POC4_LOGIN_PASSWORD_PBKDF2"] = "encoded"
|
||||
os.environ.pop("POC4_LOGIN_COOKIE_SECRET", None)
|
||||
with self.assertRaisesRegex(ValueError, "COOKIE_SECRET"):
|
||||
AuthConfig.from_environment()
|
||||
finally:
|
||||
os.environ.clear()
|
||||
os.environ.update(previous)
|
||||
|
||||
def test_http_login_check_and_logout_flow_never_uses_url_token(self) -> None:
|
||||
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
|
||||
expected = hashlib.pbkdf2_hmac(
|
||||
"sha256", b"correct-password", salt, 200_000
|
||||
).hex()
|
||||
config = AuthConfig(
|
||||
username="demo-admin",
|
||||
password_pbkdf2=(
|
||||
f"pbkdf2_sha256$200000${salt.hex()}${expected}"
|
||||
),
|
||||
cookie_secret=self.secret,
|
||||
port=8621,
|
||||
)
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(config))
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
connection = http.client.HTTPConnection(
|
||||
"127.0.0.1", server.server_address[1], timeout=3
|
||||
)
|
||||
try:
|
||||
connection.request("GET", "/auth/login")
|
||||
login_page = connection.getresponse()
|
||||
body = login_page.read().decode("utf-8")
|
||||
self.assertEqual(200, login_page.status)
|
||||
csrf_header = next(
|
||||
value
|
||||
for name, value in login_page.getheaders()
|
||||
if name.lower() == "set-cookie"
|
||||
and value.startswith("__Host-HMM_LOGIN_CSRF=")
|
||||
)
|
||||
csrf_value = csrf_header.split("=", 1)[1].split(";", 1)[0]
|
||||
self.assertIn(
|
||||
f'name="csrf" value="{csrf_value}"',
|
||||
body,
|
||||
)
|
||||
|
||||
payload = urlencode(
|
||||
{
|
||||
"csrf": csrf_value,
|
||||
"username": "demo-admin",
|
||||
"password": "correct-password",
|
||||
"remember": "yes",
|
||||
}
|
||||
)
|
||||
connection.request(
|
||||
"POST",
|
||||
"/auth/login",
|
||||
body=payload,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": f"__Host-HMM_LOGIN_CSRF={csrf_value}",
|
||||
},
|
||||
)
|
||||
logged_in = connection.getresponse()
|
||||
logged_in.read()
|
||||
self.assertEqual(303, logged_in.status)
|
||||
self.assertEqual("/", logged_in.getheader("Location"))
|
||||
self.assertNotRegex(logged_in.getheader("Location"), r"token|remember")
|
||||
session_header = next(
|
||||
value
|
||||
for name, value in logged_in.getheaders()
|
||||
if name.lower() == "set-cookie"
|
||||
and value.startswith(f"{SESSION_COOKIE_NAME}=")
|
||||
)
|
||||
session_value = session_header.split("=", 1)[1].split(";", 1)[0]
|
||||
self.assertIn("Secure", session_header)
|
||||
self.assertIn("HttpOnly", session_header)
|
||||
self.assertIn("SameSite=Lax", session_header)
|
||||
|
||||
connection.request(
|
||||
"GET",
|
||||
"/auth/check",
|
||||
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
|
||||
)
|
||||
check = connection.getresponse()
|
||||
check.read()
|
||||
self.assertEqual(204, check.status)
|
||||
self.assertEqual("demo-admin", check.getheader("X-Auth-User"))
|
||||
|
||||
connection.request(
|
||||
"GET",
|
||||
"/auth/check",
|
||||
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}x"},
|
||||
)
|
||||
tampered = connection.getresponse()
|
||||
tampered.read()
|
||||
self.assertEqual(401, tampered.status)
|
||||
|
||||
connection.request(
|
||||
"GET",
|
||||
"/auth/logout",
|
||||
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
|
||||
)
|
||||
logout = connection.getresponse()
|
||||
logout.read()
|
||||
self.assertEqual(303, logout.status)
|
||||
self.assertEqual("/auth/login", logout.getheader("Location"))
|
||||
self.assertTrue(
|
||||
any(
|
||||
name.lower() == "set-cookie" and "Max-Age=0" in value
|
||||
for name, value in logout.getheaders()
|
||||
)
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
181
ai-web-agent-console/tests/test_query_contracts.py
Normal file
181
ai-web-agent-console/tests/test_query_contracts.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import unittest
|
||||
|
||||
from ai_web_agent_console.query_contracts import (
|
||||
append_query_contract_guidance,
|
||||
evidence_contract_report,
|
||||
matching_query_contracts,
|
||||
)
|
||||
|
||||
|
||||
def test_leave_eligibility_question_selects_external_contract():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 잔여와 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
|
||||
assert [contract["id"] for contract in contracts] == [
|
||||
"leave_balance_eligibility"
|
||||
]
|
||||
|
||||
|
||||
def test_unrelated_tool_does_not_receive_leave_contract():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 잔여를 알려줘",
|
||||
"search_carrier_performance",
|
||||
)
|
||||
|
||||
assert contracts == ()
|
||||
|
||||
|
||||
def test_incomplete_result_cannot_be_interpreted_as_zero_balance():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 사용 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT: {"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"REMAINING_DAYS":0}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is False
|
||||
assert "AVAILABLE_REMAINING_DAYS" in report[0]["missing_fields"]
|
||||
assert "LEAVE_YEAR" in report[0]["missing_fields"]
|
||||
|
||||
|
||||
def test_complete_database_row_satisfies_contract_without_fixture_values():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
||||
'"AVAILABLE_REMAINING_DAYS":14}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is True
|
||||
assert report[0]["validated_record"]["LEAVE_YEAR"] == 2031
|
||||
assert report[0]["validated_record"]["AVAILABLE_REMAINING_DAYS"] == 14
|
||||
|
||||
|
||||
def test_conflicting_computed_value_fails_contract():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 연차 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
||||
'"AVAILABLE_REMAINING_DAYS":0}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is False
|
||||
assert report[0]["computed_field_checks"][0]["expected"] == "14"
|
||||
assert report[0]["computed_field_checks"][0]["actual"] == "0"
|
||||
|
||||
|
||||
def test_historical_period_rejects_yes_no_decision_and_missing_counts():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 과거 연차 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
||||
'"AVAILABLE_REMAINING_DAYS":14,'
|
||||
'"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",'
|
||||
'"CAN_APPLY":"YES","INCLUSIVE_CALENDAR_DAYS":11}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is False
|
||||
failed = {
|
||||
check["check"]
|
||||
for check in report[0]["temporal_contract_checks"]
|
||||
if not check["satisfied"]
|
||||
}
|
||||
assert failed == {
|
||||
"past_period_decision",
|
||||
"no_past_period_yes_no_decision",
|
||||
"date_range_counts",
|
||||
}
|
||||
|
||||
|
||||
def test_historical_period_contract_accepts_non_decision_status():
|
||||
contracts = matching_query_contracts(
|
||||
"직원 E9999의 과거 연차 신청 가능 여부를 알려줘",
|
||||
"search_hr_data",
|
||||
)
|
||||
evidence = {
|
||||
"result": (
|
||||
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
|
||||
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
|
||||
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
|
||||
'"AVAILABLE_REMAINING_DAYS":14,'
|
||||
'"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",'
|
||||
'"APPLICATION_DECISION_STATUS":'
|
||||
'"NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",'
|
||||
'"INCLUSIVE_CALENDAR_DAYS":11,'
|
||||
'"MONDAY_TO_FRIDAY_DAYS":7}]}'
|
||||
)
|
||||
}
|
||||
|
||||
report = evidence_contract_report(contracts, evidence)
|
||||
|
||||
assert report[0]["satisfied"] is True
|
||||
|
||||
|
||||
def test_guidance_is_appended_from_json_contract():
|
||||
guided = append_query_contract_guidance(
|
||||
"직원 E9999의 휴가 잔여를 조회해줘",
|
||||
original_question="직원 E9999의 휴가 잔여를 조회해줘",
|
||||
tool_name="search_hr_data",
|
||||
)
|
||||
|
||||
assert '"id":"leave_balance_eligibility"' in guided
|
||||
assert "AVAILABLE_REMAINING_DAYS" in guided
|
||||
|
||||
|
||||
class QueryContractTest(unittest.TestCase):
|
||||
test_leave_eligibility_question_selects_external_contract = staticmethod(
|
||||
test_leave_eligibility_question_selects_external_contract
|
||||
)
|
||||
test_unrelated_tool_does_not_receive_leave_contract = staticmethod(
|
||||
test_unrelated_tool_does_not_receive_leave_contract
|
||||
)
|
||||
test_incomplete_result_cannot_be_interpreted_as_zero_balance = staticmethod(
|
||||
test_incomplete_result_cannot_be_interpreted_as_zero_balance
|
||||
)
|
||||
test_complete_database_row_satisfies_contract_without_fixture_values = (
|
||||
staticmethod(test_complete_database_row_satisfies_contract_without_fixture_values)
|
||||
)
|
||||
test_conflicting_computed_value_fails_contract = staticmethod(
|
||||
test_conflicting_computed_value_fails_contract
|
||||
)
|
||||
test_historical_period_rejects_yes_no_decision_and_missing_counts = (
|
||||
staticmethod(test_historical_period_rejects_yes_no_decision_and_missing_counts)
|
||||
)
|
||||
test_historical_period_contract_accepts_non_decision_status = staticmethod(
|
||||
test_historical_period_contract_accepts_non_decision_status
|
||||
)
|
||||
test_guidance_is_appended_from_json_contract = staticmethod(
|
||||
test_guidance_is_appended_from_json_contract
|
||||
)
|
||||
238
ai-web-agent-console/tests/test_scenarios.py
Normal file
238
ai-web-agent-console/tests/test_scenarios.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
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-01", "FED-02", "FED-03"} & set(by_id),
|
||||
)
|
||||
self.assertTrue(
|
||||
all("선사" in by_id[scenario_id].question for scenario_id in (
|
||||
"FED-01", "FED-02", "FED-03"
|
||||
))
|
||||
)
|
||||
|
||||
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_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.assertTrue(all(item["mcp_token_env"] == "HMM_MCP_BEARER_TOKEN" for item in presets))
|
||||
self.assertTrue(all("token" not in item for item in presets))
|
||||
|
||||
def test_duplicate_id_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "scenarios.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"scenarios": [
|
||||
{"id": "HR-01", "title": "one", "question": "q1"},
|
||||
{"id": "HR-01", "title": "two", "question": "q2"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaises(ScenarioConfigError):
|
||||
load_demo_scenarios(path)
|
||||
|
||||
def test_default_mcp_tool_arguments_follow_discovered_query_schema(self) -> None:
|
||||
tool = McpTool(
|
||||
name="search_hr_data",
|
||||
description="",
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
arguments = build_mcp_tool_arguments(
|
||||
tool, "직원 E1005의 휴가 신청 내역", 50, preferred_tool="search_hr_data"
|
||||
)
|
||||
|
||||
self.assertEqual(arguments, {"query": "직원 E1005의 휴가 신청 내역"})
|
||||
|
||||
def test_term_tool_arguments_follow_discovered_term_schema(self) -> None:
|
||||
tool = McpTool(
|
||||
name="resolve_hr_term",
|
||||
description="",
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {"term": {"type": "string"}},
|
||||
"required": ["term"],
|
||||
},
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
arguments = build_mcp_tool_arguments(
|
||||
tool, "반차", 50, preferred_tool="search_hr_data"
|
||||
)
|
||||
|
||||
self.assertEqual(arguments, {"term": "반차"})
|
||||
|
||||
def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None:
|
||||
result = {
|
||||
"status": "success",
|
||||
"result": (
|
||||
"HR_POLICY_SEARCH_RESULT\n"
|
||||
"EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준"
|
||||
),
|
||||
}
|
||||
|
||||
summary = status_result_summary(result, excerpt_chars=40)
|
||||
evidence = status_result_evidence(result)
|
||||
|
||||
self.assertEqual(summary["status"], "success")
|
||||
self.assertGreater(summary["result_chars"], 40)
|
||||
self.assertIn("KR_Leave_Policy.pdf", evidence["result"])
|
||||
self.assertTrue(has_actionable_text_result(result))
|
||||
|
||||
def test_no_data_text_is_not_actionable(self) -> None:
|
||||
self.assertFalse(
|
||||
has_actionable_text_result({"status": "success", "result": "No data found"})
|
||||
)
|
||||
|
||||
@unittest.skipIf(_load_console_query_helpers() is None, "Streamlit runtime is optional")
|
||||
def test_policy_query_does_not_include_demo_user_context(self) -> None:
|
||||
prepare = _load_console_query_helpers()
|
||||
assert prepare is not None
|
||||
tool = McpTool(
|
||||
name="search_hr_policy",
|
||||
description="Search policy documents",
|
||||
schema={"properties": {"query": {"type": "string"}}},
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
query = prepare(
|
||||
question="연차 휴가 이월 기준과 제한을 알려줘",
|
||||
tool=tool,
|
||||
model_profile_key="gpt54_mini_oci",
|
||||
selected_user_id="E1001",
|
||||
selected_user_role="HR Team Manager",
|
||||
selected_user_team="HMM HR Demo Team",
|
||||
selected_user_scope="팀원 6명 관리",
|
||||
)
|
||||
|
||||
self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -80,7 +80,7 @@ COMMIT;
|
||||
-- COMMIT;
|
||||
--
|
||||
-- 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 ===
|
||||
@@ -3,7 +3,7 @@
|
||||
-- Unit-style checks for cb_agent_doc_vpd_filter.
|
||||
--
|
||||
-- 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
|
||||
SET ECHO OFF
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user