refs #741 #742: reorganize repository by application

This commit is contained in:
devmrko
2026-08-03 10:20:36 +09:00
parent 2e44ed0b97
commit e9d50e6a32
445 changed files with 1523 additions and 1885 deletions

View File

@@ -62,6 +62,8 @@ export BACKOFFICE_SELECT_AI_PROFILE=""
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 배열입니다.
@@ -103,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 가능
@@ -116,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"
@@ -137,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
View File

@@ -17,6 +17,12 @@ logs/
# Java / Maven
target/
# Python
__pycache__/
*.py[cod]
.venv/
data/
# Locally downloaded development tools (for example SQLcl)
.tools/

View File

@@ -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,20 +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 스크립트 |
| 원격 원장 seed/검증 SQL | `sql/source/` | PostgreSQL·MySQL 가상 원장과 HMM 선사 실적 데이터 |
| 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 기준은 다음과 같다.
@@ -81,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에 넣지 않는다.
---
@@ -111,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) 환경값 채우기
@@ -169,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 결과 |
|---|---|---|---|---|
@@ -187,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 행 수를 가볍게 확인합니다.
@@ -199,7 +198,7 @@ $EDITOR .env
## DDS 설정 핵심
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `sql/adb/13_dds_variant.sql` 가 이
DDS 변형을 직접 셋업할 때 알아야 하는 최소 단위. `database/adb/13_dds_variant.sql` 가 이
순서로 돌아가며, 행/컬럼 통제가 모두 **선언형 DDL 한 줄** 로 끝납니다.
### 사전 조건
@@ -259,8 +258,19 @@ 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

View 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

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

View File

@@ -28,6 +28,14 @@ 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"
@@ -54,13 +62,17 @@ class AuthConfig:
@classmethod
def from_environment(cls) -> "AuthConfig":
config = cls(
username=os.environ.get("POC4_LOGIN_USER", "").strip(),
password_pbkdf2=os.environ.get(
"POC4_LOGIN_PASSWORD_PBKDF2", ""
).strip(),
cookie_secret=os.environ.get(
"POC4_LOGIN_COOKIE_SECRET", ""
).strip(),
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(),
@@ -96,9 +108,11 @@ class AuthConfig:
def validate(self) -> None:
if not self.username or not self.password_pbkdf2:
raise ValueError("POC4 portal login credentials are not configured")
raise ValueError("AI web agent console login credentials are not configured")
if len(self.cookie_secret.encode("utf-8")) < 32:
raise ValueError("POC4_LOGIN_COOKIE_SECRET must be at least 32 bytes")
raise ValueError(
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET must be at least 32 bytes"
)
if self.bind_address not in {"127.0.0.1", "::1"}:
raise ValueError("Portal authentication service must bind to loopback")
if not 1 <= self.port <= 65535:

View File

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

View File

@@ -1,7 +1,7 @@
"""8512/8513 전용 PoC_3 model profile registry.
"""8512/8513 전용 AI Web Agent Console model profile registry.
registry는 모델 metadata만 관리한다. ``provider=oci`` 모델의 출처를 뜻하며
``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,

View File

@@ -0,0 +1,407 @@
"""Configuration-driven query and answer evidence contracts."""
from __future__ import annotations
import ast
from decimal import Decimal, InvalidOperation
from functools import lru_cache
import json
import os
from pathlib import Path
import re
from typing import Any, Mapping
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONTRACT_FILE = ROOT / "config" / "hmm_hr_query_contracts.json"
def _contract_file() -> Path:
configured = str(
os.getenv("AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH")
or os.getenv("POC4_QUERY_CONTRACTS_PATH")
or ""
).strip()
if not configured:
return DEFAULT_CONTRACT_FILE
path = Path(configured).expanduser()
return path if path.is_absolute() else ROOT / path
@lru_cache(maxsize=4)
def _load_contract_file(path_text: str) -> tuple[Mapping[str, Any], ...]:
path = Path(path_text)
if not path.exists():
return ()
value = json.loads(path.read_text(encoding="utf-8"))
contracts = value.get("contracts") if isinstance(value, Mapping) else None
if not isinstance(contracts, list):
raise ValueError("query contracts must contain a contracts array")
return tuple(item for item in contracts if isinstance(item, Mapping))
def load_query_contracts() -> tuple[Mapping[str, Any], ...]:
return _load_contract_file(str(_contract_file()))
def matching_query_contracts(
question: str,
tool_name: str,
) -> tuple[Mapping[str, Any], ...]:
normalized_question = " ".join(str(question or "").casefold().split())
normalized_tool = str(tool_name or "").strip()
selected: list[Mapping[str, Any]] = []
for contract in load_query_contracts():
tools = contract.get("applies_to_tools")
if isinstance(tools, list) and normalized_tool not in {
str(item) for item in tools
}:
continue
intent = contract.get("intent_match")
if not isinstance(intent, Mapping):
continue
subject_terms = [
str(item).casefold()
for item in intent.get("subject_terms_any", [])
if str(item).strip()
]
action_terms = [
str(item).casefold()
for item in intent.get("action_terms_any", [])
if str(item).strip()
]
if subject_terms and not any(
term in normalized_question for term in subject_terms
):
continue
if action_terms and not any(
term in normalized_question for term in action_terms
):
continue
selected.append(contract)
return tuple(selected)
def query_contract_guidance(question: str, tool_name: str) -> str:
contracts = matching_query_contracts(question, tool_name)
if not contracts:
return ""
return json.dumps(
{"query_contracts": contracts},
ensure_ascii=False,
separators=(",", ":"),
)
def append_query_contract_guidance(
tool_query: str,
*,
original_question: str,
tool_name: str,
) -> str:
guidance = query_contract_guidance(original_question, tool_name)
normalized = str(tool_query or "").strip()
if not guidance:
return normalized
return (
f"{normalized}\n"
"다음 질의 계약의 필드·계산·시간 기준을 반드시 지켜 결과를 반환하세요. "
f"계약: {guidance}"
)
def _json_data_result(value: str) -> Any:
marker = "DATA_RESULT"
position = value.find(marker)
if position < 0:
return None
remainder = value[position + len(marker) :]
object_position = remainder.find("{")
array_position = remainder.find("[")
positions = [
candidate
for candidate in (object_position, array_position)
if candidate >= 0
]
if not positions:
return None
candidate = remainder[min(positions) :]
try:
parsed, _ = json.JSONDecoder().raw_decode(candidate)
except (TypeError, ValueError, json.JSONDecodeError):
return None
return parsed
def _evidence_rows(value: Any) -> list[Mapping[str, Any]]:
rows: list[Mapping[str, Any]] = []
if isinstance(value, Mapping):
rows.append(value)
for item in value.values():
rows.extend(_evidence_rows(item))
elif isinstance(value, list):
for item in value:
rows.extend(_evidence_rows(item))
elif isinstance(value, str):
parsed = _json_data_result(value)
if parsed is not None:
rows.extend(_evidence_rows(parsed))
return rows
def _decimal(value: Any) -> Decimal:
if isinstance(value, bool) or value is None:
raise InvalidOperation
return Decimal(str(value))
def _evaluate_contract_expression(
expression: str,
row: Mapping[str, Any],
) -> Decimal:
tree = ast.parse(expression, mode="eval")
def evaluate(node: ast.AST) -> Decimal:
if isinstance(node, ast.Expression):
return evaluate(node.body)
if isinstance(node, ast.Name):
key = node.id.casefold()
if key not in row:
raise InvalidOperation
return _decimal(row[key])
if isinstance(node, ast.Constant):
return _decimal(node.value)
if isinstance(node, ast.UnaryOp) and isinstance(
node.op, (ast.UAdd, ast.USub)
):
value = evaluate(node.operand)
return value if isinstance(node.op, ast.UAdd) else -value
if isinstance(node, ast.BinOp) and isinstance(
node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)
):
left = evaluate(node.left)
right = evaluate(node.right)
if isinstance(node.op, ast.Add):
return left + right
if isinstance(node.op, ast.Sub):
return left - right
if isinstance(node.op, ast.Mult):
return left * right
return left / right
raise ValueError("unsupported contract expression")
return evaluate(tree)
def evidence_contract_report(
contracts: tuple[Mapping[str, Any], ...],
evidence: Any,
) -> list[dict[str, Any]]:
rows = [
{str(key).strip().casefold(): value for key, value in row.items()}
for row in _evidence_rows(evidence)
]
reports: list[dict[str, Any]] = []
for contract in contracts:
data_contract = contract.get("data_contract")
if not isinstance(data_contract, Mapping):
continue
required = [
str(item).strip().casefold()
for item in data_contract.get("required_fields", [])
if str(item).strip()
]
matching_row = next(
(row for row in rows if required and all(key in row for key in required)),
None,
)
observed = sorted(
{
key
for row in rows
for key in row
if not required or key in required
}
)
missing = [
key.upper()
for key in required
if matching_row is None or key not in matching_row
]
computed_checks: list[dict[str, Any]] = []
computed_fields = data_contract.get("computed_fields")
if matching_row is not None and isinstance(computed_fields, Mapping):
for field, definition in computed_fields.items():
normalized_field = str(field).strip().casefold()
expression = (
str(definition.get("expression") or "").strip()
if isinstance(definition, Mapping)
else ""
)
if not normalized_field or not expression:
continue
try:
actual = _decimal(matching_row.get(normalized_field))
expected = _evaluate_contract_expression(
expression,
matching_row,
)
matches = actual == expected
computed_checks.append(
{
"field": normalized_field.upper(),
"expression": expression,
"actual": str(actual),
"expected": str(expected),
"satisfied": matches,
}
)
except (InvalidOperation, ValueError, ZeroDivisionError):
computed_checks.append(
{
"field": normalized_field.upper(),
"expression": expression,
"satisfied": False,
}
)
calculations_satisfied = all(
bool(check.get("satisfied")) for check in computed_checks
)
temporal_checks: list[dict[str, Any]] = []
temporal_contract = contract.get("temporal_contract")
if matching_row is not None and isinstance(temporal_contract, Mapping):
status_fields = [
str(item).strip().casefold()
for item in temporal_contract.get("period_status_fields", [])
if str(item).strip()
]
past_status = str(
temporal_contract.get("past_period_status") or ""
).strip()
observed_status = next(
(
str(matching_row.get(field) or "").strip()
for field in status_fields
if str(matching_row.get(field) or "").strip()
),
"",
)
if observed_status == past_status and past_status:
decision_field = str(
temporal_contract.get("past_period_decision_field") or ""
).strip().casefold()
decision_value = str(
temporal_contract.get("past_period_decision_value") or ""
).strip()
actual_decision = str(
matching_row.get(decision_field) or ""
).strip()
temporal_checks.append(
{
"check": "past_period_decision",
"field": decision_field.upper(),
"expected": decision_value,
"actual": actual_decision,
"satisfied": bool(
decision_field
and decision_value
and actual_decision == decision_value
),
}
)
forbidden_patterns = [
str(item).strip()
for item in temporal_contract.get(
"forbidden_past_period_decision_field_patterns", []
)
if str(item).strip()
]
forbidden_fields = sorted(
key.upper()
for key in matching_row
if any(
re.search(pattern, key, flags=re.IGNORECASE)
for pattern in forbidden_patterns
)
)
temporal_checks.append(
{
"check": "no_past_period_yes_no_decision",
"forbidden_fields": forbidden_fields,
"satisfied": not forbidden_fields,
}
)
required_counts = [
str(item).strip().casefold()
for item in temporal_contract.get(
"validate_stated_days_against", []
)
if str(item).strip()
]
temporal_checks.append(
{
"check": "date_range_counts",
"required_fields": [
field.upper() for field in required_counts
],
"missing_fields": [
field.upper()
for field in required_counts
if field not in matching_row
],
"satisfied": all(
field in matching_row for field in required_counts
),
}
)
temporal_satisfied = all(
bool(check.get("satisfied")) for check in temporal_checks
)
reports.append(
{
"contract_id": str(contract.get("id") or ""),
"satisfied": bool(
matching_row is not None
and not missing
and calculations_satisfied
and temporal_satisfied
),
"required_fields": [key.upper() for key in required],
"observed_required_fields": [key.upper() for key in observed],
"missing_fields": missing,
"computed_field_checks": computed_checks,
"temporal_contract_checks": temporal_checks,
"validated_record": (
{
key.upper(): matching_row.get(key)
for key in required
}
if matching_row is not None
else {}
),
}
)
return reports
def missing_evidence_message(
contracts: tuple[Mapping[str, Any], ...],
) -> str:
for contract in contracts:
answer_contract = contract.get("answer_contract")
if not isinstance(answer_contract, Mapping):
continue
message = str(answer_contract.get("missing_evidence_message") or "").strip()
if message:
return message
return "조회 결과가 답변 계약의 필수 근거를 충족하지 않아 결론을 제공할 수 없습니다."
__all__ = [
"append_query_contract_guidance",
"evidence_contract_report",
"load_query_contracts",
"matching_query_contracts",
"missing_evidence_message",
"query_contract_guidance",
]

View File

@@ -1,4 +1,4 @@
"""PoC_3 preset catalog와 현재 질문 기반의 결정적 intent router."""
"""AI Web Agent Console preset catalog와 현재 질문 기반의 결정적 intent router."""
from __future__ import annotations
@@ -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:

View File

@@ -0,0 +1,84 @@
{
"version": 1,
"contracts": [
{
"id": "leave_balance_eligibility",
"description": "연차·휴가 잔여와 신청 가능 여부를 원장 연도 및 구성값으로 검증한다.",
"applies_to_tools": [
"search_hr_data"
],
"intent_match": {
"subject_terms_any": [
"연차",
"휴가"
],
"action_terms_any": [
"잔여",
"가능",
"승인",
"신청",
"사용할 수"
]
},
"data_contract": {
"source_object": "HMM_LEAVE_BALANCES",
"identity_fields": [
"EMPLOYEE_CODE"
],
"required_fields": [
"LEAVE_YEAR",
"ANNUAL_ENTITLEMENT",
"CARRYOVER_DAYS",
"USED_DAYS",
"PENDING_DAYS",
"AVAILABLE_REMAINING_DAYS"
],
"computed_fields": {
"AVAILABLE_REMAINING_DAYS": {
"expression": "ANNUAL_ENTITLEMENT + CARRYOVER_DAYS - USED_DAYS - PENDING_DAYS",
"authoritative_source": "DATABASE_VIRTUAL_COLUMN"
}
},
"missing_record_semantics": "NO_BALANCE_DATA_FOR_YEAR",
"forbidden_missing_record_fallbacks": [
"ZERO",
"NVL",
"COALESCE",
"OUTER_JOIN_DEFAULT",
"LITERAL_VALUE"
]
},
"temporal_contract": {
"current_balance_year": "LATEST_AVAILABLE_LEAVE_YEAR",
"requested_period_and_current_balance_are_separate": true,
"period_status_fields": [
"REQUEST_PERIOD_LABEL",
"PERIOD_STATUS"
],
"past_period_status": "HISTORICAL_REQUEST_PERIOD",
"past_period_decision": "DO_NOT_DECIDE_NEW_APPLICATION_FROM_CURRENT_BALANCE",
"past_period_decision_field": "APPLICATION_DECISION_STATUS",
"past_period_decision_value": "NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",
"forbidden_past_period_decision_field_patterns": [
"^CAN_",
"^APPROVAL_(ALLOWED|DENIED)$"
],
"validate_stated_days_against": [
"INCLUSIVE_CALENDAR_DAYS",
"MONDAY_TO_FRIDAY_DAYS"
],
"day_count_mismatch_status": "DATE_COUNT_REQUIRES_VALIDATION"
},
"answer_contract": {
"separate_data_facts_and_policy_requirements": true,
"deny_conclusions_without_required_fields": [
"ZERO_REMAINING_DAYS",
"LEAVE_APPROVAL_ALLOWED",
"LEAVE_APPROVAL_DENIED"
],
"missing_evidence_response": "STATE_INSUFFICIENT_EVIDENCE_WITHOUT_INVENTING_VALUES",
"missing_evidence_message": "조회 결과가 필수 원장 근거나 시간 기준 계약을 충족하지 않아 휴가 가능 여부를 확정할 수 없습니다. 계약 필드가 충족된 원장 결과로 다시 조회해야 합니다."
}
}
]
}

View File

@@ -8,7 +8,7 @@
"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": [

View File

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

View File

@@ -13,7 +13,7 @@ from urllib.parse import urlencode
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.agent_console.auth_gateway import ( # noqa: E402
from ai_web_agent_console.auth_gateway import ( # noqa: E402
AuthConfig,
LoginAttemptLimiter,
SESSION_COOKIE_NAME,

View File

@@ -0,0 +1,181 @@
import unittest
from ai_web_agent_console.query_contracts import (
append_query_contract_guidance,
evidence_contract_report,
matching_query_contracts,
)
def test_leave_eligibility_question_selects_external_contract():
contracts = matching_query_contracts(
"직원 E9999의 연차 잔여와 신청 가능 여부를 알려줘",
"search_hr_data",
)
assert [contract["id"] for contract in contracts] == [
"leave_balance_eligibility"
]
def test_unrelated_tool_does_not_receive_leave_contract():
contracts = matching_query_contracts(
"직원 E9999의 연차 잔여를 알려줘",
"search_carrier_performance",
)
assert contracts == ()
def test_incomplete_result_cannot_be_interpreted_as_zero_balance():
contracts = matching_query_contracts(
"직원 E9999의 연차 사용 가능 여부를 알려줘",
"search_hr_data",
)
evidence = {
"result": (
'DATA_RESULT: {"items":[{"EMPLOYEE_CODE":"E9999",'
'"REMAINING_DAYS":0}]}'
)
}
report = evidence_contract_report(contracts, evidence)
assert report[0]["satisfied"] is False
assert "AVAILABLE_REMAINING_DAYS" in report[0]["missing_fields"]
assert "LEAVE_YEAR" in report[0]["missing_fields"]
def test_complete_database_row_satisfies_contract_without_fixture_values():
contracts = matching_query_contracts(
"직원 E9999의 연차 신청 가능 여부를 알려줘",
"search_hr_data",
)
evidence = {
"result": (
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
'"AVAILABLE_REMAINING_DAYS":14}]}'
)
}
report = evidence_contract_report(contracts, evidence)
assert report[0]["satisfied"] is True
assert report[0]["validated_record"]["LEAVE_YEAR"] == 2031
assert report[0]["validated_record"]["AVAILABLE_REMAINING_DAYS"] == 14
def test_conflicting_computed_value_fails_contract():
contracts = matching_query_contracts(
"직원 E9999의 연차 신청 가능 여부를 알려줘",
"search_hr_data",
)
evidence = {
"result": (
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
'"AVAILABLE_REMAINING_DAYS":0}]}'
)
}
report = evidence_contract_report(contracts, evidence)
assert report[0]["satisfied"] is False
assert report[0]["computed_field_checks"][0]["expected"] == "14"
assert report[0]["computed_field_checks"][0]["actual"] == "0"
def test_historical_period_rejects_yes_no_decision_and_missing_counts():
contracts = matching_query_contracts(
"직원 E9999의 과거 연차 신청 가능 여부를 알려줘",
"search_hr_data",
)
evidence = {
"result": (
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
'"AVAILABLE_REMAINING_DAYS":14,'
'"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",'
'"CAN_APPLY":"YES","INCLUSIVE_CALENDAR_DAYS":11}]}'
)
}
report = evidence_contract_report(contracts, evidence)
assert report[0]["satisfied"] is False
failed = {
check["check"]
for check in report[0]["temporal_contract_checks"]
if not check["satisfied"]
}
assert failed == {
"past_period_decision",
"no_past_period_yes_no_decision",
"date_range_counts",
}
def test_historical_period_contract_accepts_non_decision_status():
contracts = matching_query_contracts(
"직원 E9999의 과거 연차 신청 가능 여부를 알려줘",
"search_hr_data",
)
evidence = {
"result": (
'DATA_RESULT\n{"items":[{"EMPLOYEE_CODE":"E9999",'
'"LEAVE_YEAR":2031,"ANNUAL_ENTITLEMENT":17,'
'"CARRYOVER_DAYS":2,"USED_DAYS":4,"PENDING_DAYS":1,'
'"AVAILABLE_REMAINING_DAYS":14,'
'"REQUEST_PERIOD_LABEL":"HISTORICAL_REQUEST_PERIOD",'
'"APPLICATION_DECISION_STATUS":'
'"NOT_APPLICABLE_FOR_HISTORICAL_PERIOD",'
'"INCLUSIVE_CALENDAR_DAYS":11,'
'"MONDAY_TO_FRIDAY_DAYS":7}]}'
)
}
report = evidence_contract_report(contracts, evidence)
assert report[0]["satisfied"] is True
def test_guidance_is_appended_from_json_contract():
guided = append_query_contract_guidance(
"직원 E9999의 휴가 잔여를 조회해줘",
original_question="직원 E9999의 휴가 잔여를 조회해줘",
tool_name="search_hr_data",
)
assert '"id":"leave_balance_eligibility"' in guided
assert "AVAILABLE_REMAINING_DAYS" in guided
class QueryContractTest(unittest.TestCase):
test_leave_eligibility_question_selects_external_contract = staticmethod(
test_leave_eligibility_question_selects_external_contract
)
test_unrelated_tool_does_not_receive_leave_contract = staticmethod(
test_unrelated_tool_does_not_receive_leave_contract
)
test_incomplete_result_cannot_be_interpreted_as_zero_balance = staticmethod(
test_incomplete_result_cannot_be_interpreted_as_zero_balance
)
test_complete_database_row_satisfies_contract_without_fixture_values = (
staticmethod(test_complete_database_row_satisfies_contract_without_fixture_values)
)
test_conflicting_computed_value_fails_contract = staticmethod(
test_conflicting_computed_value_fails_contract
)
test_historical_period_rejects_yes_no_decision_and_missing_counts = (
staticmethod(test_historical_period_rejects_yes_no_decision_and_missing_counts)
)
test_historical_period_contract_accepts_non_decision_status = staticmethod(
test_historical_period_contract_accepts_non_decision_status
)
test_guidance_is_appended_from_json_contract = staticmethod(
test_guidance_is_appended_from_json_contract
)

View File

@@ -6,27 +6,37 @@ import tempfile
import unittest
from unittest.mock import patch
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
from src.agent_console.profile import load_app_profile
from src.mcp_tool_router import McpTool, build_mcp_tool_arguments
from src.mcp_result import (
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_poc4_query_helpers():
def _load_console_query_helpers():
"""Load the Streamlit entrypoint only when its optional runtime is installed."""
try:
from apps.poc4.mcp_discovery_ui import _prepare_hmm_hr_tool_query
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(
@@ -54,7 +64,7 @@ class DemoScenarioConfigTest(unittest.TestCase):
self.assertEqual(profile.short_name, "HMM")
def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None:
path = Path(__file__).parents[1] / "src" / "agent_console" / "presentation.py"
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)
@@ -66,17 +76,20 @@ class DemoScenarioConfigTest(unittest.TestCase):
def test_audit_tab_uses_hmm_access_audit_loaders(self) -> None:
root = Path(__file__).parents[1]
entrypoint = (root / "apps" / "poc4" / "mcp_discovery_ui.py").read_text(
entrypoint = (root / "app.py").read_text(
encoding="utf-8"
)
renderer = (root / "src" / "agent_console" / "audit.py").read_text(
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("POC4_AUDIT_WALLET_PASSWORD", entrypoint)
self.assertIn(
"AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD",
entrypoint,
)
self.assertIn("HMM 접근 관리", renderer)
self.assertNotIn('AUDIT_SCHEMA = "POC_2"', entrypoint)
@@ -197,9 +210,9 @@ class DemoScenarioConfigTest(unittest.TestCase):
has_actionable_text_result({"status": "success", "result": "No data found"})
)
@unittest.skipIf(_load_poc4_query_helpers() is None, "Streamlit runtime is optional")
@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_poc4_query_helpers()
prepare = _load_console_query_helpers()
assert prepare is not None
tool = McpTool(
name="search_hr_policy",

View File

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

View File

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