Files
vpd-permission-poc/ai-web-agent-console/app.py

5905 lines
230 KiB
Python

"""HMM AI Web Agent Console Streamlit entrypoint.
Run:
streamlit run app.py --server.port 8622
This screen intentionally does not use the existing LangGraph/actor-context
pipeline. It discovers MCP tools, selects a query-capable tool, and calls it
with the Bearer token entered on the screen.
"""
from __future__ import annotations
import hashlib
import hmac
import html
import json
import logging
import os
import sqlite3
import sys
from datetime import datetime, timezone
from dataclasses import dataclass, field
from pathlib import Path
import re
from time import perf_counter
from typing import Any, Mapping
from uuid import uuid4
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
ROOT = Path(__file__).resolve().parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import streamlit as st
import streamlit.components.v1 as components
import oracledb
from ai_web_agent_console.mcp_tool_router import (
McpTool,
McpToolRouterError,
RoutedMcpTool,
build_mcp_tool_arguments,
route_mcp_tool_across_servers_with_llm,
)
from ai_web_agent_console.mcp_result import (
has_actionable_text_result,
status_result_evidence,
status_result_summary,
text_result,
)
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 load_model_registry, resolve_model_profile
from ai_web_agent_console.presentation import (
apply_console_theme,
render_console_header,
render_login_brand,
)
from ai_web_agent_console.audit import render_hmm_audit_tab
from ai_web_agent_console.profile import AppProfile, AppProfileError, load_app_profile
from ai_web_agent_console.scenarios import ScenarioConfigError, load_demo_scenarios
from ai_web_agent_console.query_contracts import (
append_query_contract_guidance,
evidence_contract_report,
matching_query_contracts,
missing_evidence_message,
)
LOG = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2025-11-25"
PREFERRED_TOOL = "search_hr_data"
DEFAULT_QUESTION = ""
MAX_RESPONSE_BYTES = 1_000_000
MAX_CONVERSATION_MESSAGES = 8
CHAT_TURNS_PER_PAGE = 3
CHAT_CONTEXT_TURNS = 4
MAX_AGENT_TOOL_STEPS = 4
AGENT_FINAL_ROUTE = "__final__"
COMPLEX_REASONING_MODEL_PROFILE_ENV = (
"AI_WEB_AGENT_CONSOLE_COMPLEX_REASONING_MODEL_PROFILE"
)
LEGACY_COMPLEX_REASONING_MODEL_PROFILE_ENV = "POC4_COMPLEX_REASONING_MODEL_PROFILE"
DEFAULT_COMPLEX_REASONING_MODEL_PROFILE = "grok43"
DEFAULT_SYNTHESIS_FALLBACK_MODEL_PROFILE = "gpt54_mini_oci"
DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
ENV_FILE = ROOT / ".env"
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json"
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
DEFAULT_VPD_USER_ID = "E1001"
VPD_OPERATIONS_URL = "https://hmm-backoffice.cloud-handson.com/"
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
PORTAL_AUTH_PROXY_USER_HEADER = "X-HMM-Authenticated-User"
PORTAL_AUTH_PROXY_EXPIRY_HEADER = "X-HMM-Auth-Expires"
AUDIT_DB_ENV_FILE = Path(
os.environ.get("AI_WEB_AGENT_CONSOLE_AUDIT_DB_ENV_FILE")
or os.environ.get("POC4_AUDIT_DB_ENV_FILE")
or str(ENV_FILE)
).expanduser()
DEFAULT_AUDIT_DB_DSN = (
"(description=(retry_count=3)(retry_delay=1)"
"(address=(protocol=tcps)(port=1521)(host=adb.ap-seoul-1.oraclecloud.com))"
"(connect_data=(service_name="
"yh0olybn5pqce4n_hmmaipoc_high.adb.oraclecloud.com))"
"(security=(ssl_server_dn_match=yes)))"
)
_OPAQUE_BEARER = re.compile(r"^[\x21-\x7e]{1,4096}$")
@dataclass(frozen=True)
class McpServer:
server_id: str
endpoint_url: str
auth_token_env: str
default_tool: str
tool_allowlist: tuple[str, ...]
router_model_profile: str
description: str
@dataclass(frozen=True)
class VpdTokenPreset:
user_id: str
name: str
role: str
channel: str
scope: str
token: str = field(repr=False, compare=False)
is_default: bool = False
team: str = ""
@property
def display_label(self) -> str:
return " · ".join(
item
for item in (
self.user_id,
self.name,
self.role,
self.team or self.channel,
self.scope,
)
if item
)
@property
def select_label(self) -> str:
return " · ".join(
item
for item in (
self.user_id,
self.name,
self.role,
)
if item
)
class PublicMcpError(RuntimeError):
"""Safe UI error. Never include request headers, tokens, or raw traces."""
class AnswerSynthesisError(RuntimeError):
"""Safe final-answer synthesis error."""
def __init__(
self,
message: str,
diagnostics: Mapping[str, Any] | None = None,
) -> None:
super().__init__(message)
self.diagnostics = dict(diagnostics or {})
@dataclass(frozen=True)
class McpDiscoveryResult:
server: McpServer
tools: tuple[McpTool, ...]
@dataclass(frozen=True)
class _JsonRpcExchange:
result: Mapping[str, Any]
session_id: str = ""
class _McpHttpStatusError(PublicMcpError):
def __init__(self, code: int) -> None:
self.code = code
super().__init__(f"MCP 호출이 실패했습니다. HTTP {code}")
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _chat_db_path() -> Path:
configured = _runtime_env_value(
"AI_WEB_AGENT_CONSOLE_CHAT_DB_PATH",
"POC4_CHAT_DB_PATH",
)
if not configured:
return CHAT_DB_FILE
path = Path(configured).expanduser()
return path if path.is_absolute() else ROOT / path
def _chat_db_connect() -> sqlite3.Connection:
path = _chat_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(path)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA busy_timeout=5000")
return connection
def init_chat_store() -> None:
with _chat_db_connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS poc4_mcp_chat_conversations (
conversation_id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
title TEXT NOT NULL DEFAULT ''
)
"""
)
columns = {
str(row["name"])
for row in connection.execute(
"PRAGMA table_info(poc4_mcp_chat_conversations)"
).fetchall()
}
if "title" not in columns:
connection.execute(
"ALTER TABLE poc4_mcp_chat_conversations "
"ADD COLUMN title TEXT NOT NULL DEFAULT ''"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS poc4_mcp_chat_turns (
turn_id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
created_at TEXT NOT NULL,
selected_user_id TEXT,
selected_user_label TEXT,
question TEXT NOT NULL,
standalone_question TEXT NOT NULL,
answer TEXT NOT NULL,
basis_json TEXT NOT NULL,
limitations TEXT NOT NULL,
details_json TEXT NOT NULL,
FOREIGN KEY(conversation_id)
REFERENCES poc4_mcp_chat_conversations(conversation_id)
)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_poc4_chat_turns_conversation_latest
ON poc4_mcp_chat_turns(conversation_id, turn_id DESC)
"""
)
def ensure_conversation(conversation_id: str) -> None:
now = _utc_now()
with _chat_db_connect() as connection:
connection.execute(
"""
INSERT OR IGNORE INTO poc4_mcp_chat_conversations
(conversation_id, created_at, updated_at, title)
VALUES (?, ?, ?, '')
""",
(conversation_id, now, now),
)
def count_chat_turns(conversation_id: str) -> int:
with _chat_db_connect() as connection:
row = connection.execute(
"""
SELECT COUNT(*) AS count
FROM poc4_mcp_chat_turns
WHERE conversation_id = ?
""",
(conversation_id,),
).fetchone()
return int(row["count"] if row else 0)
def list_conversations(limit: int = 50) -> list[dict[str, Any]]:
with _chat_db_connect() as connection:
rows = connection.execute(
"""
SELECT
c.conversation_id,
c.created_at,
c.updated_at,
c.title,
COUNT(t.turn_id) AS turn_count,
GROUP_CONCAT(
COALESCE(t.question, '') || ' ' ||
COALESCE(t.standalone_question, '') || ' ' ||
COALESCE(t.answer, '') || ' ' ||
COALESCE(t.selected_user_id, '') || ' ' ||
COALESCE(t.selected_user_label, ''),
' '
) AS search_text,
(
SELECT latest.question
FROM poc4_mcp_chat_turns latest
WHERE latest.conversation_id = c.conversation_id
ORDER BY latest.turn_id DESC
LIMIT 1
) AS latest_question
FROM poc4_mcp_chat_conversations c
LEFT JOIN poc4_mcp_chat_turns t
ON t.conversation_id = c.conversation_id
GROUP BY c.conversation_id, c.created_at, c.updated_at, c.title
HAVING COUNT(t.turn_id) > 0
ORDER BY c.updated_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def rename_conversation(conversation_id: str, title: str) -> None:
with _chat_db_connect() as connection:
connection.execute(
"""
UPDATE poc4_mcp_chat_conversations
SET title = ?, updated_at = ?
WHERE conversation_id = ?
""",
(title.strip()[:80], _utc_now(), conversation_id),
)
def delete_conversation(conversation_id: str) -> None:
with _chat_db_connect() as connection:
connection.execute(
"DELETE FROM poc4_mcp_chat_turns WHERE conversation_id = ?",
(conversation_id,),
)
connection.execute(
"DELETE FROM poc4_mcp_chat_conversations WHERE conversation_id = ?",
(conversation_id,),
)
def load_all_chat_turns(conversation_id: str) -> list[dict[str, Any]]:
with _chat_db_connect() as connection:
rows = connection.execute(
"""
SELECT turn_id, created_at, selected_user_id, selected_user_label,
question, standalone_question, answer, basis_json,
limitations, details_json
FROM poc4_mcp_chat_turns
WHERE conversation_id = ?
ORDER BY turn_id ASC
""",
(conversation_id,),
).fetchall()
return [_chat_turn_from_row(row) for row in rows]
def filter_conversations(
rows: list[dict[str, Any]],
search_text: str,
) -> list[dict[str, Any]]:
query = search_text.strip().casefold()
if not query:
return rows
filtered: list[dict[str, Any]] = []
for row in rows:
haystack = " ".join(
str(row.get(key) or "")
for key in (
"conversation_id",
"title",
"latest_question",
"search_text",
"updated_at",
)
).casefold()
if query in haystack:
filtered.append(row)
return filtered
def conversation_label(item: Mapping[str, Any]) -> str:
turn_count = int(item.get("turn_count") or 0)
saved_title = str(item.get("title") or "").strip()
latest_question = str(item.get("latest_question") or "").strip()
if saved_title:
title = saved_title[:34] + ("..." if len(saved_title) > 34 else "")
elif latest_question:
title = latest_question[:34] + ("..." if len(latest_question) > 34 else "")
else:
title = "새 대화"
updated_at = str(item.get("updated_at") or "")
short_id = str(item.get("conversation_id") or "")[-8:]
return f"{title} · {turn_count}건 · {updated_at} · {short_id}"
def load_chat_turns(conversation_id: str, page: int) -> list[dict[str, Any]]:
offset = max(page, 0) * CHAT_TURNS_PER_PAGE
with _chat_db_connect() as connection:
rows = connection.execute(
"""
SELECT turn_id, created_at, selected_user_id, selected_user_label,
question, standalone_question, answer, basis_json,
limitations, details_json
FROM poc4_mcp_chat_turns
WHERE conversation_id = ?
ORDER BY turn_id DESC
LIMIT ? OFFSET ?
""",
(conversation_id, CHAT_TURNS_PER_PAGE, offset),
).fetchall()
return [_chat_turn_from_row(row) for row in rows]
def _is_failed_synthesis_answer(value: object) -> bool:
text = str(value or "").strip()
if not text:
return False
return (
text.startswith("MCP 조회는 완료됐지만 최종 답변 합성에 실패했습니다.")
or "MCP 결과 기반 최종 답변 생성에 실패했습니다" in text
)
def load_chat_context(conversation_id: str) -> list[dict[str, str]]:
with _chat_db_connect() as connection:
rows = connection.execute(
"""
SELECT question, answer
FROM poc4_mcp_chat_turns
WHERE conversation_id = ?
ORDER BY turn_id DESC
LIMIT ?
""",
(conversation_id, CHAT_CONTEXT_TURNS),
).fetchall()
messages: list[dict[str, str]] = []
for row in reversed(rows):
question = str(row["question"] or "").strip()
answer = str(row["answer"] or "").strip()
if question:
messages.append({"role": "user", "content": question[:1200]})
if answer and not _is_failed_synthesis_answer(answer):
messages.append({"role": "assistant", "content": answer[:1200]})
return messages[-MAX_CONVERSATION_MESSAGES:]
def save_chat_turn(
*,
conversation_id: str,
selected_user_id: str,
selected_user_label: str,
question: str,
standalone_question: str,
answer: str,
basis: Any,
limitations: str,
details: Mapping[str, Any],
) -> None:
now = _utc_now()
with _chat_db_connect() as connection:
connection.execute(
"""
INSERT OR IGNORE INTO poc4_mcp_chat_conversations
(conversation_id, created_at, updated_at, title)
VALUES (?, ?, ?, '')
""",
(conversation_id, now, now),
)
connection.execute(
"""
INSERT INTO poc4_mcp_chat_turns (
conversation_id, created_at, selected_user_id,
selected_user_label, question, standalone_question, answer,
basis_json, limitations, details_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
conversation_id,
now,
selected_user_id,
selected_user_label,
question,
standalone_question,
answer,
json.dumps(basis if isinstance(basis, list) else [], ensure_ascii=False),
limitations,
json.dumps(dict(details), ensure_ascii=False, default=str),
),
)
connection.execute(
"""
UPDATE poc4_mcp_chat_conversations
SET updated_at = ?,
title = CASE
WHEN title IS NULL OR title = '' THEN ?
ELSE title
END
WHERE conversation_id = ?
""",
(now, question[:60], conversation_id),
)
def _json_loads_or(value: object, fallback: Any) -> Any:
if not isinstance(value, str):
return fallback
try:
return json.loads(value)
except ValueError:
return fallback
def _chat_turn_from_row(row: sqlite3.Row) -> dict[str, Any]:
return {
"turn_id": row["turn_id"],
"created_at": row["created_at"],
"selected_user_id": row["selected_user_id"],
"selected_user_label": row["selected_user_label"],
"question": row["question"],
"standalone_question": row["standalone_question"],
"answer": row["answer"],
"basis": _json_loads_or(row["basis_json"], []),
"limitations": row["limitations"],
"details": _json_loads_or(row["details_json"], {}),
}
def new_conversation_id() -> str:
return "poc4-" + uuid4().hex
def _question_label(question: object) -> str:
question_id = str(getattr(question, "question_id"))
category = str(getattr(question, "category"))
title = str(getattr(question, "title", getattr(question, "text")))
return f"{question_id} · {category} · {title}"
def _apply_console_theme(profile: AppProfile) -> None:
apply_console_theme(st, profile)
def _portal_auth_value(*names: str) -> str:
for name in names:
value = (os.environ.get(name) or _dotenv_value(name)).strip()
if value:
return value
return ""
def _proxy_auth_headers() -> tuple[str, int]:
try:
headers = st.context.headers
username = str(headers.get(PORTAL_AUTH_PROXY_USER_HEADER) or "").strip()
expires_at = int(
str(headers.get(PORTAL_AUTH_PROXY_EXPIRY_HEADER) or "0").strip()
)
except (AttributeError, TypeError, ValueError):
return "", 0
return username, expires_at
def _restore_portal_proxy_session() -> None:
username, expires_at = _proxy_auth_headers()
expected_username = _portal_auth_value(
"AI_WEB_AGENT_CONSOLE_LOGIN_USER",
"POC4_LOGIN_USER",
)
authenticated = bool(
username
and expected_username
and expires_at > int(datetime.now(timezone.utc).timestamp())
and hmac.compare_digest(username, expected_username)
)
if not authenticated:
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
return
st.session_state[PORTAL_AUTHENTICATED_KEY] = True
st.session_state[PORTAL_AUTH_USER_KEY] = username
def _render_portal_login(profile: AppProfile) -> None:
with st.container(key="console_login_container"):
render_login_brand(st, profile)
st.error(
"인증 게이트웨이의 사용자 확인 정보가 없습니다. "
"공식 포털 주소로 다시 접속해 주세요."
)
st.markdown(
f'<p class="console-muted">{html.escape(profile.login_footer)}</p>',
unsafe_allow_html=True,
)
def _render_app_header(profile: AppProfile) -> None:
render_console_header(st, profile)
def _render_demo_user_card(preset: VpdTokenPreset) -> None:
st.markdown(
f"""
<div class="kb-vpd-card">
<div class="kb-vpd-user">
{html.escape(preset.user_id)} · {html.escape(preset.name)}
</div>
<div class="kb-vpd-meta">
{html.escape(preset.role)} · {html.escape(preset.team or preset.channel)}
</div>
<div class="kb-vpd-scope">
테스트 문맥: {html.escape(preset.scope)}
</div>
</div>
""",
unsafe_allow_html=True,
)
def _normalized_bearer(value: object) -> str:
if not isinstance(value, str):
return ""
token = value.strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
return token if _OPAQUE_BEARER.fullmatch(token) else ""
def _token_fingerprint(token: str) -> str:
value = str(token or "")
if not value:
return "empty"
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
return f"len={len(value)}, sha256={digest}"
def _bounded_json(value: Any, max_chars: int = 24000) -> str:
text = json.dumps(value, ensure_ascii=False, default=str)
if len(text) <= max_chars:
return text
return text[:max_chars] + "\n...<truncated>"
def _default_vpd_user_id(
presets: tuple[VpdTokenPreset, ...],
) -> str | None:
for preset in presets:
if preset.user_id == DEFAULT_VPD_USER_ID:
return preset.user_id
for preset in presets:
if preset.is_default:
return preset.user_id
return presets[0].user_id if presets else None
def _dotenv_value(name: str, path: Path = ENV_FILE) -> str:
if not name or not path.exists():
return ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return ""
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
if stripped.startswith("export "):
stripped = stripped[7:].lstrip()
key, raw_value = stripped.split("=", 1)
if key.strip() != name:
continue
value = raw_value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
return value.strip()
return ""
class AuditLogError(RuntimeError):
"""Safe audit-log error that never includes DB credentials or provider details."""
def _audit_db_env_value(name: str, default: str = "") -> str:
names = [name]
if name.startswith("AI_WEB_AGENT_CONSOLE_"):
names.append(name.replace("AI_WEB_AGENT_CONSOLE_", "POC4_", 1))
for candidate in names:
value = (
os.environ.get(candidate)
or _dotenv_value(candidate, AUDIT_DB_ENV_FILE)
).strip()
if value:
return value
return default.strip()
@st.cache_resource(show_spinner=False)
def _audit_db_pool() -> Any:
password = _audit_db_env_value("AI_WEB_AGENT_CONSOLE_AUDIT_DB_PASSWORD")
if not password:
raise AuditLogError("감사로그 DB 접속 설정을 확인해 주세요.")
dsn = _audit_db_env_value(
"AI_WEB_AGENT_CONSOLE_AUDIT_DSN", DEFAULT_AUDIT_DB_DSN
)
wallet_password = _audit_db_env_value(
"AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_PASSWORD"
)
pool_options: dict[str, Any] = {}
if wallet_password:
wallet_dir = Path(
_audit_db_env_value(
"AI_WEB_AGENT_CONSOLE_AUDIT_WALLET_DIR",
"/home/opc/apps/vpd-backoffice/wallet",
)
).expanduser().resolve()
if not wallet_dir.is_dir():
raise AuditLogError("감사로그 DB Wallet 경로를 확인해 주세요.")
pool_options.update(
config_dir=str(wallet_dir),
wallet_location=str(wallet_dir),
wallet_password=wallet_password,
)
elif not (dsn.lstrip().startswith("(") or dsn.lower().startswith("tcps://")):
raise AuditLogError(
"감사로그 DB DSN은 TLS 접속 기술자이거나 Wallet 암호와 함께 제공되어야 합니다."
)
try:
return oracledb.create_pool(
user=_audit_db_env_value(
"AI_WEB_AGENT_CONSOLE_AUDIT_DB_USER", "ADMIN"
),
password=password,
dsn=dsn,
min=1,
max=2,
increment=1,
getmode=oracledb.POOL_GETMODE_WAIT,
**pool_options,
)
except (oracledb.Error, OSError, ValueError):
raise AuditLogError("감사로그 DB에 연결하지 못했습니다.") from None
def _audit_rows(
sql: str,
binds: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
try:
with _audit_db_pool().acquire() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, dict(binds or {}))
columns = [item[0].lower() for item in cursor.description]
records: list[dict[str, Any]] = []
for row in cursor:
record: dict[str, Any] = {}
for column, value in zip(columns, row):
if hasattr(value, "read"):
value = value.read()
record[column] = value
records.append(record)
return records
except AuditLogError:
raise
except (oracledb.Error, OSError, ValueError):
raise AuditLogError("감사로그를 조회하지 못했습니다.") from None
@st.cache_data(ttl=60, show_spinner=False)
def _load_hmm_audit_inventory() -> list[dict[str, Any]]:
return _audit_rows(
"""
SELECT event_type,
COUNT(*) AS event_count,
TO_CHAR(
MAX(created_at) AT TIME ZONE 'Asia/Seoul',
'YYYY-MM-DD HH24:MI:SS'
) AS latest_event_time
FROM ADMIN.HMM_ACCESS_AUDIT
GROUP BY event_type
ORDER BY event_type
"""
)
@st.cache_data(ttl=30, show_spinner=False)
def _load_hmm_audit_events(
days: int,
row_limit: int,
event_type: str,
status: str,
) -> list[dict[str, Any]]:
return _audit_rows(
"""
SELECT *
FROM (
SELECT audit_id,
TO_CHAR(
created_at AT TIME ZONE 'Asia/Seoul',
'YYYY-MM-DD HH24:MI:SS'
) AS event_time,
event_type,
key_id,
object_id,
status,
row_count,
error_code,
message
FROM ADMIN.HMM_ACCESS_AUDIT
WHERE created_at >= (
SYSTIMESTAMP - NUMTODSINTERVAL(:days, 'DAY')
)
AND (:event_type IS NULL OR event_type = :event_type)
AND (:status IS NULL OR status = :status)
ORDER BY created_at DESC, audit_id DESC
)
WHERE ROWNUM <= :row_limit
""",
{
"days": int(days),
"event_type": event_type or None,
"status": status or None,
"row_limit": int(row_limit),
},
)
def _security_evidence_kind(question: str) -> str:
text = " ".join(str(question or "").casefold().split())
if "보험료" in text and any(
term in text for term in ("합계", "개별", "상세", "마스킹")
):
return "PREMIUM_MASK_AGGREGATE"
if any(term in text for term in ("주민번호", "rrn_masked")):
return "RRN_DISPLAY_MASK"
if (
any(term in text for term in ("공통계정", "공통 계정"))
and "채널" in text
):
return "CHANNEL_SCOPE"
if re.search(r"\bCT[0-9]+\b", str(question or ""), re.IGNORECASE) and any(
term in text for term in ("조회되는지", "담당 고객이 아님", "권한")
):
return "CONTRACT_SCOPE"
if any(term in text for term in ("로그인하지 않은", "미인증", "인증 실패")):
return "AUTH_DENIAL"
return ""
def _safe_audit_log(
cursor: Any,
*,
user_id: str,
action_name: str,
policy_result: str,
request_text: str,
response_summary: str,
) -> Mapping[str, Any]:
cursor.callproc(
"POC_2.LOG_TOOL_CALL",
[
user_id,
"KB_AI_PORTAL",
"KB_SECURITY_EVIDENCE",
action_name,
policy_result,
request_text[:1000],
response_summary[:2000],
],
)
cursor.execute(
"""
SELECT log_id, TO_CHAR(event_ts, 'YYYY-MM-DD HH24:MI:SS')
FROM POC_2.KB_SECURITY_AUDIT_LOG
WHERE log_id = (
SELECT MAX(log_id)
FROM POC_2.KB_SECURITY_AUDIT_LOG
WHERE end_user_id = :user_id
AND tool_name = 'KB_SECURITY_EVIDENCE'
AND action_name = :action_name
)
""",
{"user_id": user_id, "action_name": action_name},
)
row = cursor.fetchone()
return {
"audit_log_id": int(row[0]) if row and row[0] is not None else None,
"audit_event_time": str(row[1]) if row and row[1] is not None else "",
}
def collect_security_evidence(
question: str,
token_preset: VpdTokenPreset | None,
*,
failure_message: str = "",
) -> Mapping[str, Any]:
"""Collect predefined, token-scoped security evidence without exposing secrets."""
kind = _security_evidence_kind(question)
if not kind or token_preset is None:
return {}
user_id = token_preset.user_id.strip()
evidence: dict[str, Any] = {
"evidence_type": kind,
"user_id": user_id,
"role": token_preset.role,
"channel": token_preset.channel,
}
try:
with _audit_db_pool().acquire() as connection:
with connection.cursor() as cursor:
context_ready = False
try:
cursor.callproc(
"ADMIN.CB_AGENT_CTX_PKG.SET_USER_BY_BEARER",
[token_preset.token],
)
context_ready = True
cursor.execute(
"""
SELECT SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE'),
SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')
FROM dual
"""
)
context_row = cursor.fetchone() or ("", "")
evidence["verified_role"] = str(context_row[0] or "")
evidence["verified_channel"] = str(context_row[1] or "")
except oracledb.Error:
if kind != "AUTH_DENIAL":
raise
if kind == "PREMIUM_MASK_AGGREGATE" and context_ready:
cursor.execute(
"""
SELECT (SELECT COUNT(*)
FROM POC_2.KB_CONTRACTS
WHERE fc_channel = :channel),
ADMIN.CB_KB_PREMIUM_SUM()
FROM dual
""",
{"channel": evidence.get("verified_channel")},
)
row = cursor.fetchone() or (0, None)
cursor.execute(
"""
SELECT COUNT(*)
FROM redaction_policies
WHERE object_owner = 'POC_2'
AND object_name = 'KB_CONTRACTS'
AND policy_name = 'KB_CONTRACT_PREMIUM_REDACT'
AND enable = 'YES'
"""
)
policy_count = int((cursor.fetchone() or (0,))[0] or 0)
evidence.update(
{
"visible_contract_count": int(row[0] or 0),
"premium_sum": None if row[1] is None else int(row[1]),
"individual_premium_state": (
"MASKED" if policy_count else "POLICY_NOT_FOUND"
),
"redaction_policy": "KB_CONTRACT_PREMIUM_REDACT",
}
)
elif kind == "RRN_DISPLAY_MASK" and context_ready:
cursor.execute(
"""
SELECT COUNT(*) AS visible_rows,
SUM(CASE WHEN REGEXP_LIKE(
rrn_masked,
'^[0-9]{6}-[0-9][*]{6}$',
'c'
) THEN 1 ELSE 0 END) AS masked_rows,
SUM(CASE WHEN REGEXP_LIKE(
rrn_masked,
'^[0-9]{6}-[0-9]{7}$',
'c'
) THEN 1 ELSE 0 END) AS plaintext_rows
FROM POC_2.KB_CUSTOMERS customer
WHERE EXISTS (
SELECT 1
FROM POC_2.KB_CONTRACTS contract
WHERE contract.cust_id = customer.cust_id
AND contract.fc_id = :user_id
)
""",
{"user_id": user_id},
)
row = cursor.fetchone() or (0, 0, 0)
evidence.update(
{
"visible_rows": int(row[0] or 0),
"masked_format_rows": int(row[1] or 0),
"plaintext_rows": int(row[2] or 0),
"display_format": "YYMMDD-N******",
}
)
elif kind == "CHANNEL_SCOPE" and context_ready:
cursor.execute(
"""
SELECT SUM(CASE WHEN fc_channel = '다이렉트' THEN 1 ELSE 0 END),
SUM(CASE WHEN fc_channel = '설계사' THEN 1 ELSE 0 END),
SUM(CASE WHEN fc_channel = 'GA' THEN 1 ELSE 0 END),
SUM(CASE WHEN fc_channel = '제휴' THEN 1 ELSE 0 END),
COUNT(*)
FROM POC_2.KB_CONTRACTS
WHERE fc_channel = :channel
""",
{"channel": evidence.get("verified_channel")},
)
row = cursor.fetchone() or (0, 0, 0, 0, 0)
evidence["channel_contract_counts"] = {
"다이렉트": int(row[0] or 0),
"설계사": int(row[1] or 0),
"GA": int(row[2] or 0),
"제휴": int(row[3] or 0),
"전체": int(row[4] or 0),
}
elif kind == "CONTRACT_SCOPE" and context_ready:
match = re.search(r"\bCT[0-9]+\b", question, re.IGNORECASE)
contract_no = match.group(0).upper() if match else ""
cursor.execute(
"""
SELECT COUNT(*)
FROM POC_2.KB_CONTRACTS
WHERE contract_no = :contract_no
AND fc_id = :user_id
""",
{"contract_no": contract_no, "user_id": user_id},
)
row = cursor.fetchone()
evidence.update(
{
"contract_no": contract_no,
"visible_rows": int(row[0] or 0) if row else 0,
"policy_result": "FILTERED"
if not row or int(row[0] or 0) == 0
else "ALLOWED",
}
)
elif kind == "AUTH_DENIAL":
evidence.update(
{
"authentication_status": (
"DENIED"
if user_id == "GUEST_000"
or not context_ready
or failure_message
else "VALID"
),
"returned_rows": 0,
"failure_message": str(failure_message or "")[:500],
}
)
summary = json.dumps(evidence, ensure_ascii=False, default=str)
audit_record = _safe_audit_log(
cursor,
user_id=user_id,
action_name=kind,
policy_result=str(
evidence.get("policy_result")
or evidence.get("authentication_status")
or "EVIDENCE_CAPTURED"
),
request_text=str(question or ""),
response_summary=summary,
)
connection.commit()
evidence.update(audit_record)
evidence["audit_source"] = "POC_2.KB_SECURITY_AUDIT_LOG"
except (AuditLogError, oracledb.Error, OSError, ValueError) as exc:
evidence["evidence_error"] = type(exc).__name__
return evidence
def _enrich_answer_with_security_evidence(
answer: str,
evidence: Mapping[str, Any] | None,
) -> str:
if not evidence:
return str(answer or "")
kind = str(evidence.get("evidence_type") or "")
prefix = ""
if kind == "PREMIUM_MASK_AGGREGATE":
premium_sum = evidence.get("premium_sum")
formatted_sum = (
f"{int(premium_sum):,}" if premium_sum is not None else "조회 불가"
)
prefix = (
f"{evidence.get('user_id')} 권한에서는 개별 계약 보험료가 마스킹됩니다. "
f"{evidence.get('channel')} 전체 보험료 합계는 {formatted_sum}입니다."
)
elif kind == "RRN_DISPLAY_MASK":
prefix = (
f"평문 주민번호 반환 건수는 {int(evidence.get('plaintext_rows') or 0):,}건입니다. "
"주민번호는 모든 역할에서 상시 마스킹되며 YYMMDD-N****** 형태로만 표시됩니다."
)
elif kind == "CHANNEL_SCOPE":
counts = evidence.get("channel_contract_counts")
if isinstance(counts, Mapping):
prefix = (
f"{evidence.get('user_id')} 권한 조회 결과: 다이렉트 "
f"{int(counts.get('다이렉트') or 0):,}건, 설계사 "
f"{int(counts.get('설계사') or 0):,}건, GA "
f"{int(counts.get('GA') or 0):,}건, 제휴 "
f"{int(counts.get('제휴') or 0):,}건입니다."
)
elif kind == "CONTRACT_SCOPE":
prefix = (
f"사용자 {evidence.get('user_id')}{evidence.get('contract_no')} 조회 결과는 "
f"{int(evidence.get('visible_rows') or 0):,}건이며 권한 필터 결과는 "
f"{evidence.get('policy_result')}입니다. 계약·고객·보험료 상세는 노출되지 않았습니다."
)
elif kind == "AUTH_DENIAL":
prefix = (
f"{evidence.get('user_id')} 인증 결과는 "
f"{evidence.get('authentication_status')}이며 반환 데이터는 "
f"{int(evidence.get('returned_rows') or 0):,}건입니다."
)
if evidence.get("audit_log_id"):
prefix += (
f" 감사로그 ID는 {evidence.get('audit_log_id')}, 실행시각은 "
f"{evidence.get('audit_event_time') or '확인 필요'}입니다."
)
normalized = str(answer or "").strip()
return f"{prefix}\n\n{normalized}".strip() if prefix else normalized
def _business_evidence_kind(question: str) -> str:
text = " ".join(str(question or "").casefold().split())
if "41048" in text and "삼성화재" in text and "대물배상" in text:
return "CLAUSE_COMPARISON"
if "41047" in text and any(term in text for term in ("구버전", "섞이지")):
return "VERSION_GOVERNANCE"
if all(term in text for term in ("원본 pdf", "청크", "추적")):
return "LINEAGE_TRACE"
if "신규 약관 pdf" in text and any(
term in text for term in ("메타", "카탈로그")
):
return "METADATA_CATALOG"
if "c1001025" in text and "당사" in text and "타사" in text and "건강" in text:
return "CROSS_HOLDING"
if "c1001006" not in text:
return ""
if "계약별 담당 채널" in text:
return "VISIBLE_CONTRACT_SCOPE"
if "자차담보" in text:
return "CUSTOMER_COVERAGE"
if "내 담당 보험" in text:
return "CUSTOMER_CONTRACTS"
if "갱신 상담" in text and "삼성화재" in text:
return "AUTO_RENEWAL_COMPARISON"
if "갱신월" in text and any(term in text for term in ("전환", "유지")):
return "RENEWAL_CONSULTING"
return ""
def _cursor_rows(cursor: Any) -> list[dict[str, Any]]:
columns = [item[0].lower() for item in cursor.description]
records: list[dict[str, Any]] = []
for row in cursor:
record: dict[str, Any] = {}
for column, value in zip(columns, row):
if hasattr(value, "read"):
value = value.read()
record[column] = value
records.append(record)
return records
def _business_rows(
cursor: Any,
sql: str,
binds: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
cursor.execute(sql, dict(binds or {}))
return _cursor_rows(cursor)
def _catalog_documents(
cursor: Any,
product_codes: tuple[str, ...],
) -> list[dict[str, Any]]:
if not product_codes:
return []
placeholders = ", ".join(f":product_code_{index}" for index in range(len(product_codes)))
binds = {
f"product_code_{index}": product_code
for index, product_code in enumerate(product_codes)
}
return _business_rows(
cursor,
f"""
SELECT document_id, source_file, file_name, company, product_code,
product_name, insurance_type, product_type, document_kind,
version_label, sales_status, ingestion_status, metadata_status,
activation_eligible
FROM ADMIN.KB_DOCUMENT_V2
WHERE product_code IN ({placeholders})
ORDER BY product_code, activation_eligible DESC, version_label, file_name
""",
binds,
)
def _derived_article_metadata(text: object) -> tuple[str, str]:
normalized = " ".join(str(text or "").split())
doubled_matches = re.findall(
r"(?<![0-9])([0-9]{1,3})조\1조\s*([^0-9①-⑳]{0,30})",
normalized,
)
if doubled_matches:
number, title = doubled_matches[-1]
return f"{number}", title.strip(" ()[]·:")
explicit = re.search(r"\s*([0-9]{1,3})\s*조(?:\s*\(([^)]{1,40})\))?", normalized)
if explicit:
return f"{explicit.group(1)}", str(explicit.group(2) or "").strip()
return "", ""
def _decorate_clause_chunks(
chunks: list[dict[str, Any]],
*,
default_title: str = "약관 본문",
) -> list[dict[str, Any]]:
current_number = ""
current_title = ""
for chunk in chunks:
derived_number, derived_title = _derived_article_metadata(chunk.get("excerpt"))
if derived_number:
current_number = derived_number
current_title = derived_title or current_title
article_number = str(chunk.get("article_number") or current_number or "")
article_title = str(
chunk.get("article_title") or current_title or default_title
)
page_start = chunk.get("page_start")
page_end = chunk.get("page_end")
chunk.update(
{
"derived_article_number": article_number,
"derived_article_title": article_title,
"logical_locator": " ".join(
part for part in (article_number, article_title) if part
),
"physical_page_range": f"{page_start}-{page_end}",
"chunk_type": "body",
}
)
return chunks
def _catalog_chunks(
cursor: Any,
*,
product_code: str,
first_term: str,
second_term: str = "",
row_limit: int = 3,
) -> list[dict[str, Any]]:
first_pattern = f"%{first_term}%"
second_pattern = f"%{second_term or first_term}%"
chunks = _business_rows(
cursor,
"""
SELECT chunk_id, document_id, source_file, company, product_code,
product_name, insurance_type, product_type, version_label,
sales_status, page_start, page_end, article_number,
article_title, heading_path, sequence_in_document,
SUBSTR(display_markdown, 1, 900) excerpt
FROM (
SELECT chunk_id, document_id, source_file, company, product_code,
product_name, insurance_type, product_type, version_label,
sales_status, page_start, page_end, article_number,
article_title, heading_path, sequence_in_document,
display_markdown,
ROW_NUMBER() OVER (
ORDER BY CASE
WHEN display_markdown LIKE :first_pattern THEN 1
ELSE 2
END,
LENGTH(display_markdown) DESC,
page_start,
sequence_in_document
) AS rn
FROM ADMIN.KB_CHUNK_ACTIVE_V
WHERE product_code = :product_code
AND version_label = 'current'
AND chunk_id LIKE 'chk_%'
AND (
display_markdown LIKE :first_pattern
OR display_markdown LIKE :second_pattern
)
)
WHERE rn <= :row_limit
ORDER BY rn
""",
{
"product_code": product_code,
"first_pattern": first_pattern,
"second_pattern": second_pattern,
"row_limit": int(row_limit),
},
)
return _decorate_clause_chunks(chunks)
def _catalog_section_chunks(
cursor: Any,
*,
product_code: str,
anchor_term: str,
row_limit: int = 6,
) -> list[dict[str, Any]]:
chunks = _business_rows(
cursor,
"""
WITH anchor AS (
SELECT MIN(sequence_in_document) anchor_sequence,
MIN(page_start) KEEP (
DENSE_RANK FIRST ORDER BY sequence_in_document
) anchor_page
FROM ADMIN.KB_CHUNK_ACTIVE_V
WHERE product_code = :product_code
AND version_label = 'current'
AND chunk_id LIKE 'chk_%'
AND display_markdown LIKE :anchor_pattern
)
SELECT *
FROM (
SELECT chunk.chunk_id, chunk.document_id, chunk.source_file,
chunk.company, chunk.product_code, chunk.product_name,
chunk.insurance_type, chunk.product_type,
chunk.version_label, chunk.sales_status,
chunk.page_start, chunk.page_end, chunk.article_number,
chunk.article_title, chunk.heading_path,
chunk.sequence_in_document,
SUBSTR(chunk.display_markdown, 1, 1200) excerpt
FROM ADMIN.KB_CHUNK_ACTIVE_V chunk
CROSS JOIN anchor
WHERE chunk.product_code = :product_code
AND chunk.version_label = 'current'
AND chunk.chunk_id LIKE 'chk_%'
AND chunk.sequence_in_document BETWEEN
anchor.anchor_sequence AND anchor.anchor_sequence + 7
AND chunk.page_start BETWEEN anchor.anchor_page AND anchor.anchor_page + 3
ORDER BY chunk.sequence_in_document, chunk.page_start
)
WHERE ROWNUM <= :row_limit
""",
{
"product_code": product_code,
"anchor_pattern": f"%{anchor_term}%",
"row_limit": int(row_limit),
},
)
return _decorate_clause_chunks(chunks)
def _health_catalog_evidence(cursor: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
documents = _business_rows(
cursor,
"""
SELECT document_id, source_file, file_name, company, product_code,
product_name, insurance_type, product_type, document_kind,
version_label, ingestion_status, metadata_status,
activation_eligible
FROM ADMIN.KB_DOCUMENT_V2
WHERE product_code = '25213'
AND file_name LIKE '%일반심사형%'
AND version_label = 'current'
AND activation_eligible = 1
ORDER BY file_name
""",
)
chunks = _business_rows(
cursor,
"""
SELECT chunk_id, document_id, source_file, company, product_code,
product_name, version_label, page_start, page_end,
article_number, article_title, heading_path,
sequence_in_document, SUBSTR(display_markdown, 1, 900) excerpt
FROM (
SELECT chunk.*, ROW_NUMBER() OVER (
ORDER BY chunk.page_start, chunk.sequence_in_document
) rn
FROM ADMIN.KB_CHUNK_ACTIVE_V chunk
WHERE chunk.product_code = '25213'
AND chunk.source_file LIKE '%일반심사형%'
AND chunk.version_label = 'current'
AND chunk.chunk_id LIKE 'chk_%'
AND (
chunk.search_text LIKE '%암진단%'
OR chunk.search_text LIKE '%암 진단%'
OR chunk.search_text LIKE '%간편심사%'
)
)
WHERE rn <= 3
ORDER BY rn
""",
)
return documents, _decorate_clause_chunks(
chunks,
default_title="상품요약 및 보험금 지급제한",
)
def _external_health_catalog_evidence(
cursor: Any,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
documents = _business_rows(
cursor,
"""
SELECT document_id, source_file, file_name, company, product_code,
product_name, insurance_type, product_type, document_kind,
version_label, ingestion_status, metadata_status,
activation_eligible
FROM ADMIN.KB_DOCUMENT_V2
WHERE file_name = '약관_31084(03)_20260101.pdf'
AND version_label = 'current'
AND activation_eligible = 1
""",
)
chunks = _business_rows(
cursor,
"""
SELECT chunk_id, document_id, source_file, company, product_code,
product_name, version_label, page_start, page_end,
article_number, article_title, heading_path,
sequence_in_document, SUBSTR(display_markdown, 1, 900) excerpt
FROM (
SELECT chunk.*, ROW_NUMBER() OVER (
ORDER BY chunk.page_start, chunk.sequence_in_document
) rn
FROM ADMIN.KB_CHUNK_ACTIVE_V chunk
WHERE chunk.document_id =
'doc_16b68344b8_16b68344_약관_31084_03_20260101'
AND chunk.chunk_id LIKE 'chk_%'
AND (
chunk.search_text LIKE '%%'
OR chunk.search_text LIKE '%보장%'
OR chunk.search_text LIKE '%갱신%'
)
)
WHERE rn <= 3
ORDER BY rn
""",
)
return documents, _decorate_clause_chunks(
chunks,
default_title="상품요약 및 보험금 지급제한",
)
def collect_business_evidence(
question: str,
token_preset: VpdTokenPreset | None,
) -> Mapping[str, Any]:
"""Collect token-validated, explicitly scoped business and catalog evidence."""
kind = _business_evidence_kind(question)
if not kind or token_preset is None:
return {}
customer_match = re.search(r"\bC[0-9]+\b", question, re.IGNORECASE)
customer_id = customer_match.group(0).upper() if customer_match else ""
evidence: dict[str, Any] = {
"evidence_type": kind,
"user_id": token_preset.user_id.strip(),
"role": token_preset.role,
"channel": token_preset.channel,
"customer_id": customer_id,
"scope_enforcement": "Bearer 토큰 검증 + CUST_ID/FC_ID 명시 필터",
}
try:
with _audit_db_pool().acquire() as connection:
with connection.cursor() as cursor:
cursor.callproc(
"ADMIN.CB_AGENT_CTX_PKG.SET_USER_BY_BEARER",
[token_preset.token],
)
cursor.execute(
"""
SELECT SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'),
SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE'),
SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')
FROM dual
"""
)
context_row = cursor.fetchone() or ("", "", "")
verified_user_id = str(context_row[0] or "")
evidence.update(
{
"verified_user_id": verified_user_id,
"verified_role": str(context_row[1] or ""),
"verified_channel": str(context_row[2] or ""),
}
)
if verified_user_id != token_preset.user_id.strip():
raise AuditLogError("사용자 토큰 검증 결과가 일치하지 않습니다.")
customer_kinds = {
"AUTO_RENEWAL_COMPARISON",
"VISIBLE_CONTRACT_SCOPE",
"CROSS_HOLDING",
"CUSTOMER_COVERAGE",
"CUSTOMER_CONTRACTS",
"RENEWAL_CONSULTING",
}
if kind in customer_kinds:
if not customer_id:
return {}
binds = {
"customer_id": customer_id,
"user_id": verified_user_id,
}
evidence["contracts"] = _business_rows(
cursor,
"""
SELECT contract_no, cust_id, product_cd, contract_status,
pay_cycle, fc_channel, fc_id
FROM POC_2.KB_CONTRACTS
WHERE cust_id = :customer_id
AND fc_id = :user_id
ORDER BY contract_no
""",
binds,
)
evidence["products"] = _business_rows(
cursor,
"""
SELECT product_cd, clause_product_nm, insurance_type,
product_type, clause_version, sale_status,
active_yn, version_div
FROM POC_2.KB_PRODUCTS product
WHERE EXISTS (
SELECT 1
FROM POC_2.KB_CONTRACTS contract
WHERE contract.cust_id = :customer_id
AND contract.fc_id = :user_id
AND contract.product_cd = product.product_cd
)
ORDER BY product_cd, clause_version
""",
binds,
)
evidence["coverages"] = _business_rows(
cursor,
"""
SELECT coverage.contract_no, coverage.coverage_nm,
coverage.coverage_type, coverage.coverage_div,
coverage.insured_amt,
TO_CHAR(coverage.renew_due_dt, 'YYYY-MM-DD') renew_due_dt
FROM POC_2.KB_COVERAGES coverage
JOIN POC_2.KB_CONTRACTS contract
ON contract.contract_no = coverage.contract_no
WHERE contract.cust_id = :customer_id
AND contract.fc_id = :user_id
ORDER BY coverage.contract_no, coverage.coverage_nm
""",
binds,
)
if kind in {
"AUTO_RENEWAL_COMPARISON",
"CROSS_HOLDING",
"RENEWAL_CONSULTING",
}:
evidence["external_holdings"] = _business_rows(
cursor,
"""
SELECT holding.cust_id, holding.ext_insurer,
holding.ext_product_grp,
holding.ext_product_type,
holding.ext_renew_month,
holding.ext_clause_nm, holding.ext_file_nm,
holding.ext_sale_status
FROM POC_2.KB_EXTERNAL_HOLDINGS holding
WHERE holding.cust_id = :customer_id
AND EXISTS (
SELECT 1
FROM POC_2.KB_CONTRACTS contract
WHERE contract.cust_id = holding.cust_id
AND contract.fc_id = :user_id
)
ORDER BY holding.ext_insurer, holding.ext_file_nm
""",
binds,
)
if kind in {
"AUTO_RENEWAL_COMPARISON",
"CUSTOMER_COVERAGE",
"CUSTOMER_CONTRACTS",
"RENEWAL_CONSULTING",
"METADATA_CATALOG",
"LINEAGE_TRACE",
"CLAUSE_COMPARISON",
}:
evidence["catalog_documents"] = _catalog_documents(
cursor,
("41048", "20071")
if kind in {
"AUTO_RENEWAL_COMPARISON",
"RENEWAL_CONSULTING",
"CLAUSE_COMPARISON",
}
else ("41048",),
)
evidence["kb_clause_chunks"] = _catalog_section_chunks(
cursor,
product_code="41048",
anchor_term=(
"21조21조보상하는 손해"
if kind == "CUSTOMER_COVERAGE"
else "제2절 대인배상Ⅱ와 대물배상"
),
)
if kind in {
"AUTO_RENEWAL_COMPARISON",
"RENEWAL_CONSULTING",
"CLAUSE_COMPARISON",
}:
evidence["external_clause_chunks"] = _catalog_chunks(
cursor,
product_code="20071",
first_term="타인의 차량 및 재물",
second_term="보상하지 않는 손해",
row_limit=5,
)
if kind == "CROSS_HOLDING":
own_docs, own_chunks = _health_catalog_evidence(cursor)
ext_docs, ext_chunks = _external_health_catalog_evidence(cursor)
evidence.update(
{
"own_clause_documents": own_docs,
"own_clause_chunks": own_chunks,
"external_clause_documents": ext_docs,
"external_clause_chunks": ext_chunks,
}
)
elif kind == "VERSION_GOVERNANCE":
evidence["products"] = _business_rows(
cursor,
"""
SELECT product_cd, clause_product_nm, insurance_type,
product_type, clause_version, sale_status,
active_yn, version_div
FROM POC_2.KB_PRODUCTS
WHERE product_cd IN ('41047', '41047_OLD')
ORDER BY clause_version DESC
""",
)
evidence["catalog_documents"] = _catalog_documents(
cursor,
("41047",),
)
evidence["kb_clause_chunks"] = _catalog_chunks(
cursor,
product_code="41047",
first_term="대물배상",
second_term="자기차량손해",
row_limit=1,
)
elif kind in {"METADATA_CATALOG", "LINEAGE_TRACE"}:
evidence["products"] = _business_rows(
cursor,
"""
SELECT product_cd, clause_product_nm, insurance_type,
product_type, clause_version, sale_status,
active_yn, version_div
FROM POC_2.KB_PRODUCTS
WHERE product_cd = '41048'
""",
)
required_groups: tuple[str, ...]
if kind == "VISIBLE_CONTRACT_SCOPE":
required_groups = ("contracts",)
elif kind == "CROSS_HOLDING":
required_groups = (
"contracts",
"products",
"external_holdings",
"own_clause_documents",
"external_clause_documents",
)
elif kind == "CUSTOMER_COVERAGE":
required_groups = ("contracts", "coverages", "kb_clause_chunks")
elif kind == "CUSTOMER_CONTRACTS":
required_groups = ("contracts", "products", "kb_clause_chunks")
elif kind in {"AUTO_RENEWAL_COMPARISON", "RENEWAL_CONSULTING"}:
required_groups = (
"contracts",
"products",
"external_holdings",
"kb_clause_chunks",
"external_clause_chunks",
)
elif kind in {"METADATA_CATALOG", "VERSION_GOVERNANCE"}:
required_groups = ("products", "catalog_documents", "kb_clause_chunks")
else:
required_groups = (
"catalog_documents",
"kb_clause_chunks",
)
if kind == "CLAUSE_COMPARISON":
required_groups += ("external_clause_chunks",)
evidence["evidence_complete"] = all(
bool(evidence.get(group)) for group in required_groups
)
evidence["evidence_sources"] = [
"POC_2.KB_CONTRACTS/KB_PRODUCTS/KB_COVERAGES/KB_EXTERNAL_HOLDINGS",
"ADMIN.KB_DOCUMENT_V2/KB_CHUNK_ACTIVE_V",
]
except (AuditLogError, oracledb.Error, OSError, ValueError) as exc:
evidence["evidence_error"] = type(exc).__name__
return evidence
def _first_mapping(
evidence: Mapping[str, Any],
key: str,
*,
predicate: Any | None = None,
) -> Mapping[str, Any]:
values = evidence.get(key)
if not isinstance(values, list):
return {}
for item in values:
if isinstance(item, Mapping) and (predicate is None or predicate(item)):
return item
return {}
def _evidence_date(value: Any) -> str:
return str(value or "").split(" ", 1)[0]
def _evidence_money(value: Any) -> str:
try:
return f"{int(value):,}"
except (TypeError, ValueError):
return "확인 필요"
def _chunk_matching(
evidence: Mapping[str, Any],
key: str,
*needles: str,
) -> Mapping[str, Any]:
values = evidence.get(key)
if not isinstance(values, list):
return {}
for item in values:
if not isinstance(item, Mapping):
continue
text = str(item.get("excerpt") or "")
if all(needle in text for needle in needles):
return item
return _first_mapping(evidence, key)
def _chunk_reference(chunk: Mapping[str, Any]) -> str:
source_file = str(chunk.get("source_file") or "")
file_name = source_file.rsplit("/", 1)[-1]
locator = str(chunk.get("logical_locator") or "약관 본문")
return (
f"{locator}, {file_name}, {chunk.get('chunk_id')}, "
f"p.{chunk.get('page_start')}-{chunk.get('page_end')}"
)
def _auto_clause_comparison_table(evidence: Mapping[str, Any]) -> str:
kb_anchor = _chunk_matching(
evidence,
"kb_clause_chunks",
"6조6조보상하는 손해",
)
kb_coverage = _chunk_matching(
evidence,
"kb_clause_chunks",
"② 「대물배상」",
)
kb_exclusion = _chunk_matching(
evidence,
"kb_clause_chunks",
"보상하지 않는 손해",
)
external_coverage = _chunk_matching(
evidence,
"external_clause_chunks",
"1사고당 보험가입금액",
)
external_exclusion = _chunk_matching(
evidence,
"external_clause_chunks",
"보상하지 않는 손해",
)
kb_source_file = str(kb_anchor.get("source_file") or "").rsplit("/", 1)[-1]
kb_coverage_reference = (
f"제6조 보상하는 손해, {kb_source_file}, "
f"{kb_anchor.get('chunk_id')} + {kb_coverage.get('chunk_id')}, "
f"p.{kb_anchor.get('page_start')}-{kb_coverage.get('page_end')}"
)
return (
"| 보험사·상품코드 | 조항 | 확인 내용 | 원본 근거 |\n"
"|---|---|---|---|\n"
"| 기준 데이터 41048 | 제6조 보상하는 손해 | 피보험자동차 사고로 "
"타인의 재물을 없애거나 훼손해 부담한 법률상 손해배상책임을 보상 | "
f"{kb_coverage_reference} |\n"
"| 기준 데이터 41048 | 제8조 보상하지 않는 손해 | 고의, 전쟁·폭동, "
"천재지변, 핵연료 영향, 반복적 유상 사용 등은 약관상 제외 조건 | "
f"{_chunk_reference(kb_exclusion)} |\n"
"| 삼성화재 20071 | 대물배상 상품요약 | 타인 차량·재물 손해를 "
"1사고당 가입금액 한도로 수리비·교환가액·대차료·휴차료·영업손실·"
"시세하락손해 범위에서 보상 | "
f"{_chunk_reference(external_coverage)} |\n"
"| 삼성화재 20071 | 보상하지 않는 손해 | 고의, 전쟁·내란·폭동, "
"천재지변, 핵연료 영향, 반복적 유상 사용 등 제외 조건을 별도 확인 | "
f"{_chunk_reference(external_exclusion)} |"
)
def _business_answer_from_evidence(evidence: Mapping[str, Any] | None) -> str:
if not evidence or evidence.get("evidence_error"):
return ""
kind = str(evidence.get("evidence_type") or "")
user_id = str(evidence.get("verified_user_id") or evidence.get("user_id") or "")
customer_id = str(evidence.get("customer_id") or "")
contracts = [
item for item in evidence.get("contracts", []) if isinstance(item, Mapping)
]
products = [
item for item in evidence.get("products", []) if isinstance(item, Mapping)
]
product = products[0] if products else {}
kb_doc = _first_mapping(
evidence,
"catalog_documents",
predicate=lambda item: str(item.get("product_code") or "") == "41048",
)
external_doc = _first_mapping(
evidence,
"catalog_documents",
predicate=lambda item: str(item.get("product_code") or "") == "20071",
)
kb_chunk = _first_mapping(evidence, "kb_clause_chunks")
external_chunk = _first_mapping(evidence, "external_clause_chunks")
if kind == "VISIBLE_CONTRACT_SCOPE":
rows = "\n".join(
f"- {item.get('contract_no')}: {item.get('fc_channel')} / "
f"{item.get('fc_id')} / {item.get('contract_status')}"
for item in contracts
)
return (
"이 답변은 전체 계약 간 차이 비교가 아니라 현재 사용자에게 허용된 조회 "
f"범위를 확인한 결과입니다. {user_id} 권한에서 {customer_id} 고객의 본인 "
f"담당 계약은 {len(contracts):,}건입니다.\n{rows}\n\n"
"권한 내 두 계약의 채널·담당자는 설계사/FC00789로 확인됩니다. 권한 밖 "
"다이렉트 계약의 식별자와 상세는 조회·노출하지 않았으므로 이 결과를 고객의 "
"전체 계약 비교로 일반화할 수 없습니다. 전체 채널 비교는 별도 채널 통합 "
"검증 사용자 시나리오로 분리합니다."
).strip()
if kind == "CROSS_HOLDING":
contract = contracts[0] if contracts else {}
holding = _first_mapping(evidence, "external_holdings")
own_limit = _chunk_matching(evidence, "own_clause_chunks", "90일")
own_refund = _chunk_matching(evidence, "own_clause_chunks", "해약환급금")
ext_limit = _chunk_matching(evidence, "external_clause_chunks", "90일")
ext_reduction = _chunk_matching(
evidence,
"external_clause_chunks",
"일정기간 보험금",
)
ext_renewal = _chunk_matching(
evidence,
"external_clause_chunks",
"갱신 시 보험료",
)
return (
f"{customer_id}는 당사와 타사 건강보험을 모두 보유한 교차보유 고객입니다.\n\n"
f"- 당사: {contract.get('contract_no')} / {contract.get('product_cd')} / "
f"{product.get('insurance_type')}·{product.get('product_type')} / "
f"{contract.get('contract_status')} / {contract.get('pay_cycle')}\n"
f"- 타사: {holding.get('ext_insurer')} / {holding.get('ext_product_grp')}·"
f"{holding.get('ext_product_type')} / 갱신예정월 "
f"{holding.get('ext_renew_month')} / {holding.get('ext_file_nm')}\n\n"
"검증 경로: `KB_CONTRACTS.CUST_ID = KB_EXTERNAL_HOLDINGS.CUST_ID`를 "
"독립 EXISTS로 확인해 당사 계약과 타사 보유를 결합했습니다.\n\n"
"| 비교 항목 | 당사 25213_B | DB손보 약관_31084(03)_20260101 |\n"
"|---|---|---|\n"
"| 암 보장 면책 | 암진단비·암수술비 등 일부 담보는 가입 후 90일간 "
f"보장 제외 ({_chunk_reference(own_limit)}) | 암진단담보 예시도 가입 후 "
f"90일간 보장 제외 ({_chunk_reference(ext_limit)}) |\n"
"| 감액·한도 | 면책기간·감액지급·보장한도·자기부담금 조건을 담보별 "
f"확인 ({_chunk_reference(own_limit)}) | 가입 후 일정 기간 50% 지급, "
f"최초 1회·입원일수 한도 예시 확인 ({_chunk_reference(ext_reduction)}) |\n"
"| 해약환급·갱신 | 납입기간 중 해지 시 환급금 제한 구조를 확인 "
f"({_chunk_reference(own_refund)}) | 해약환급금 제한과 갱신 시 연령·"
f"위험률에 따른 보험료 인상 가능성 확인 ({_chunk_reference(ext_renewal)}) |\n\n"
"상담에서는 실제 가입 담보의 진단 정의, 지급 조건, 면책·감액기간, 갱신 "
"여부와 해약환급금 구조를 위 조항과 1:1로 대조해야 합니다."
)
if kind == "CUSTOMER_COVERAGE":
contract = _first_mapping(
evidence,
"contracts",
predicate=lambda item: item.get("contract_status") == "정상",
)
coverage = _first_mapping(
evidence,
"coverages",
predicate=lambda item: "자기차량" in str(item.get("coverage_nm") or ""),
)
coverage_clause = _chunk_matching(
evidence,
"kb_clause_chunks",
"① 「자기차량손해」",
)
exclusion_clause = _chunk_matching(
evidence,
"kb_clause_chunks",
"23조23조보상하지 않는 손해",
)
calculation_clause = _chunk_matching(
evidence,
"kb_clause_chunks",
"지급보험금=",
)
return (
f"{customer_id}{contract.get('contract_no')}은 정상 계약이며 상품코드는 "
f"{contract.get('product_cd')}입니다. 담보는 {coverage.get('coverage_nm')}"
f"({coverage.get('coverage_type')}), 가입금액은 "
f"{_evidence_money(coverage.get('insured_amt'))}, 갱신예정일은 "
f"{coverage.get('renew_due_dt')}입니다.\n\n"
"약관 근거는 다음과 같습니다.\n"
f"- 제21조(보상하는 손해): 타인 자동차와의 충돌은 상대 차량 등록번호와 "
"운전자 또는 소유자가 확인된 경우, 그리고 피보험자동차 전부 도난으로 인한 "
"직접 손해를 보험가입금액 한도에서 보상합니다. 보험가입금액이 보험가액보다 "
f"크면 보험가액이 한도입니다. ({_chunk_reference(coverage_clause)})\n"
"- 제23조(보상하지 않는 손해): 고의, 전쟁·폭동, 천재지변, 핵연료 영향, "
"반복적 유상 사용, 사기·횡령, 자연소모, 일부 부품만의 도난, 시험·경기용 "
f"사용 등은 제외됩니다. ({_chunk_reference(exclusion_clause)})\n"
"- 제24조(지급보험금의 계산): 손해액과 약정 비용에서 보험증권상 "
f"자기부담금을 공제합니다. ({_chunk_reference(calculation_clause)})\n\n"
f"원본 문서: {kb_doc.get('document_id')} / {kb_doc.get('source_file')}"
)
if kind == "CUSTOMER_CONTRACTS":
rows = "\n".join(
f"- {item.get('contract_no')}: {item.get('product_cd')} / "
f"{item.get('contract_status')}"
for item in contracts
)
property_clause = _chunk_matching(
evidence,
"kb_clause_chunks",
"② 「대물배상」",
)
exclusion_clause = _chunk_matching(
evidence,
"kb_clause_chunks",
"보상하지 않는 손해",
)
return (
f"{user_id} 권한에서 {customer_id}의 본인 담당 계약은 {len(contracts):,}건이며 "
f"보험종류는 {product.get('insurance_type')}입니다.\n{rows}\n\n"
"검증 경로는 `KB_CONTRACTS.PRODUCT_CD = KB_PRODUCTS.PRODUCT_CD`이며, "
f"상품 41048의 보험종류={product.get('insurance_type')}, 상품유형="
f"{product.get('product_type')}, 버전={_evidence_date(product.get('clause_version'))}, "
f"상태={product.get('version_div')}을 확인했습니다. 다이렉트 담당 계약은 "
"권한 밖이므로 노출하지 않았습니다.\n\n"
"41048 현행 약관의 주요 보장종목은 ① 대인배상Ⅰ, ② 대인배상Ⅱ, "
"③ 대물배상, ④ 자기신체사고, ⑤ 무보험자동차에 의한 상해, "
"⑥ 자기차량손해입니다. 제6조는 대인배상Ⅱ·대물배상의 보상 범위를, "
"제8조는 고의·천재지변·반복적 유상사용 등 보상 제외 조건을 규정합니다.\n\n"
f"- 제6조 근거: {_chunk_reference(property_clause)}\n"
f"- 제8조 근거: {_chunk_reference(exclusion_clause)}\n"
f"- 원본 문서: {kb_doc.get('document_id')} / {kb_doc.get('source_file')}"
)
if kind in {"AUTO_RENEWAL_COMPARISON", "RENEWAL_CONSULTING"}:
contract = _first_mapping(
evidence,
"contracts",
predicate=lambda item: item.get("contract_status") == "정상",
)
holding = _first_mapping(evidence, "external_holdings")
return (
f"{user_id} 권한에서 비교 가능한 당사 정상 계약은 "
f"{contract.get('contract_no')} 1건이며, 상품은 "
f"{product.get('clause_product_nm')}({contract.get('product_cd')}, "
f"{product.get('version_div')} {_evidence_date(product.get('clause_version'))})입니다. "
"다른 담당자의 계약은 비교 범위에 포함하지 않았습니다.\n\n"
f"타사 보유는 {holding.get('ext_insurer')} "
f"{holding.get('ext_clause_nm')}({holding.get('ext_product_grp')}·"
f"{holding.get('ext_product_type')}, {holding.get('ext_sale_status') or '판매중'})이며 "
f"갱신예정월은 {holding.get('ext_renew_month')}입니다. 따라서 2026-08 전에 "
"전환 또는 유지 상담 대상으로 분류합니다.\n\n"
f"{_auto_clause_comparison_table(evidence)}\n\n"
"제안 포인트: 동일 가입한도 기준으로 대물 확대특약과 면책조건을 먼저 "
"대조하고, 자기차량손해의 충돌·도난 범위와 자기부담금, 무보험차 상해, "
"긴급출동·운전자범위 특약, 갱신 보험료를 순서대로 비교합니다.\n\n"
f"문서 식별자: KB={kb_doc.get('document_id')}, "
f"삼성화재={external_doc.get('document_id')}; 타사 원장 파일키="
f"{holding.get('ext_file_nm')}"
)
if kind == "METADATA_CATALOG":
return (
"41048 약관은 상품 메타, 문서 카탈로그, 조항 청크가 하나의 검색 경로로 "
"통합되어 있습니다. 원시 카탈로그의 metadata_status가 partial인 항목은 "
"DB 값을 수정하지 않고 포털 조회 단계에서 조항번호·논리 위치를 본문 패턴으로 "
"자동 파생해 보강합니다.\n\n"
f"- 상품코드: {product.get('product_cd')}\n"
f"- 보험종류/상품유형: {product.get('insurance_type')} / "
f"{product.get('product_type')}\n"
f"- 약관버전: {_evidence_date(product.get('clause_version'))}\n"
f"- 판매상태/활성/버전구분: {product.get('sale_status')} / "
f"{product.get('active_yn')} / {product.get('version_div')}\n"
f"- document_id: {kb_doc.get('document_id')}\n"
f"- chunk_id: {kb_chunk.get('chunk_id')}\n"
f"- 조항번호/논리 위치: {kb_chunk.get('derived_article_number')} / "
f"{kb_chunk.get('logical_locator')}\n"
f"- chunk_type: {kb_chunk.get('chunk_type')}\n"
f"- 원본: {kb_doc.get('source_file')}\n"
f"- physical_page_range: {kb_chunk.get('physical_page_range')}\n"
f"- 저장 메타 상태/활성 대상: {kb_doc.get('metadata_status')} / "
f"{kb_doc.get('activation_eligible')}\n\n"
"따라서 상품코드·보험종류·상품유형·버전·판매상태뿐 아니라 조항번호, "
"청크ID, 원본 PDF와 페이지가 검색 가능한 응답 메타로 제공됩니다."
)
if kind == "LINEAGE_TRACE":
return (
"약관 근거는 검증 기준의 순서로 원본까지 역추적할 수 있습니다.\n\n"
f"검색 응답 → 실제 chunk_id `{kb_chunk.get('chunk_id')}` → product_cd "
f"`{kb_doc.get('product_code')}` → 02_자사상품원장 `KB_PRODUCTS`의 "
f"`{product.get('clause_product_nm')}`(약관버전 "
f"{_evidence_date(product.get('clause_version'))}, "
f"{product.get('version_div')}) → 원본 PDF `{kb_doc.get('source_file')}` → "
f"{kb_chunk.get('logical_locator')} / p.{kb_chunk.get('physical_page_range')}\n\n"
f"보조 문서키는 `{kb_doc.get('document_id')}`, chunk_type은 "
f"`{kb_chunk.get('chunk_type')}`입니다. 정답지의 `CHK_41048_03`은 형식 "
"예시이며, 위 값이 현재 카탈로그에 저장된 실제 청크ID입니다."
)
if kind == "VERSION_GOVERNANCE":
current_product = next(
(item for item in products if item.get("product_cd") == "41047"),
{},
)
old_product = next(
(item for item in products if item.get("product_cd") == "41047_OLD"),
{},
)
current_doc = _first_mapping(
evidence,
"catalog_documents",
predicate=lambda item: item.get("activation_eligible") == 1,
)
old_doc = _first_mapping(
evidence,
"catalog_documents",
predicate=lambda item: item.get("activation_eligible") == 0,
)
return (
"41047 답변 근거는 현행 약관으로 제한됩니다.\n\n"
f"- 사용: 41047 / {_evidence_date(current_product.get('clause_version'))} / "
f"{current_product.get('sale_status')} / 활성 {current_product.get('active_yn')} / "
f"{current_product.get('version_div')} / {current_doc.get('source_file')}\n"
f"- 제외: 41047_OLD / {_evidence_date(old_product.get('clause_version'))} / "
f"{old_product.get('sale_status')} / 활성 {old_product.get('active_yn')} / "
f"{old_product.get('version_div')} / ingestion_status="
f"{old_doc.get('ingestion_status')}\n\n"
f"실제 검색 청크도 현행 문서의 {kb_chunk.get('chunk_id')}만 사용했습니다."
)
if kind == "CLAUSE_COMPARISON":
return (
f"{_auto_clause_comparison_table(evidence)}\n\n"
"비교 결과, 양쪽 모두 타인 재물 손해배상책임을 기본 보장으로 두지만 실제 "
"차이는 가입한 보상한도·확대특약과 제8조 계열 면책조건, 대차료·휴차료·"
"영업손실·시세하락손해 지급기준을 동일 조건으로 대조해야 확정할 수 있습니다.\n\n"
f"- KB 문서ID/원본: {kb_doc.get('document_id')} / "
f"{kb_doc.get('file_name')}\n"
f"- 삼성화재 문서ID/원본 파일키/실제 PDF: "
f"{external_doc.get('document_id')} / 20071_0_20260611_file1 / "
f"{external_doc.get('file_name')}"
)
return ""
def _enrich_answer_with_business_evidence(
answer: str,
evidence: Mapping[str, Any] | None,
) -> str:
verified = _business_answer_from_evidence(evidence)
normalized = str(answer or "").strip()
if not verified:
return normalized
if evidence and evidence.get("evidence_complete"):
return verified
return f"{verified}\n\n{normalized}".strip()
def _runtime_env_value(*names: object) -> str:
for name in names:
key = str(name or "").strip()
if not key:
continue
value = (os.environ.get(key) or _dotenv_value(key)).strip()
if value:
return value
return ""
def _looks_like_http_url(value: str) -> bool:
return value.startswith(("http://", "https://"))
def _resolve_mcp_endpoint_config(item: Mapping[str, Any]) -> str:
raw_endpoint = str(
item.get("endpoint_url") or item.get("mcp_endpoint") or ""
).strip()
if _looks_like_http_url(raw_endpoint):
return raw_endpoint
if raw_endpoint:
resolved = _runtime_env_value(raw_endpoint)
if resolved:
return resolved
for env_key in ("endpoint_url_env", "mcp_endpoint_env", "base_url_env"):
resolved = _runtime_env_value(item.get(env_key))
if resolved:
return resolved
return raw_endpoint
def load_vpd_token_presets(
path: Path = VPD_TOKEN_PRESETS_FILE,
) -> tuple[VpdTokenPreset, ...]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return ()
except (OSError, UnicodeError, ValueError):
raise PublicMcpError(f"데모 사용자 preset 설정을 읽지 못했습니다: {path}") from None
raw_presets = payload.get("presets") if isinstance(payload, Mapping) else None
if not isinstance(raw_presets, list):
raise PublicMcpError("데모 사용자 preset 설정에 presets 배열이 필요합니다.")
presets: list[VpdTokenPreset] = []
seen: set[str] = set()
for item in raw_presets:
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
continue
token_env = str(
item.get("mcp_token_env") or item.get("token_env") or ""
).strip()
token = _normalized_bearer(
_runtime_env_value(token_env) if token_env else item.get("token")
)
user_id = str(item.get("user_id") or "").strip()
if not user_id or user_id in seen:
continue
presets.append(
VpdTokenPreset(
token=token,
user_id=user_id,
name=str(item.get("name") or "").strip(),
role=str(item.get("role") or "").strip(),
channel=str(item.get("channel") or item.get("team") or "").strip(),
scope=str(item.get("scope") or "").strip(),
is_default=item.get("default") is True,
team=str(item.get("team") or "").strip(),
)
)
seen.add(user_id)
return tuple(presets)
class _NoRedirectHandler(HTTPRedirectHandler):
"""Do not forward Authorization to a redirected endpoint."""
def redirect_request(self, request, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
del request, fp, code, msg, headers, newurl
return None
def load_mcp_servers(path: Path = MCP_SERVERS_FILE) -> tuple[list[McpServer], int]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, ValueError):
raise PublicMcpError(f"MCP 서버 설정을 읽지 못했습니다: {path}") from None
if not isinstance(payload, Mapping):
raise PublicMcpError("MCP 서버 설정 형식이 올바르지 않습니다.")
default_server_id = str(payload.get("default_server_id") or "").strip()
raw_servers = payload.get("servers")
if not isinstance(raw_servers, list):
raise PublicMcpError("MCP 서버 설정에 servers 배열이 필요합니다.")
servers: list[McpServer] = []
default_index = 0
for item in raw_servers:
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
continue
server_id = str(item.get("id") or "").strip()
endpoint_url = _resolve_mcp_endpoint_config(item)
if not server_id or not endpoint_url:
continue
raw_allowlist = item.get("tool_allowlist", [])
allowlist = (
tuple(str(name).strip() for name in raw_allowlist if str(name).strip())
if isinstance(raw_allowlist, list)
else ()
)
server = McpServer(
server_id=server_id,
endpoint_url=endpoint_url,
auth_token_env=str(item.get("auth_token_env") or "").strip(),
default_tool=str(item.get("default_tool") or PREFERRED_TOOL).strip(),
tool_allowlist=allowlist,
router_model_profile=str(
item.get("router_model_profile") or "gpt55_oci"
).strip(),
description=str(item.get("description") or ""),
)
if server.server_id == default_server_id:
default_index = len(servers)
servers.append(server)
if not servers:
raise PublicMcpError("사용 가능한 MCP 서버 설정이 없습니다.")
return servers, default_index
def _mcp_endpoint(base_url: str) -> str:
value = str(base_url or "").strip().rstrip("/")
if not value:
raise PublicMcpError("MCP URL을 입력해 주세요.")
parts = urlsplit(value)
if parts.scheme not in {"http", "https"} or not parts.netloc:
raise PublicMcpError("MCP URL 형식이 올바르지 않습니다.")
if parts.query or parts.fragment:
raise PublicMcpError("MCP URL에는 query/fragment를 넣지 마세요.")
return value if parts.path.endswith("/mcp") else f"{value}/mcp"
def _safe_request_id(prefix: str) -> str:
return f"poc4-{prefix}"
def _jsonrpc_payload(raw: bytes, request_id: str) -> Mapping[str, Any]:
if len(raw) > MAX_RESPONSE_BYTES:
raise PublicMcpError("MCP 응답이 너무 큽니다.")
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
raise PublicMcpError("MCP 응답이 JSON 형식이 아닙니다.") from None
stripped = text.strip()
if stripped.startswith("event:") or "\ndata:" in stripped:
data_lines = [
line[5:].strip()
for line in stripped.splitlines()
if line.startswith("data:")
]
stripped = "\n".join(data_lines).strip()
try:
payload = json.loads(stripped)
except ValueError:
raise PublicMcpError("MCP 응답이 JSON 형식이 아닙니다.") from None
if not isinstance(payload, Mapping):
raise PublicMcpError("MCP 응답 형식이 올바르지 않습니다.")
if payload.get("jsonrpc") != "2.0" or payload.get("id") != request_id:
raise PublicMcpError("MCP JSON-RPC 응답 형식이 올바르지 않습니다.")
if "error" in payload:
raise PublicMcpError("MCP 서버가 오류를 반환했습니다.")
return payload
def _jsonrpc_exchange(
*,
base_url: str,
bearer_token: str,
method: str,
params: Mapping[str, Any] | None = None,
request_id: str,
session_id: str = "",
) -> _JsonRpcExchange:
token = str(bearer_token or "").strip()
if not token:
raise PublicMcpError("Bearer 토큰을 입력해 주세요.")
if token.lower().startswith("bearer "):
token = token[7:].strip()
if not token or any(ch.isspace() for ch in token):
raise PublicMcpError("Bearer 토큰 형식이 올바르지 않습니다.")
message = {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": dict(params or {}),
}
body = json.dumps(
message,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
"Authorization": f"Bearer {token}",
}
if session_id:
headers["Mcp-Session-Id"] = session_id
request = Request(
_mcp_endpoint(base_url),
data=body,
method="POST",
headers=headers,
)
try:
with build_opener(_NoRedirectHandler()).open(request, timeout=60) as response:
raw = response.read(MAX_RESPONSE_BYTES + 1)
response_session_id = (
response.headers.get("Mcp-Session-Id")
or response.headers.get("mcp-session-id")
or ""
)
except HTTPError as exc:
if exc.code in {401, 403}:
raise PublicMcpError("MCP 인증에 실패했습니다. Bearer 토큰을 확인하세요.") from None
raise _McpHttpStatusError(exc.code) from None
except (URLError, TimeoutError, OSError):
raise PublicMcpError("MCP 서버에 연결하지 못했습니다.") from None
payload = _jsonrpc_payload(raw, request_id)
result = payload.get("result")
if not isinstance(result, Mapping):
raise PublicMcpError("MCP result 형식이 올바르지 않습니다.")
return _JsonRpcExchange(result=result, session_id=response_session_id)
def _jsonrpc_notification(
*,
base_url: str,
bearer_token: str,
method: str,
params: Mapping[str, Any] | None = None,
session_id: str,
) -> None:
token = str(bearer_token or "").strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
message = {"jsonrpc": "2.0", "method": method, "params": dict(params or {})}
body = json.dumps(
message,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
"Authorization": f"Bearer {token}",
"Mcp-Session-Id": session_id,
}
request = Request(_mcp_endpoint(base_url), data=body, method="POST", headers=headers)
try:
with build_opener(_NoRedirectHandler()).open(request, timeout=60) as response:
response.read(MAX_RESPONSE_BYTES + 1)
except HTTPError as exc:
if exc.code in {202, 204}:
return
if exc.code in {401, 403}:
raise PublicMcpError("MCP 인증에 실패했습니다. Bearer 토큰을 확인하세요.") from None
raise _McpHttpStatusError(exc.code) from None
except (URLError, TimeoutError, OSError):
raise PublicMcpError("MCP 서버에 연결하지 못했습니다.") from None
def _jsonrpc(
*,
base_url: str,
bearer_token: str,
method: str,
params: Mapping[str, Any] | None = None,
request_id: str,
session_id: str = "",
) -> Mapping[str, Any]:
return _jsonrpc_exchange(
base_url=base_url,
bearer_token=bearer_token,
method=method,
params=params,
request_id=request_id,
session_id=session_id,
).result
def _jsonrpc_with_session_fallback(
*,
base_url: str,
bearer_token: str,
method: str,
params: Mapping[str, Any] | None = None,
request_id: str,
) -> Mapping[str, Any]:
try:
return _jsonrpc(
base_url=base_url,
bearer_token=bearer_token,
method=method,
params=params,
request_id=request_id,
)
except _McpHttpStatusError as exc:
if exc.code != 400:
raise
return _session_jsonrpc(
base_url=base_url,
bearer_token=bearer_token,
method=method,
params=params,
request_id=request_id,
)
def _session_jsonrpc(
*,
base_url: str,
bearer_token: str,
method: str,
params: Mapping[str, Any] | None,
request_id: str,
) -> Mapping[str, Any]:
initialized = _jsonrpc_exchange(
base_url=base_url,
bearer_token=bearer_token,
method="initialize",
params={
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "poc4-mcp-discovery-ui", "version": "0.1.0"},
},
request_id=_safe_request_id("initialize"),
)
if not initialized.session_id:
raise PublicMcpError("MCP 세션 ID를 받지 못했습니다.")
_jsonrpc_notification(
base_url=base_url,
bearer_token=bearer_token,
method="notifications/initialized",
params={},
session_id=initialized.session_id,
)
return _jsonrpc(
base_url=base_url,
bearer_token=bearer_token,
method=method,
params=params,
request_id=request_id,
session_id=initialized.session_id,
)
def discover_tools(base_url: str, bearer_token: str) -> list[McpTool]:
result = _jsonrpc_with_session_fallback(
base_url=base_url,
bearer_token=bearer_token,
method="tools/list",
params={},
request_id=_safe_request_id("tools-list"),
)
raw_tools = result.get("tools", [])
if not isinstance(raw_tools, list):
raise PublicMcpError("MCP tools/list 응답 형식이 올바르지 않습니다.")
tools: list[McpTool] = []
for item in raw_tools:
if not isinstance(item, Mapping):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
schema = item.get("inputSchema")
tools.append(
McpTool(
name=name,
description=str(item.get("description") or ""),
schema=schema if isinstance(schema, Mapping) else {},
read_only=item.get("readOnly") is not False,
)
)
return tools
def discover_enabled_server_tools(
servers: list[McpServer], bearer_token: str
) -> tuple[list[McpDiscoveryResult], list[dict[str, str]]]:
discovered: list[McpDiscoveryResult] = []
failures: list[dict[str, str]] = []
for server in servers:
try:
tools = discover_tools(server.endpoint_url, bearer_token)
except PublicMcpError as exc:
failures.append({"server_id": server.server_id, "error": str(exc)})
continue
allowed = tuple(
tool
for tool in tools
if not server.tool_allowlist or tool.name in server.tool_allowlist
)
discovered.append(McpDiscoveryResult(server=server, tools=allowed))
return discovered, failures
def _mcp_server_cache_rows(
servers: list[McpServer],
) -> tuple[tuple[str, str, str, str, tuple[str, ...], str, str], ...]:
return tuple(
(
server.server_id,
server.endpoint_url,
server.auth_token_env,
server.default_tool,
server.tool_allowlist,
server.router_model_profile,
server.description,
)
for server in servers
)
def _mcp_servers_from_cache_rows(
rows: tuple[tuple[str, str, str, str, tuple[str, ...], str, str], ...],
) -> list[McpServer]:
return [
McpServer(
server_id=row[0],
endpoint_url=row[1],
auth_token_env=row[2],
default_tool=row[3],
tool_allowlist=tuple(row[4]),
router_model_profile=row[5],
description=row[6],
)
for row in rows
]
@st.cache_data(show_spinner=False)
def cached_discover_enabled_server_tools(
server_rows: tuple[tuple[str, str, str, str, tuple[str, ...], str, str], ...],
token_fingerprint: str,
cache_generation: int,
_bearer_token: str,
) -> tuple[list[McpDiscoveryResult], list[dict[str, str]]]:
"""Cache tools/list until the user explicitly refreshes it."""
del token_fingerprint, cache_generation
return discover_enabled_server_tools(
_mcp_servers_from_cache_rows(server_rows),
_bearer_token,
)
def call_tool(
*,
base_url: str,
bearer_token: str,
tool: McpTool,
arguments: Mapping[str, Any],
) -> Mapping[str, Any]:
return _jsonrpc_with_session_fallback(
base_url=base_url,
bearer_token=bearer_token,
method="tools/call",
params={"name": tool.name, "arguments": dict(arguments)},
request_id=_safe_request_id("tools-call"),
)
def _content_text_json(result: Mapping[str, Any]) -> Any:
content = result.get("content")
if not isinstance(content, list) or not content:
return None
first = content[0]
if not isinstance(first, Mapping) or first.get("type") != "text":
return None
text = first.get("text")
if not isinstance(text, str):
return None
try:
return json.loads(text)
except ValueError:
return text
def _mcp_response_payload(mcp_result: Any) -> Mapping[str, Any]:
if isinstance(mcp_result, Mapping):
response = mcp_result.get("response")
if isinstance(response, Mapping):
return response
return mcp_result
return {}
def _mcp_generated_sql(mcp_result: Any) -> str:
payload = _mcp_response_payload(mcp_result)
return str(payload.get("generatedSql") or payload.get("generated_sql") or "").strip()
def _mcp_items(mcp_result: Any) -> list[Any]:
payload = _mcp_response_payload(mcp_result)
items = payload.get("items")
return items if isinstance(items, list) else []
def _mcp_summary(mcp_result: Any) -> dict[str, Any]:
if not isinstance(mcp_result, Mapping):
return {"type": type(mcp_result).__name__}
payload = _mcp_response_payload(mcp_result)
items = _mcp_items(mcp_result)
summary: dict[str, Any] = status_result_summary(mcp_result)
for key in ("toolName", "profile", "ordsPath", "generatedSql"):
value = mcp_result.get(key) if key in mcp_result else payload.get(key)
if value:
summary[key] = value
if items:
summary["items_count"] = len(items)
elif isinstance(payload.get("items"), list):
summary["items_count"] = 0
results = payload.get("results")
if isinstance(results, list):
summary["results_count"] = len(results)
return summary or {"keys": sorted(str(key) for key in mcp_result.keys())}
def _route_key(server_id: str, tool_name: str) -> str:
return f"{server_id}::{tool_name}"
def _agent_tool_catalog(
routed_tools: list[RoutedMcpTool],
) -> tuple[list[dict[str, Any]], dict[str, RoutedMcpTool]]:
routes: dict[str, RoutedMcpTool] = {}
catalog: list[dict[str, Any]] = []
for route in routed_tools:
key = _route_key(route.server_id, route.tool.name)
routes[key] = route
properties = route.tool.schema.get("properties")
catalog.append(
{
"route_key": key,
"server_id": route.server_id,
"tool_name": route.tool.name,
"description": route.tool.description[:1000],
"input_properties": sorted(properties.keys())
if isinstance(properties, Mapping)
else [],
"read_only": route.tool.read_only,
}
)
return catalog, routes
def _complex_reasoning_model_profile(default_model_profile: str) -> str:
configured = (
os.environ.get(COMPLEX_REASONING_MODEL_PROFILE_ENV)
or _dotenv_value(COMPLEX_REASONING_MODEL_PROFILE_ENV)
or os.environ.get(LEGACY_COMPLEX_REASONING_MODEL_PROFILE_ENV)
or _dotenv_value(LEGACY_COMPLEX_REASONING_MODEL_PROFILE_ENV)
).strip()
return configured or DEFAULT_COMPLEX_REASONING_MODEL_PROFILE or default_model_profile
def _model_profile_select_options() -> tuple[tuple[str, str], ...]:
registry = load_model_registry()
return tuple(
(profile.model_key, profile.display_name)
for profile in registry.selector_options()
)
def _default_single_route(routed_tools: list[RoutedMcpTool]) -> RoutedMcpTool:
if not routed_tools:
raise McpToolRouterError("라우팅 가능한 MCP tool이 없습니다.")
for route in routed_tools:
if route.tool.name == PREFERRED_TOOL:
return route
return routed_tools[0]
def _clean_agent_tool_query(value: object, fallback: str) -> str:
text = str(value or "").strip()
if not text:
return fallback
cleaned: list[str] = []
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
if re.fullmatch(r"(?i)(limit|max_rows|top_k|candidate_k)\s*:\s*\d+", line):
continue
matched = re.match(r"(?i)^(prompt|query|question)\s*:\s*(.+)$", line)
cleaned.append(matched.group(2).strip() if matched else line)
return " ".join(cleaned).strip() or fallback
def _mcp_has_actionable_result(mcp_result: Any) -> bool:
if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result):
return True
if has_actionable_text_result(mcp_result):
return True
payload = _mcp_response_payload(mcp_result)
results = payload.get("results")
if isinstance(results, list) and results:
return True
result_count = payload.get("result_count")
return isinstance(result_count, int) and result_count > 0
def _compact_evidence_item(value: Any, *, max_text: int = 700) -> Any:
if not isinstance(value, Mapping):
text = str(value)
return text[:max_text] + ("..." if len(text) > max_text else "")
preferred_keys = (
"insurer",
"product_name",
"product_cd",
"product_code",
"contract_no",
"cust_id",
"document_id",
"chunk_id",
"source_file_name",
"file_name",
"ext_file_nm",
"clause_version",
"sale_status",
"active_yn",
"chunk_type",
"logical_locator",
"physical_page_range",
"score",
"display_markdown",
"text",
"content",
)
source = value
compact: dict[str, Any] = {}
keys = [key for key in preferred_keys if key in source]
if not keys:
keys = list(source.keys())[:12]
for key in keys:
item = source.get(key)
if isinstance(item, str):
normalized = " ".join(item.split())
compact[str(key)] = (
normalized[:max_text] + "..."
if len(normalized) > max_text
else normalized
)
else:
compact[str(key)] = item
return compact
def _mcp_answer_evidence(
mcp_result: Any,
*,
max_items: int = 20,
max_text: int = 700,
include_sql: bool = True,
) -> Any:
if not isinstance(mcp_result, Mapping):
return mcp_result
payload = _mcp_response_payload(mcp_result)
evidence: dict[str, Any] = status_result_evidence(
mcp_result,
max_chars=max(3500, min(7000, max_text * 15)),
)
for key in (
"toolName",
"profile",
"ordsPath",
"status",
"success",
"error",
"errorCode",
"errorMessage",
"generatedSql",
"generated_sql",
"result_count",
"row_count",
"audit_event_id",
"audit_log_key",
"candidate_count",
"top_k",
"candidate_k",
"retrieval_mode",
):
if not include_sql and key in {"generatedSql", "generated_sql"}:
continue
value = mcp_result.get(key) if key in mcp_result else payload.get(key)
if value not in (None, "", []):
evidence[key] = value
items = _mcp_items(mcp_result)
if items:
evidence["items"] = [
_compact_evidence_item(item, max_text=max_text)
for item in items[:max_items]
]
if isinstance(payload.get("items"), list):
evidence["items_count"] = len(items)
results = payload.get("results")
if isinstance(results, list) and results:
evidence["results"] = [
_compact_evidence_item(item, max_text=max_text)
for item in results[:max_items]
]
if isinstance(results, list):
evidence["results_count"] = len(results)
return evidence or _mcp_summary(mcp_result)
def _select_ai_query_guidance(question: str) -> list[str]:
text = str(question or "").casefold()
guidance: list[str] = [
"고객번호·고객ID·고객 식별번호는 CUST_ID이며 KB_ 테이블의 실제 컬럼만 "
"사용하고 확인되지 않은 영문 컬럼명을 생성하지 않는다"
]
if any(term in text for term in ("지급 대상", "지급대상", "보험금 지급")):
guidance.append(
"KB_CONTRACTS.CONTRACT_STATUS와 KB_CLAIMS.CLAIM_AMT, "
"PAID_AMT, CLAIM_STATUS를 CONTRACT_NO로 연결해 함께 반환한다"
)
if "보험료" in text and any(
term in text for term in ("합계", "개별", "상세", "마스킹")
):
guidance.append(
"지점장 개별 계약은 CONTRACT_NO와 마스킹 상태만 식별하고 PREMIUM은 "
"KB_CONTRACT_PREMIUM_REDACT 결과를 "
"MASKED로 표시하고 숫자 0을 실제 보험료로 해석하지 않는다. 채널 전체 "
"합계는 개별값과 분리하여 ADMIN.CB_KB_PREMIUM_SUM()의 집계 결과를 반환한다"
)
if any(term in text for term in ("주민번호", "rrn_masked")):
guidance.append(
"KB_CUSTOMERS의 CUST_ID와 RRN_MASKED 실제 반환값 및 반환 건수를 "
"조회하며 WHERE 1=0 같은 무효 조건을 만들지 않는다"
)
if any(term in text for term in ("자차담보", "실손 담보", "담보 구성")):
guidance.append(
"KB_CONTRACTS와 KB_COVERAGES를 CONTRACT_NO로 연결하고 CONTRACT_NO, "
"PRODUCT_CD, COVERAGE_NM, COVERAGE_TYPE, INSURED_AMT, RENEW_DUE_DT를 반환한다"
)
guidance.append(
"자사 담보 질문에는 KB_EXTERNAL_HOLDINGS를 조인하지 않으며 계약·담보·상품을 "
"각각 확인한 뒤 PRODUCT_CD를 후속 약관 검색 식별자로 반환한다"
)
if any(term in text for term in ("리니지", "카탈로그", "메타가 자동")):
guidance.append(
"KB_PRODUCTS의 PRODUCT_CD, INSURANCE_TYPE, PRODUCT_TYPE, "
"CLAUSE_VERSION, SALE_STATUS, ACTIVE_YN을 실제 컬럼으로 조회한다"
)
if "납입" in text and any(term in text for term in ("기준", "맞지", "위반")):
guidance.append(
"KB_CONTRACTS.PRODUCT_CD를 KB_PRODUCTS.PRODUCT_CD와 조인하고 "
"INSURANCE_TYPE이 장기이면 월납·3개월납·6개월납, 자동차 또는 "
"일반이면 연납을 허용 기준으로 PAY_CYCLE 위반 건수만 반환한다"
)
if _question_needs_cross_source(text):
guidance.append(
"고객·계약·상품·타사보험 식별을 위해 CUST_ID, CONTRACT_NO, PRODUCT_CD, "
"EXT_INSURER, EXT_PRODUCT_GRP, EXT_PRODUCT_TYPE, EXT_RENEW_MONTH, "
"EXT_CLAUSE_NM, EXT_FILE_NM을 필요한 테이블에서 함께 반환한다"
)
guidance.append(
"KB_CONTRACTS, KB_PRODUCTS, KB_EXTERNAL_HOLDINGS의 선택적 INNER JOIN으로 "
"전체 결과를 0건으로 만들지 않는다. 당사 계약과 타사 보유를 CUST_ID 기준 "
"독립 EXISTS 또는 별도 결과로 조회하고 답변 단계에서 결합한다"
)
if "c1001025" in text and any(term in text for term in ("건강보험", "건강 보험")):
guidance.append(
"C1001025의 당사 계약은 CONTRACT_STATUS와 PAY_CYCLE까지 반환하고 타사 건강 "
"보유는 EXT_PRODUCT_TYPE LIKE '%건강%'로 판정하며 EXT_PRODUCT_GRP='건강'"
"강제하지 않는다. EXT_FILE_NM을 정확히 반환한다"
)
if "c1001006" in text and any(term in text for term in ("내 담당", "상품 유형")):
guidance.append(
"현재 VPD 가시범위의 C1001006 계약을 CONTRACT_NO, PRODUCT_CD, "
"CONTRACT_STATUS별로 먼저 반환하고 타사 보유 테이블을 조인하지 않는다"
)
if "갱신월" in text and "타사" in text:
guidance.append(
"KB_EXTERNAL_HOLDINGS는 CUST_ID와 EXT_FILE_NM 기준으로 중복 제거하고, "
"당사 정상 계약은 별도 조회한 뒤 상담 답변에서 결합한다"
)
if any(term in text for term in ("공통계정", "공통 계정")) and "채널" in text:
guidance.append(
"빈 SUM 1행을 계약 1건으로 해석하지 않는다. SUM(CASE WHEN ... THEN 1 "
"ELSE 0 END) 또는 COUNT(CASE WHEN ... THEN 1 END)에 NVL을 적용해 다이렉트, "
"설계사, GA, 제휴 채널 건수를 각각 숫자 0 이상으로 반환한다"
)
if "41047" in text:
guidance.append(
"KB_PRODUCTS에서 PRODUCT_CD='41047'의 현행·구버전 행을 모두 확인하여 "
"CLAUSE_VERSION, SALE_STATUS, ACTIVE_YN, VERSION_DIV를 반환한다"
)
return guidance
def _vector_query_guidance(question: str) -> list[str]:
text = str(question or "").casefold()
guidance = [
"검색 결과에 document_id, chunk_id, product_cd, 원본 파일명, "
"logical_locator, physical_page_range, chunk_type을 가능한 범위에서 포함한다"
]
if any(term in text for term in ("비교", "차별", "타사", "삼성화재")):
guidance.append(
"자사와 타사 양쪽 약관을 검색하고 상품코드·보험사·원본 파일명·버전을 "
"서로 섞지 않는다"
)
guidance.append(
"비교 대상별 검색을 독립 실행하고 양쪽 문서의 원본 파일명·상품코드·조항·"
"페이지가 모두 확보되지 않으면 비교 완료로 표시하지 않는다"
)
if any(term in text for term in ("구버전", "최신", "현행")):
guidance.append(
"상품코드와 약관버전, 판매상태, 활성여부가 일치하는 현행 문서를 우선하고 "
"OLD·판매중지·비활성 문서는 제외한다"
)
if "41047" in text:
guidance.append(
"product_cd=41047, clause_version=2026-06-11, sale_status=판매중, "
"active_yn=Y, version_div=현행 문서만 근거로 사용하고 41047_OLD와 "
"판매중지·비활성 문서를 제외한다"
)
if "41048" in text:
guidance.append(
"product_cd=41048인 당사 현행 문서를 exact filter하고 원본 파일명과 "
"document_id, chunk_id, 조항 위치, 페이지를 함께 반환한다"
)
if "c1001025" in text:
guidance.append(
"당사 PRODUCT_CD=25213_B 문서와 타사 EXT_FILE_NM="
"약관_31084(03)_20260101 문서를 각각 exact filter한다"
)
if "대물배상" in text and "삼성화재" in text:
guidance.append(
"당사 41048 현행 문서와 삼성화재 20071_0_20260611_file1 문서에서 "
"대물배상 조항만 각각 검색한다"
)
if any(term in text for term in ("리니지", "원본 pdf", "청크", "추적")):
guidance.append(
"응답에 product_cd, clause_version, document_id, chunk_id, "
"source_file_name, logical_locator, physical_page_range를 빠짐없이 반환한다"
)
return guidance
def _append_query_guidance(query: str, guidance: list[str]) -> str:
normalized = str(query or "").strip()
if not guidance:
return normalized
return normalized + " 추가 검증 조건: " + "; ".join(guidance) + "."
def _answer_requirements(question: str) -> list[str]:
text = str(question or "").casefold()
requirements = [
"items_count=0 또는 results_count=0은 결과 전달 누락이 아니라 실제 0건으로 해석한다",
"감사 이벤트 ID나 감사 조회 결과가 없으면 감사로그 기록 여부를 단정하지 않는다",
]
if _question_needs_cross_source(text):
requirements.append(
"구조화 데이터의 계약·상품·타사보유 사실과 벡터 검색의 약관 조항을 구분해 "
"결합하고, 양쪽 출처가 없으면 확인된 항목과 미확인 항목을 나눈다"
)
if "보험료" in text and any(
term in text for term in ("합계", "개별", "상세", "마스킹")
):
requirements.append(
"security_evidence의 premium_sum을 채널 전체 합계로 사용하고 개별 PREMIUM은 "
"MASKED로 표시한다. 마스킹된 숫자 0을 실제 금액이나 합계로 해석하지 않는다"
)
if any(term in text for term in ("지급 대상", "지급대상", "보험금 지급")):
requirements.append(
"지급 판정에는 CONTRACT_STATUS, CLAIM_AMT, PAID_AMT, CLAIM_STATUS를 "
"모두 확인하고 누락 시 재조회 필요 항목을 명시한다"
)
if any(term in text for term in ("주민번호", "rrn_masked")):
requirements.append(
"security_evidence의 plaintext_rows와 masked_format_rows를 근거로 평문 "
"반환 건수와 YYMMDD-N****** 표시 형식을 답하고 원문을 추정하거나 복원하지 않는다"
)
if any(term in text for term in ("담보", "보장 개요", "가입금액")):
requirements.append(
"계약번호, 상품코드, 담보명, 가입금액을 계약 사실로 먼저 제시하고 약관 "
"요약은 별도 근거로 표시한다"
)
if any(term in text for term in ("리니지", "청크", "원본 pdf", "추적")):
requirements.append(
"product_cd, document_id, chunk_id, 원본 파일명, 조항 위치 중 실제 제공된 "
"식별자를 빠짐없이 표시하고 누락 식별자를 명시한다"
)
if _security_evidence_kind(text) in {"CONTRACT_SCOPE", "AUTH_DENIAL"}:
requirements.append(
"security_evidence의 차단 결과, 반환 건수, audit_log_id와 실행 사용자를 "
"함께 제시하고 권한 밖 계약·고객·보험료 상세를 노출하지 않는다"
)
if _security_evidence_kind(text) == "CHANNEL_SCOPE":
requirements.append(
"security_evidence.channel_contract_counts의 네 채널 숫자를 모두 제시하고 "
"전체는 실제 행 COUNT로 계산한다. 빈 집계 행을 1건으로 세지 않는다"
)
if "41047" in text:
requirements.append(
"41047 현행 2026-06-11·판매중·활성 Y 근거만 사용하고 41047_OLD, "
"2022-03-16, 판매중지, 활성 N은 제외됐음을 명시한다"
)
if "대물배상" in text and "삼성화재" in text:
requirements.append(
"41048과 20071_0_20260611_file1의 대물배상 조항을 표로 비교하고 각 행에 "
"상품코드, 원본 파일명, 조항 또는 페이지를 표시한다"
)
return requirements
def _question_needs_cross_source(question: str) -> bool:
text = str(question or "").casefold()
source_terms = (
"약관",
"삼성화재",
"db손보",
"타사",
"경쟁사",
"원본 pdf",
"청크",
"보유 자동차보험",
"갱신 상담",
)
return any(keyword in text for keyword in source_terms)
def _question_prefers_structured_single(question: str) -> bool:
text = str(question or "").casefold()
return any(
term in text
for term in (
"지급 대상",
"지급대상",
"주민번호",
"rrn_masked",
"납입구분",
"납입 기준",
"보험료 합계",
"보험료 상세",
)
)
def _is_search_route(route: RoutedMcpTool) -> bool:
properties = route.tool.schema.get("properties")
if not isinstance(properties, Mapping):
properties = {}
route_text = f"{route.server_id} {route.tool.name} {route.tool.description}".casefold()
return (
_is_vector_route(route)
or "query" in properties
or "search" in route_text
or "vector" in route_text
or "rerank" in route_text
or "hybrid" in route_text
)
def _is_vector_route(route: RoutedMcpTool) -> bool:
route_text = f"{route.server_id} {route.tool.name} {route.tool.description}".casefold()
return any(
marker in route_text
for marker in ("vector", "hybrid", "rerank", "약관 검색")
)
def _is_structured_route(route: RoutedMcpTool) -> bool:
route_text = f"{route.server_id} {route.tool.name} {route.tool.description}".casefold()
return not _is_vector_route(route) and any(
marker in route_text for marker in ("select_ai", "select ai", "ords.query")
)
def _select_unvisited_route(
question: str,
routes_by_key: Mapping[str, RoutedMcpTool],
completed_route_keys: set[str],
) -> tuple[str, RoutedMcpTool] | None:
candidates = [
(key, route)
for key, route in routes_by_key.items()
if key not in completed_route_keys
]
if not candidates:
return None
if _question_needs_cross_source(question):
if not completed_route_keys:
for key, route in candidates:
if _is_structured_route(route):
return key, route
for key, route in candidates:
if _is_vector_route(route):
return key, route
return candidates[0]
def _fallback_tool_query_for_route(
question: str,
route: RoutedMcpTool,
observations: list[Mapping[str, Any]],
) -> str:
text = str(question or "").strip()
if _is_structured_route(route):
return _append_query_guidance(text, _select_ai_query_guidance(text))
if _is_vector_route(route):
structured_context = " ".join(
str(item.get("result_excerpt") or "")
for item in observations
if "kb_mcp" in str(item.get("route_key") or "")
)
if structured_context:
text = (
f"{text} 구조화 조회에서 확인된 식별자와 파일명은 다음과 같다: "
f"{structured_context[:1600]}"
)
return _append_query_guidance(text, _vector_query_guidance(text))
return text
def _cross_source_vector_queries(
question: str,
observations: list[Mapping[str, Any]],
) -> list[str]:
text = str(question or "").strip()
folded = text.casefold()
structured_context = " ".join(
str(item.get("result_excerpt") or "")
for item in observations
if "kb_mcp" in str(item.get("route_key") or "")
)[:1800]
context_suffix = (
f" 구조화 조회 식별자: {structured_context}" if structured_context else ""
)
queries: list[str]
if "c1001025" in folded:
queries = [
"당사 상품코드 25213_B 현행 건강보험 약관 주요 보장 조항",
"DB손보 원본 파일 약관_31084(03)_20260101 건강보험 약관 주요 보장 조항",
]
elif "대물배상" in folded and "삼성화재" in folded:
queries = [
"당사 상품코드 41048 현행 자동차보험 대물배상 약관 조항",
"삼성화재 원본 파일 20071_0_20260611_file1 개인용애니카다이렉트자동차보험 대물배상 약관 조항",
]
elif "삼성화재" in folded and any(
term in folded for term in ("비교", "차별", "갱신")
):
queries = [
"당사 상품코드 41048 현행 KB개인용자동차보험 관련 보장 약관 조항",
"삼성화재 개인용애니카다이렉트자동차보험 현행 원본 약관 관련 보장 조항",
]
else:
queries = [text]
return [
_append_query_guidance(
query + context_suffix,
_vector_query_guidance(text),
)
for query in queries
]
def plan_mcp_execution_mode(
*,
question: str,
routed_tools: list[RoutedMcpTool],
model_profile_key: str,
mode_override: str,
) -> Mapping[str, Any]:
tool_catalog, routes_by_key = _agent_tool_catalog(routed_tools)
route_keys = list(routes_by_key)
fallback = _default_single_route(routed_tools)
fallback_key = _route_key(fallback.server_id, fallback.tool.name)
override = str(mode_override or "auto").strip().lower()
if override in {"single", "agent"}:
return {
"mode": override,
"route_key": fallback_key,
"reason": f"사용자 선택: {override}",
"model_profile": "",
}
if len(routed_tools) <= 1:
return {
"mode": "single",
"route_key": fallback_key,
"reason": "사용 가능한 MCP route가 1개라 단일 호출로 처리합니다.",
"model_profile": "",
}
if _question_needs_cross_source(question):
structured = next(
(route for route in routed_tools if _is_structured_route(route)),
fallback,
)
return {
"mode": "agent",
"route_key": _route_key(structured.server_id, structured.tool.name),
"reason": (
"계약·상품 데이터와 약관 근거를 함께 요구하여 구조화 MCP부터 "
"벡터 MCP까지 순차 호출합니다."
),
"model_profile": model_profile_key,
}
if _question_prefers_structured_single(question):
structured = next(
(route for route in routed_tools if _is_structured_route(route)),
fallback,
)
return {
"mode": "single",
"route_key": _route_key(structured.server_id, structured.tool.name),
"reason": "업무 테이블의 실제 컬럼과 결과 행만으로 검증 가능한 질의입니다.",
"model_profile": model_profile_key,
}
try:
profile = resolve_model_profile(model_profile_key)
client = build_oci_genai_completion_client(
profile.model_id,
profile.answer_model_region,
profile.answer_model_endpoint,
)
text = client.complete(
system_prompt=(
"You are a lightweight MCP execution planner. Decide whether "
"the user's Korean question should be answered with one MCP "
"tool call or with a multi-tool ReAct loop. Prefer mode=single "
"unless the question explicitly requires comparing, combining, "
"or validating evidence across different MCP tools/sources. "
"Return only JSON matching the schema. Never request or expose "
"bearer tokens."
),
user_prompt=json.dumps(
{
"question": question,
"routes": tool_catalog,
"single_policy": (
"counts, lists, summaries, and direct DB lookups should "
"use single unless another source is clearly required"
),
"agent_policy": (
"use agent only for cross-source comparison, contract "
"plus terms/document search, or multi-step validation"
),
},
ensure_ascii=False,
),
response_schema={
"type": "object",
"additionalProperties": False,
"required": ["mode", "route_key", "reason"],
"properties": {
"mode": {"type": "string", "enum": ["single", "agent"]},
"route_key": {"type": "string", "enum": route_keys},
"reason": {"type": "string"},
},
},
max_tokens=300,
temperature=temperature_for_model_profile(profile),
)
parsed = json.loads(text)
except Exception:
return {
"mode": "single",
"route_key": fallback_key,
"reason": "실행 방식 판단 실패로 단일 MCP 호출로 폴백했습니다.",
"model_profile": model_profile_key,
}
if not isinstance(parsed, Mapping):
return {
"mode": "single",
"route_key": fallback_key,
"reason": "실행 방식 판단 응답 형식 오류로 단일 MCP 호출로 폴백했습니다.",
"model_profile": model_profile_key,
}
mode = str(parsed.get("mode") or "single").strip().lower()
route_key = str(parsed.get("route_key") or fallback_key).strip()
if mode not in {"single", "agent"}:
mode = "single"
if route_key not in routes_by_key:
route_key = fallback_key
return {
"mode": mode,
"route_key": route_key,
"reason": str(parsed.get("reason") or "").strip(),
"model_profile": model_profile_key,
}
def _plan_agent_step(
*,
question: str,
conversation: list[Mapping[str, str]],
tool_catalog: list[Mapping[str, Any]],
observations: list[Mapping[str, Any]],
model_profile_key: str,
) -> Mapping[str, Any]:
route_keys = [str(item["route_key"]) for item in tool_catalog]
try:
profile = resolve_model_profile(model_profile_key)
client = build_oci_genai_completion_client(
profile.model_id,
profile.answer_model_region,
profile.answer_model_endpoint,
)
text = client.complete(
system_prompt=(
"You are a concise ReAct-style MCP tool planner. "
"Use the available MCP tools to answer the Korean business question. "
"If no tool has been called yet, choose action=call_tool. "
"After each observation, decide whether another tool call is needed "
"or action=final_answer is enough. Do not expose or request bearer tokens. "
"tool_query must be plain natural-language query text only; do not include "
"argument labels such as limit:, prompt:, query:, top_k:, or candidate_k:. "
"Do not write SQL. Preserve identifiers exactly. If the user says "
"계약번호, write it as 계약번호(CONTRACT_NO); if the user says 상품코드 "
"or product code, write it as 상품코드(PRODUCT_CD). Do not convert one "
"identifier type into the other. 고객번호, 고객ID, 고객 식별번호는 "
"반드시 고객번호(CUST_ID)로 작성한다. For a cross-source question, "
"call the structured kb_mcp route first to identify CUST_ID, CONTRACT_NO, "
"PRODUCT_CD, insurer, clause name, and source file. Then call the vector "
"route using those exact identifiers. Do not finish before both routes "
"have been attempted. "
"Return only JSON matching the schema."
),
user_prompt=json.dumps(
{
"question": question,
"conversation": conversation,
"available_routes": tool_catalog,
"observations": observations,
"max_tool_steps": MAX_AGENT_TOOL_STEPS,
},
ensure_ascii=False,
),
response_schema={
"type": "object",
"additionalProperties": False,
"required": ["thought", "action", "route_key", "tool_query"],
"properties": {
"thought": {"type": "string"},
"action": {"type": "string", "enum": ["call_tool", "final_answer"]},
"route_key": {
"type": "string",
"enum": [*route_keys, AGENT_FINAL_ROUTE],
},
"tool_query": {"type": "string"},
},
},
max_tokens=700,
temperature=temperature_for_model_profile(profile),
)
parsed = json.loads(text)
except Exception:
raise McpToolRouterError("MCP agent planner 호출에 실패했습니다.") from None
if not isinstance(parsed, Mapping):
raise McpToolRouterError("MCP agent planner 응답 형식이 올바르지 않습니다.")
return parsed
def run_mcp_agent_loop(
*,
question: str,
conversation: list[Mapping[str, str]],
routed_tools: list[RoutedMcpTool],
servers: list[McpServer],
bearer_token: str,
limit: int,
model_profile_key: str,
progress_callback: Any = None,
) -> dict[str, Any]:
tool_catalog, routes_by_key = _agent_tool_catalog(routed_tools)
servers_by_id = {server.server_id: server for server in servers}
observations: list[dict[str, Any]] = []
steps: list[dict[str, Any]] = []
last: dict[str, Any] | None = None
attempted_route_keys: set[str] = set()
actionable_route_keys: set[str] = set()
completed_vector_queries: set[str] = set()
stop_reason = ""
for step_no in range(1, MAX_AGENT_TOOL_STEPS + 1):
forced_plan = None
if step_no == 1 and _question_needs_cross_source(question):
forced_plan = _select_unvisited_route(
question,
routes_by_key,
attempted_route_keys,
)
if (
forced_plan is None
and _question_needs_cross_source(question)
and any(
_is_structured_route(routes_by_key[key])
for key in attempted_route_keys
if key in routes_by_key
)
):
vector_route = next(
(
(key, route)
for key, route in routes_by_key.items()
if _is_vector_route(route)
),
None,
)
pending_queries = [
query
for query in _cross_source_vector_queries(question, observations)
if query not in completed_vector_queries
]
if vector_route is not None and pending_queries:
forced_key, forced_route = vector_route
forced_plan = (forced_key, forced_route)
if forced_plan is not None:
forced_key, forced_route = forced_plan
pending_vector_queries = [
query
for query in _cross_source_vector_queries(question, observations)
if query not in completed_vector_queries
]
forced_query = (
pending_vector_queries[0]
if _is_vector_route(forced_route) and pending_vector_queries
else _fallback_tool_query_for_route(
question,
forced_route,
observations,
)
)
plan = {
"thought": (
"비교 대상별 약관 근거를 독립 검색"
if _is_vector_route(forced_route)
else "교차 조회 식별자를 확보하기 위한 구조화 MCP 우선 호출"
),
"action": "call_tool",
"route_key": forced_key,
"tool_query": forced_query,
}
else:
try:
plan = _plan_agent_step(
question=question,
conversation=conversation,
tool_catalog=tool_catalog,
observations=observations,
model_profile_key=model_profile_key,
)
except McpToolRouterError:
fallback_route = _select_unvisited_route(
question,
routes_by_key,
attempted_route_keys,
)
if fallback_route is None or not _question_needs_cross_source(question):
raise
fallback_key, route = fallback_route
plan = {
"thought": "Agent planner 장애로 미호출 MCP route를 순차 실행",
"action": "call_tool",
"route_key": fallback_key,
"tool_query": _fallback_tool_query_for_route(
question,
route,
observations,
),
}
action = str(plan.get("action") or "").strip()
route_key = str(plan.get("route_key") or "").strip()
thought = str(plan.get("thought") or "").strip()
tool_query = _clean_agent_tool_query(plan.get("tool_query"), question)
if action == "final_answer" and observations:
forced = _select_unvisited_route(
question,
routes_by_key,
attempted_route_keys,
)
if forced is None or not _question_needs_cross_source(question):
stop_reason = "planner가 추가 MCP 호출이 불필요하다고 판단했습니다."
break
route_key, route = forced
action = "call_tool"
tool_query = _fallback_tool_query_for_route(
question,
route,
observations,
)
thought = (
f"{thought} / 비교·약관 질문인데 미호출 MCP route가 있어 "
f"{route_key} 호출로 전환합니다."
).strip(" /")
if action != "call_tool" and not observations:
action = "call_tool"
route = routes_by_key.get(route_key)
if route is None:
route = next(iter(routes_by_key.values()))
route_key = _route_key(route.server_id, route.tool.name)
tool_query = append_query_contract_guidance(
tool_query,
original_question=question,
tool_name=route.tool.name,
)
is_distinct_vector_query = (
_is_vector_route(route)
and tool_query not in completed_vector_queries
)
if (
route_key in attempted_route_keys
and last is not None
and not is_distinct_vector_query
):
forced = _select_unvisited_route(
question,
routes_by_key,
attempted_route_keys,
)
if forced is None:
stop_reason = f"중복 MCP route 재호출 차단: {route_key}"
break
repeated_route_key = route_key
route_key, route = forced
tool_query = _fallback_tool_query_for_route(
question,
route,
observations,
)
tool_query = append_query_contract_guidance(
tool_query,
original_question=question,
tool_name=route.tool.name,
)
thought = (
f"{thought} / 중복 MCP route {repeated_route_key} 대신 "
f"미호출 route {route_key}로 전환합니다."
).strip(" /")
server = servers_by_id.get(route.server_id)
if server is None:
raise PublicMcpError("선택된 MCP 서버 설정을 찾지 못했습니다.")
started = perf_counter()
arguments = build_mcp_tool_arguments(
route.tool,
tool_query,
int(limit),
preferred_tool=server.default_tool,
)
raw_result = call_tool(
base_url=server.endpoint_url,
bearer_token=bearer_token,
tool=route.tool,
arguments=arguments,
)
parsed = _content_text_json(raw_result)
mcp_result = parsed if parsed is not None else raw_result
elapsed = perf_counter() - started
summary = _mcp_summary(mcp_result)
step = {
"step": step_no,
"thought": thought,
"action": "call_tool",
"server_id": server.server_id,
"tool_name": route.tool.name,
"route_key": route_key,
"tool_query": tool_query,
"arguments": arguments,
"mcp_result": mcp_result,
"result_summary": summary,
"elapsed_seconds": round(elapsed, 3),
}
steps.append(step)
observations.append(
{
"step": step_no,
"route_key": route_key,
"tool_query": tool_query,
"result_summary": summary,
"result_excerpt": _bounded_json(mcp_result, max_chars=6000),
}
)
last = {
"server": server,
"tool": route.tool,
"arguments": arguments,
"mcp_result": mcp_result,
}
attempted_route_keys.add(route_key)
if _is_vector_route(route):
completed_vector_queries.add(tool_query)
if _mcp_has_actionable_result(mcp_result):
actionable_route_keys.add(route_key)
if progress_callback:
progress_callback(step)
if last is None:
raise PublicMcpError("MCP agent가 실행한 tool 호출이 없습니다.")
return {
"server": last["server"],
"tool": last["tool"],
"arguments": last["arguments"],
"mcp_result": last["mcp_result"],
"agent_steps": steps,
"stop_reason": stop_reason,
"attempted_route_keys": sorted(attempted_route_keys),
"actionable_route_keys": sorted(actionable_route_keys),
}
def _render_mcp_result_sections(
details: Mapping[str, Any],
message_key: str,
) -> None:
mcp_result = details.get("mcp_result", {})
agent_steps = details.get("agent_steps")
execution_events = details.get("execution_events")
generated_sql = _mcp_generated_sql(mcp_result)
items = _mcp_items(mcp_result)
if isinstance(execution_events, list) and execution_events:
with st.expander(f"처리 시간 로그 · {len(execution_events)}"):
rows: list[dict[str, Any]] = []
for event in execution_events:
if not isinstance(event, Mapping):
continue
rows.append(
{
"진행률": event.get("percent"),
"단계": str(event.get("label") or ""),
"단계소요(s)": event.get("elapsed_seconds"),
"누적소요(s)": event.get("total_elapsed_seconds"),
"상세": str(event.get("detail") or ""),
}
)
if rows:
st.dataframe(rows, hide_index=True, width="stretch")
if isinstance(agent_steps, list) and agent_steps:
with st.expander(f"Agent 실행 단계 · {len(agent_steps)}"):
stop_reason = str(details.get("agent_stop_reason") or "").strip()
if stop_reason:
st.caption(f"종료 사유: {stop_reason}")
for step in agent_steps:
if not isinstance(step, Mapping):
continue
st.markdown(
f"**Step {step.get('step')} · "
f"{step.get('server_id')} / {step.get('tool_name')}**"
)
thought = str(step.get("thought") or "").strip()
tool_query = str(step.get("tool_query") or "").strip()
if thought:
st.caption(f"판단: {thought}")
if tool_query:
st.caption(f"툴 질의: {tool_query}")
st.markdown("전달 arguments")
st.json(step.get("arguments", {}))
st.markdown("응답 요약")
st.json(step.get("result_summary", {}))
if generated_sql:
with st.expander("생성 SQL"):
st.code(generated_sql, language="sql")
if items:
with st.expander(f"조회 결과 테이블 · {len(items)}"):
display_items = items[:100]
st.dataframe(display_items, use_container_width=True)
if len(items) > len(display_items):
st.caption(f"화면에는 최초 {len(display_items)}건만 표시합니다.")
with st.expander("MCP 호출 상세"):
st.write(f"route: {details.get('server_id')} / {details.get('tool_name')}")
execution_mode = str(details.get("execution_mode") or "").strip()
execution_reason = str(details.get("execution_mode_reason") or "").strip()
if execution_mode:
st.caption(
"실행 방식: "
+ execution_mode
+ (f" · {execution_reason}" if execution_reason else "")
)
reasoning_model = str(details.get("reasoning_model_profile") or "").strip()
answer_model = str(details.get("answer_model_profile") or "").strip()
fallback_model = str(
details.get("answer_synthesis_fallback_model") or ""
).strip()
planner_model = str(details.get("execution_mode_model_profile") or "").strip()
if planner_model or reasoning_model or answer_model:
st.caption(
"모델: "
+ f"planner={planner_model or '-'} · "
+ f"reasoning={reasoning_model or '-'} · "
+ f"answer={answer_model or '-'}"
+ (f" · fallback={fallback_model}" if fallback_model else "")
)
if details.get("standalone_question"):
st.caption(f"MCP 질의: {details['standalone_question']}")
if details.get("tool_query"):
st.caption(f"툴별 정제 질의: {details['tool_query']}")
st.markdown("전달 arguments")
st.json(details.get("arguments", {}))
st.markdown("MCP 응답 요약")
st.json(_mcp_summary(mcp_result))
synthesis_error = details.get("answer_synthesis_error")
if isinstance(synthesis_error, Mapping) and synthesis_error:
st.markdown("최종 답변 합성 실패 진단")
st.json(dict(synthesis_error))
raw_key = f"poc4_show_raw_mcp_{message_key}"
if st.button("MCP 원본 JSON 보기/숨기기", key=f"{raw_key}_button"):
st.session_state[raw_key] = not bool(st.session_state.get(raw_key))
if st.session_state.get(raw_key):
st.json(mcp_result)
routes = details.get("discovered_routes")
failures = details.get("discovery_failures")
if routes or failures:
st.markdown("Discovered routes")
st.json({"routes": routes or [], "failures": failures or []})
def _conversation_context(messages: list[Mapping[str, Any]]) -> list[dict[str, str]]:
context: list[dict[str, str]] = []
for message in messages[-MAX_CONVERSATION_MESSAGES:]:
role = str(message.get("role") or "").strip()
content = str(message.get("content") or "").strip()
if role in {"user", "assistant"} and content:
context.append({"role": role, "content": content[:2000]})
return context
def resolve_standalone_question(
*,
question: str,
messages: list[Mapping[str, Any]],
model_profile_key: str,
) -> str:
context = _conversation_context(messages)
if not context:
return question
try:
profile = resolve_model_profile(model_profile_key)
client = build_oci_genai_completion_client(
profile.model_id,
profile.answer_model_region,
profile.answer_model_endpoint,
)
text = client.complete(
system_prompt=(
"Rewrite the user's latest Korean question into one standalone "
"MCP tool query. Use the conversation only to resolve references. "
"Do not answer the question."
),
user_prompt=json.dumps(
{"conversation": context, "latest_question": question},
ensure_ascii=False,
),
response_schema={
"type": "object",
"additionalProperties": False,
"required": ["standalone_question"],
"properties": {"standalone_question": {"type": "string"}},
},
max_tokens=500,
temperature=temperature_for_model_profile(profile),
)
parsed = json.loads(text)
rewritten = str(parsed.get("standalone_question") or "").strip()
except Exception:
return question
return rewritten or question
def prepare_tool_query_for_mcp(
*,
question: str,
server: McpServer,
tool: McpTool,
model_profile_key: str,
selected_user_id: str = "",
selected_user_role: str = "",
selected_user_channel: str = "",
selected_user_scope: str = "",
) -> str:
"""Rewrite the user question into a selected-tool-specific natural query."""
fallback = str(question or "").strip()
if not fallback:
return fallback
if server.server_id == "hmm_hr_mcp":
return _prepare_hmm_hr_tool_query(
question=fallback,
tool=tool,
model_profile_key=model_profile_key,
selected_user_id=selected_user_id,
selected_user_role=selected_user_role,
selected_user_team=selected_user_channel,
selected_user_scope=selected_user_scope,
)
normalized_question = " ".join(fallback.casefold().split())
individual_scope_terms = (
"내 담당이 아닌",
"다른 설계사",
"다른 채널",
"타 설계사",
"타 채널",
)
is_select_ai_tool = (
server.server_id == "kb_mcp"
and "select_ai" in tool.name.casefold()
)
query_guidance = (
_select_ai_query_guidance(fallback)
if is_select_ai_tool
else _vector_query_guidance(fallback)
if "vector" in server.server_id.casefold()
else []
)
is_channel_scope_validation = (
bool(selected_user_id.strip())
and is_select_ai_tool
and (
"공통계정" in normalized_question
or "공통 계정" in normalized_question
)
and "채널" in normalized_question
and any(term in normalized_question for term in ("조회", "접근", "권한", "vpd"))
)
if is_channel_scope_validation:
user_id = selected_user_id.strip()
expected_channel = selected_user_channel.strip() or "선택 채널"
return (
f"POC_2.KB_CONTRACTS에서 현재 VPD 공통계정 사용자 {user_id}에게 실제로 "
f"조회되는 계약을 검증해줘. 이 사용자의 권한 채널은 {expected_channel}이다. "
"조회 결과 전체를 한 번 집계해서 FC_CHANNEL='다이렉트' 계약 건수, "
"FC_CHANNEL='설계사' 계약 건수, FC_CHANNEL='GA' 계약 건수, "
"FC_CHANNEL='제휴' 계약 건수를 각각 별도 컬럼으로 반드시 보여줘. "
"계약번호는 CONTRACT_NO, 고객번호는 CUST_ID, 담당자는 FC_ID, "
"담당 채널은 FC_CHANNEL 실제 컬럼만 사용해줘. "
"VPD 정책, 시스템 카탈로그, 사용자 또는 권한 메타데이터는 조회하지 말고 "
"현재 토큰으로 보이는 KB_CONTRACTS 업무 데이터만 사용해줘."
)
is_individual_scope_validation = (
bool(selected_user_id.strip())
and is_select_ai_tool
and any(term in normalized_question for term in individual_scope_terms)
and any(term in normalized_question for term in ("조회", "접근", "권한", "vpd"))
)
if is_individual_scope_validation:
user_id = selected_user_id.strip()
return (
f"POC_2.KB_CONTRACTS에서 현재 VPD 사용자 {user_id}에게 실제로 조회되는 "
"계약을 FC_CHANNEL별로 집계해줘. 전체 계약 건수, "
f"FC_ID='{user_id}'인 본인 담당 계약 건수, "
f"FC_ID<>'{user_id}'인 다른 설계사 계약 건수를 각각 보여줘. "
"계약번호는 CONTRACT_NO, 고객번호는 CUST_ID, 담당자는 FC_ID, "
"담당 채널은 FC_CHANNEL 실제 컬럼만 사용해줘. "
"VPD 정책, 시스템 카탈로그, 사용자 또는 권한 메타데이터는 조회하지 말고 "
"현재 토큰으로 보이는 KB_CONTRACTS 업무 데이터만 사용해줘."
)
properties = tool.schema.get("properties")
if not isinstance(properties, Mapping):
properties = {}
safe_properties = {
str(name): {
"type": value.get("type") if isinstance(value, Mapping) else None,
"description": (
str(value.get("description") or "")[:500]
if isinstance(value, Mapping)
else ""
),
}
for name, value in properties.items()
if str(name).lower()
not in {"bearertoken", "bearer_token", "token", "authorization"}
}
try:
profile = resolve_model_profile(model_profile_key)
client = build_oci_genai_completion_client(
profile.model_id,
profile.answer_model_region,
profile.answer_model_endpoint,
)
text = client.complete(
system_prompt=(
"You prepare one selected-tool-specific MCP query. "
"Return only JSON matching the schema. Do not answer the user. "
"Do not write SQL. Do not expose or request bearer tokens. "
"The output tool_query must be plain Korean natural-language text "
"for the selected MCP tool, not a JSON string and not argument labels. "
"Never include labels such as prompt:, query:, question:, limit:, "
"top_k:, candidate_k:, or max_rows:. "
"Use the tool description and input properties to make the query "
"fit that tool. For database Select AI tools, make filters, columns, "
"and identifier meanings explicit. For document/vector search tools, "
"produce compact search keywords and document terms. Preserve all IDs "
"and numbers exactly. If the user says 계약번호, write 계약번호(CONTRACT_NO); "
"if the user says 상품코드 or product code, write 상품코드(PRODUCT_CD). "
"고객번호, 고객ID, 고객 식별번호는 반드시 고객번호(CUST_ID)로 작성한다. "
"담당 채널은 담당 채널(FC_CHANNEL), 설계사는 설계사(FC_ID), "
"계약상태는 계약상태(CONTRACT_STATUS)로 작성한다. "
"VPD 적용 여부, 권한 범위, 접근 가능 여부를 묻는 질의는 VPD 정책, "
"시스템 카탈로그, 사용자 또는 권한 메타데이터를 조회하지 않는다. "
"현재 선택 사용자의 VPD 컨텍스트로 보이는 POC_2.KB_CONTRACTS의 "
"업무 데이터 결과만 사용해 실효 접근 범위를 검증한다. "
"DB Select AI 질의에는 제공된 KB 테이블의 실제 컬럼만 사용하고, "
"확인되지 않은 영문 컬럼명을 추측하여 생성하지 않는다. "
"보험금 지급 판단은 KB_CONTRACTS.CONTRACT_STATUS와 "
"KB_CLAIMS.CLAIM_AMT, PAID_AMT, CLAIM_STATUS를 포함한다. "
"담보 질문은 KB_COVERAGES.COVERAGE_NM, COVERAGE_TYPE, "
"INSURED_AMT, RENEW_DUE_DT를 포함한다. 보험료 질문에서 개별값과 "
"채널 합계는 별도 범위로 계산한다. 주민번호 확인 질문은 "
"KB_CUSTOMERS.RRN_MASKED 실제 결과를 조회하며 WHERE 1=0을 만들지 않는다. "
"Do not convert one identifier type into the other."
),
user_prompt=json.dumps(
{
"question": fallback,
"selected_server_id": server.server_id,
"selected_tool_name": tool.name,
"selected_tool_description": tool.description[:1200],
"input_properties": safe_properties,
"default_tool": server.default_tool,
"selected_vpd_user": {
"user_id": selected_user_id.strip(),
"role": selected_user_role.strip(),
"channel": selected_user_channel.strip(),
"scope": selected_user_scope.strip(),
},
"required_query_guidance": query_guidance,
},
ensure_ascii=False,
),
response_schema={
"type": "object",
"additionalProperties": False,
"required": ["tool_query"],
"properties": {"tool_query": {"type": "string"}},
},
max_tokens=600,
temperature=temperature_for_model_profile(profile),
)
parsed = json.loads(text)
rewritten = _clean_agent_tool_query(parsed.get("tool_query"), fallback)
except Exception:
return _append_query_guidance(fallback, query_guidance)
return _append_query_guidance(rewritten or fallback, query_guidance)
def _hmm_demo_user_context(
question: str,
*,
user_id: str,
role: str,
team: str,
scope: str,
) -> str:
"""Make a selected HR persona useful without claiming row-level enforcement."""
normalized = str(question or "").strip()
if not user_id:
return normalized
profile = " · ".join(item for item in (user_id, role, team) if item)
purpose = f" 테스트 목적: {scope}." if scope else ""
return (
f"현재 HMM HR 데모 사용자: {profile}.{purpose} "
"질문의 ‘나’, ‘내’, ‘우리 팀’은 이 데모 사용자를 기준으로 해석하고, "
"실제 행 수준 권한이 적용됐다고 주장하지 마세요.\n"
f"질문: {normalized}"
)
def _prepare_hmm_hr_tool_query(
*,
question: str,
tool: McpTool,
model_profile_key: str,
selected_user_id: str,
selected_user_role: str,
selected_user_team: str,
selected_user_scope: str,
) -> str:
"""Prepare an HMM HR query without inheriting retired KB/VPD prompt rules."""
fallback = str(question or "").strip()
def with_contract(value: str) -> str:
return append_query_contract_guidance(
value,
original_question=fallback,
tool_name=tool.name,
)
if tool.name in {"resolve_hr_term", "search_hr_policy"}:
return with_contract(fallback)
contextual_question = _hmm_demo_user_context(
fallback,
user_id=selected_user_id.strip(),
role=selected_user_role.strip(),
team=selected_user_team.strip(),
scope=selected_user_scope.strip(),
)
if tool.name == "search_carrier_performance":
return with_contract(contextual_question)
try:
profile = resolve_model_profile(model_profile_key)
client = build_oci_genai_completion_client(
profile.model_id,
profile.answer_model_region,
profile.answer_model_endpoint,
)
text = client.complete(
system_prompt=(
"You prepare one Korean natural-language query for an HMM HR MCP tool. "
"Return only JSON matching the schema. Do not answer the user, write SQL, "
"or expose/request tokens. Preserve employee codes exactly. "
"The HMM HR data tool can query organization, employees, leave balances, "
"leave requests, attendance, and standardized HR terms. The policy tool searches "
"HR policy PDF abstracts and chunks. The carrier federation tool joins ADB "
"employee assignments with PostgreSQL carrier KPI data by CARRIER_CODE. "
"A selected demo user only resolves pronouns "
"such as 'my' or 'our team'; do not claim that it enforces database access control."
),
user_prompt=json.dumps(
{
"question": contextual_question,
"selected_tool_name": tool.name,
"selected_tool_description": tool.description[:1200],
},
ensure_ascii=False,
),
response_schema={
"type": "object",
"additionalProperties": False,
"required": ["tool_query"],
"properties": {"tool_query": {"type": "string"}},
},
max_tokens=500,
temperature=temperature_for_model_profile(profile),
)
parsed = json.loads(text)
rewritten = _clean_agent_tool_query(parsed.get("tool_query"), fallback)
except Exception:
return with_contract(contextual_question)
return with_contract(
_hmm_demo_user_context(
rewritten or fallback,
user_id=selected_user_id.strip(),
role=selected_user_role.strip(),
team=selected_user_team.strip(),
scope=selected_user_scope.strip(),
)
)
def synthesize_answer(
*,
question: str,
conversation: list[Mapping[str, str]],
server: McpServer,
tool: McpTool,
arguments: Mapping[str, Any],
mcp_result: Any,
model_profile_key: str,
agent_steps: list[Mapping[str, Any]] | None = None,
security_evidence: Mapping[str, Any] | None = None,
business_evidence: Mapping[str, Any] | None = None,
) -> Mapping[str, Any]:
contract_tools = {tool.name}
contract_tools.update(
str(step.get("tool_name") or "")
for step in (agent_steps or [])
if isinstance(step, Mapping)
)
active_contracts_by_id: dict[str, Mapping[str, Any]] = {}
for contract_tool in contract_tools:
for contract in matching_query_contracts(question, contract_tool):
contract_id = str(contract.get("id") or "")
active_contracts_by_id[contract_id] = contract
active_contracts = tuple(active_contracts_by_id.values())
contract_evidence = {
"mcp_result": mcp_result,
"agent_steps": list(agent_steps or []),
}
contract_report = evidence_contract_report(active_contracts, contract_evidence)
if contract_report and any(
not bool(report.get("satisfied")) for report in contract_report
):
missing_fields = sorted(
{
str(field)
for report in contract_report
for field in report.get("missing_fields", [])
}
)
failed_calculations = sorted(
{
str(check.get("field") or "")
for report in contract_report
for check in report.get("computed_field_checks", [])
if not bool(check.get("satisfied"))
}
)
failed_temporal_checks = sorted(
{
str(check.get("check") or "")
for report in contract_report
for check in report.get("temporal_contract_checks", [])
if not bool(check.get("satisfied"))
}
)
evidence_issues = []
if missing_fields:
evidence_issues.append("필수 필드 누락: " + ", ".join(missing_fields))
if failed_calculations:
evidence_issues.append(
"계산 결과 불일치: " + ", ".join(failed_calculations)
)
if failed_temporal_checks:
evidence_issues.append(
"시간 기준 계약 불일치: " + ", ".join(failed_temporal_checks)
)
return {
"answer": missing_evidence_message(active_contracts),
"basis": evidence_issues or ["질의 계약 검증 실패"],
"limitations": (
"필수 원장 근거가 충족되지 않아 잔여일수나 승인 가능 여부를 "
"추정하지 않았습니다."
),
"query_contracts": list(active_contracts),
"evidence_contract_report": contract_report,
}
def build_user_prompt(*, compact: bool) -> str:
evidence_items = 10 if compact else 20
evidence_text = 260 if compact else 380
step_items = 4 if compact else 6
step_text = 300 if compact else 480
prompt_payload = {
"question": question,
"required_answer_checks": _answer_requirements(question),
"query_contracts": list(active_contracts),
"evidence_contract_report": contract_report,
"conversation": [] if compact else conversation[-4:],
"selected_route": {
"server_id": server.server_id,
"tool_name": tool.name,
},
"tool_arguments": dict(arguments),
"security_evidence": dict(security_evidence or {}),
"business_evidence": _bounded_json(
dict(business_evidence or {}),
max_chars=7000 if compact else 11000,
),
"mcp_result": _bounded_json(
_mcp_answer_evidence(
mcp_result,
max_items=evidence_items,
max_text=evidence_text,
include_sql=False,
),
max_chars=6000 if compact else 8000,
),
"agent_steps": [
{
"step": step.get("step"),
"server_id": step.get("server_id"),
"tool_name": step.get("tool_name"),
"tool_query": step.get("tool_query"),
"arguments": step.get("arguments", {}),
"mcp_result_evidence": _bounded_json(
_mcp_answer_evidence(
step.get("mcp_result"),
max_items=step_items,
max_text=step_text,
include_sql=False,
),
max_chars=3600 if compact else 5200,
),
}
for step in (agent_steps or [])
],
}
return json.dumps(prompt_payload, ensure_ascii=False)
user_prompt = build_user_prompt(compact=False)
response_schema = {
"type": "object",
"additionalProperties": False,
"required": ["answer", "basis", "limitations"],
"properties": {
"answer": {"type": "string"},
"basis": {
"type": "array",
"items": {"type": "string"},
"maxItems": 5,
},
"limitations": {"type": "string"},
},
}
diagnostics: dict[str, Any] = {
"model_profile": str(model_profile_key),
"payload_chars": len(user_prompt),
"agent_steps": len(agent_steps or []),
"security_evidence": bool(security_evidence),
"business_evidence": bool(business_evidence),
}
try:
profile = resolve_model_profile(model_profile_key)
client = build_oci_genai_completion_client(
profile.model_id,
profile.answer_model_region,
profile.answer_model_endpoint,
)
diagnostics.update(
{
"model_key": profile.model_key,
"model_id": profile.model_id,
"region": profile.answer_model_region,
}
)
except Exception as exc:
diagnostics.update(
{
"stage": "client_init",
"error_type": type(exc).__name__,
"error": str(exc)[:500],
}
)
LOG.warning("poc4_answer_synthesis_client_init_failed %s", diagnostics)
raise AnswerSynthesisError(
"MCP 결과 기반 최종 답변 생성에 실패했습니다.",
diagnostics,
) from None
system_prompt = (
"You write final Korean answers from MCP tool results. "
"Use English for internal instructions, but the final answer "
"field must be written only in Korean. "
"Use only the provided MCP results as evidence. Do not invent "
"facts. If the MCP result is an error or permission denial, "
"say that clearly. Treat items_count=0 or results_count=0 as an actual "
"zero-row result, not as missing tool delivery. Never claim that an audit "
"event exists unless its identifier or audit result is in the evidence. "
"For compatibility MCP tools, the result string contains the factual "
"DOC and EVIDENCE lines; read it as evidence rather than treating it as metadata. "
"For cross-source questions, keep structured contract/product facts separate "
"from vector clause evidence, then combine only matching identifiers. If one "
"source is missing, list confirmed and unconfirmed points separately instead "
"of saying only that comparison is impossible. Do not confuse row-level "
"masked values with aggregate values. Preserve product_cd, contract_no, "
"document_id, chunk_id, source filename, version, and clause locator when "
"they are provided. Security evidence is a predefined token-scoped database "
"verification result and takes precedence over ambiguous redacted MCP values. "
"Business evidence is a Bearer-token-validated result with explicit CUST_ID and "
"FC_ID scoping for customer data, plus active catalog document/chunk evidence. "
"It takes precedence when Select AI returns an ambiguous or incorrect zero-row "
"result. Never expose customer rows outside the business evidence scope. "
"The query_contracts and evidence_contract_report are authoritative. Follow "
"their required fields, calculations, temporal semantics, missing-record "
"semantics, forbidden fallbacks, and answer restrictions. Keep data facts "
"and policy requirements separate. "
"Show audit_log_id when it is provided. Follow every required_answer_checks item in the user "
"payload. When the user requests a result list, include every "
"row provided in the MCP evidence, up to 20 rows, and state the total "
"returned row count. Do not arbitrarily stop at five rows. Keep the "
"answer concise and business-readable."
)
for attempt in range(1, 3):
compact = attempt > 1
if compact:
user_prompt = build_user_prompt(compact=True)
diagnostics["payload_chars"] = len(user_prompt)
diagnostics["compact_payload"] = True
text = ""
try:
text = client.complete(
system_prompt=system_prompt,
user_prompt=user_prompt,
response_schema=response_schema,
max_tokens=1600,
temperature=temperature_for_model_profile(profile),
)
if not text.strip():
raise ValueError("empty structured response")
parsed = json.loads(text)
if not isinstance(parsed, Mapping) or not isinstance(
parsed.get("answer"), str
):
raise ValueError("invalid structured answer")
enriched = dict(parsed)
enriched["answer"] = _enrich_answer_with_security_evidence(
str(parsed.get("answer") or ""),
security_evidence,
)
enriched["answer"] = _enrich_answer_with_business_evidence(
str(enriched.get("answer") or ""),
business_evidence,
)
basis = list(parsed.get("basis") or [])
if security_evidence and security_evidence.get("audit_log_id"):
basis.append(
"보안 검증 증적: "
f"{security_evidence.get('audit_source')} "
f"LOG_ID={security_evidence.get('audit_log_id')}"
)
if business_evidence and business_evidence.get("evidence_complete"):
basis.append(
"업무 검증 증적: "
+ ", ".join(
str(source)
for source in business_evidence.get("evidence_sources", [])
)
)
evidence_verified = bool(
business_evidence
and business_evidence.get("evidence_complete")
and not business_evidence.get("evidence_error")
) or bool(
security_evidence
and security_evidence.get("evidence_type")
and not security_evidence.get("evidence_error")
)
if evidence_verified:
enriched["limitations"] = ""
enriched["basis"] = basis[:5]
enriched["query_contracts"] = list(active_contracts)
enriched["evidence_contract_report"] = contract_report
return enriched
except Exception as exc:
last_error = {
"stage": "completion",
"attempt": attempt,
"error_type": type(exc).__name__,
"error": str(exc)[:500],
"raw_text_chars": len(text),
"raw_text_head": text[:200],
}
diagnostics.update(last_error)
LOG.warning("poc4_answer_synthesis_attempt_failed %s", diagnostics)
if str(exc) == "empty structured response":
break
raise AnswerSynthesisError(
"MCP 결과 기반 최종 답변 생성에 실패했습니다.",
diagnostics,
) from None
def fallback_answer_from_mcp(
mcp_result: Any,
agent_steps: list[Mapping[str, Any]] | None = None,
) -> str:
items = _mcp_items(mcp_result)
payload = _mcp_response_payload(mcp_result)
results = payload.get("results")
lines = ["MCP 조회는 완료됐지만 최종 답변 합성에 실패했습니다."]
if items:
lines.append(f"조회 결과는 {len(items)}건입니다.")
lines.append("")
lines.append("주요 결과:")
for item in items[:20]:
compact = _compact_evidence_item(item, max_text=300)
lines.append(f"- {_bounded_json(compact, max_chars=500)}")
elif isinstance(results, list) and results:
lines.append(f"검색 결과는 {len(results)}건입니다.")
lines.append("")
lines.append("주요 검색 결과:")
for item in results[:20]:
compact = _compact_evidence_item(item, max_text=320)
if isinstance(compact, Mapping):
title = " / ".join(
str(compact.get(key) or "")
for key in ("insurer", "product_name", "chunk_type")
if compact.get(key)
)
body = str(
compact.get("display_markdown")
or compact.get("text")
or compact.get("content")
or ""
).strip()
page = compact.get("physical_page_range")
suffix = f" (p.{page})" if page else ""
lines.append(f"- {title or '검색 결과'}{suffix}: {body}")
else:
lines.append(f"- {compact}")
elif text_result(mcp_result):
result = text_result(mcp_result)
lines.append("")
lines.append("MCP 반환 근거:")
lines.append(result[:6000] + ("..." if len(result) > 6000 else ""))
elif agent_steps:
lines.append(f"Agent는 MCP tool을 {len(agent_steps)}회 호출했습니다.")
else:
lines.append("상세 영역에서 MCP 원본 응답을 확인해 주세요.")
return "\n".join(lines)
_DISPLAY_MARKDOWN_METADATA_KEYS = (
"insurer",
"product_name",
"product_cd",
"product_code",
"document_title",
"title",
"document_id",
"chunk_id",
"source_file_name",
"file_name",
"ext_file_nm",
"clause_version",
"chunk_type",
"logical_locator",
"physical_page_range",
"page",
"score",
)
def _display_markdown_records(details: Mapping[str, Any] | None) -> list[dict[str, Any]]:
if not isinstance(details, Mapping):
return []
sources: list[Any] = [details.get("mcp_result")]
agent_steps = details.get("agent_steps")
if isinstance(agent_steps, list):
sources.extend(
step.get("mcp_result")
for step in agent_steps
if isinstance(step, Mapping)
)
records: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
def collect(value: Any, depth: int = 0) -> None:
if depth > 12 or len(records) >= 50:
return
if isinstance(value, Mapping):
display_markdown = value.get("display_markdown")
if isinstance(display_markdown, str) and display_markdown.strip():
record = {
key: value.get(key)
for key in _DISPLAY_MARKDOWN_METADATA_KEYS
if value.get(key) not in (None, "", [])
}
record["display_markdown"] = display_markdown.strip()
identity = (
str(record.get("document_id") or ""),
str(record.get("chunk_id") or ""),
record["display_markdown"],
)
if identity not in seen:
seen.add(identity)
records.append(record)
for child in value.values():
if isinstance(child, (Mapping, list, tuple)):
collect(child, depth + 1)
elif isinstance(value, (list, tuple)):
for child in value:
collect(child, depth + 1)
for source in sources:
collect(source)
if isinstance(source, Mapping):
collect(_mcp_response_payload(source))
return records
def _display_markdown_match_score(basis: str, record: Mapping[str, Any]) -> int:
normalized_basis = " ".join(basis.casefold().split())
score = 0
for key in _DISPLAY_MARKDOWN_METADATA_KEYS:
value = " ".join(str(record.get(key) or "").casefold().split())
if len(value) >= 3 and value in normalized_basis:
score += min(len(value), 40)
if key in {
"document_id",
"chunk_id",
"source_file_name",
"file_name",
"product_name",
}:
score += 30
return score
def _match_basis_to_display_markdown(
basis: list[Any],
records: list[dict[str, Any]],
) -> list[dict[str, Any] | None]:
remaining = set(range(len(records)))
matched: list[dict[str, Any] | None] = []
for item in basis:
basis_text = str(item or "")
ranked = sorted(
(
(_display_markdown_match_score(basis_text, records[index]), index)
for index in remaining
),
reverse=True,
)
selected_index: int | None = None
if ranked and ranked[0][0] > 0:
selected_index = ranked[0][1]
elif remaining:
selected_index = min(remaining)
if selected_index is None:
matched.append(None)
continue
remaining.remove(selected_index)
matched.append(records[selected_index])
return matched
def _display_markdown_source_label(record: Mapping[str, Any]) -> str:
source = next(
(
str(record.get(key) or "").strip()
for key in (
"source_file_name",
"file_name",
"ext_file_nm",
"product_name",
"document_title",
"document_id",
)
if str(record.get(key) or "").strip()
),
"MCP 참조 문서",
)
details: list[str] = []
page = str(record.get("physical_page_range") or record.get("page") or "").strip()
chunk_id = str(record.get("chunk_id") or "").strip()
clause_version = str(record.get("clause_version") or "").strip()
if page:
details.append(f"페이지 {page}")
if chunk_id:
details.append(f"청크 {chunk_id}")
if clause_version:
details.append(f"약관 버전 {clause_version}")
return " · ".join([source, *details])
def _readable_basis_summary(value: Any) -> str:
text = " ".join(str(value or "").split()).strip()
if not text:
return ""
metadata_match = re.search(
r"\s+(?:문서[_ ]?id|document_id|chunk_id|logical_locator|"
r"physical_page_range)\s*:",
text,
flags=re.IGNORECASE,
)
if metadata_match:
readable = text[: metadata_match.start()].strip(" /·:-")
if readable:
return readable
return text
def _render_assistant_message(
message: Mapping[str, Any],
message_key: str,
) -> None:
st.markdown(str(message.get("content") or ""))
details = message.get("details")
basis = message.get("basis")
if isinstance(basis, list) and basis:
display_records = _display_markdown_records(
details if isinstance(details, Mapping) else None
)
matched_records = _match_basis_to_display_markdown(basis, display_records)
with st.expander(f"답변 근거 요약 · {len(basis)}"):
for index, (item, record) in enumerate(
zip(basis, matched_records),
start=1,
):
with st.container(border=True):
readable_summary = _readable_basis_summary(item)
source_title = (
_display_markdown_source_label(record).split(" · ", 1)[0]
if isinstance(record, Mapping)
else ""
)
heading = f"근거 {index}"
if source_title:
heading += f" · {source_title}"
st.markdown(f"#### {heading}")
if readable_summary and (
not source_title
or readable_summary.casefold() != source_title.casefold()
):
st.markdown(readable_summary)
if isinstance(record, Mapping):
st.caption(
"참조 문서 · "
+ _display_markdown_source_label(record)
)
st.markdown("**MCP 원문 근거 (`display_markdown`)**")
st.markdown(str(record.get("display_markdown") or ""))
limitations = str(message.get("limitations") or "").strip()
if limitations:
st.caption(f"제약/주의: {limitations}")
if isinstance(details, Mapping):
_render_mcp_result_sections(details, message_key)
def _render_chat_turn(turn: Mapping[str, Any]) -> None:
user_label = str(turn.get("selected_user_id") or "").strip()
created_at = str(turn.get("created_at") or "").strip()
meta = " · ".join(item for item in (created_at, user_label) if item)
with st.chat_message("user"):
if meta:
st.caption(meta)
st.markdown(str(turn.get("question") or ""))
with st.chat_message("assistant"):
_render_assistant_message(
{
"content": str(turn.get("answer") or ""),
"basis": turn.get("basis", []),
"limitations": str(turn.get("limitations") or ""),
"details": turn.get("details", {}),
},
f"turn_{turn.get('turn_id')}",
)
def _render_chat_history(conversation_id: str, page_key: str) -> None:
st.markdown(
'<div class="kb-section-title result" role="heading" aria-level="3">'
"질의 결과"
"</div>",
unsafe_allow_html=True,
)
total_turns = count_chat_turns(conversation_id)
if total_turns <= 0:
st.info("아직 저장된 대화가 없습니다.")
return
max_page = max((total_turns - 1) // CHAT_TURNS_PER_PAGE, 0)
current_page = int(st.session_state.get(page_key, 0) or 0)
current_page = min(max(current_page, 0), max_page)
st.session_state[page_key] = current_page
start_no = current_page * CHAT_TURNS_PER_PAGE + 1
end_no = min(start_no + CHAT_TURNS_PER_PAGE - 1, total_turns)
st.caption(
f"{total_turns}건 · 최신순 {start_no}~{end_no}건 표시 · "
f"페이지 {current_page + 1}/{max_page + 1}"
)
nav_cols = st.columns([1, 1, 1, 5])
with nav_cols[0]:
if st.button(
"최신",
key=f"{page_key}_first_{conversation_id}",
disabled=current_page == 0,
):
st.session_state[page_key] = 0
st.rerun()
with nav_cols[1]:
if st.button(
"이전 내역",
key=f"{page_key}_older_{conversation_id}",
disabled=current_page >= max_page,
):
st.session_state[page_key] = current_page + 1
st.rerun()
with nav_cols[2]:
if st.button(
"다음 내역",
key=f"{page_key}_newer_{conversation_id}",
disabled=current_page <= 0,
):
st.session_state[page_key] = current_page - 1
st.rerun()
turns = load_chat_turns(conversation_id, current_page)
if not turns:
st.info("아직 저장된 대화가 없습니다.")
return
for turn in turns:
_render_chat_turn(turn)
def _render_architecture_tab() -> None:
st.subheader("운영 아키텍처")
st.caption("사용자 권한을 기준으로 AI 질의, MCP 도구, Oracle Database를 연결합니다.")
experience, integration, security = st.columns(3)
with experience:
st.markdown("**01 · 사용자와 권한**")
st.write("사용자 역할과 담당 범위를 요청 컨텍스트에 적용합니다.")
with integration:
st.markdown("**02 · AI와 MCP**")
st.write("질의에 맞는 데이터·지식 검색 도구를 선택합니다.")
with security:
st.markdown("**03 · 데이터와 보안**")
st.write("VPD와 감사 정책으로 데이터 접근을 통제합니다.")
st.divider()
st.markdown("#### 질의 처리 흐름")
st.markdown(
"1. 사용자 권한과 질문을 요청에 반영합니다. \n"
"2. AI가 필요한 MCP 도구를 선택합니다. \n"
"3. Oracle Database에서 근거를 조회하고 결과를 표시합니다."
)
def _render_vpd_operations_tab() -> None:
st.markdown(
f"""
<section aria-labelledby="kb-ops-title">
<div class="kb-section-title input" id="kb-ops-title"
role="heading" aria-level="3">
RLS / CLS 설정 ( 오라클 VPD / 마스킹 )
</div>
<div class="kb-ops-lead">
업무 사용자와 데이터 접근 기준을 관리하고,
DB가 적용한 결과까지 한 흐름에서 확인합니다.
</div>
<h3 class="kb-ops-section-heading">보안 설정 업무 프로세스</h3>
<div class="kb-ops-guide-copy">
권한을 먼저 만들고 보호 대상을 연결한 뒤, 실제 사용자 토큰으로 결과를 검증합니다.
권한은 토큰에 복사되지 않아 이후 변경도 다음 요청부터 반영됩니다.
</div>
<div class="kb-ops-flow">
<div class="kb-ops-step">
<div class="kb-ops-step-no">1</div>
<strong>사용자·그룹</strong>
<span>업무 대상을 등록합니다.</span>
</div>
<div class="kb-ops-step">
<div class="kb-ops-step-no">2</div>
<strong>역할</strong>
<span>직접·그룹 역할을 부여합니다.</span>
</div>
<div class="kb-ops-step accent">
<div class="kb-ops-step-no">3</div>
<strong>접근 규칙</strong>
<span>객체·행·컬럼 접근을 설정합니다.</span>
</div>
<div class="kb-ops-step accent">
<div class="kb-ops-step-no">4</div>
<strong>보호·연결</strong>
<span>VPD·ORDS 대상을 확인합니다.</span>
</div>
<div class="kb-ops-step success">
<div class="kb-ops-step-no">5</div>
<strong>유효 권한·접근 검증</strong>
<span>토큰으로 실제 결과를 확인합니다.</span>
</div>
</div>
<div class="kb-ops-portal">
<strong>보안 운영 포털</strong>
<p>
권한 등록, 접근 규칙 관리와 검증 결과 확인은 별도 운영 화면에서 수행합니다.
</p>
<a class="kb-ops-link" href="{VPD_OPERATIONS_URL}"
target="_blank" rel="noopener noreferrer">
권한 운영 화면 열기&nbsp;&#8599;
</a>
</div>
</section>
""",
unsafe_allow_html=True,
)
def _process_submitted_question(
*,
question: str,
conversation_id: str,
chat_page_key: str,
query_progress_slot: Any,
query_progress_notice_key: str,
bearer_token: str,
servers: list[McpServer],
default_router_model_profile: str,
selected_query_model_profile: str,
mcp_cache_generation_key: str,
limit: int,
selected_token_preset: VpdTokenPreset | None,
execution_mode_override: str,
) -> None:
normalized_question = question.strip()
if not normalized_question:
query_progress_slot.warning("질문을 입력해 주세요.")
return
active_model_profile = (
selected_query_model_profile.strip()
or _complex_reasoning_model_profile(default_router_model_profile)
)
single_tool_query = ""
process_started = perf_counter()
execution_events: list[dict[str, Any]] = []
with query_progress_slot.container():
with st.chat_message("user"):
st.markdown(normalized_question)
processing_status = st.status("질의 진행 상황 · 준비 중", expanded=True)
with processing_status:
progress_bar = st.progress(0, text="질의 처리를 준비하고 있습니다.")
def record_execution_event(
*,
percent: int,
label: str,
detail: str = "",
elapsed_seconds: float | None = None,
) -> None:
event: dict[str, Any] = {
"percent": int(percent),
"label": str(label),
"total_elapsed_seconds": round(perf_counter() - process_started, 3),
}
if detail:
event["detail"] = str(detail)
if elapsed_seconds is not None:
event["elapsed_seconds"] = round(float(elapsed_seconds), 3)
execution_events.append(event)
def refresh_progress(
percent: int,
label: str,
completed_detail: str | None = None,
elapsed_seconds: float | None = None,
) -> None:
progress_bar.progress(percent, text=label)
processing_status.update(
label=f"질의 진행 상황 · {percent}% · {label}",
state="running",
expanded=True,
)
if completed_detail:
processing_status.write(completed_detail)
record_execution_event(
percent=percent,
label=label,
detail=completed_detail,
elapsed_seconds=elapsed_seconds,
)
try:
with processing_status:
refresh_progress(5, "대화 문맥을 불러오고 있습니다.")
step_started = perf_counter()
conversation = load_chat_context(conversation_id)
step_elapsed = perf_counter() - step_started
refresh_progress(
15,
"대화 문맥을 불러왔습니다.",
f"대화 문맥 로드 완료 · {step_elapsed:.1f}s",
elapsed_seconds=step_elapsed,
)
refresh_progress(20, "질문 문맥을 정리하고 있습니다.")
step_started = perf_counter()
standalone_question = resolve_standalone_question(
question=normalized_question,
messages=conversation,
model_profile_key=active_model_profile,
)
step_elapsed = perf_counter() - step_started
refresh_progress(
30,
"질문 문맥을 정리했습니다.",
f"후속 질문 정리 완료 · {step_elapsed:.1f}s",
elapsed_seconds=step_elapsed,
)
refresh_progress(35, "사용 가능한 MCP 도구를 확인하고 있습니다.")
step_started = perf_counter()
cache_token = _normalized_bearer(bearer_token)
discovery_results, discovery_failures = cached_discover_enabled_server_tools(
_mcp_server_cache_rows(servers),
_token_fingerprint(cache_token),
int(st.session_state.get(mcp_cache_generation_key, 0) or 0),
bearer_token,
)
step_elapsed = perf_counter() - step_started
refresh_progress(
50,
"MCP 도구 확인을 완료했습니다.",
f"MCP 툴 디스커버리 완료 · {step_elapsed:.1f}s",
elapsed_seconds=step_elapsed,
)
routed_tools = [
RoutedMcpTool(server_id=result.server.server_id, tool=tool)
for result in discovery_results
for tool in result.tools
]
if discovery_failures:
st.warning(
"일부 MCP 서버 디스커버리 실패: "
+ ", ".join(
f"{failure['server_id']}={failure['error']}"
for failure in discovery_failures
)
)
if not routed_tools:
raise PublicMcpError("디스커버리된 MCP 툴이 없습니다.")
refresh_progress(55, "MCP 실행 방식을 판단하고 있습니다.")
step_started = perf_counter()
execution_model_profile = active_model_profile
execution_mode_plan = plan_mcp_execution_mode(
question=standalone_question,
routed_tools=routed_tools,
model_profile_key=execution_model_profile,
mode_override=execution_mode_override,
)
if (
execution_mode_override == "auto"
and execution_model_profile != default_router_model_profile
and str(execution_mode_plan.get("reason") or "").startswith(
"실행 방식 판단 실패"
)
):
execution_mode_plan = plan_mcp_execution_mode(
question=standalone_question,
routed_tools=routed_tools,
model_profile_key=default_router_model_profile,
mode_override=execution_mode_override,
)
execution_mode = str(execution_mode_plan.get("mode") or "single")
is_complex_execution = execution_mode == "agent" and len(routed_tools) > 1
reasoning_model_profile = active_model_profile
route_key = str(execution_mode_plan.get("route_key") or "")
_, routes_by_key = _agent_tool_catalog(routed_tools)
selected_route = routes_by_key.get(route_key) or _default_single_route(
routed_tools
)
if execution_mode_override == "single" and len(routed_tools) > 1:
selected_route = route_mcp_tool_across_servers_with_llm(
routed_tools,
standalone_question,
router_model_profile=execution_model_profile,
)
step_elapsed = perf_counter() - step_started
execution_mode_detail = (
"실행 방식 판단 완료 · "
f"{execution_mode} · "
f"{execution_mode_plan.get('reason') or 'reason 없음'} · "
f"{step_elapsed:.1f}s"
)
processing_status.write(execution_mode_detail)
record_execution_event(
percent=55,
label="실행 방식 판단 완료",
detail=execution_mode_detail,
elapsed_seconds=step_elapsed,
)
agent_steps: list[Mapping[str, Any]] = []
agent_stop_reason = ""
if is_complex_execution:
refresh_progress(60, "멀티툴 Agent가 MCP 실행 계획을 세우고 있습니다.")
step_started = perf_counter()
def on_agent_step(step: Mapping[str, Any]) -> None:
step_elapsed = float(step.get("elapsed_seconds") or 0)
agent_detail = (
f"Agent step {step.get('step')} · "
f"{step.get('server_id')}/{step.get('tool_name')} · "
f"{step_elapsed:.1f}s"
)
processing_status.write(agent_detail)
record_execution_event(
percent=60,
label=f"Agent step {step.get('step')}",
detail=agent_detail,
elapsed_seconds=step_elapsed,
)
agent_result = run_mcp_agent_loop(
question=standalone_question,
conversation=conversation,
routed_tools=routed_tools,
servers=servers,
bearer_token=bearer_token,
limit=int(limit),
model_profile_key=reasoning_model_profile,
progress_callback=on_agent_step,
)
selected_server = agent_result["server"]
selected = agent_result["tool"]
arguments = agent_result["arguments"]
answer_source = agent_result["mcp_result"]
agent_steps = list(agent_result["agent_steps"])
agent_stop_reason = str(agent_result.get("stop_reason") or "")
step_elapsed = perf_counter() - step_started
refresh_progress(
85,
"Agent MCP 실행을 완료했습니다.",
f"Agent loop 완료 · {len(agent_steps)} step · "
f"{step_elapsed:.1f}s",
elapsed_seconds=step_elapsed,
)
else:
refresh_progress(65, "MCP 단일 호출을 실행하고 있습니다.")
step_started = perf_counter()
selected_server = next(
server
for server in servers
if server.server_id == selected_route.server_id
)
selected = selected_route.tool
tool_query = prepare_tool_query_for_mcp(
question=standalone_question,
server=selected_server,
tool=selected,
model_profile_key=reasoning_model_profile,
selected_user_id=(
selected_token_preset.user_id
if selected_token_preset is not None
else ""
),
selected_user_role=(
selected_token_preset.role
if selected_token_preset is not None
else ""
),
selected_user_channel=(
selected_token_preset.channel
if selected_token_preset is not None
else ""
),
selected_user_scope=(
selected_token_preset.scope
if selected_token_preset is not None
else ""
),
)
single_tool_query = tool_query
arguments = build_mcp_tool_arguments(
selected,
tool_query,
int(limit),
preferred_tool=selected_server.default_tool,
)
raw_result = call_tool(
base_url=selected_server.endpoint_url,
bearer_token=bearer_token,
tool=selected,
arguments=arguments,
)
parsed = _content_text_json(raw_result)
answer_source = parsed if parsed is not None else raw_result
step_elapsed = perf_counter() - step_started
refresh_progress(
85,
"MCP 단일 호출을 완료했습니다.",
f"MCP 1회 호출 완료 · {selected_server.server_id}/{selected.name} · "
f"{step_elapsed:.1f}s",
elapsed_seconds=step_elapsed,
)
except (PublicMcpError, McpToolRouterError) as exc:
security_evidence = collect_security_evidence(
normalized_question,
selected_token_preset,
failure_message=str(exc),
)
business_evidence = collect_business_evidence(
normalized_question,
selected_token_preset,
)
security_verified = bool(
security_evidence and not security_evidence.get("evidence_error")
)
business_verified = bool(
business_evidence
and business_evidence.get("evidence_complete")
and not business_evidence.get("evidence_error")
)
if security_verified or business_verified:
verified_answer = _enrich_answer_with_security_evidence(
"업무 MCP 호출은 완료되지 않았지만 검증 근거는 정상 확인되었습니다.",
security_evidence,
)
verified_answer = _enrich_answer_with_business_evidence(
verified_answer,
business_evidence,
)
verified_basis: list[str] = []
if security_verified:
verified_basis.append(
"보안 검증 증적: "
f"{security_evidence.get('audit_source')} "
f"LOG_ID={security_evidence.get('audit_log_id')}"
)
if business_verified:
verified_basis.append(
"업무 검증 증적: "
+ ", ".join(
str(source)
for source in business_evidence.get("evidence_sources", [])
)
)
save_chat_turn(
conversation_id=conversation_id,
selected_user_id=(
selected_token_preset.user_id
if selected_token_preset is not None
else ""
),
selected_user_label=(
selected_token_preset.display_label
if selected_token_preset is not None
else "직접 입력"
),
question=normalized_question,
standalone_question=locals().get(
"standalone_question", normalized_question
),
answer=verified_answer,
basis=verified_basis,
limitations="",
details={
"server_id": "verified_evidence",
"tool_name": str(
security_evidence.get("evidence_type") or ""
)
or str(business_evidence.get("evidence_type") or ""),
"standalone_question": locals().get(
"standalone_question", normalized_question
),
"security_evidence": security_evidence,
"business_evidence": business_evidence,
"mcp_error": str(exc),
"execution_events": execution_events,
},
)
progress_bar.progress(100, text="검증 결과를 확인했습니다.")
processing_status.update(
label="질의 진행 상황 · 100% · 근거 검증 완료",
state="complete",
expanded=False,
)
st.session_state[query_progress_notice_key] = (
"업무 MCP 오류와 별개로 토큰 범위 검증 결과를 확인했습니다."
)
st.session_state[chat_page_key] = 0
st.rerun()
progress_bar.empty()
processing_status.update(label="질문 처리 실패", state="error", expanded=True)
processing_status.error(str(exc))
return
security_evidence = collect_security_evidence(
normalized_question,
selected_token_preset,
)
business_evidence = collect_business_evidence(
normalized_question,
selected_token_preset,
)
evidence_verified_for_answer = bool(
business_evidence
and business_evidence.get("evidence_complete")
and not business_evidence.get("evidence_error")
) or bool(
security_evidence
and security_evidence.get("evidence_type")
and not security_evidence.get("evidence_error")
)
assistant_message: dict[str, Any]
synthesis_error_details: dict[str, Any] = {}
query_contract_details: list[Any] = []
evidence_contract_details: list[Any] = []
answer_model_profile = reasoning_model_profile
answer_synthesis_fallback_model = ""
try:
with processing_status:
refresh_progress(90, "질의 결과를 정리하고 있습니다.")
step_started = perf_counter()
synthesized = synthesize_answer(
question=normalized_question,
conversation=conversation,
server=selected_server,
tool=selected,
arguments=arguments,
mcp_result=answer_source,
model_profile_key=reasoning_model_profile,
agent_steps=agent_steps,
security_evidence=security_evidence,
business_evidence=business_evidence,
)
step_elapsed = perf_counter() - step_started
refresh_progress(
96,
"질의 결과 정리를 완료했습니다.",
f"최종 답변 생성 완료 · {step_elapsed:.1f}s",
elapsed_seconds=step_elapsed,
)
assistant_message = {
"role": "assistant",
"content": str(synthesized["answer"]),
"basis": synthesized.get("basis", []),
"limitations": str(synthesized.get("limitations") or ""),
}
query_contract_details = list(synthesized.get("query_contracts") or [])
evidence_contract_details = list(
synthesized.get("evidence_contract_report") or []
)
except AnswerSynthesisError as exc:
synthesis_error_details = getattr(exc, "diagnostics", {}) or {}
fallback_profile = DEFAULT_SYNTHESIS_FALLBACK_MODEL_PROFILE
if fallback_profile and fallback_profile != reasoning_model_profile:
try:
with processing_status:
refresh_progress(
93,
f"최종 답변을 {fallback_profile}로 재시도하고 있습니다.",
)
step_started = perf_counter()
synthesized = synthesize_answer(
question=normalized_question,
conversation=conversation,
server=selected_server,
tool=selected,
arguments=arguments,
mcp_result=answer_source,
model_profile_key=fallback_profile,
agent_steps=agent_steps,
security_evidence=security_evidence,
business_evidence=business_evidence,
)
step_elapsed = perf_counter() - step_started
refresh_progress(
96,
"질의 결과 정리를 완료했습니다.",
f"fallback 답변 생성 완료 · {fallback_profile} · "
f"{step_elapsed:.1f}s",
elapsed_seconds=step_elapsed,
)
answer_model_profile = fallback_profile
answer_synthesis_fallback_model = fallback_profile
assistant_message = {
"role": "assistant",
"content": str(synthesized["answer"]),
"basis": synthesized.get("basis", []),
"limitations": str(synthesized.get("limitations") or ""),
}
query_contract_details = list(
synthesized.get("query_contracts") or []
)
evidence_contract_details = list(
synthesized.get("evidence_contract_report") or []
)
except AnswerSynthesisError as fallback_exc:
synthesis_error_details = {
"primary": synthesis_error_details,
"fallback": getattr(fallback_exc, "diagnostics", {}) or {},
}
fallback_content = fallback_answer_from_mcp(answer_source, agent_steps)
fallback_content = _enrich_answer_with_security_evidence(
fallback_content,
security_evidence,
)
fallback_content = _enrich_answer_with_business_evidence(
fallback_content,
business_evidence,
)
assistant_message = {
"role": "assistant",
"content": fallback_content,
"basis": [],
"limitations": (
""
if evidence_verified_for_answer
else f"{fallback_exc} MCP 원본 응답은 상세에서 확인 가능합니다."
),
}
refresh_progress(96, "원본 MCP 응답으로 결과를 구성했습니다.")
else:
fallback_content = fallback_answer_from_mcp(answer_source, agent_steps)
fallback_content = _enrich_answer_with_security_evidence(
fallback_content,
security_evidence,
)
fallback_content = _enrich_answer_with_business_evidence(
fallback_content,
business_evidence,
)
assistant_message = {
"role": "assistant",
"content": fallback_content,
"basis": [],
"limitations": (
""
if evidence_verified_for_answer
else f"{exc} MCP 원본 응답은 상세에서 확인 가능합니다."
),
}
refresh_progress(96, "원본 MCP 응답으로 결과를 구성했습니다.")
pre_save_elapsed = perf_counter() - process_started
record_execution_event(
percent=98,
label="질의 결과 저장 준비 완료",
detail=f"저장 전 처리 완료 · {pre_save_elapsed:.1f}s",
elapsed_seconds=pre_save_elapsed,
)
assistant_message["details"] = {
"server_id": selected_server.server_id,
"tool_name": selected.name,
"standalone_question": standalone_question,
"tool_query": single_tool_query,
"arguments": arguments,
"mcp_result": answer_source,
"execution_mode": str(execution_mode_plan.get("mode") or ""),
"execution_mode_reason": str(execution_mode_plan.get("reason") or ""),
"execution_mode_model_profile": str(
execution_mode_plan.get("model_profile") or ""
),
"reasoning_model_profile": reasoning_model_profile,
"answer_model_profile": answer_model_profile,
"answer_synthesis_fallback_model": answer_synthesis_fallback_model,
"answer_synthesis_error": synthesis_error_details,
"agent_steps": agent_steps,
"agent_stop_reason": agent_stop_reason,
"security_evidence": security_evidence,
"business_evidence": business_evidence,
"query_contracts": query_contract_details,
"evidence_contract_report": evidence_contract_details,
"execution_events": execution_events,
"discovered_routes": [
{
"server_id": discovery.server.server_id,
"tool_name": tool.name,
"readOnly": tool.read_only,
"description": tool.description,
}
for discovery in discovery_results
for tool in discovery.tools
],
"discovery_failures": discovery_failures,
}
refresh_progress(98, "질의 결과를 저장하고 있습니다.")
save_chat_turn(
conversation_id=conversation_id,
selected_user_id=(
selected_token_preset.user_id if selected_token_preset is not None else ""
),
selected_user_label=(
selected_token_preset.display_label
if selected_token_preset is not None
else "직접 입력"
),
question=normalized_question,
standalone_question=standalone_question,
answer=str(assistant_message["content"]),
basis=assistant_message.get("basis", []),
limitations=str(assistant_message.get("limitations") or ""),
details=assistant_message["details"],
)
total_elapsed = perf_counter() - process_started
refresh_progress(
100,
"질의 처리가 완료되었습니다.",
f"전체 처리 완료 · {total_elapsed:.1f}s",
elapsed_seconds=total_elapsed,
)
processing_status.update(
label="질의 진행 상황 · 100% · 처리 완료",
state="complete",
expanded=False,
)
st.session_state[query_progress_notice_key] = (
"질의 처리가 완료되었습니다. "
f"사용 경로: {selected_server.server_id} / {selected.name} · "
f"{'agent step ' + str(len(agent_steps)) + '' if agent_steps else 'MCP 1회 호출'}"
)
st.session_state[chat_page_key] = 0
st.rerun()
def main() -> None:
try:
profile = load_app_profile(APP_PROFILE_FILE, ENV_FILE)
except AppProfileError as exc:
st.set_page_config(page_title="AI 업무 에이전트", layout="wide")
st.error(str(exc))
return
st.set_page_config(
page_title=profile.page_title, page_icon=profile.page_icon, layout="wide"
)
_apply_console_theme(profile)
_restore_portal_proxy_session()
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
_render_portal_login(profile)
return
try:
questions = load_demo_scenarios(DEMO_SCENARIOS_FILE)
except ScenarioConfigError as exc:
st.error(str(exc))
return
scenario_key = "poc4_mcp_discovery_scenario"
question_text_key = "poc4_mcp_discovery_question_text"
loaded_scenario_key = "poc4_mcp_discovery_loaded_scenario_id"
conversation_id_key = "poc4_mcp_discovery_conversation_id"
chat_page_key = "poc4_mcp_discovery_page"
query_progress_notice_key = "poc4_query_progress_notice"
mcp_cache_generation_key = "poc4_mcp_tools_cache_generation"
selected_scenario_state = st.session_state.get(scenario_key)
scenario_ids = {question.question_id for question in questions}
if str(getattr(selected_scenario_state, "question_id", "")).strip().upper() not in (
"",
*scenario_ids,
):
st.session_state.pop(scenario_key, None)
st.session_state.pop(loaded_scenario_key, None)
st.session_state[question_text_key] = DEFAULT_QUESTION
init_chat_store()
if conversation_id_key not in st.session_state:
st.session_state[conversation_id_key] = new_conversation_id()
if chat_page_key not in st.session_state:
st.session_state[chat_page_key] = 0
if mcp_cache_generation_key not in st.session_state:
st.session_state[mcp_cache_generation_key] = 0
conversation_id = str(st.session_state[conversation_id_key])
try:
servers, default_server_index = load_mcp_servers()
except PublicMcpError as exc:
st.error(str(exc))
return
try:
token_presets = load_vpd_token_presets()
except PublicMcpError as exc:
st.error(str(exc))
return
default_router_model_profile = servers[default_server_index].router_model_profile
configured_mcp_bearer = _runtime_env_value(
servers[default_server_index].auth_token_env
)
query_model_profile_key = "poc4_query_model_profile"
default_query_model_profile = DEFAULT_QUERY_MODEL_PROFILE
try:
model_profile_options = _model_profile_select_options()
except ValueError:
model_profile_options = (
(default_query_model_profile, default_query_model_profile),
)
model_profile_labels = dict(model_profile_options)
model_profile_keys = tuple(model_profile_labels)
if default_query_model_profile not in model_profile_keys:
default_query_model_profile = (
default_router_model_profile
if default_router_model_profile in model_profile_keys
else model_profile_keys[0]
)
if st.session_state.get(query_model_profile_key) not in model_profile_keys:
st.session_state[query_model_profile_key] = default_query_model_profile
_render_app_header(profile)
with st.sidebar:
st.caption(
f"포털 사용자 · {st.session_state.get(PORTAL_AUTH_USER_KEY, '')}"
)
st.markdown(
'<a class="console-logout-button" href="/auth/logout" '
'target="_self">로그아웃</a>',
unsafe_allow_html=True,
)
st.divider()
st.markdown('<div class="kb-panel-title">AI 사용자 설정</div>', unsafe_allow_html=True)
selected_query_model_profile = st.selectbox(
"LLM 모델",
options=model_profile_keys,
key=query_model_profile_key,
format_func=lambda value: (
f"{model_profile_labels.get(value, value)} · {value}"
),
help=(
"선택한 모델을 후속 질문 정리, 실행 방식 판단, MCP tool 라우팅, "
"Agent planning, 최종 답변 합성에 사용합니다."
),
)
if token_presets:
vpd_user_key = "poc4_vpd_token_preset"
token_preset_by_id = {
preset.user_id: preset for preset in token_presets
}
token_preset_ids = tuple(token_preset_by_id)
stored_vpd_user = st.session_state.get(vpd_user_key)
if vpd_user_key not in st.session_state:
st.session_state[vpd_user_key] = _default_vpd_user_id(token_presets)
elif isinstance(stored_vpd_user, VpdTokenPreset):
st.session_state[vpd_user_key] = stored_vpd_user.user_id
elif stored_vpd_user not in token_preset_by_id:
st.session_state[vpd_user_key] = _default_vpd_user_id(token_presets)
current_vpd_user_id = st.session_state[vpd_user_key]
current_vpd_user_label = token_preset_by_id[
current_vpd_user_id
].select_label
st.markdown(
'<div class="kb-sidebar-section-title">데모 사용자</div>',
unsafe_allow_html=True,
)
vpd_popover_key = "poc4_vpd_user_popover_open"
def close_vpd_user_popover() -> None:
st.session_state[vpd_popover_key] = False
with st.popover(
f"**{current_vpd_user_label}**",
use_container_width=True,
key=vpd_popover_key,
on_change="rerun",
):
selected_vpd_user_id = st.radio(
"데모 사용자 선택",
options=token_preset_ids,
key=vpd_user_key,
format_func=lambda user_id: token_preset_by_id[
user_id
].select_label,
label_visibility="collapsed",
on_change=close_vpd_user_popover,
)
selected_token_preset = (
token_preset_by_id.get(selected_vpd_user_id)
if selected_vpd_user_id is not None
else None
)
else:
selected_token_preset = None
selected_preset_bearer = (
selected_token_preset.token if selected_token_preset is not None else ""
)
if configured_mcp_bearer or selected_preset_bearer:
# The HMM MCP gateway credential is never rendered. Each demo-user
# preset refers to its runtime env key, allowing secure profile swaps.
st.caption("MCP 인증: 선택 사용자 preset의 서버 관리 토큰 적용")
manual_bearer_token = ""
elif selected_token_preset is not None:
manual_bearer_token = st.text_input(
"Bearer token",
value=selected_token_preset.token,
type="default",
disabled=True,
key=f"poc4_bearer_token_{selected_token_preset.user_id}",
help="선택한 데모 사용자의 MCP gateway token입니다.",
)
else:
manual_bearer_token = st.text_input(
"Bearer token",
type="default",
key="poc4_manual_bearer_token",
)
bearer_token = selected_preset_bearer or configured_mcp_bearer or manual_bearer_token
if selected_token_preset is not None:
_render_demo_user_card(selected_token_preset)
all_conversation_rows = list_conversations(limit=200)
current_row = next(
(
row
for row in all_conversation_rows
if row["conversation_id"] == conversation_id
),
{
"conversation_id": conversation_id,
"created_at": "",
"updated_at": "",
"title": "",
"turn_count": 0,
"latest_question": "",
},
)
conversation_search = st.text_input(
"대화 검색",
placeholder="질문, 제목, 세션 ID",
key="poc4_conversation_search",
)
conversation_rows = filter_conversations(
all_conversation_rows,
conversation_search,
)
search_query = conversation_search.strip()
current_in_results = any(
row["conversation_id"] == conversation_id for row in conversation_rows
)
if search_query and conversation_rows and not current_in_results:
st.session_state[conversation_id_key] = str(
conversation_rows[0]["conversation_id"]
)
st.session_state[chat_page_key] = 0
st.rerun()
if search_query and not conversation_rows:
st.info("검색 결과가 없습니다.")
if not search_query and not current_in_results:
conversation_rows.insert(0, current_row)
conversation_ids = [str(row["conversation_id"]) for row in conversation_rows]
conversation_by_id = {
str(row["conversation_id"]): row for row in conversation_rows
}
if conversation_ids:
selected_conversation_id = st.selectbox(
"대화 세션",
options=conversation_ids,
index=(
conversation_ids.index(conversation_id)
if conversation_id in conversation_ids
else 0
),
format_func=lambda value: conversation_label(conversation_by_id[value]),
)
else:
st.caption("저장된 대화가 없습니다.")
selected_conversation_id = conversation_id
conversation_by_id = {conversation_id: current_row}
if selected_conversation_id != conversation_id:
st.session_state[conversation_id_key] = selected_conversation_id
st.session_state[chat_page_key] = 0
st.rerun()
if st.button("새 대화 시작"):
st.session_state[conversation_id_key] = new_conversation_id()
st.session_state[chat_page_key] = 0
st.rerun()
with st.expander("대화 관리"):
selected_row = conversation_by_id.get(conversation_id, current_row)
title_key = f"poc4_conversation_title_{conversation_id}"
title_value = st.text_input(
"대화 이름",
value=str(selected_row.get("title") or ""),
key=title_key,
max_chars=80,
)
if st.button("대화 이름 저장"):
rename_conversation(conversation_id, title_value)
st.rerun()
st.download_button(
"대화 JSON 다운로드",
data=json.dumps(
{
"conversation_id": conversation_id,
"title": title_value.strip(),
"turns": load_all_chat_turns(conversation_id),
},
ensure_ascii=False,
indent=2,
default=str,
),
file_name=f"{conversation_id}.json",
mime="application/json",
)
delete_confirmed = st.checkbox("현재 대화 삭제 확인")
if st.button("현재 대화 삭제", disabled=not delete_confirmed):
delete_conversation(conversation_id)
st.session_state[conversation_id_key] = new_conversation_id()
st.session_state[chat_page_key] = 0
st.rerun()
with st.expander("MCP 고급 설정", expanded=False):
st.markdown(
'<div class="kb-sidebar-section-title">MCP Servers</div>',
unsafe_allow_html=True,
)
st.caption(", ".join(server.server_id for server in servers))
if st.button("MCP 툴 새로고침"):
st.session_state[mcp_cache_generation_key] = (
int(st.session_state.get(mcp_cache_generation_key, 0) or 0) + 1
)
st.toast("MCP tools/list 캐시를 새로고침합니다.")
st.caption(
f"tools/list cache generation: "
f"{int(st.session_state.get(mcp_cache_generation_key, 0) or 0)}"
)
limit = st.number_input(
"limit",
min_value=1,
max_value=1000,
value=50,
)
execution_mode_override = st.selectbox(
"MCP 실행 모드",
options=("auto", "single", "agent"),
index=0,
format_func=lambda value: {
"auto": "자동 · 작은 모델이 단일/멀티툴 판단",
"single": "단일 · MCP 1회 호출",
"agent": "멀티툴 Agent · ReAct loop",
}[value],
help=(
"속도 우선이면 자동 또는 단일을 사용하세요. "
"자동 모드는 선택한 LLM 모델로 실행 방식을 판단합니다."
),
)
st.markdown("**Resolved MCP endpoints**")
for server in servers:
st.caption(f"{server.server_id}: {server.endpoint_url}")
if st.checkbox("MCP 설정 JSON 보기"):
try:
st.json(
json.loads(MCP_SERVERS_FILE.read_text(encoding="utf-8"))
)
except (OSError, UnicodeError, ValueError):
st.warning("MCP 설정 JSON을 읽지 못했습니다.")
st.caption(f"config: {MCP_SERVERS_FILE}")
st.caption(f"scenario config: {DEMO_SCENARIOS_FILE}")
st.caption(f"token presets: {VPD_TOKEN_PRESETS_FILE}")
st.caption(f"selected LLM model: {selected_query_model_profile}")
st.caption(f"default model: {default_query_model_profile}")
st.caption(
"synthesis fallback model: "
f"{DEFAULT_SYNTHESIS_FALLBACK_MODEL_PROFILE}"
)
st.markdown(
'<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>',
unsafe_allow_html=True,
)
architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs(
["아키텍처", "시나리오", "감사로그", "보안관리"]
)
with architecture_tab:
_render_architecture_tab()
with scenario_tab:
st.markdown(
'<div class="kb-section-title input" role="heading" aria-level="3">'
"질문 입력"
"</div>",
unsafe_allow_html=True,
)
selected_scenario = st.selectbox(
"업무 데모 질의 샘플",
options=(None, *questions),
format_func=lambda item: (
"선택 안 함 · 직접 질문" if item is None else _question_label(item)
),
key=scenario_key,
)
selected_scenario_id = (
None
if selected_scenario is None
else str(getattr(selected_scenario, "question_id"))
)
if question_text_key not in st.session_state:
st.session_state[question_text_key] = DEFAULT_QUESTION
if st.session_state.get(loaded_scenario_key, "") != str(
selected_scenario_id or ""
):
if selected_scenario is not None:
st.session_state[question_text_key] = str(
getattr(selected_scenario, "text")
)
st.session_state[loaded_scenario_key] = str(selected_scenario_id or "")
with st.form("poc4_mcp_question_form"):
question = st.text_area(
"질문",
label_visibility="collapsed",
key=question_text_key,
height=120,
max_chars=1_000,
placeholder="업무 질문을 직접 입력하거나 위 질의 샘플을 선택하세요.",
)
submitted = st.form_submit_button(
"질문 전송",
type="primary",
icon=":material/send:",
icon_position="right",
width="stretch",
)
if submitted:
components.html(
"""
<script>
const target = window.parent.document.getElementById(
"kb-main-tabs-anchor"
);
if (target) {
window.parent.requestAnimationFrame(() => {
target.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
</script>
""",
height=0,
)
st.markdown(
'<div id="kb-query-results-anchor" '
'style="scroll-margin-top: 0.75rem;"></div>',
unsafe_allow_html=True,
)
query_progress_slot = st.empty()
query_progress_notice = st.session_state.pop(
query_progress_notice_key,
None,
)
if query_progress_notice:
query_progress_slot.success(str(query_progress_notice))
_render_chat_history(conversation_id, chat_page_key)
if query_progress_notice:
components.html(
"""
<script>
window.parent.setTimeout(() => {
const target = window.parent.document.getElementById(
"kb-query-results-anchor"
);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, 250);
</script>
""",
height=0,
)
with audit_tab:
render_hmm_audit_tab(
st,
_load_hmm_audit_inventory,
_load_hmm_audit_events,
AuditLogError,
)
with operations_tab:
_render_vpd_operations_tab()
if not submitted:
return
with scenario_tab:
_process_submitted_question(
question=question,
conversation_id=conversation_id,
chat_page_key=chat_page_key,
query_progress_slot=query_progress_slot,
query_progress_notice_key=query_progress_notice_key,
bearer_token=bearer_token,
servers=servers,
default_router_model_profile=default_router_model_profile,
selected_query_model_profile=selected_query_model_profile,
mcp_cache_generation_key=mcp_cache_generation_key,
limit=int(limit),
selected_token_preset=selected_token_preset,
execution_mode_override=execution_mode_override,
)
if __name__ == "__main__":
main()