1
ai-web-agent-console/ai_web_agent_console/__init__.py
Normal file
1
ai-web-agent-console/ai_web_agent_console/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable presentation shell for MCP-backed Streamlit agent consoles."""
|
||||
190
ai-web-agent-console/ai_web_agent_console/audit.py
Normal file
190
ai-web-agent-console/ai_web_agent_console/audit.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Reusable audit-tab renderer with data loaders supplied by the application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
AuditInventoryLoader = Callable[[], list[dict[str, Any]]]
|
||||
AuditEventsLoader = Callable[[int, int, str, str], list[dict[str, Any]]]
|
||||
|
||||
|
||||
def render_hmm_audit_tab(
|
||||
st: Any,
|
||||
inventory_loader: AuditInventoryLoader,
|
||||
events_loader: AuditEventsLoader,
|
||||
error_type: type[Exception],
|
||||
) -> None:
|
||||
"""Render HMM access audit data without owning DB connection details."""
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||
'감사로그 ( <strong>HMM 접근 관리</strong> )'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
st.markdown(
|
||||
'<div class="kb-audit-lead">'
|
||||
'HMM 백오피스의 사용자·그룹·역할·토큰·접근 정책 변경 이력을 시간순으로 확인합니다. '
|
||||
'이벤트 유형과 처리 상태로 필터링해 운영 변경의 성공·실패를 추적할 수 있습니다.'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
try:
|
||||
inventory = inventory_loader()
|
||||
except error_type as exc:
|
||||
st.error(str(exc))
|
||||
return
|
||||
|
||||
event_types = tuple(
|
||||
str(item.get("event_type") or "").strip()
|
||||
for item in inventory
|
||||
if str(item.get("event_type") or "").strip()
|
||||
)
|
||||
st.markdown('<div class="kb-audit-heading">조회 조건</div>', unsafe_allow_html=True)
|
||||
with st.container(key="poc4_hmm_audit_filters"):
|
||||
event_column, status_column = st.columns(2)
|
||||
with event_column:
|
||||
selected_event_type = st.selectbox(
|
||||
"이벤트 유형",
|
||||
options=("", *event_types),
|
||||
format_func=lambda value: "전체 이벤트" if not value else value,
|
||||
key="poc4_hmm_audit_event_filter",
|
||||
)
|
||||
with status_column:
|
||||
selected_status = st.selectbox(
|
||||
"처리 상태",
|
||||
options=("", "SUCCESS", "FAILURE", "DENIED"),
|
||||
format_func=lambda value: "전체 상태" if not value else value,
|
||||
key="poc4_hmm_audit_status_filter",
|
||||
)
|
||||
days_column, limit_column, refresh_column = st.columns([1.5, 1, 0.8])
|
||||
with days_column:
|
||||
days = st.slider(
|
||||
"조회 기간",
|
||||
min_value=1,
|
||||
max_value=90,
|
||||
value=7,
|
||||
format="%d일",
|
||||
key="poc4_hmm_audit_days",
|
||||
)
|
||||
with limit_column:
|
||||
row_limit = st.number_input(
|
||||
"최대 건수",
|
||||
min_value=10,
|
||||
max_value=500,
|
||||
value=100,
|
||||
step=10,
|
||||
key="poc4_hmm_audit_row_limit",
|
||||
)
|
||||
with refresh_column:
|
||||
st.markdown('<div style="height: 28px"></div>', unsafe_allow_html=True)
|
||||
if st.button(
|
||||
"새로고침",
|
||||
icon=":material/refresh:",
|
||||
width="stretch",
|
||||
key="poc4_hmm_audit_refresh",
|
||||
):
|
||||
inventory_loader.clear()
|
||||
events_loader.clear()
|
||||
st.rerun()
|
||||
|
||||
try:
|
||||
events = events_loader(
|
||||
int(days), int(row_limit), selected_event_type, selected_status
|
||||
)
|
||||
except error_type as exc:
|
||||
st.error(str(exc))
|
||||
return
|
||||
|
||||
success_count = sum(
|
||||
1 for item in events if str(item.get("status") or "").upper() == "SUCCESS"
|
||||
)
|
||||
with st.container(key="poc4_hmm_audit_metrics"):
|
||||
type_metric, event_metric, success_metric, failure_metric = st.columns(4)
|
||||
type_metric.metric("이벤트 유형", len(inventory))
|
||||
event_metric.metric("조회 이벤트", len(events))
|
||||
success_metric.metric("성공", success_count)
|
||||
failure_metric.metric("실패·거부", len(events) - success_count)
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-audit-heading">이벤트 유형 현황</div>'
|
||||
f'<div class="kb-audit-caption">현재 기록된 이벤트 유형 {len(inventory)}개 · '
|
||||
'유형별 누적 건수와 최근 발생 시각을 확인합니다.</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
with st.container(key="poc4_hmm_audit_inventory_panel"):
|
||||
with st.expander("감사 이벤트 유형", expanded=True):
|
||||
if inventory:
|
||||
st.dataframe(
|
||||
[
|
||||
{
|
||||
"이벤트 유형": str(item.get("event_type") or ""),
|
||||
"누적 건수": int(item.get("event_count") or 0),
|
||||
"최근 발생(KST)": str(item.get("latest_event_time") or ""),
|
||||
}
|
||||
for item in inventory
|
||||
],
|
||||
column_config={
|
||||
"이벤트 유형": st.column_config.TextColumn(width="large"),
|
||||
"누적 건수": st.column_config.NumberColumn(width="small"),
|
||||
"최근 발생(KST)": st.column_config.TextColumn(width="medium"),
|
||||
},
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
height=min(360, 72 + 36 * len(inventory)),
|
||||
)
|
||||
else:
|
||||
st.caption("아직 기록된 HMM 접근 관리 이벤트가 없습니다.")
|
||||
|
||||
st.markdown(
|
||||
'<div class="kb-audit-heading">감사 이벤트</div>'
|
||||
'<div class="kb-audit-caption">최신 이벤트부터 표시합니다. '
|
||||
'처리 상태와 대상 식별자, 오류 메시지를 먼저 확인하세요.</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
show_details = st.toggle(
|
||||
"상세 메시지 표시",
|
||||
value=True,
|
||||
key="poc4_hmm_audit_show_details",
|
||||
)
|
||||
if not events:
|
||||
st.info("선택한 조건에 해당하는 HMM 접근 관리 이벤트가 없습니다.")
|
||||
return
|
||||
|
||||
display_rows: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
row: dict[str, Any] = {
|
||||
"감사 ID": int(event.get("audit_id") or 0),
|
||||
"발생시각(KST)": str(event.get("event_time") or ""),
|
||||
"이벤트 유형": str(event.get("event_type") or ""),
|
||||
"상태": str(event.get("status") or ""),
|
||||
"토큰 Key ID": event.get("key_id"),
|
||||
"대상 Object ID": event.get("object_id"),
|
||||
"처리 행": event.get("row_count"),
|
||||
"오류 코드": str(event.get("error_code") or ""),
|
||||
}
|
||||
if show_details:
|
||||
row["메시지"] = str(event.get("message") or "")
|
||||
display_rows.append(row)
|
||||
|
||||
column_config: dict[str, Any] = {
|
||||
"감사 ID": st.column_config.NumberColumn(width="small"),
|
||||
"발생시각(KST)": st.column_config.TextColumn(width="medium"),
|
||||
"이벤트 유형": st.column_config.TextColumn(width="large"),
|
||||
"상태": st.column_config.TextColumn(width="small"),
|
||||
"토큰 Key ID": st.column_config.NumberColumn(width="small"),
|
||||
"대상 Object ID": st.column_config.NumberColumn(width="small"),
|
||||
"처리 행": st.column_config.NumberColumn(width="small"),
|
||||
"오류 코드": st.column_config.TextColumn(width="medium"),
|
||||
}
|
||||
if show_details:
|
||||
column_config["메시지"] = st.column_config.TextColumn(width="large")
|
||||
with st.container(key="poc4_hmm_audit_event_panel"):
|
||||
st.dataframe(
|
||||
display_rows,
|
||||
column_config=column_config,
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
height=min(640, 104 + 38 * len(display_rows)),
|
||||
)
|
||||
563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""Small localhost authentication service for the HMM Streamlit portal.
|
||||
|
||||
Nginx owns the public security boundary. This module validates the existing
|
||||
PBKDF2 login, issues a signed HttpOnly cookie, and answers Nginx auth_request
|
||||
subrequests. Authentication values are never accepted from a URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Deque
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
||||
def _environment_value(*names: str, default: str = "") -> str:
|
||||
for name in names:
|
||||
value = os.environ.get(name)
|
||||
if value is not None and value.strip():
|
||||
return value.strip()
|
||||
return default
|
||||
|
||||
|
||||
LOG = logging.getLogger("hmm_portal_auth")
|
||||
SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION"
|
||||
CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF"
|
||||
SESSION_TOKEN_VERSION = 2
|
||||
MAX_REQUEST_BYTES = 8_192
|
||||
MAX_FIELD_CHARS = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfig:
|
||||
username: str
|
||||
password_pbkdf2: str
|
||||
cookie_secret: str
|
||||
bind_address: str = "127.0.0.1"
|
||||
port: int = 8621
|
||||
session_seconds: int = 12 * 60 * 60
|
||||
remember_seconds: int = 7 * 24 * 60 * 60
|
||||
product_name: str = "HMM AI 업무 에이전트"
|
||||
login_title: str = "HMM AI 업무 에이전트"
|
||||
login_description: str = "사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다."
|
||||
login_footer: str = "승인된 사용자만 접속할 수 있습니다."
|
||||
primary_color: str = "#004b87"
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "AuthConfig":
|
||||
config = cls(
|
||||
username=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_USER", "POC4_LOGIN_USER"
|
||||
),
|
||||
password_pbkdf2=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2",
|
||||
"POC4_LOGIN_PASSWORD_PBKDF2",
|
||||
),
|
||||
cookie_secret=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET",
|
||||
"POC4_LOGIN_COOKIE_SECRET",
|
||||
),
|
||||
bind_address=os.environ.get(
|
||||
"PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1"
|
||||
).strip(),
|
||||
port=int(os.environ.get("PORTAL_AUTH_PORT", "8621")),
|
||||
session_seconds=int(
|
||||
os.environ.get("PORTAL_AUTH_SESSION_SECONDS", str(12 * 60 * 60))
|
||||
),
|
||||
remember_seconds=int(
|
||||
os.environ.get(
|
||||
"PORTAL_AUTH_REMEMBER_SECONDS", str(7 * 24 * 60 * 60)
|
||||
)
|
||||
),
|
||||
product_name=os.environ.get(
|
||||
"AGENT_CONSOLE_NAME", "HMM AI 업무 에이전트"
|
||||
).strip(),
|
||||
login_title=os.environ.get(
|
||||
"AGENT_CONSOLE_LOGIN_TITLE", "HMM AI 업무 에이전트"
|
||||
).strip(),
|
||||
login_description=os.environ.get(
|
||||
"AGENT_CONSOLE_LOGIN_DESCRIPTION",
|
||||
"사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다.",
|
||||
).strip(),
|
||||
login_footer=os.environ.get(
|
||||
"AGENT_CONSOLE_LOGIN_FOOTER",
|
||||
"승인된 사용자만 접속할 수 있습니다.",
|
||||
).strip(),
|
||||
primary_color=os.environ.get(
|
||||
"AGENT_CONSOLE_PRIMARY_COLOR", "#004b87"
|
||||
).strip(),
|
||||
)
|
||||
config.validate()
|
||||
return config
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.username or not self.password_pbkdf2:
|
||||
raise ValueError("AI web agent console login credentials are not configured")
|
||||
if len(self.cookie_secret.encode("utf-8")) < 32:
|
||||
raise ValueError(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET must be at least 32 bytes"
|
||||
)
|
||||
if self.bind_address not in {"127.0.0.1", "::1"}:
|
||||
raise ValueError("Portal authentication service must bind to loopback")
|
||||
if not 1 <= self.port <= 65535:
|
||||
raise ValueError("PORTAL_AUTH_PORT is invalid")
|
||||
if not 300 <= self.session_seconds <= 24 * 60 * 60:
|
||||
raise ValueError("PORTAL_AUTH_SESSION_SECONDS is outside the safe range")
|
||||
if not self.session_seconds <= self.remember_seconds <= 30 * 24 * 60 * 60:
|
||||
raise ValueError("PORTAL_AUTH_REMEMBER_SECONDS is outside the safe range")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedSession:
|
||||
username: str
|
||||
expires_at: int
|
||||
|
||||
|
||||
class SessionTokenCodec:
|
||||
def __init__(self, secret: str):
|
||||
self._secret = secret.encode("utf-8")
|
||||
|
||||
def issue(self, username: str, lifetime_seconds: int, now: int | None = None) -> str:
|
||||
issued_at = int(time.time()) if now is None else now
|
||||
payload = {
|
||||
"v": SESSION_TOKEN_VERSION,
|
||||
"u": username,
|
||||
"i": issued_at,
|
||||
"e": issued_at + lifetime_seconds,
|
||||
"n": secrets.token_urlsafe(18),
|
||||
}
|
||||
encoded = _base64url_encode(
|
||||
json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
signature = hmac.new(
|
||||
self._secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
def verify(self, token: str, expected_username: str, now: int | None = None) -> AuthenticatedSession | None:
|
||||
if not token or len(token) > 2048:
|
||||
return None
|
||||
current_time = int(time.time()) if now is None else now
|
||||
try:
|
||||
encoded, supplied_signature = token.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
self._secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied_signature, expected_signature):
|
||||
return None
|
||||
payload = json.loads(_base64url_decode(encoded).decode("utf-8"))
|
||||
version = int(payload["v"])
|
||||
username = str(payload["u"])
|
||||
issued_at = int(payload["i"])
|
||||
expires_at = int(payload["e"])
|
||||
except (
|
||||
binascii.Error,
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
UnicodeDecodeError,
|
||||
json.JSONDecodeError,
|
||||
):
|
||||
return None
|
||||
if version != SESSION_TOKEN_VERSION:
|
||||
return None
|
||||
if issued_at > current_time + 30 or expires_at <= current_time:
|
||||
return None
|
||||
if expires_at - issued_at > 30 * 24 * 60 * 60:
|
||||
return None
|
||||
if not hmac.compare_digest(username, expected_username):
|
||||
return None
|
||||
return AuthenticatedSession(username=username, expires_at=expires_at)
|
||||
|
||||
|
||||
class LoginAttemptLimiter:
|
||||
def __init__(self, maximum_failures: int = 5, window_seconds: int = 300):
|
||||
self._maximum_failures = maximum_failures
|
||||
self._window_seconds = window_seconds
|
||||
self._failures: dict[str, Deque[float]] = defaultdict(deque)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def blocked(self, key: str, now: float | None = None) -> bool:
|
||||
current_time = time.monotonic() if now is None else now
|
||||
with self._lock:
|
||||
failures = self._failures[key]
|
||||
self._prune(failures, current_time)
|
||||
return len(failures) >= self._maximum_failures
|
||||
|
||||
def record_failure(self, key: str, now: float | None = None) -> None:
|
||||
current_time = time.monotonic() if now is None else now
|
||||
with self._lock:
|
||||
failures = self._failures[key]
|
||||
self._prune(failures, current_time)
|
||||
failures.append(current_time)
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(key, None)
|
||||
|
||||
def _prune(self, failures: Deque[float], now: float) -> None:
|
||||
cutoff = now - self._window_seconds
|
||||
while failures and failures[0] < cutoff:
|
||||
failures.popleft()
|
||||
|
||||
|
||||
def password_matches(password: str, encoded_password: str) -> bool:
|
||||
try:
|
||||
scheme, iterations_text, salt_hex, expected_hex = encoded_password.split(
|
||||
"$", 3
|
||||
)
|
||||
iterations = int(iterations_text)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(expected_hex)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000:
|
||||
return False
|
||||
candidate = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, iterations
|
||||
)
|
||||
return hmac.compare_digest(candidate, expected)
|
||||
|
||||
|
||||
def session_cookie_header(token: str, max_age: int | None) -> str:
|
||||
attributes = [
|
||||
f"{SESSION_COOKIE_NAME}={token}",
|
||||
"Path=/",
|
||||
"Secure",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
]
|
||||
if max_age is not None:
|
||||
attributes.append(f"Max-Age={max_age}")
|
||||
return "; ".join(attributes)
|
||||
|
||||
|
||||
def clear_session_cookie_header() -> str:
|
||||
return (
|
||||
f"{SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; "
|
||||
"Secure; HttpOnly; SameSite=Lax"
|
||||
)
|
||||
|
||||
|
||||
def csrf_cookie_header(value: str, max_age: int = 600) -> str:
|
||||
return (
|
||||
f"{CSRF_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; "
|
||||
"Secure; HttpOnly; SameSite=Strict"
|
||||
)
|
||||
|
||||
|
||||
def clear_csrf_cookie_header() -> str:
|
||||
return (
|
||||
f"{CSRF_COOKIE_NAME}=; Path=/; Max-Age=0; "
|
||||
"Secure; HttpOnly; SameSite=Strict"
|
||||
)
|
||||
|
||||
|
||||
def _base64url_encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _base64url_decode(value: str) -> bytes:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(padded)
|
||||
|
||||
|
||||
def _cookie_value(cookie_header: str, name: str) -> str:
|
||||
try:
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(cookie_header)
|
||||
morsel = cookies.get(name)
|
||||
return morsel.value if morsel is not None else ""
|
||||
except (KeyError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _login_page(config: AuthConfig, csrf_value: str, error: str = "") -> bytes:
|
||||
error_html = (
|
||||
f'<div class="error" role="alert">{html.escape(error)}</div>'
|
||||
if error
|
||||
else ""
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{html.escape(config.product_name)}</title>
|
||||
<style>
|
||||
:root {{ --primary:{html.escape(config.primary_color)}; --text:#17232d;
|
||||
--muted:#60717f; --border:#d9e0e5; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#fff; color:var(--text);
|
||||
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
|
||||
main {{ width:min(420px,calc(100% - 40px)); margin:12vh auto 0; }}
|
||||
.wordmark {{ color:var(--primary); font-size:1.25rem; font-weight:800;
|
||||
letter-spacing:.08em; }}
|
||||
h1 {{ margin:16px 0 8px; font-size:1.75rem; }}
|
||||
.description,.footer {{ color:var(--muted); line-height:1.55; }}
|
||||
form {{ margin-top:28px; }}
|
||||
label {{ display:block; margin:0 0 18px; font-weight:700; }}
|
||||
input[type="text"],input[type="password"] {{ width:100%; margin-top:8px;
|
||||
padding:12px 13px; border:1px solid var(--border); border-radius:5px;
|
||||
font:inherit; color:var(--text); background:#fff; }}
|
||||
.remember {{ display:flex; align-items:center; gap:8px; font-weight:500; }}
|
||||
.remember input {{ width:17px; height:17px; }}
|
||||
button {{ width:100%; padding:12px; border:1px solid var(--primary);
|
||||
border-radius:5px; background:var(--primary); color:#fff; font:inherit;
|
||||
font-weight:800; cursor:pointer; }}
|
||||
.error {{ margin:18px 0 0; padding:11px 12px; border:1px solid #d99898;
|
||||
border-radius:5px; color:#8a2222; background:#fff7f7; }}
|
||||
.footer {{ margin-top:22px; font-size:.9rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="wordmark">HMM</div>
|
||||
<h1>{html.escape(config.login_title)}</h1>
|
||||
<p class="description">{html.escape(config.login_description)}</p>
|
||||
{error_html}
|
||||
<form action="/auth/login" method="post" autocomplete="on">
|
||||
<input type="hidden" name="csrf" value="{html.escape(csrf_value)}">
|
||||
<label>사용자 ID
|
||||
<input name="username" type="text" maxlength="80" autocomplete="username"
|
||||
required autofocus>
|
||||
</label>
|
||||
<label>비밀번호
|
||||
<input name="password" type="password" maxlength="200"
|
||||
autocomplete="current-password" required>
|
||||
</label>
|
||||
<label class="remember">
|
||||
<input name="remember" type="checkbox" value="yes"> 로그인 유지 (7일)
|
||||
</label>
|
||||
<button type="submit">로그인</button>
|
||||
</form>
|
||||
<p class="footer">{html.escape(config.login_footer)}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>""".encode("utf-8")
|
||||
|
||||
|
||||
def build_handler(config: AuthConfig) -> type[BaseHTTPRequestHandler]:
|
||||
codec = SessionTokenCodec(config.cookie_secret)
|
||||
limiter = LoginAttemptLimiter()
|
||||
|
||||
class PortalAuthHandler(BaseHTTPRequestHandler):
|
||||
server_version = "HMMPortalAuth/1.0"
|
||||
sys_version = ""
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
self._route(send_body=False)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._route(send_body=True)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/auth/login":
|
||||
self._login()
|
||||
elif path == "/auth/logout":
|
||||
self._logout()
|
||||
else:
|
||||
self._send_text(HTTPStatus.NOT_FOUND, "Not found")
|
||||
|
||||
def _route(self, send_body: bool) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/auth/check":
|
||||
self._check()
|
||||
elif path == "/auth/login":
|
||||
self._show_login(send_body=send_body)
|
||||
elif path == "/auth/logout":
|
||||
self._logout()
|
||||
elif path == "/auth/healthz":
|
||||
self._send_text(HTTPStatus.OK, "ok", send_body=send_body)
|
||||
else:
|
||||
self._send_text(HTTPStatus.NOT_FOUND, "Not found", send_body=send_body)
|
||||
|
||||
def _check(self) -> None:
|
||||
session = self._session()
|
||||
if session is None:
|
||||
self._send_empty(HTTPStatus.UNAUTHORIZED)
|
||||
return
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self._security_headers()
|
||||
self.send_header("X-Auth-User", session.username)
|
||||
self.send_header("X-Auth-Expires", str(session.expires_at))
|
||||
self.end_headers()
|
||||
|
||||
def _show_login(self, send_body: bool = True, error: str = "") -> None:
|
||||
if self._session() is not None and not error:
|
||||
self._redirect("/")
|
||||
return
|
||||
csrf_value = secrets.token_urlsafe(32)
|
||||
body = _login_page(config, csrf_value, error)
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self._security_headers()
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Set-Cookie", csrf_cookie_header(csrf_value))
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _login(self) -> None:
|
||||
client_key = self._client_key()
|
||||
if limiter.blocked(client_key):
|
||||
self._show_login(error="로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해 주세요.")
|
||||
return
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
content_length = 0
|
||||
if not 1 <= content_length <= MAX_REQUEST_BYTES:
|
||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
||||
return
|
||||
raw_body = self.rfile.read(content_length)
|
||||
try:
|
||||
form = parse_qs(
|
||||
raw_body.decode("utf-8"),
|
||||
keep_blank_values=True,
|
||||
strict_parsing=False,
|
||||
max_num_fields=8,
|
||||
)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
||||
return
|
||||
username = _form_value(form, "username")
|
||||
password = _form_value(form, "password")
|
||||
csrf_form = _form_value(form, "csrf")
|
||||
csrf_cookie = _cookie_value(
|
||||
self.headers.get("Cookie", ""), CSRF_COOKIE_NAME
|
||||
)
|
||||
if (
|
||||
not csrf_form
|
||||
or not csrf_cookie
|
||||
or not hmac.compare_digest(csrf_form, csrf_cookie)
|
||||
):
|
||||
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
|
||||
return
|
||||
valid_credentials = (
|
||||
len(username) <= 80
|
||||
and len(password) <= MAX_FIELD_CHARS
|
||||
and hmac.compare_digest(username.strip(), config.username)
|
||||
and password_matches(password, config.password_pbkdf2)
|
||||
)
|
||||
if not valid_credentials:
|
||||
limiter.record_failure(client_key)
|
||||
self._show_login(error="사용자 ID 또는 비밀번호를 확인해 주세요.")
|
||||
return
|
||||
limiter.reset(client_key)
|
||||
remember = _form_value(form, "remember") == "yes"
|
||||
lifetime = (
|
||||
config.remember_seconds if remember else config.session_seconds
|
||||
)
|
||||
token = codec.issue(config.username, lifetime)
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._security_headers()
|
||||
self.send_header("Location", "/")
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
session_cookie_header(token, lifetime if remember else None),
|
||||
)
|
||||
self.send_header("Set-Cookie", clear_csrf_cookie_header())
|
||||
self.end_headers()
|
||||
|
||||
def _logout(self) -> None:
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._security_headers()
|
||||
self.send_header("Location", "/auth/login")
|
||||
self.send_header("Set-Cookie", clear_session_cookie_header())
|
||||
self.send_header("Set-Cookie", clear_csrf_cookie_header())
|
||||
self.end_headers()
|
||||
|
||||
def _session(self) -> AuthenticatedSession | None:
|
||||
token = _cookie_value(
|
||||
self.headers.get("Cookie", ""), SESSION_COOKIE_NAME
|
||||
)
|
||||
return codec.verify(token, config.username)
|
||||
|
||||
def _client_key(self) -> str:
|
||||
forwarded = self.headers.get("X-Real-IP", "").strip()
|
||||
return forwarded or self.client_address[0]
|
||||
|
||||
def _redirect(self, location: str) -> None:
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._security_headers()
|
||||
self.send_header("Location", location)
|
||||
self.end_headers()
|
||||
|
||||
def _send_empty(self, status: HTTPStatus) -> None:
|
||||
self.send_response(status)
|
||||
self._security_headers()
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def _send_text(
|
||||
self,
|
||||
status: HTTPStatus,
|
||||
message: str,
|
||||
send_body: bool = True,
|
||||
) -> None:
|
||||
body = message.encode("utf-8")
|
||||
self.send_response(status)
|
||||
self._security_headers()
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _security_headers(self) -> None:
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; style-src 'unsafe-inline'; "
|
||||
"form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
||||
)
|
||||
|
||||
def log_message(self, _format: str, *args: object) -> None:
|
||||
# Do not log query strings, cookies, form bodies, or tokens.
|
||||
LOG.info("%s %s", self.command, urlsplit(self.path).path)
|
||||
|
||||
return PortalAuthHandler
|
||||
|
||||
|
||||
def _form_value(form: dict[str, list[str]], name: str) -> str:
|
||||
values = form.get(name)
|
||||
return values[0] if values else ""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("PORTAL_AUTH_LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
config = AuthConfig.from_environment()
|
||||
server = ThreadingHTTPServer(
|
||||
(config.bind_address, config.port), build_handler(config)
|
||||
)
|
||||
LOG.info("HMM portal authentication service listening on loopback port %s", config.port)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
80
ai-web-agent-console/ai_web_agent_console/mcp_result.py
Normal file
80
ai-web-agent-console/ai_web_agent_console/mcp_result.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Pure helpers for MCP result envelopes used by the Streamlit console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def response_payload(value: Any) -> Mapping[str, Any]:
|
||||
"""Return the business payload from a direct or nested MCP response."""
|
||||
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
nested = value.get("response")
|
||||
return nested if isinstance(nested, Mapping) else value
|
||||
|
||||
|
||||
def text_result(value: Any) -> str:
|
||||
"""Return a textual `result` field without stringifying other structures."""
|
||||
|
||||
result = response_payload(value).get("result")
|
||||
return result.strip() if isinstance(result, str) else ""
|
||||
|
||||
|
||||
def status_result_summary(value: Any, *, excerpt_chars: int = 900) -> dict[str, Any]:
|
||||
"""Build a safe UI summary for status/result-style compatibility tools."""
|
||||
|
||||
payload = response_payload(value)
|
||||
summary: dict[str, Any] = {}
|
||||
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
||||
item = payload.get(key)
|
||||
if item not in (None, "", []):
|
||||
summary[key] = item
|
||||
result = text_result(value)
|
||||
if result:
|
||||
summary["result_chars"] = len(result)
|
||||
summary["result_excerpt"] = result[:excerpt_chars] + (
|
||||
"..." if len(result) > excerpt_chars else ""
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def status_result_evidence(value: Any, *, max_chars: int = 7000) -> dict[str, Any]:
|
||||
"""Preserve bounded textual policy/data evidence for final answer synthesis."""
|
||||
|
||||
payload = response_payload(value)
|
||||
evidence: dict[str, Any] = {}
|
||||
for key in ("status", "success", "error", "errorCode", "errorMessage"):
|
||||
item = payload.get(key)
|
||||
if item not in (None, "", []):
|
||||
evidence[key] = item
|
||||
result = text_result(value)
|
||||
if result:
|
||||
evidence["result"] = result[:max_chars] + (
|
||||
"..." if len(result) > max_chars else ""
|
||||
)
|
||||
evidence["result_chars"] = len(result)
|
||||
return evidence
|
||||
|
||||
|
||||
def has_actionable_text_result(value: Any) -> bool:
|
||||
"""Return whether a textual result contains evidence worth stopping on."""
|
||||
|
||||
result = text_result(value)
|
||||
if not result:
|
||||
return False
|
||||
normalized = " ".join(result.casefold().split())
|
||||
return not any(
|
||||
marker in normalized
|
||||
for marker in ("no data found", "no evidence found", "error:")
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"has_actionable_text_result",
|
||||
"response_payload",
|
||||
"status_result_evidence",
|
||||
"status_result_summary",
|
||||
"text_result",
|
||||
]
|
||||
196
ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py
Normal file
196
ai-web-agent-console/ai_web_agent_console/mcp_tool_router.py
Normal 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",
|
||||
]
|
||||
434
ai-web-agent-console/ai_web_agent_console/model_registry.py
Normal file
434
ai-web-agent-console/ai_web_agent_console/model_registry.py
Normal 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",
|
||||
]
|
||||
274
ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py
Normal file
274
ai-web-agent-console/ai_web_agent_console/oci_genai_sdk.py
Normal 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",
|
||||
]
|
||||
124
ai-web-agent-console/ai_web_agent_console/presentation.py
Normal file
124
ai-web-agent-console/ai_web_agent_console/presentation.py
Normal 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,
|
||||
)
|
||||
139
ai-web-agent-console/ai_web_agent_console/profile.py
Normal file
139
ai-web-agent-console/ai_web_agent_console/profile.py
Normal 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
|
||||
407
ai-web-agent-console/ai_web_agent_console/query_contracts.py
Normal file
407
ai-web-agent-console/ai_web_agent_console/query_contracts.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Configuration-driven query and answer evidence contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_CONTRACT_FILE = ROOT / "config" / "hmm_hr_query_contracts.json"
|
||||
|
||||
|
||||
def _contract_file() -> Path:
|
||||
configured = str(
|
||||
os.getenv("AI_WEB_AGENT_CONSOLE_QUERY_CONTRACTS_PATH")
|
||||
or os.getenv("POC4_QUERY_CONTRACTS_PATH")
|
||||
or ""
|
||||
).strip()
|
||||
if not configured:
|
||||
return DEFAULT_CONTRACT_FILE
|
||||
path = Path(configured).expanduser()
|
||||
return path if path.is_absolute() else ROOT / path
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_contract_file(path_text: str) -> tuple[Mapping[str, Any], ...]:
|
||||
path = Path(path_text)
|
||||
if not path.exists():
|
||||
return ()
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
contracts = value.get("contracts") if isinstance(value, Mapping) else None
|
||||
if not isinstance(contracts, list):
|
||||
raise ValueError("query contracts must contain a contracts array")
|
||||
return tuple(item for item in contracts if isinstance(item, Mapping))
|
||||
|
||||
|
||||
def load_query_contracts() -> tuple[Mapping[str, Any], ...]:
|
||||
return _load_contract_file(str(_contract_file()))
|
||||
|
||||
|
||||
def matching_query_contracts(
|
||||
question: str,
|
||||
tool_name: str,
|
||||
) -> tuple[Mapping[str, Any], ...]:
|
||||
normalized_question = " ".join(str(question or "").casefold().split())
|
||||
normalized_tool = str(tool_name or "").strip()
|
||||
selected: list[Mapping[str, Any]] = []
|
||||
for contract in load_query_contracts():
|
||||
tools = contract.get("applies_to_tools")
|
||||
if isinstance(tools, list) and normalized_tool not in {
|
||||
str(item) for item in tools
|
||||
}:
|
||||
continue
|
||||
intent = contract.get("intent_match")
|
||||
if not isinstance(intent, Mapping):
|
||||
continue
|
||||
subject_terms = [
|
||||
str(item).casefold()
|
||||
for item in intent.get("subject_terms_any", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
action_terms = [
|
||||
str(item).casefold()
|
||||
for item in intent.get("action_terms_any", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
if subject_terms and not any(
|
||||
term in normalized_question for term in subject_terms
|
||||
):
|
||||
continue
|
||||
if action_terms and not any(
|
||||
term in normalized_question for term in action_terms
|
||||
):
|
||||
continue
|
||||
selected.append(contract)
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
def query_contract_guidance(question: str, tool_name: str) -> str:
|
||||
contracts = matching_query_contracts(question, tool_name)
|
||||
if not contracts:
|
||||
return ""
|
||||
return json.dumps(
|
||||
{"query_contracts": contracts},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def append_query_contract_guidance(
|
||||
tool_query: str,
|
||||
*,
|
||||
original_question: str,
|
||||
tool_name: str,
|
||||
) -> str:
|
||||
guidance = query_contract_guidance(original_question, tool_name)
|
||||
normalized = str(tool_query or "").strip()
|
||||
if not guidance:
|
||||
return normalized
|
||||
return (
|
||||
f"{normalized}\n"
|
||||
"다음 질의 계약의 필드·계산·시간 기준을 반드시 지켜 결과를 반환하세요. "
|
||||
f"계약: {guidance}"
|
||||
)
|
||||
|
||||
|
||||
def _json_data_result(value: str) -> Any:
|
||||
marker = "DATA_RESULT"
|
||||
position = value.find(marker)
|
||||
if position < 0:
|
||||
return None
|
||||
remainder = value[position + len(marker) :]
|
||||
object_position = remainder.find("{")
|
||||
array_position = remainder.find("[")
|
||||
positions = [
|
||||
candidate
|
||||
for candidate in (object_position, array_position)
|
||||
if candidate >= 0
|
||||
]
|
||||
if not positions:
|
||||
return None
|
||||
candidate = remainder[min(positions) :]
|
||||
try:
|
||||
parsed, _ = json.JSONDecoder().raw_decode(candidate)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _evidence_rows(value: Any) -> list[Mapping[str, Any]]:
|
||||
rows: list[Mapping[str, Any]] = []
|
||||
if isinstance(value, Mapping):
|
||||
rows.append(value)
|
||||
for item in value.values():
|
||||
rows.extend(_evidence_rows(item))
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
rows.extend(_evidence_rows(item))
|
||||
elif isinstance(value, str):
|
||||
parsed = _json_data_result(value)
|
||||
if parsed is not None:
|
||||
rows.extend(_evidence_rows(parsed))
|
||||
return rows
|
||||
|
||||
|
||||
def _decimal(value: Any) -> Decimal:
|
||||
if isinstance(value, bool) or value is None:
|
||||
raise InvalidOperation
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _evaluate_contract_expression(
|
||||
expression: str,
|
||||
row: Mapping[str, Any],
|
||||
) -> Decimal:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
|
||||
def evaluate(node: ast.AST) -> Decimal:
|
||||
if isinstance(node, ast.Expression):
|
||||
return evaluate(node.body)
|
||||
if isinstance(node, ast.Name):
|
||||
key = node.id.casefold()
|
||||
if key not in row:
|
||||
raise InvalidOperation
|
||||
return _decimal(row[key])
|
||||
if isinstance(node, ast.Constant):
|
||||
return _decimal(node.value)
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(
|
||||
node.op, (ast.UAdd, ast.USub)
|
||||
):
|
||||
value = evaluate(node.operand)
|
||||
return value if isinstance(node.op, ast.UAdd) else -value
|
||||
if isinstance(node, ast.BinOp) and isinstance(
|
||||
node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)
|
||||
):
|
||||
left = evaluate(node.left)
|
||||
right = evaluate(node.right)
|
||||
if isinstance(node.op, ast.Add):
|
||||
return left + right
|
||||
if isinstance(node.op, ast.Sub):
|
||||
return left - right
|
||||
if isinstance(node.op, ast.Mult):
|
||||
return left * right
|
||||
return left / right
|
||||
raise ValueError("unsupported contract expression")
|
||||
|
||||
return evaluate(tree)
|
||||
|
||||
|
||||
def evidence_contract_report(
|
||||
contracts: tuple[Mapping[str, Any], ...],
|
||||
evidence: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = [
|
||||
{str(key).strip().casefold(): value for key, value in row.items()}
|
||||
for row in _evidence_rows(evidence)
|
||||
]
|
||||
reports: list[dict[str, Any]] = []
|
||||
for contract in contracts:
|
||||
data_contract = contract.get("data_contract")
|
||||
if not isinstance(data_contract, Mapping):
|
||||
continue
|
||||
required = [
|
||||
str(item).strip().casefold()
|
||||
for item in data_contract.get("required_fields", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
matching_row = next(
|
||||
(row for row in rows if required and all(key in row for key in required)),
|
||||
None,
|
||||
)
|
||||
observed = sorted(
|
||||
{
|
||||
key
|
||||
for row in rows
|
||||
for key in row
|
||||
if not required or key in required
|
||||
}
|
||||
)
|
||||
missing = [
|
||||
key.upper()
|
||||
for key in required
|
||||
if matching_row is None or key not in matching_row
|
||||
]
|
||||
computed_checks: list[dict[str, Any]] = []
|
||||
computed_fields = data_contract.get("computed_fields")
|
||||
if matching_row is not None and isinstance(computed_fields, Mapping):
|
||||
for field, definition in computed_fields.items():
|
||||
normalized_field = str(field).strip().casefold()
|
||||
expression = (
|
||||
str(definition.get("expression") or "").strip()
|
||||
if isinstance(definition, Mapping)
|
||||
else ""
|
||||
)
|
||||
if not normalized_field or not expression:
|
||||
continue
|
||||
try:
|
||||
actual = _decimal(matching_row.get(normalized_field))
|
||||
expected = _evaluate_contract_expression(
|
||||
expression,
|
||||
matching_row,
|
||||
)
|
||||
matches = actual == expected
|
||||
computed_checks.append(
|
||||
{
|
||||
"field": normalized_field.upper(),
|
||||
"expression": expression,
|
||||
"actual": str(actual),
|
||||
"expected": str(expected),
|
||||
"satisfied": matches,
|
||||
}
|
||||
)
|
||||
except (InvalidOperation, ValueError, ZeroDivisionError):
|
||||
computed_checks.append(
|
||||
{
|
||||
"field": normalized_field.upper(),
|
||||
"expression": expression,
|
||||
"satisfied": False,
|
||||
}
|
||||
)
|
||||
calculations_satisfied = all(
|
||||
bool(check.get("satisfied")) for check in computed_checks
|
||||
)
|
||||
temporal_checks: list[dict[str, Any]] = []
|
||||
temporal_contract = contract.get("temporal_contract")
|
||||
if matching_row is not None and isinstance(temporal_contract, Mapping):
|
||||
status_fields = [
|
||||
str(item).strip().casefold()
|
||||
for item in temporal_contract.get("period_status_fields", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
past_status = str(
|
||||
temporal_contract.get("past_period_status") or ""
|
||||
).strip()
|
||||
observed_status = next(
|
||||
(
|
||||
str(matching_row.get(field) or "").strip()
|
||||
for field in status_fields
|
||||
if str(matching_row.get(field) or "").strip()
|
||||
),
|
||||
"",
|
||||
)
|
||||
if observed_status == past_status and past_status:
|
||||
decision_field = str(
|
||||
temporal_contract.get("past_period_decision_field") or ""
|
||||
).strip().casefold()
|
||||
decision_value = str(
|
||||
temporal_contract.get("past_period_decision_value") or ""
|
||||
).strip()
|
||||
actual_decision = str(
|
||||
matching_row.get(decision_field) or ""
|
||||
).strip()
|
||||
temporal_checks.append(
|
||||
{
|
||||
"check": "past_period_decision",
|
||||
"field": decision_field.upper(),
|
||||
"expected": decision_value,
|
||||
"actual": actual_decision,
|
||||
"satisfied": bool(
|
||||
decision_field
|
||||
and decision_value
|
||||
and actual_decision == decision_value
|
||||
),
|
||||
}
|
||||
)
|
||||
forbidden_patterns = [
|
||||
str(item).strip()
|
||||
for item in temporal_contract.get(
|
||||
"forbidden_past_period_decision_field_patterns", []
|
||||
)
|
||||
if str(item).strip()
|
||||
]
|
||||
forbidden_fields = sorted(
|
||||
key.upper()
|
||||
for key in matching_row
|
||||
if any(
|
||||
re.search(pattern, key, flags=re.IGNORECASE)
|
||||
for pattern in forbidden_patterns
|
||||
)
|
||||
)
|
||||
temporal_checks.append(
|
||||
{
|
||||
"check": "no_past_period_yes_no_decision",
|
||||
"forbidden_fields": forbidden_fields,
|
||||
"satisfied": not forbidden_fields,
|
||||
}
|
||||
)
|
||||
required_counts = [
|
||||
str(item).strip().casefold()
|
||||
for item in temporal_contract.get(
|
||||
"validate_stated_days_against", []
|
||||
)
|
||||
if str(item).strip()
|
||||
]
|
||||
temporal_checks.append(
|
||||
{
|
||||
"check": "date_range_counts",
|
||||
"required_fields": [
|
||||
field.upper() for field in required_counts
|
||||
],
|
||||
"missing_fields": [
|
||||
field.upper()
|
||||
for field in required_counts
|
||||
if field not in matching_row
|
||||
],
|
||||
"satisfied": all(
|
||||
field in matching_row for field in required_counts
|
||||
),
|
||||
}
|
||||
)
|
||||
temporal_satisfied = all(
|
||||
bool(check.get("satisfied")) for check in temporal_checks
|
||||
)
|
||||
reports.append(
|
||||
{
|
||||
"contract_id": str(contract.get("id") or ""),
|
||||
"satisfied": bool(
|
||||
matching_row is not None
|
||||
and not missing
|
||||
and calculations_satisfied
|
||||
and temporal_satisfied
|
||||
),
|
||||
"required_fields": [key.upper() for key in required],
|
||||
"observed_required_fields": [key.upper() for key in observed],
|
||||
"missing_fields": missing,
|
||||
"computed_field_checks": computed_checks,
|
||||
"temporal_contract_checks": temporal_checks,
|
||||
"validated_record": (
|
||||
{
|
||||
key.upper(): matching_row.get(key)
|
||||
for key in required
|
||||
}
|
||||
if matching_row is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
return reports
|
||||
|
||||
|
||||
def missing_evidence_message(
|
||||
contracts: tuple[Mapping[str, Any], ...],
|
||||
) -> str:
|
||||
for contract in contracts:
|
||||
answer_contract = contract.get("answer_contract")
|
||||
if not isinstance(answer_contract, Mapping):
|
||||
continue
|
||||
message = str(answer_contract.get("missing_evidence_message") or "").strip()
|
||||
if message:
|
||||
return message
|
||||
return "조회 결과가 답변 계약의 필수 근거를 충족하지 않아 결론을 제공할 수 없습니다."
|
||||
|
||||
|
||||
__all__ = [
|
||||
"append_query_contract_guidance",
|
||||
"evidence_contract_report",
|
||||
"load_query_contracts",
|
||||
"matching_query_contracts",
|
||||
"missing_evidence_message",
|
||||
"query_contract_guidance",
|
||||
]
|
||||
264
ai-web-agent-console/ai_web_agent_console/questions.py
Normal file
264
ai-web-agent-console/ai_web_agent_console/questions.py
Normal 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",
|
||||
]
|
||||
71
ai-web-agent-console/ai_web_agent_console/scenarios.py
Normal file
71
ai-web-agent-console/ai_web_agent_console/scenarios.py
Normal 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)
|
||||
Reference in New Issue
Block a user