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

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

@@ -0,0 +1 @@
"""Reusable presentation shell for MCP-backed Streamlit agent consoles."""

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,196 @@
"""LLM-based MCP tool routing.
The router receives only the user question and the discovered MCP tool
descriptors. It never receives MCP bearer tokens or provider credentials.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any, Mapping
from ai_web_agent_console.oci_genai_sdk import (
build_oci_genai_completion_client,
temperature_for_model_profile,
)
from ai_web_agent_console.model_registry import resolve_model_profile
@dataclass(frozen=True)
class McpTool:
name: str
description: str
schema: Mapping[str, Any]
read_only: bool
@dataclass(frozen=True)
class RoutedMcpTool:
server_id: str
tool: McpTool
class McpToolRouterError(RuntimeError):
"""Safe routing error. Must not contain secrets or provider traces."""
def route_mcp_tool_across_servers_with_llm(
tools: list[RoutedMcpTool],
question: str,
*,
router_model_profile: str,
) -> RoutedMcpTool:
"""Select one discovered MCP server/tool pair with OCI GenAI."""
candidates = list(tools)
if not candidates:
raise McpToolRouterError("라우팅 가능한 MCP tool이 없습니다.")
by_key = {
"{}::{}".format(candidate.server_id, candidate.tool.name): candidate
for candidate in candidates
}
route_keys = list(by_key)
try:
profile = resolve_model_profile(router_model_profile)
client = build_oci_genai_completion_client(
profile.model_id,
profile.answer_model_region,
profile.answer_model_endpoint,
)
tool_catalog = [
{
"route_key": "{}::{}".format(candidate.server_id, candidate.tool.name),
"server_id": candidate.server_id,
"tool_name": candidate.tool.name,
"description": candidate.tool.description[:1000],
"input_properties": sorted(
(
candidate.tool.schema.get("properties", {})
if isinstance(
candidate.tool.schema.get("properties"), Mapping
)
else {}
).keys()
),
"read_only": candidate.tool.read_only,
}
for candidate in candidates
]
text = client.complete(
system_prompt=(
"You are an MCP server and tool router. Choose exactly one "
"server/tool route for the user question from the discovered "
"routes. Return only JSON that matches the schema. Never "
"request or expose bearer tokens. Do not invent server ids or "
"tool names."
),
user_prompt=json.dumps(
{
"question": question,
"routes": tool_catalog,
},
ensure_ascii=False,
),
response_schema={
"type": "object",
"additionalProperties": False,
"required": ["route_key"],
"properties": {
"route_key": {
"type": "string",
"enum": route_keys,
}
},
},
max_tokens=256,
temperature=temperature_for_model_profile(profile),
)
routed = json.loads(text)
except Exception:
raise McpToolRouterError("LLM tool router 호출에 실패했습니다.") from None
if not isinstance(routed, Mapping):
raise McpToolRouterError("LLM tool router 응답 형식이 올바르지 않습니다.")
selected_key = str(routed.get("route_key") or "").strip()
selected = by_key.get(selected_key)
if selected is None:
raise McpToolRouterError("LLM tool router가 허용되지 않은 route를 선택했습니다.")
return selected
def route_mcp_tool_with_llm(
tools: list[McpTool],
question: str,
*,
preferred_tool: str,
tool_allowlist: tuple[str, ...],
router_model_profile: str,
) -> McpTool:
"""Backward-compatible single-server routing helper."""
candidates = [
tool for tool in tools if not tool_allowlist or tool.name in tool_allowlist
]
routed = route_mcp_tool_across_servers_with_llm(
[RoutedMcpTool(server_id="default", tool=tool) for tool in candidates],
question,
router_model_profile=router_model_profile,
)
return routed.tool
def build_mcp_tool_arguments(
tool: McpTool,
question: str,
limit: int,
*,
preferred_tool: str,
) -> dict[str, Any]:
"""Build bounded tool arguments from the selected tool schema."""
properties = tool.schema.get("properties")
if not isinstance(properties, Mapping):
properties = {}
# A server's default/preferred tool still has to obey its discovered schema.
# HMM tools use `query` and `term`; forcing the legacy `prompt`/`limit` shape
# makes an otherwise valid tool fail argument validation.
del preferred_tool
input_name = next(
(name for name in ("prompt", "question", "query", "term", "text") if name in properties),
"",
)
if not input_name:
required = tool.schema.get("required")
if isinstance(required, list):
input_name = next(
(
str(name)
for name in required
if isinstance(properties.get(str(name)), Mapping)
and properties[str(name)].get("type") == "string"
),
"",
)
if input_name:
args: dict[str, Any] = {input_name: question}
if "max_evidence" in properties:
args["max_evidence"] = min(limit, 10)
elif "max_rows" in properties:
args["max_rows"] = limit
elif "limit" in properties:
args["limit"] = limit
return args
return {"prompt": question, "limit": limit}
__all__ = [
"McpTool",
"McpToolRouterError",
"RoutedMcpTool",
"build_mcp_tool_arguments",
"route_mcp_tool_across_servers_with_llm",
"route_mcp_tool_with_llm",
]

View File

@@ -0,0 +1,434 @@
"""8512/8513 전용 AI Web Agent Console model profile registry.
이 registry는 모델 metadata만 관리한다. ``provider=oci``는 모델의 출처를 뜻하며
``AI_WEB_AGENT_CONSOLE_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재
MCP 실행 경로는 계속 ``mock``일 수 있다.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
import json
import os
from pathlib import Path
import re
from typing import Any, Mapping, Optional
ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = ROOT / "config" / "model_profiles.json"
DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci"
MODEL_PROFILE_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE"
MODEL_PROFILE_DEFAULT_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE_DEFAULT"
EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b")
MODEL_PROFILE_ALIASES = {
"gpt54_mini": "gpt54_mini_oci",
"llama33": "llama33_70b",
}
EXPECTED_SOURCE_TAG = "poc_2-gpt55-oci-partial"
EXPECTED_SOURCE_COMMIT = "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f"
_EXPECTED_ANSWER_MODEL_ROUTES = {
"gpt55_oci": (
"openai.gpt-5.5",
"us-chicago-1",
"OPENAI_GPT_5_5_CHAT",
"OCI_REGIONAL_DEFAULT",
),
"gpt54_mini_oci": (
"openai.gpt-5.4-mini",
"us-chicago-1",
"OPENAI_GPT_5_4_MINI_CHAT",
"OCI_REGIONAL_DEFAULT",
),
"grok43": (
"xai.grok-4.3",
"us-chicago-1",
"XAI_GROK_4_3_CHAT",
"OCI_REGIONAL_DEFAULT",
),
"llama4_maverick": (
"meta.llama-4-maverick-17b-128e-instruct-fp8",
"us-chicago-1",
"META_LLAMA_4_MAVERICK_CHAT",
"OCI_REGIONAL_DEFAULT",
),
"llama33_70b": (
"meta.llama-3.3-70b-instruct",
"us-chicago-1",
"META_LLAMA_3_3_70B_CHAT",
"OCI_REGIONAL_DEFAULT",
),
}
_MODEL_KEY = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
_MODEL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
_ANSWER_MODEL_ID_ALIAS = re.compile(r"^[A-Z][A-Z0-9_]{1,127}$")
_OCI_REGION = re.compile(r"^[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*$")
_OCI_REGIONAL_ENDPOINT = re.compile(
r"^https://inference\.generativeai\."
r"(?P<region>[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*)\.oci\.oraclecloud\.com$"
)
_ORACLE_IDENTIFIER = re.compile(r"^[A-Z][A-Z0-9_$#]{0,127}$")
_SOURCE_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$")
_VERIFICATION_STATUSES = frozenset(
{"VERIFIED", "PARTIAL_VERIFIED", "VERIFIED_WITH_WARNINGS"}
)
_REQUIRED_PROFILE_FIELDS = (
"model_key",
"display_name",
"provider",
"model_id",
"answer_model_id_alias",
"answer_model_region",
"answer_model_endpoint_mode",
"poc2_select_ai_profile",
"poc2_native_agent_team",
"verification_status",
"default_for_console",
"source_tag",
"notes",
)
_PROFILE_ROUTE_ENV_KEYS = {
"gpt55_oci": (
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_ENDPOINT",
),
"gpt54_mini_oci": (
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT",
),
"grok43": (
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_ENDPOINT",
),
"llama4_maverick": (
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_ENDPOINT",
),
"llama33_70b": (
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_ENDPOINT",
),
}
def _regional_endpoint(region: str) -> str:
return "https://inference.generativeai.%s.oci.oraclecloud.com" % region
def _endpoint_host_alias(region: str) -> str:
return "OCI_GENAI_INFERENCE_%s" % region.upper().replace("-", "_")
def _env_override(
environ: Mapping[str, str],
key: str,
default: str,
) -> str:
if key not in environ:
return default
value = environ.get(key)
if not isinstance(value, str) or not value.strip():
raise ValueError("answer model route override is invalid")
return value.strip()
@dataclass(frozen=True)
class ModelProfile:
"""UI와 workflow가 공유하는 비밀값 없는 model metadata."""
model_key: str
display_name: str
provider: str
model_id: str
answer_model_id_alias: str
answer_model_region: str
answer_model_endpoint_mode: str
answer_model_endpoint: str = field(repr=False)
poc2_select_ai_profile: str
poc2_native_agent_team: str
verification_status: str
default_for_console: bool
source_tag: str
notes: str
display_order: int = 999
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile":
missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value]
if missing:
raise ValueError("AI Web Agent Console model profile fields are missing")
if not isinstance(value.get("default_for_console"), bool):
raise ValueError("default_for_console must be boolean")
order = value.get("display_order", 999)
if isinstance(order, bool) or not isinstance(order, int) or order < 0:
raise ValueError("model profile display_order is invalid")
answer_model_region = str(value["answer_model_region"]).strip().lower()
profile = cls(
model_key=str(value["model_key"]).strip().lower(),
display_name=str(value["display_name"]).strip(),
provider=str(value["provider"]).strip().lower(),
model_id=str(value["model_id"]).strip(),
answer_model_id_alias=str(value["answer_model_id_alias"])
.strip()
.upper(),
answer_model_region=answer_model_region,
answer_model_endpoint_mode=str(
value["answer_model_endpoint_mode"]
).strip().upper(),
answer_model_endpoint=_regional_endpoint(answer_model_region),
poc2_select_ai_profile=str(value["poc2_select_ai_profile"])
.strip()
.upper(),
poc2_native_agent_team=str(value["poc2_native_agent_team"])
.strip()
.upper(),
verification_status=str(value["verification_status"]).strip().upper(),
default_for_console=value["default_for_console"],
source_tag=str(value["source_tag"]).strip(),
notes=str(value["notes"]).strip(),
display_order=order,
)
if not _MODEL_KEY.fullmatch(profile.model_key):
raise ValueError("model profile key is invalid")
if not profile.display_name or len(profile.display_name) > 128:
raise ValueError("model profile display name is invalid")
if profile.provider != "oci":
raise ValueError("unsupported model provider")
if not _MODEL_ID.fullmatch(profile.model_id):
raise ValueError("model id is invalid")
if not _ANSWER_MODEL_ID_ALIAS.fullmatch(profile.answer_model_id_alias):
raise ValueError("answer model id alias is invalid")
if not _OCI_REGION.fullmatch(profile.answer_model_region):
raise ValueError("answer model region is invalid")
if profile.answer_model_endpoint_mode != "OCI_REGIONAL_DEFAULT":
raise ValueError("answer model endpoint mode is invalid")
endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch(
profile.answer_model_endpoint
)
if (
endpoint_match is None
or endpoint_match.group("region") != profile.answer_model_region
):
raise ValueError("answer model endpoint is invalid")
if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_select_ai_profile):
raise ValueError("PoC_2 Select AI profile mapping is invalid")
if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_native_agent_team):
raise ValueError("PoC_2 Native Agent team mapping is invalid")
if profile.verification_status not in _VERIFICATION_STATUSES:
raise ValueError("model verification status is invalid")
if not _SOURCE_TAG.fullmatch(profile.source_tag):
raise ValueError("model profile source tag is invalid")
if not profile.notes or len(profile.notes) > 1_000:
raise ValueError("model profile notes are invalid")
return profile
def with_answer_route_overrides(
self,
environ: Mapping[str, str],
) -> "ModelProfile":
"""Apply only this profile's validated, non-secret OCI route settings."""
keys = _PROFILE_ROUTE_ENV_KEYS.get(self.model_key)
if keys is None:
raise ValueError("answer model route is not registered")
model_id = _env_override(environ, keys[0], self.model_id)
region = _env_override(environ, keys[1], self.answer_model_region).lower()
endpoint = _env_override(
environ,
keys[2],
_regional_endpoint(region),
)
if not _MODEL_ID.fullmatch(model_id):
raise ValueError("answer model route override is invalid")
if not _OCI_REGION.fullmatch(region):
raise ValueError("answer model route override is invalid")
endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch(endpoint)
if endpoint_match is None or endpoint_match.group("region") != region:
raise ValueError("answer model route override is invalid")
return replace(
self,
model_id=model_id,
answer_model_region=region,
answer_model_endpoint=endpoint,
)
def public_metadata(self) -> dict[str, object]:
"""System Details에 투영 가능한 비밀값 없는 metadata를 반환한다."""
return {
"model_key": self.model_key,
"display_name": self.display_name,
"provider": self.provider,
"answer_model_id_alias": self.answer_model_id_alias,
"answer_model_region": self.answer_model_region,
"answer_model_endpoint_mode": self.answer_model_endpoint_mode,
"answer_model_endpoint_host_alias": _endpoint_host_alias(
self.answer_model_region
),
"poc2_select_ai_profile": self.poc2_select_ai_profile,
"poc2_native_agent_team": self.poc2_native_agent_team,
"verification_status": self.verification_status,
"default_for_console": self.default_for_console,
"source_tag": self.source_tag,
"notes": self.notes,
}
@dataclass(frozen=True)
class ModelProfileRegistry:
"""검증된 AI Web Agent Console model profile 집합."""
profiles: tuple[ModelProfile, ...]
default_model_profile: str
registry_name: str
source_commit: str
schema_version: int = 1
def by_key(self, model_key: object) -> ModelProfile:
candidate = str(model_key or "").strip().lower()
candidate = MODEL_PROFILE_ALIASES.get(candidate, candidate)
for profile in self.profiles:
if profile.model_key == candidate:
return profile
# 사용자 입력이나 환경변수 원문을 오류에 반사하지 않는다.
raise ValueError("model profile is not registered")
@property
def default_profile(self) -> ModelProfile:
return self.by_key(self.default_model_profile)
def selector_options(self) -> tuple[ModelProfile, ...]:
return tuple(sorted(self.profiles, key=lambda item: item.display_order))
def resolve(
self,
requested: object = None,
*,
environ: Optional[Mapping[str, str]] = None,
) -> ModelProfile:
"""명시 요청은 엄격히 검증하고 환경 기본값은 안전하게 fallback한다."""
source = os.environ if environ is None else environ
if requested is not None and str(requested).strip():
return self.by_key(requested).with_answer_route_overrides(source)
for name in (MODEL_PROFILE_ENV, MODEL_PROFILE_DEFAULT_ENV):
candidate = source.get(name)
if not candidate or not candidate.strip():
continue
try:
profile = self.by_key(candidate)
except ValueError:
continue
return profile.with_answer_route_overrides(source)
return self.default_profile.with_answer_route_overrides(source)
def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry:
"""JSON registry를 매 호출마다 검증해 반환한다."""
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("AI Web Agent Console model profile registry cannot be loaded") from exc
if not isinstance(payload, Mapping):
raise ValueError("AI Web Agent Console model profile registry must be an object")
raw_profiles = payload.get("profiles")
if not isinstance(raw_profiles, list) or not raw_profiles:
raise ValueError("AI Web Agent Console model profile registry has no profiles")
profiles = tuple(
ModelProfile.from_mapping(item)
for item in raw_profiles
if isinstance(item, Mapping)
)
if len(profiles) != len(raw_profiles):
raise ValueError("AI Web Agent Console model profile registry contains an invalid profile")
keys = tuple(item.model_key for item in profiles)
if len(set(keys)) != len(keys):
raise ValueError("AI Web Agent Console model profile keys must be unique")
if len({item.display_name for item in profiles}) != len(profiles):
raise ValueError("AI Web Agent Console model profile display names must be unique")
defaults = tuple(item.model_key for item in profiles if item.default_for_console)
configured_default = str(payload.get("default_model_profile") or "").strip().lower()
if defaults != (configured_default,):
raise ValueError("AI Web Agent Console model profile default is inconsistent")
if configured_default != DEFAULT_MODEL_PROFILE_KEY:
raise ValueError("AI Web Agent Console GPT-5.5 default contract is not satisfied")
if not set(EXISTING_MODEL_PROFILE_KEYS).issubset(keys):
raise ValueError("existing AI Web Agent Console selector models are missing")
actual_answer_routes = {
item.model_key: (
item.model_id,
item.answer_model_region,
item.answer_model_id_alias,
item.answer_model_endpoint_mode,
)
for item in profiles
}
if actual_answer_routes != _EXPECTED_ANSWER_MODEL_ROUTES:
raise ValueError("AI Web Agent Console answer model route mapping is inconsistent")
if str(payload.get("source_commit") or "").strip() != EXPECTED_SOURCE_COMMIT:
raise ValueError("PoC_2 source commit is inconsistent")
if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles):
raise ValueError("PoC_2 source tag is inconsistent")
if payload.get("schema_version") != 1:
raise ValueError("unsupported AI Web Agent Console model profile registry schema")
registry_name = str(payload.get("registry_name") or "").strip()
if not registry_name:
raise ValueError("AI Web Agent Console model profile registry name is missing")
return ModelProfileRegistry(
profiles=profiles,
default_model_profile=configured_default,
registry_name=registry_name,
source_commit=EXPECTED_SOURCE_COMMIT,
)
def resolve_model_profile(
requested: object = None,
*,
environ: Optional[Mapping[str, str]] = None,
) -> ModelProfile:
return load_model_registry().resolve(requested, environ=environ)
def resolve_model_profile_key(
requested: object = None,
*,
environ: Optional[Mapping[str, str]] = None,
) -> str:
return resolve_model_profile(requested, environ=environ).model_key
def is_registered_model_profile(value: object) -> bool:
try:
load_model_registry().by_key(value)
except ValueError:
return False
return True
__all__ = [
"DEFAULT_MODEL_PROFILE_KEY",
"EXISTING_MODEL_PROFILE_KEYS",
"EXPECTED_SOURCE_COMMIT",
"EXPECTED_SOURCE_TAG",
"MODEL_PROFILE_DEFAULT_ENV",
"MODEL_PROFILE_ENV",
"MODEL_PROFILE_ALIASES",
"ModelProfile",
"ModelProfileRegistry",
"REGISTRY_PATH",
"is_registered_model_profile",
"load_model_registry",
"resolve_model_profile",
"resolve_model_profile_key",
]

View File

@@ -0,0 +1,274 @@
"""Common OCI Generative AI chat-completion SDK boundary.
This module is intentionally small: callers provide a validated model route
and a JSON schema, and this boundary performs one OCI GenAI chat call. It
does not know about MCP, Streamlit, business payloads, or bearer tokens.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import lru_cache
import os
from pathlib import Path
import re
from typing import Dict, Optional, Protocol
ROOT = Path(__file__).resolve().parents[1]
DOTENV_PATH = ROOT / ".env"
ALLOWED_OCI_SETTINGS = frozenset(
{
"OCI_AUTH_TYPE",
"OCI_CONFIG_FILE",
"OCI_GENAI_COMPARTMENT_ID",
"OCI_PROFILE",
}
)
_COMPARTMENT_ID = re.compile(r"^ocid1\.compartment\.[A-Za-z0-9._-]+$")
class CompletionClient(Protocol):
"""Minimal completion client contract shared by app layers."""
def complete(
self,
system_prompt: str,
user_prompt: str,
response_schema: Mapping[str, object],
max_tokens: int,
temperature: Optional[float],
) -> str:
"""Return the assistant message text."""
def read_allowed_dotenv(path: Optional[Path] = None) -> Dict[str, str]:
"""Read only non-secret OCI routing/auth-mode settings from .env."""
selected_path = DOTENV_PATH if path is None else path
try:
lines = selected_path.read_text(encoding="utf-8").splitlines()
except OSError:
return {}
values: Dict[str, str] = {}
for raw_line in lines:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[7:].lstrip()
key, separator, raw_value = line.partition("=")
key = key.strip()
if not separator or key not in ALLOWED_OCI_SETTINGS:
continue
value = raw_value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
if "\x00" not in value and "\n" not in value and "\r" not in value:
values[key] = value
return values
@dataclass(frozen=True, repr=False)
class OCISettings:
auth_type: str
config_file: str
profile: str
compartment_id: str
def load_oci_settings() -> OCISettings:
"""Resolve OCI GenAI settings from safe .env keys and environment."""
values = read_allowed_dotenv()
for key in ALLOWED_OCI_SETTINGS:
value = os.environ.get(key)
if isinstance(value, str) and value.strip():
values[key] = value.strip()
auth_type = values.get("OCI_AUTH_TYPE", "config_file").strip().casefold()
auth_type = auth_type.replace("-", "_")
if auth_type in {"api_key", "config", "config_file"}:
auth_type = "config_file"
elif auth_type not in {"instance_principal", "resource_principal"}:
raise ValueError("unsupported OCI authentication mode")
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
if not _COMPARTMENT_ID.fullmatch(compartment_id):
raise ValueError("OCI Generative AI compartment is not configured")
return OCISettings(
auth_type=auth_type,
config_file=values.get("OCI_CONFIG_FILE", "~/.oci/config").strip(),
profile=values.get("OCI_PROFILE", "DEFAULT").strip() or "DEFAULT",
compartment_id=compartment_id,
)
class OCICompletionClient:
"""Minimal OCI GenericChatRequest adapter."""
def __init__(
self,
settings: OCISettings,
model_id: str,
region: str,
endpoint: str,
) -> None:
try:
import oci
from oci.generative_ai_inference import GenerativeAiInferenceClient
except (ImportError, AttributeError):
raise RuntimeError("OCI SDK is unavailable") from None
kwargs: Dict[str, object] = {}
if settings.auth_type == "config_file":
try:
config = oci.config.from_file(
file_location=os.path.expandvars(
os.path.expanduser(settings.config_file)
),
profile_name=settings.profile,
)
except Exception:
raise RuntimeError("OCI SDK configuration is unavailable") from None
config["region"] = region
elif settings.auth_type == "instance_principal":
try:
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
except Exception:
raise RuntimeError("OCI signer is unavailable") from None
config = {"region": region}
kwargs["signer"] = signer
else:
try:
signer = oci.auth.signers.get_resource_principals_signer()
except Exception:
raise RuntimeError("OCI signer is unavailable") from None
config = {"region": region}
kwargs["signer"] = signer
if not config.get("region"):
raise RuntimeError("OCI region is unavailable")
kwargs["service_endpoint"] = endpoint
try:
self._client = GenerativeAiInferenceClient(config, **kwargs)
except Exception:
raise RuntimeError("OCI Generative AI client is unavailable") from None
self._compartment_id = settings.compartment_id
self._model_id = model_id
def complete(
self,
system_prompt: str,
user_prompt: str,
response_schema: Mapping[str, object],
max_tokens: int,
temperature: Optional[float],
) -> str:
try:
from oci.generative_ai_inference.models import (
ChatDetails,
GenericChatRequest,
JsonSchemaResponseFormat,
OnDemandServingMode,
ResponseJsonSchema,
SystemMessage,
TextContent,
UserMessage,
)
schema = ResponseJsonSchema(
name="oci_genai_json_response",
description="Strict JSON response",
schema=dict(response_schema),
is_strict=True,
)
request_options: Dict[str, object] = {
"api_format": "GENERIC",
"messages": [
SystemMessage(content=[TextContent(text=system_prompt)]),
UserMessage(content=[TextContent(text=user_prompt)]),
],
"max_completion_tokens": max_tokens,
"is_stream": False,
"response_format": JsonSchemaResponseFormat(json_schema=schema),
}
if temperature is not None:
request_options["temperature"] = temperature
request = GenericChatRequest(**request_options)
details = ChatDetails(
compartment_id=self._compartment_id,
serving_mode=OnDemandServingMode(model_id=self._model_id),
chat_request=request,
)
response = self._client.chat(details)
data = getattr(response, "data", None)
chat_response = getattr(data, "chat_response", None)
choices = getattr(chat_response, "choices", None)
if not isinstance(choices, Sequence) or not choices:
raise RuntimeError("OCI response has no choice")
message = getattr(choices[0], "message", None)
content = getattr(message, "content", None)
if not isinstance(content, Sequence) or isinstance(
content, (str, bytes, bytearray)
) or not content:
raise RuntimeError("OCI response has no content")
text = getattr(content[0], "text", None)
if not isinstance(text, str):
raise RuntimeError("OCI response content is invalid")
return text
except Exception:
raise RuntimeError("OCI GenAI completion call failed") from None
def build_oci_genai_completion_client(
model_id: str,
region: str,
endpoint: str,
) -> CompletionClient:
"""Build a completion client for one validated model route."""
return _cached_oci_genai_completion_client(
load_oci_settings(),
model_id,
region,
endpoint,
)
@lru_cache(maxsize=16)
def _cached_oci_genai_completion_client(
settings: OCISettings,
model_id: str,
region: str,
endpoint: str,
) -> CompletionClient:
"""Reuse OCI GenAI clients within one Python process."""
return OCICompletionClient(settings, model_id, region, endpoint)
def temperature_for_model_key(model_key: object) -> Optional[float]:
"""Return provider-compatible temperature for one registered model key."""
key = str(model_key or "").strip().lower()
if key.startswith("gpt"):
return None
return 0.1
def temperature_for_model_profile(profile: object) -> Optional[float]:
return temperature_for_model_key(getattr(profile, "model_key", profile))
__all__ = [
"ALLOWED_OCI_SETTINGS",
"CompletionClient",
"OCICompletionClient",
"OCISettings",
"build_oci_genai_completion_client",
"load_oci_settings",
"read_allowed_dotenv",
"temperature_for_model_key",
"temperature_for_model_profile",
]

View File

@@ -0,0 +1,124 @@
"""Shared, intentionally small Streamlit presentation primitives."""
from __future__ import annotations
from html import escape
from typing import Any
from .profile import AppProfile
def apply_console_theme(st: Any, profile: AppProfile) -> None:
"""Apply one predictable light theme from the product profile."""
st.markdown(
f"""
<style>
:root {{ color-scheme: light !important; --console-primary: {escape(profile.primary_color)};
--console-text: {escape(profile.text_color)}; --console-muted: {escape(profile.muted_color)};
--console-border: {escape(profile.border_color)}; }}
html, body, [data-testid="stAppViewContainer"], .stApp {{ background:#fff !important;
color:var(--console-text) !important; color-scheme:light !important;
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
header[data-testid="stHeader"] {{ display:none !important; }}
.block-container {{ max-width:1180px; padding:2rem 3rem 4rem; }}
section[data-testid="stSidebar"], section[data-testid="stSidebar"] > div {{ background:#fff !important; }}
section[data-testid="stSidebar"] {{ border-right:1px solid var(--console-border); }}
[data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span,
[data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1,
[data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3,
[data-testid="stAppViewContainer"] li, [data-testid="stAppViewContainer"] dt,
[data-testid="stAppViewContainer"] dd, [data-testid="stAppViewContainer"] blockquote,
[data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea,
section[data-testid="stSidebar"] * {{ color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
background:#fff !important; border:1px solid var(--console-border) !important;
border-radius:4px !important; box-shadow:none !important; }}
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; }}
.console-muted {{ color:var(--console-muted) !important; }}
.st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }}
.console-login {{ text-align:left; }}
.console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }}
a.console-logout-button {{ display:block; width:100%; padding:.55rem .8rem;
margin:.25rem 0 .75rem; background:#fff; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important;
border:1px solid var(--console-border); border-radius:4px;
text-align:center; text-decoration:none; font-weight:700; }}
a.console-logout-button:hover {{ background:#f6f8fa; }}
@media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }}
</style>
""",
unsafe_allow_html=True,
)
def render_console_header(st: Any, profile: AppProfile) -> None:
st.markdown(
f"""<section class="console-header"><div class="console-wordmark">{escape(profile.short_name)}</div>
<h1>{escape(profile.header_title)}</h1><p class="console-muted">{escape(profile.header_description)}</p></section>""",
unsafe_allow_html=True,
)
def render_login_brand(st: Any, profile: AppProfile) -> None:
st.markdown(
f"""<section class="console-login"><div class="console-wordmark">{escape(profile.short_name)}</div>
<p class="console-muted">{escape(profile.login_kicker)}</p><h1>{escape(profile.login_title)}</h1>
<p class="console-muted">{escape(profile.login_description)}</p></section>""",
unsafe_allow_html=True,
)

View File

@@ -0,0 +1,139 @@
"""Configuration-backed product profile for a reusable agent console."""
from __future__ import annotations
from dataclasses import dataclass
import json
import os
from pathlib import Path
from typing import Any, Mapping
class AppProfileError(RuntimeError):
"""Safe error for an invalid product profile."""
@dataclass(frozen=True)
class AppProfile:
product_name: str
short_name: str
page_title: str
page_icon: str
header_title: str
header_description: str
login_kicker: str
login_title: str
login_description: str
login_footer: str
primary_color: str
text_color: str
muted_color: str
border_color: str
_ENV_FIELD_NAMES = {
"product_name": "AGENT_CONSOLE_NAME",
"short_name": "AGENT_CONSOLE_SHORT_NAME",
"page_title": "AGENT_CONSOLE_PAGE_TITLE",
"page_icon": "AGENT_CONSOLE_PAGE_ICON",
"header_title": "AGENT_CONSOLE_HEADER_TITLE",
"header_description": "AGENT_CONSOLE_HEADER_DESCRIPTION",
"login_kicker": "AGENT_CONSOLE_LOGIN_KICKER",
"login_title": "AGENT_CONSOLE_LOGIN_TITLE",
"login_description": "AGENT_CONSOLE_LOGIN_DESCRIPTION",
"login_footer": "AGENT_CONSOLE_LOGIN_FOOTER",
"primary_color": "AGENT_CONSOLE_PRIMARY_COLOR",
"text_color": "AGENT_CONSOLE_TEXT_COLOR",
"muted_color": "AGENT_CONSOLE_MUTED_COLOR",
"border_color": "AGENT_CONSOLE_BORDER_COLOR",
}
def _string(section: Mapping[str, Any], key: str, fallback: str = "") -> str:
return str(section.get(key) or fallback).strip()
def resolve_profile_path(default_path: Path) -> Path:
configured = os.environ.get("AGENT_CONSOLE_PROFILE_PATH", "").strip()
if not configured:
return default_path
path = Path(configured).expanduser()
return path if path.is_absolute() else default_path.parent / path
def _dotenv_value(name: str, path: Path | None) -> str:
"""Read one simple KEY=value runtime setting without importing a dotenv lib."""
if path is None:
return ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return ""
prefix = f"{name}="
for line in lines:
stripped = line.strip()
if stripped.startswith(prefix):
return stripped[len(prefix) :].strip().strip('"').strip("'")
return ""
def _apply_environment_overrides(profile: AppProfile, env_file: Path | None) -> AppProfile:
"""Apply deployment-specific presentation values without a code change."""
values = {
field_name: (
os.environ.get(env_name, "").strip()
or _dotenv_value(env_name, env_file)
or getattr(profile, field_name)
)
for field_name, env_name in _ENV_FIELD_NAMES.items()
}
return AppProfile(**values)
def load_app_profile(default_path: Path, env_file: Path | None = None) -> AppProfile:
"""Load the selectable product skin without coupling it to a PoC name."""
path = resolve_profile_path(default_path)
try:
payload: Any = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, ValueError):
raise AppProfileError(f"애플리케이션 프로필을 읽지 못했습니다: {path}") from None
if not isinstance(payload, Mapping):
raise AppProfileError("애플리케이션 프로필 형식이 올바르지 않습니다.")
product = payload.get("product")
theme = payload.get("theme")
if not isinstance(product, Mapping) or not isinstance(theme, Mapping):
raise AppProfileError("애플리케이션 프로필에 product와 theme 객체가 필요합니다.")
profile = _apply_environment_overrides(AppProfile(
product_name=_string(product, "name"),
short_name=_string(product, "short_name"),
page_title=_string(product, "page_title"),
page_icon=_string(product, "page_icon", "🤖"),
header_title=_string(product, "header_title"),
header_description=_string(product, "header_description"),
login_kicker=_string(product, "login_kicker"),
login_title=_string(product, "login_title"),
login_description=_string(product, "login_description"),
login_footer=_string(product, "login_footer"),
primary_color=_string(theme, "primary_color"),
text_color=_string(theme, "text_color"),
muted_color=_string(theme, "muted_color"),
border_color=_string(theme, "border_color"),
), env_file)
required = (
profile.product_name,
profile.short_name,
profile.page_title,
profile.header_title,
profile.login_title,
profile.primary_color,
profile.text_color,
profile.muted_color,
profile.border_color,
)
if not all(required):
raise AppProfileError("애플리케이션 프로필의 필수 표시값 또는 색상이 비어 있습니다.")
return profile

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

@@ -0,0 +1,264 @@
"""AI Web Agent Console preset catalog와 현재 질문 기반의 결정적 intent router."""
from __future__ import annotations
from dataclasses import dataclass
import re
import unicodedata
QUESTION_CATEGORIES = ("STRUCTURED", "RAG", "HYBRID")
GENERIC_RAG_QUESTION_ID = "R0"
MAX_QUESTION_CHARS = 2_000
@dataclass(frozen=True)
class DemoQuestion:
"""ID와 분류가 고정된 데모 질문."""
question_id: str
category: str
text: str
def __post_init__(self) -> None:
if self.category not in QUESTION_CATEGORIES:
raise ValueError("unsupported demo question category")
if not self.question_id or not self.text.strip():
raise ValueError("demo question id/text is required")
@dataclass(frozen=True)
class ResolvedQuestionIntent:
"""현재 질문만으로 결정된 실행 intent.
``question_id``는 local route/fixture를 설명하는 분류 label이다. 8500 provider
입력으로 전달되지 않으며 UI에서 선택한 scenario ID도 이 모델에 들어오지
않는다.
"""
question_id: str
category: str
def __post_init__(self) -> None:
if self.category not in QUESTION_CATEGORIES:
raise ValueError("unsupported resolved question category")
if self.question_id not in {
GENERIC_RAG_QUESTION_ID,
"S1",
"S2",
"S3",
"S4",
"S5",
"R1",
"R2",
"H1",
"H2",
"H3",
"H4",
}:
raise ValueError("unsupported resolved question id")
COMMON_DEMO_QUESTIONS = (
DemoQuestion("S1", "STRUCTURED", "상품별 계약 건수를 보여줘."),
DemoQuestion("S2", "STRUCTURED", "총 지급보험금이 가장 큰 상품은?"),
DemoQuestion(
"S3",
"STRUCTURED",
"숫자로 계산 가능한 평균 보장금액이 높은 상품 10개를 보여줘.",
),
DemoQuestion("S4", "STRUCTURED", "고객 등급별 평균 보험료를 보여줘."),
DemoQuestion("S5", "STRUCTURED", "이해관계자 역할별 인원 수를 보여줘."),
DemoQuestion("R1", "RAG", "자동차보험 약관의 면책 사항은?"),
DemoQuestion("R2", "RAG", "보험금 청구 시 필요한 서류는?"),
DemoQuestion(
"H1",
"HYBRID",
"보험금이 가장 큰 상품의 주요 면책 조항을 알려줘.",
),
DemoQuestion(
"H2",
"HYBRID",
"청구가 많은 상품군의 보장 제외 조건을 알려줘.",
),
DemoQuestion(
"H3",
"HYBRID",
"고객 등급별 보험료 수준을 보고, 관련 약관상 유의해야 할 보장 제외 조건도 함께 알려줘.",
),
)
_QUESTION_BY_ID = {item.question_id: item for item in COMMON_DEMO_QUESTIONS}
if len(_QUESTION_BY_ID) != len(COMMON_DEMO_QUESTIONS):
raise RuntimeError("duplicate AI Web Agent Console demo question id")
def question_by_id(question_id: str) -> DemoQuestion:
"""정규화된 ID로 질문을 찾되, 알 수 없는 ID는 거부한다."""
normalized = str(question_id).strip().upper()
try:
return _QUESTION_BY_ID[normalized]
except KeyError:
raise ValueError("unknown AI Web Agent Console demo question id") from None
def normalize_scenario_id(value: object) -> str | None:
"""선택적인 preset ID를 정규화하되 실행 routing에는 관여하지 않는다."""
if value is None:
return None
if not isinstance(value, str):
raise ValueError("scenario id must be a string")
normalized = value.strip().upper()
if not normalized:
return None
question_by_id(normalized)
return normalized
def _normalized_question(question: object) -> tuple[str, str]:
if not isinstance(question, str):
raise ValueError("question must be a non-empty string")
if len(question) > MAX_QUESTION_CHARS:
raise ValueError("question exceeds the supported length")
normalized = re.sub(
r"\s+", " ", unicodedata.normalize("NFKC", question).strip().lower()
)
if not normalized:
raise ValueError("question must be a non-empty string")
return normalized, normalized.replace(" ", "")
def _contains_any(text: str, candidates: tuple[str, ...]) -> bool:
return any(candidate in text for candidate in candidates)
def resolve_question_intent(question: object) -> ResolvedQuestionIntent:
"""현재 질문 텍스트만으로 S/R/H route intent를 결정한다.
명확한 structured/hybrid intent에 해당하지 않는 질문은 임의로 추측하지 않고
``R0`` generic RAG 검색으로 보낸다. 이 함수는 scenario 또는
question ID hint를 받지 않으므로 preset metadata가 실행을 바꿀 수 없다.
"""
normalized, compact = _normalized_question(question)
# Canonical preset은 기존 10문항 동작을 byte-for-byte 보존한다.
for item in COMMON_DEMO_QUESTIONS:
candidate, _ = _normalized_question(item.text)
if normalized == candidate:
return ResolvedQuestionIntent(item.question_id, item.category)
has_product = _contains_any(compact, ("상품", "상품군"))
has_exclusion = _contains_any(
compact,
("면책", "보장제외", "제외조건", "보상하지않", "약관상유의"),
)
has_top = _contains_any(
compact, ("가장큰", "최대", "최고", "1위", "상위", "제일많", "높은")
)
# 자유 질문에서 자동차보험 보유 규모와 타사 대비 강점을 함께 요구하면
# generic structured 조회와 자유 evidence 검색을 로컬에서 합성하는 H4로
# 보낸다. Label은 routing metadata일 뿐 provider query ID가 아니다.
has_auto_insurance = "자동차보험" in compact
has_count = _contains_any(
compact,
(
"갯수",
"개수",
"건수",
"계약수",
"몇개",
"몇건",
"보유수",
"상품수",
),
)
has_competitor_comparison = _contains_any(
compact, ("타사", "경쟁사", "다른회사", "타보험사")
) and _contains_any(compact, ("강점", "장점", "차별", "우위", "비교"))
if has_auto_insurance and has_count and has_competitor_comparison:
return ResolvedQuestionIntent("H4", "HYBRID")
# Hybrid를 먼저 판별해 정형 키워드가 포함된 복합 질문이 S/R 단일 route로
# 축소되지 않도록 한다.
if (
_contains_any(compact, ("고객등급", "등급별"))
and "보험료" in compact
and has_exclusion
):
return ResolvedQuestionIntent("H3", "HYBRID")
if (
"청구" in compact
and _contains_any(compact, ("많은", "빈도", "건수", "상위"))
and has_product
and has_exclusion
):
return ResolvedQuestionIntent("H2", "HYBRID")
if (
has_product
and _contains_any(compact, ("지급보험금", "보험금"))
and has_top
and has_exclusion
):
return ResolvedQuestionIntent("H1", "HYBRID")
if (
has_product
and "계약" in compact
and _contains_any(compact, ("건수", "계약수", "몇건", "집계"))
):
return ResolvedQuestionIntent("S1", "STRUCTURED")
if (
has_product
and _contains_any(compact, ("지급보험금", "보험금총액", "총보험금"))
and has_top
):
return ResolvedQuestionIntent("S2", "STRUCTURED")
if (
has_product
and _contains_any(compact, ("보장금액", "가입금액"))
and "평균" in compact
and has_top
):
return ResolvedQuestionIntent("S3", "STRUCTURED")
if (
_contains_any(compact, ("고객등급", "등급별"))
and "보험료" in compact
and "평균" in compact
):
return ResolvedQuestionIntent("S4", "STRUCTURED")
if (
"이해관계자" in compact
and "역할" in compact
and _contains_any(compact, ("인원", "사람수", "몇명", "명수", ""))
):
return ResolvedQuestionIntent("S5", "STRUCTURED")
if (
"자동차보험" in compact
and _contains_any(compact, ("면책", "보상하지않", "보장제외", "제외사항"))
):
return ResolvedQuestionIntent("R1", "RAG")
if (
_contains_any(compact, ("보험금", "청구"))
and _contains_any(compact, ("서류", "문서", "증빙", "제출자료"))
):
return ResolvedQuestionIntent("R2", "RAG")
return ResolvedQuestionIntent(GENERIC_RAG_QUESTION_ID, "RAG")
__all__ = [
"COMMON_DEMO_QUESTIONS",
"DemoQuestion",
"GENERIC_RAG_QUESTION_ID",
"MAX_QUESTION_CHARS",
"QUESTION_CATEGORIES",
"ResolvedQuestionIntent",
"normalize_scenario_id",
"question_by_id",
"resolve_question_intent",
]

View File

@@ -0,0 +1,71 @@
"""File-backed demo scenarios used by the PoC4 Streamlit screen.
Scenario content is deliberately configuration, not executable routing policy.
Changing this JSON changes only the menu shown to a demo user.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
from typing import Any, Mapping
class ScenarioConfigError(RuntimeError):
"""A safe, user-facing error for invalid scenario configuration."""
@dataclass(frozen=True)
class DemoScenario:
scenario_id: str
category: str
title: str
question: str
# Compatibility aliases keep the Streamlit rendering independent from the
# storage field names and make a future scenario source interchangeable.
@property
def question_id(self) -> str:
return self.scenario_id
@property
def text(self) -> str:
return self.question
def load_demo_scenarios(path: Path) -> tuple[DemoScenario, ...]:
"""Read enabled scenarios and reject malformed or duplicated entries."""
try:
payload: Any = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, ValueError):
raise ScenarioConfigError(f"질문 시나리오 설정을 읽지 못했습니다: {path}") from None
raw_scenarios = payload.get("scenarios") if isinstance(payload, Mapping) else None
if not isinstance(raw_scenarios, list):
raise ScenarioConfigError("질문 시나리오 설정에 scenarios 배열이 필요합니다.")
scenarios: list[DemoScenario] = []
seen_ids: set[str] = set()
for raw in raw_scenarios:
if not isinstance(raw, Mapping) or raw.get("enabled", True) is not True:
continue
scenario_id = str(raw.get("id") or "").strip().upper()
category = str(raw.get("category") or "일반").strip()
title = str(raw.get("title") or "").strip()
question = str(raw.get("question") or "").strip()
if not scenario_id or not title or not question:
raise ScenarioConfigError("각 질문 시나리오에는 id, title, question이 필요합니다.")
if scenario_id in seen_ids:
raise ScenarioConfigError(f"중복된 질문 시나리오 ID입니다: {scenario_id}")
scenarios.append(
DemoScenario(
scenario_id=scenario_id,
category=category,
title=title,
question=question,
)
)
seen_ids.add(scenario_id)
return tuple(scenarios)

5904
ai-web-agent-console/app.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"version": 1,
"product": {
"name": "AI 업무 에이전트",
"short_name": "AGENT",
"page_title": "AI 업무 에이전트",
"page_icon": "🤖",
"header_title": "AI 업무 에이전트",
"header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.",
"login_kicker": "DATA & AI DEMO",
"login_title": "AI 업무 에이전트",
"login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.",
"login_footer": "인증된 DEMO 사용자만 접근할 수 있습니다."
},
"theme": {
"primary_color": "#003b70",
"text_color": "#172b3a",
"muted_color": "#667785",
"border_color": "#dfe7ed"
}
}

View File

@@ -0,0 +1,62 @@
{
"version": 1,
"description": "HMM AI 업무 에이전트 화면에 표시할 데모 질문 목록입니다. 이 파일을 수정하면 재배포 후 질문 메뉴에 반영됩니다.",
"scenarios": [
{
"id": "HR-01",
"enabled": true,
"category": "휴가 규정",
"title": "연차 이월 기준",
"question": "연차 휴가 이월 기준과 제한을 알려줘"
},
{
"id": "HR-02",
"enabled": true,
"category": "휴가 규정",
"title": "입사일 기준 연차",
"question": "입사일 기준으로 연차가 언제 발생하는지 알려줘"
},
{
"id": "HR-03",
"enabled": true,
"category": "근태 용어",
"title": "휴가 명칭 표준화",
"question": "반차와 반일 휴가의 표준 근태 용어를 알려줘"
},
{
"id": "HR-04",
"enabled": true,
"category": "조직·인력",
"title": "팀 구성 조회",
"question": "팀별 인원과 매니저를 보여줘"
},
{
"id": "HR-05",
"enabled": true,
"category": "근태 현황",
"title": "휴가 현황 조회",
"question": "이번 달 팀원별 휴가 사용 현황을 보여줘"
},
{
"id": "FED-01",
"enabled": true,
"category": "선사 실적 Federation",
"title": "팀원별 담당 선사 최신 실적",
"question": "E1001 팀장의 팀원별 담당 선사와 해당 선사의 최신 매출, 매출총이익, 정시 운항률, 위험 등급을 보여줘"
},
{
"id": "FED-02",
"enabled": true,
"category": "선사 실적 Federation",
"title": "위험 선사와 담당자",
"question": "E1001 팀에서 최신 위험 등급이 RED인 선사를 담당하는 직원, 선사명, 매출, 정시 운항률과 클레임 발생률을 보여줘"
},
{
"id": "FED-03",
"enabled": true,
"category": "선사 실적 Federation",
"title": "담당 선사 월별 추이",
"question": "E1006 직원이 담당하는 선사의 월별 매출, 운송 물동량, 매출총이익과 위험 등급을 기준월 순서로 보여줘"
}
]
}

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

@@ -0,0 +1,23 @@
{
"default_server_id": "hmm_hr_mcp",
"servers": [
{
"id": "hmm_hr_mcp",
"enabled": true,
"provider": "hmm_compat_mcp",
"transport": "http",
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
"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_carrier_performance"
],
"description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server"
}
]
}

View File

@@ -0,0 +1,88 @@
{
"schema_version": 1,
"registry_name": "AI_WEB_AGENT_CONSOLE_MODEL_PROFILES",
"default_model_profile": "gpt55_oci",
"source_commit": "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f",
"profiles": [
{
"model_key": "gpt55_oci",
"display_name": "GPT-5.5 (OCI GenAI)",
"provider": "oci",
"model_id": "openai.gpt-5.5",
"answer_model_id_alias": "OPENAI_GPT_5_5_CHAT",
"answer_model_region": "us-chicago-1",
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2",
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT55_OCI_V2",
"verification_status": "VERIFIED_WITH_WARNINGS",
"default_for_console": true,
"source_tag": "poc_2-gpt55-oci-partial",
"notes": "PoC_2 Select AI S1~S5와 승인된 read-only 실행은 5/5 PASS. 8503 Native Agent는 S1/S2/R1/H1 PASS이나 S3 safe-conversion marker 미확인 WARN으로 PARTIAL이다.",
"display_order": 0
},
{
"model_key": "gpt54_mini_oci",
"display_name": "GPT-5.4 Mini (OCI GenAI)",
"provider": "oci",
"model_id": "openai.gpt-5.4-mini",
"answer_model_id_alias": "OPENAI_GPT_5_4_MINI_CHAT",
"answer_model_region": "us-chicago-1",
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT54_MINI_OCI_PROFILE_V1",
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT54_MINI_OCI_V1",
"verification_status": "PARTIAL_VERIFIED",
"default_for_console": false,
"source_tag": "poc_2-gpt55-oci-partial",
"notes": "PoC4 MCP 실행 방식(single/agent) 판단용 경량 planner profile. Select AI/Agent Team 본 처리 기본값은 gpt55_oci를 유지한다.",
"display_order": 1
},
{
"model_key": "grok43",
"display_name": "Grok 4.3",
"provider": "oci",
"model_id": "xai.grok-4.3",
"answer_model_id_alias": "XAI_GROK_4_3_CHAT",
"answer_model_region": "us-chicago-1",
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_GROK43_PROFILE_V2",
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GROK43_V3",
"verification_status": "VERIFIED",
"default_for_console": false,
"source_tag": "poc_2-gpt55-oci-partial",
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
"display_order": 2
},
{
"model_key": "llama4_maverick",
"display_name": "Llama 4 Maverick",
"provider": "oci",
"model_id": "meta.llama-4-maverick-17b-128e-instruct-fp8",
"answer_model_id_alias": "META_LLAMA_4_MAVERICK_CHAT",
"answer_model_region": "us-chicago-1",
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA4_MAVERICK_PROFILE_V2",
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA4_MAVERICK_V3",
"verification_status": "VERIFIED",
"default_for_console": false,
"source_tag": "poc_2-gpt55-oci-partial",
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
"display_order": 3
},
{
"model_key": "llama33_70b",
"display_name": "Llama 3.3 70B",
"provider": "oci",
"model_id": "meta.llama-3.3-70b-instruct",
"answer_model_id_alias": "META_LLAMA_3_3_70B_CHAT",
"answer_model_region": "us-chicago-1",
"answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT",
"poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA33_PROFILE_V2",
"poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA33_V3",
"verification_status": "VERIFIED",
"default_for_console": false,
"source_tag": "poc_2-gpt55-oci-partial",
"notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.",
"display_order": 4
}
]
}

View 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일 연차 신청과 휴가 근태를 확인하는 팀원 데모"
}
]
}

View File

@@ -0,0 +1,4 @@
-r requirements.txt
fastmcp==3.4.3
langgraph>=1.2,<2
oci>=2.180,<3

View File

@@ -0,0 +1,5 @@
openpyxl>=3.1,<4
oci>=2.180,<3
oracledb>=2,<4
pandas>=2,<3
streamlit>=1.35,<2

View File

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

View File

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

View File

@@ -0,0 +1,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()