diff --git a/docs/design/620-poc4-mcp-discovery-streamable-http/README.md b/docs/design/620-poc4-mcp-discovery-streamable-http/README.md index bb0ce9d..80286e6 100644 --- a/docs/design/620-poc4-mcp-discovery-streamable-http/README.md +++ b/docs/design/620-poc4-mcp-discovery-streamable-http/README.md @@ -1,9 +1,9 @@ # 설계서: PoC_4 MCP Discovery UI — KB VPD Streamable HTTP 연동 정비 (#620) -> **상태**: Draft -> **작성**: [AI] Architect · **최종수정**: 2026-07-09 +> **상태**: Draft · source snapshot imported +> **작성**: [AI] Architect · **최종수정**: 2026-07-14 > **추적성** — Redmine: #620 · 관련 ADR: 없음 -> · 구현 파일: `apps/poc4/mcp_discovery_ui.py`, `config/mcp_servers.json`, `config/mcp_servers.sample.json`, VPD Backoffice의 `/mcp` endpoint · 테스트: PoC_4 단위 테스트 및 실제 MCP HTTP smoke test +> · 스냅샷: `poc4_active_source_20260714/` · 원본 archive: `poc4_active_source_20260714/poc4_active_source_20260714.tar.gz` · 목표 구현 파일: `apps/poc4/mcp_discovery_ui.py`, `config/mcp_servers.json`, VPD Backoffice의 `/mcp` endpoint · 테스트: PoC_4 단위 테스트 및 실제 MCP HTTP smoke test ## 1. 목적 (Why) @@ -198,3 +198,18 @@ Authorization: Bearer - Streamable HTTP의 GET/SSE 및 `Mcp-Session-Id`를 완전 지원할지, stateless POST profile로 운영할지 결정이 필요하다. - 데모 이후 사용자 VPD bearer를 OAuth 2.1 access token으로 전환할지, 현 토큰을 resource-server token으로 계속 운영할지 결정이 필요하다. - chat 대화 이력의 `basis_json`/`details_json`에 민감 데이터 보존 기간과 삭제 정책을 별도로 정해야 한다. + +## 13. 2026-07-14 소스 스냅샷 반입 + +배포 서버의 PoC4 활성 화면 소스를 현재 저장소에 별도 폴더로 반입했다. + +- 원격 실제 위치: `/home/opc/poc_4/poc4_active_source_20260714.tar.gz` +- 사용자 제시 경로 `/home/opc/poc_4/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz`에는 파일이 없었고, 실제 archive는 `/home/opc/poc_4/` 바로 아래에 있었다. +- 저장소 위치: `poc4_active_source_20260714/` +- 포함 파일: Streamlit UI, MCP router, OCI GenAI client, 모델 profile, token preset sample, 기동/status script, requirements +- 보안 확인: 실제 `.env`, 실제 VPD token 원문, 대화 SQLite DB는 포함하지 않았다. `vpd_token_presets.json`에는 placeholder만 있다. +- DB 참조 경로: VPD 개발본 배포 서버에서는 `/home/opc/kbmcp/.env`의 접속 정보를 사용하고, wallet directory는 `/home/opc/wallet/kbaipoc`를 사용한다. 두 경로의 파일 내용은 저장소에 포함하지 않는다. +- 런타임 전제: Python 3.11 이상. 현재 개발 서버 기본 `python3`는 3.6.8이므로 이 소스의 문법 검증에는 맞지 않는다. +- 검증: 배포 서버 원본 경로에서 PoC4 전용 Python `3.11.15`로 `py_compile` 통과. 기존 Java 백오피스 `mvn -q test` 통과. + +주의: 이 반입은 소스 스냅샷 보관이다. 아직 본 설계서의 목표 계약인 `kb_vpd_streamable_http`, `KB_MCP_BASE_URL` 단일 endpoint 해석, 단일 도구 direct routing을 구현 완료했다는 의미는 아니다. diff --git a/poc4_active_source_20260714/.env.sample b/poc4_active_source_20260714/.env.sample new file mode 100644 index 0000000..e3d87db --- /dev/null +++ b/poc4_active_source_20260714/.env.sample @@ -0,0 +1,113 @@ +# PoC_4 runtime environment template +# +# 사용법: +# cp .env.sample .env +# chmod 600 .env +# 편집 후 scripts/poc4/start_*_nohup.sh 로 기동합니다. +# +# 실제 token, password, OCID, wallet 경로는 이 샘플에 기록하지 않습니다. +# 이 파일은 Bash에서 읽히므로 KEY=value 형식만 사용하고 명령 치환은 넣지 않습니다. + +# ----------------------------------------------------------------------------- +# MCP runtime (현재 PoC_4가 공유하는 PoC_3 호환 환경변수 계약) +# ----------------------------------------------------------------------------- +POC3_MCP_PROVIDER=custom_python +POC3_MCP_BASE_URL=http://127.0.0.1:8500 +POC3_MCP_AUTH_MODE=bearer +POC3_MCP_TOKEN= +POC3_MCP_TIMEOUT_SECONDS=30 +POC3_MCP_FALLBACK_TO_MOCK=false +POC3_MCP_LIVE_SMOKE=false + +# MCP server registry +# 실제 서버 URL/token 값은 JSON에 직접 넣지 않고 위 환경변수 이름을 참조합니다. +# 사용 전 config/mcp_servers.sample.json을 아래 파일명으로 복사해 조정합니다. +POC4_MCP_SERVERS_FILE=config/mcp_servers.json +POC4_MCP_DEFAULT_SERVER_ID=local_adb_mcp + +# MCP discovery UI conversation history store +# 기본값: /home/opc/poc_4/data/poc4_mcp_chat.sqlite3 +POC4_CHAT_DB_PATH=data/poc4_mcp_chat.sqlite3 + +# ----------------------------------------------------------------------------- +# Local trace / optional LangSmith metadata +# ----------------------------------------------------------------------------- +POC3_TRACE_MODE=local +POC3_TRACE_UI_ENABLED=true +LANGSMITH_TRACING=false +LANGSMITH_API_KEY= +LANGSMITH_PROJECT=kb-aidp-poc4 + +# ----------------------------------------------------------------------------- +# PoC_4 UI ports +# 현재 VM 기본 포트만 사용합니다. 공식 포트 전환은 .env만으로 허용되지 않으며 +# POC4_ALLOW_OFFICIAL_PORTS=1을 기동 명령의 환경에 별도로 지정해야 합니다. +# ----------------------------------------------------------------------------- +POC4_LANGGRAPH_UI_PORT=8612 +POC4_AGENT_TEAM_UI_PORT=8613 +POC4_LANGGRAPH_TC_UI_PORT=8622 +POC4_AGENT_TEAM_TC_UI_PORT=8623 + +# ----------------------------------------------------------------------------- +# OCI Generative AI +# compartment ID는 배포 환경의 값을 입력합니다. API key/private key 원문은 넣지 +# 않고 OCI config file 또는 instance/resource principal을 사용합니다. +# ----------------------------------------------------------------------------- +OCI_AUTH_TYPE=config_file +OCI_CONFIG_FILE=~/.oci/config +OCI_PROFILE=DEFAULT +OCI_GENAI_COMPARTMENT_ID= + +# 모델 route는 config/poc3_model_profiles.json의 검증된 기본값을 사용합니다. +# 배포 환경에서 route를 바꿔야 할 때만 아래 항목의 주석을 해제합니다. +# POC3_LLM_GPT55_OCI_MODEL_ID=openai.gpt-5.5 +# POC3_LLM_GPT55_OCI_REGION=us-chicago-1 +# POC3_LLM_GPT55_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com +# POC3_LLM_GPT54_MINI_OCI_MODEL_ID=openai.gpt-5.4-mini +# POC3_LLM_GPT54_MINI_OCI_REGION=us-chicago-1 +# POC3_LLM_GPT54_MINI_OCI_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com +# POC3_LLM_GROK43_MODEL_ID=xai.grok-4.3 +# POC3_LLM_GROK43_REGION=us-chicago-1 +# POC3_LLM_GROK43_ENDPOINT=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com +# 화면 LLM 모델 선택의 기본값입니다. +# 선택 모델은 후속 질문 정리, 실행 방식 판단, MCP tool 라우팅, +# Agent planning, 최종 답변 합성에 사용됩니다. +# 실패 시 애플리케이션 기본 fallback인 gpt54_mini_oci로 재시도합니다. +POC4_COMPLEX_REASONING_MODEL_PROFILE=grok43 + +# ----------------------------------------------------------------------------- +# Audit / security evidence DB connection +# ----------------------------------------------------------------------------- +# VPD 개발본 배포 서버 기준: +# - DB 접속 secret은 /home/opc/kbmcp/.env 에 둡니다. +# - DB wallet은 /home/opc/wallet/kbaipoc 를 사용합니다. +# 이 샘플에는 DB password, wallet password, token 원문을 넣지 않습니다. +POC4_AUDIT_DB_ENV_FILE=/home/opc/kbmcp/.env +ORACLE_WALLET_DIR=/home/opc/wallet/kbaipoc + +# ----------------------------------------------------------------------------- +# Direct Oracle DB administration tools (목표 계약) +# Streamlit/MCP-only 배포에서는 모두 비워 둡니다. 현재 launcher는 .env 전체를 UI +# process에 export하므로 역할별 설정 loader가 구현되기 전에는 이 파일에 DB secret을 +# 채우지 않습니다. DB migration/audit 전용 process environment에서만 주입합니다. +# ----------------------------------------------------------------------------- +POC4_DB_ADMIN_ENABLED=false +POC4_DB_USERNAME= +POC4_DB_PASSWORD= +POC4_DB_DSN= +POC4_DB_WALLET_DIR= +POC4_DB_WALLET_PASSWORD= +POC4_DB_EXPECTED_SCHEMA= +POC4_DB_CLIENT_LIB_DIR= +POC4_DB_MODE=thick +POC4_DB_CONNECT_TIMEOUT_SECONDS=60 +POC4_DB_CALL_TIMEOUT_MS=120000 + +# 신규 tenancy 이관 preflight에서만 사용하며 값 자체는 출력하지 않습니다. +POC4_EXPECTED_TENANCY_ID= + +# 다음 값은 복제된 .env가 보안/프로세스 제어를 바꾸지 못하도록 launcher 호출자만 +# 지정할 수 있습니다. 이 파일에 활성 값으로 추가하지 않습니다. +# POC4_BIND_ADDRESS=0.0.0.0 +# POC4_ALLOW_OFFICIAL_PORTS=1 +# POC4_MANAGED_FOREGROUND=1 diff --git a/poc4_active_source_20260714/SOURCE_README.md b/poc4_active_source_20260714/SOURCE_README.md new file mode 100644 index 0000000..e2bcec3 --- /dev/null +++ b/poc4_active_source_20260714/SOURCE_README.md @@ -0,0 +1,46 @@ +# PoC4 MCP AI Console source snapshot + +생성일: 2026-07-14 GMT + +이 폴더는 현재 PoC4 MCP AI Console 실행에 필요한 소스 파일을 원래 경로 구조로 추려 복사한 스냅샷입니다. + +## Entrypoint + +```bash +streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.port 8622 +``` + +## Runtime + +- Python 3.11 이상을 사용합니다. +- 이 개발 서버의 기본 `python3`가 3.6 계열이면 문법 검증이 실패합니다. +- 배포 서버 검증 런타임: `/home/opc/poc_4/.python-runtime/cpython-3.11.15+20260610/bin/python3.11` + +## Deployment DB reference + +- VPD 개발본 배포 서버의 DB 접속 정보는 `/home/opc/kbmcp/.env`를 사용합니다. +- DB wallet directory는 `/home/opc/wallet/kbaipoc`를 사용합니다. +- 이 저장소에는 wallet 파일, DB password, wallet password, 실제 VPD token 원문을 넣지 않습니다. + +## Included + +- `apps/poc4/mcp_discovery_ui.py` +- `apps/poc4/ui_theme.py` +- `src/mcp_tool_router.py` +- `src/oci_genai_sdk.py` +- `src/poc3/model_registry.py` +- `src/poc3/questions.py` +- `config/mcp_servers.json` +- `config/poc3_model_profiles.json` +- `config/vpd_token_presets.json` +- `.env.sample` +- `requirements.txt` +- `requirements-langgraph.txt` +- `scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh` +- `scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh` + +## Security note + +- 실제 `.env`는 복사하지 않았습니다. `.env.sample`을 기준으로 새로 만드세요. +- 실제 VPD 토큰 원문은 복사하지 않았습니다. `config/vpd_token_presets.json`의 `token` 값을 배포 환경에서 교체하세요. +- 대화 DB `data/poc4_mcp_chat.sqlite3`는 개인정보/대화 내용이 포함될 수 있어 복사하지 않았습니다. diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py new file mode 100644 index 0000000..44f9556 --- /dev/null +++ b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py @@ -0,0 +1,7252 @@ +"""Minimal MCP discovery UI for PoC4. + +Run: + streamlit run apps/poc4/mcp_discovery_ui.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 base64 +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().parents[2] +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 src.mcp_tool_router import ( + McpTool, + McpToolRouterError, + RoutedMcpTool, + build_mcp_tool_arguments, + route_mcp_tool_across_servers_with_llm, +) +from src.oci_genai_sdk import ( + build_oci_genai_completion_client, + temperature_for_model_profile, +) +from src.poc3.model_registry import load_model_registry, resolve_model_profile +from src.poc3.questions import COMMON_DEMO_QUESTIONS + + +LOG = logging.getLogger(__name__) +MCP_PROTOCOL_VERSION = "2025-11-25" +PREFERRED_TOOL = "ords.query.kb_select_ai_vpd" +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 = "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" +CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3" +KB_OFFICIAL_LOGO_URL = "https://www.kbinsure.co.kr/extrnl/image/common/kakao_ci_800.png" +KB_HEADER_LOGO_FILE = ROOT / "assets" / "kbsonbo_logo_horizontal.png" +KB_TITLE_FONT_FILE = ROOT / "assets" / "KBFGTextM.woff2" +KB_TITLE_FONT_BOLD_FILE = ROOT / "assets" / "KBFGTextB.woff2" +KB_TITLE_FONT_URL = ( + "data:font/woff2;base64," + + base64.b64encode(KB_TITLE_FONT_FILE.read_bytes()).decode("ascii") + if KB_TITLE_FONT_FILE.exists() + else "https://www.kbinsure.co.kr/library/font/KBFGTextM.woff2" +) +KB_TITLE_FONT_BOLD_URL = ( + "data:font/woff2;base64," + + base64.b64encode(KB_TITLE_FONT_BOLD_FILE.read_bytes()).decode("ascii") + if KB_TITLE_FONT_BOLD_FILE.exists() + else "https://www.kbinsure.co.kr/library/font/KBFGTextB.woff2" +) +DEFAULT_VPD_USER_ID = "FC00789" +VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/" +PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated" +PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" +PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" +AUDIT_SCHEMA = "POC_2" +AUDIT_DB_ENV_FILE = Path( + os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env") +).expanduser() +_OPAQUE_BEARER = re.compile(r"^[\x21-\x7e]{1,4096}$") +KB_THEME_CSS = """ + +""".replace("__KB_TITLE_FONT_URL__", KB_TITLE_FONT_URL).replace( + "__KB_TITLE_FONT_BOLD_URL__", KB_TITLE_FONT_BOLD_URL +) + + +@dataclass(frozen=True) +class McpServer: + server_id: str + endpoint_url: 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 + + @property + def display_label(self) -> str: + return " · ".join( + item + for item in ( + self.user_id, + self.name, + self.role, + self.channel, + self.scope, + ) + if item + ) + + @property + def select_label(self) -> str: + return " · ".join( + item + for item in ( + self.user_id, + self.name, + ) + 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("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")) + text = str(getattr(question, "text")) + return f"{question_id} · {category} · {text}" + + +def _apply_kb_theme() -> None: + st.markdown(KB_THEME_CSS, unsafe_allow_html=True) + + +def _portal_auth_value(name: str) -> str: + return (os.environ.get(name) or _dotenv_value(name)).strip() + + +def _portal_password_matches(password: str, encoded_password: str) -> bool: + try: + scheme, iterations_text, salt_hex, expected_hex = encoded_password.split("$", 3) + iterations = int(iterations_text) + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(expected_hex) + except (TypeError, ValueError): + return False + if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000: + return False + candidate = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + ) + return hmac.compare_digest(candidate, expected) + + +def _portal_auth_configured() -> bool: + return bool( + _portal_auth_value("POC4_LOGIN_USER") + and _portal_auth_value("POC4_LOGIN_PASSWORD_PBKDF2") + ) + + +def _portal_credentials_are_valid(username: str, password: str) -> bool: + expected_username = _portal_auth_value("POC4_LOGIN_USER") + encoded_password = _portal_auth_value("POC4_LOGIN_PASSWORD_PBKDF2") + username_matches = hmac.compare_digest(username.strip(), expected_username) + password_matches = _portal_password_matches(password, encoded_password) + return username_matches and password_matches + + +def _render_portal_login() -> None: + logo_url = html.escape(_header_logo_url(), quote=True) + with st.container(key="kb_login_container"): + st.markdown( + f""" +
+ + +

KB손해보험 AI 업무 에이전트

+

사용자 인증 후 AI 질의·보안 관리 기능을 이용할 수 있습니다.

+
+ """, + unsafe_allow_html=True, + ) + if not _portal_auth_configured(): + st.error("포털 로그인 설정을 확인해 주세요.") + return + with st.form("poc4_portal_login_form", clear_on_submit=True): + username = st.text_input( + "사용자 ID", + max_chars=80, + placeholder="사용자 ID를 입력하세요.", + ) + password = st.text_input( + "비밀번호", + type="password", + max_chars=200, + placeholder="비밀번호를 입력하세요.", + ) + submitted = st.form_submit_button("로그인", use_container_width=True) + if submitted: + if _portal_credentials_are_valid(username, password): + st.session_state[PORTAL_AUTHENTICATED_KEY] = True + st.session_state[PORTAL_AUTH_USER_KEY] = username.strip() + st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False + st.rerun() + st.session_state[PORTAL_LOGIN_FAILURE_KEY] = True + if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False): + st.error("사용자 ID 또는 비밀번호를 확인해 주세요.") + st.markdown( + '
' + '인증된 DEMO 사용자만 접근할 수 있습니다.' + '
', + unsafe_allow_html=True, + ) + + +def _logout_portal() -> None: + st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None) + st.session_state.pop(PORTAL_AUTH_USER_KEY, None) + st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None) + st.rerun() + + +def _header_logo_url() -> str: + try: + encoded = base64.b64encode(KB_HEADER_LOGO_FILE.read_bytes()).decode("ascii") + except OSError: + return KB_OFFICIAL_LOGO_URL + return f"data:image/png;base64,{encoded}" + + +def _render_kb_header() -> None: + logo_url = html.escape(_header_logo_url(), quote=True) + st.markdown( + f""" +
+
+
+
KB
+
+
KB손해보험 AX Demo
+
국민의 평생 희망파트너 · AI 업무 질의
+
+
+

KB손해보험 AI 업무 에이전트

+

+ 사용자 권한을 기준으로 AI 질의 응답을 수행하고 관리하는 기능을 확인할 수 있는 + DEMO 화면입니다. +

+
+
+ KB손해보험 로고 +
+
+ """, + unsafe_allow_html=True, + ) + + +def _render_vpd_user_card(preset: VpdTokenPreset) -> None: + st.markdown( + f""" +
+
+ {html.escape(preset.user_id)} · {html.escape(preset.name)} +
+
+ {html.escape(preset.role)} · {html.escape(preset.channel)} +
+
+ 권한 범위: {html.escape(preset.scope)} +
+
+ """, + 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..." + + +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: + return ( + os.environ.get(name) + or _dotenv_value(name, AUDIT_DB_ENV_FILE) + or default + ).strip() + + +@st.cache_resource(show_spinner=False) +def _audit_db_pool() -> Any: + password = _audit_db_env_value("ORACLE_DB_PASSWORD") + if not password: + raise AuditLogError("감사로그 DB 접속 설정을 확인해 주세요.") + wallet_dir = Path( + _audit_db_env_value( + "ORACLE_WALLET_DIR", + "/home/opc/wallet/kbaipoc", + ) + ).expanduser().resolve() + if not wallet_dir.is_dir(): + raise AuditLogError("감사로그 DB Wallet 경로를 확인해 주세요.") + try: + return oracledb.create_pool( + user=_audit_db_env_value("ORACLE_DB_USER", "ADMIN"), + password=password, + dsn=_audit_db_env_value("ORACLE_DSN", "kbaipoc_high"), + config_dir=str(wallet_dir), + wallet_location=str(wallet_dir), + wallet_password=_audit_db_env_value("ORACLE_WALLET_PASSWORD") or None, + min=1, + max=2, + increment=1, + getmode=oracledb.POOL_GETMODE_WAIT, + ) + 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_fga_inventory() -> dict[str, list[dict[str, Any]]]: + policies = _audit_rows( + """ + SELECT policy.object_name, + policy.policy_name, + policy.policy_text, + LISTAGG(policy_columns.policy_column, ',') WITHIN GROUP ( + ORDER BY policy_columns.policy_column + ) AS policy_column, + policy.enabled, + policy.sel, + policy.ins, + policy.upd, + policy.del + FROM dba_audit_policies policy + LEFT JOIN dba_audit_policy_columns policy_columns + ON policy_columns.object_schema = policy.object_schema + AND policy_columns.object_name = policy.object_name + AND policy_columns.policy_name = policy.policy_name + WHERE policy.object_schema = :schema + GROUP BY policy.object_name, + policy.policy_name, + policy.policy_text, + policy.enabled, + policy.sel, + policy.ins, + policy.upd, + policy.del + ORDER BY policy.object_name, policy.policy_name + """, + {"schema": AUDIT_SCHEMA}, + ) + catalog = _audit_rows( + """ + SELECT object_name, + column_name, + policy_name, + policy_expression, + enabled_yn, + description, + updated_at + FROM POC_2.KB_SECURITY_POLICY_CATALOG + WHERE control_type = 'DBMS_FGA' + ORDER BY object_name, policy_name, column_name + """ + ) + return {"policies": policies, "catalog": catalog} + + +@st.cache_data(ttl=30, show_spinner=False) +def _load_fga_audit_events( + days: int, + row_limit: int, + policy_name: str, + object_name: str, +) -> list[dict[str, Any]]: + return _audit_rows( + """ + SELECT * + FROM ( + SELECT TO_CHAR( + audit_event.event_timestamp AT TIME ZONE 'Asia/Seoul', + 'YYYY-MM-DD HH24:MI:SS' + ) AS event_time, + audit_event.dbusername, + audit_event.client_identifier, + audit_event.userhost, + audit_event.object_schema, + audit_event.object_name, + audit_event.action_name, + audit_event.fga_policy_name, + policy_columns.audit_column, + audit_event.return_code, + DBMS_LOB.SUBSTR(audit_event.sql_text, 1000, 1) AS sql_text + FROM unified_audit_trail audit_event + LEFT JOIN ( + SELECT object_schema, + object_name, + policy_name, + LISTAGG(policy_column, ',') WITHIN GROUP ( + ORDER BY policy_column + ) AS audit_column + FROM dba_audit_policy_columns + WHERE object_schema = :schema + GROUP BY object_schema, object_name, policy_name + ) policy_columns + ON policy_columns.object_schema = audit_event.object_schema + AND policy_columns.object_name = audit_event.object_name + AND policy_columns.policy_name = audit_event.fga_policy_name + WHERE audit_event.object_schema = :schema + AND audit_event.fga_policy_name IS NOT NULL + AND audit_event.event_timestamp >= ( + SYSTIMESTAMP - NUMTODSINTERVAL(:days, 'DAY') + ) + AND (:policy_name IS NULL OR audit_event.fga_policy_name = :policy_name) + AND (:object_name IS NULL OR audit_event.object_name = :object_name) + ORDER BY audit_event.event_timestamp DESC + ) + WHERE ROWNUM <= :row_limit + """, + { + "schema": AUDIT_SCHEMA, + "days": int(days), + "policy_name": policy_name or None, + "object_name": object_name 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"(? 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" + "| KB손해보험 41048 | 제6조 보상하는 손해 | 피보험자동차 사고로 " + "타인의 재물을 없애거나 훼손해 부담한 법률상 손해배상책임을 보상 | " + f"{kb_coverage_reference} |\n" + "| KB손해보험 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(name: object) -> str: + key = str(name or "").strip() + if not key: + return "" + return (os.environ.get(key) or _dotenv_value(key)).strip() + + +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"VPD 토큰 preset 설정을 읽지 못했습니다: {path}") from None + raw_presets = payload.get("presets") if isinstance(payload, Mapping) else None + if not isinstance(raw_presets, list): + raise PublicMcpError("VPD 토큰 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 = _normalized_bearer(item.get("token")) + user_id = str(item.get("user_id") or "").strip() + if not token or 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 "").strip(), + scope=str(item.get("scope") or "").strip(), + is_default=item.get("default") is True, + ) + ) + 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, + 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, tuple[str, ...], str, str], ...]: + return tuple( + ( + server.server_id, + server.endpoint_url, + 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, tuple[str, ...], str, str], ...], +) -> list[McpServer]: + return [ + McpServer( + server_id=row[0], + endpoint_url=row[1], + default_tool=row[2], + tool_allowlist=tuple(row[3]), + router_model_profile=row[4], + description=row[5], + ) + for row in rows + ] + + +@st.cache_data(show_spinner=False) +def cached_discover_enabled_server_tools( + server_rows: tuple[tuple[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] = {} + 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) + ).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 + 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] = {} + 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) + 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, + ) + 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 + 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 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]: + 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), + "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 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. " + "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] + 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 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( + '
' + "질의 결과" + "
", + 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: + architecture_html = """ +
+
+ 전체 시스템 구성 +
+
+ 사용자 권한 컨텍스트가 AI 라우터와 MCP 도구를 거쳐 Oracle Database의 + 정형 데이터·약관 지식·보안 정책으로 연결되는 현재 DEMO 실행 구조입니다. +
+ +
+
+ 01 · AI Experience + 사용자 권한 기반 AI 업무 경험 +
+
+ 사용자·권한 + VPD 사용자와 Bearer Token으로 역할·지점·담당 범위를 요청 컨텍스트에 결합 +
+
+ KB손해보험 AI 업무 에이전트 + OCI Compute · 아키텍처·시나리오·감사로그·보안관리 +
+
+ OCI GenAI Router + 질의 분류, 단일·멀티툴 판단, GPT·Grok·Llama 기반 최종 답변 합성 +
+
+
+ + +
+ 02 · MCP INTEGRATION + 정형·벡터 MCP +

업무 SQL과 보험 약관 근거를 목적별 도구로 조회

+
kb_mcp · ords.query.kb_select_ai_vpd
+
kb_vector_mcp · hybrid_rerank_search
+
+ + +
+ 03 · DATA & SECURITY + Oracle ADB KBAIPOC +

POC_2 · KB_* · Select AI · Vector Search

+
VPD RLS/CLS · DBMS_FGA · Unified Audit Trail
+
+
+ +
+
+

질의 처리 흐름

+
+ 1 +
권한 컨텍스트 생성선택한 사용자 토큰과 질문을 세션 요청에 결합합니다.
+
+
+ 2 +
AI 실행 계획 수립선택 LLM이 단일 도구 또는 멀티툴 실행 경로를 판단합니다.
+
+
+ 3 +
MCP 근거 조회정형 Select AI/VPD와 약관 Hybrid·Rerank 검색을 필요한 만큼 호출합니다.
+
+
+ 4 +
DB 보안 정책 적용행·컬럼 접근 통제 후 민감 컬럼 접근을 FGA 감사 로그로 기록합니다.
+
+
+ 5 +
결과 합성·표시원본 MCP 결과를 보존하고 AI 답변, 진행 상태와 감사 결과를 화면에 표시합니다.
+
+
+ +
+

구성 요소 설명

+
+
+ 정형 업무 데이터 + POC_2의 KB_CUSTOMERS, KB_CONTRACTS, KB_CLAIMS 등 KB_* 핵심 테이블을 Select AI로 조회합니다. +
+
+ 약관 지식 검색 + OCI Embedding, Oracle Vector Search와 OCI Rerank를 결합해 자사·타사 약관 근거를 찾습니다. +
+
+ 데이터 보안 + Bearer Token의 사용자 식별자를 기준으로 VPD 행·컬럼 정책을 적용하고 접근 결과를 반환합니다. +
+
+ 관리·감사 + 보안 운영 포털에서 정책을 관리하고, 감사로그 탭은 Wallet 연결로 FGA 정책과 Unified Audit Trail을 조회합니다. +
+
+
+
+
+ """ + st.markdown( + "\n".join( + line.strip() + for line in architecture_html.splitlines() + if line.strip() + ), + unsafe_allow_html=True, + ) + + +def _render_fga_audit_tab() -> None: + st.markdown( + '
' + '감사로그 ( 오라클 FGA )' + '
', + unsafe_allow_html=True, + ) + st.markdown( + '
' + 'Oracle FGA 정책 상태와 민감 컬럼 접근 이력을 시간순으로 확인합니다. ' + '조회 조건을 선택하면 정책·객체·사용자·SQL 원문을 함께 비교할 수 있습니다.' + '
', + unsafe_allow_html=True, + ) + try: + inventory = _load_fga_inventory() + except AuditLogError as exc: + st.error(str(exc)) + return + + policies = inventory["policies"] + catalog = inventory["catalog"] + policy_names = sorted( + { + str(item.get("policy_name") or "").strip() + for item in (*policies, *catalog) + if str(item.get("policy_name") or "").strip() + } + ) + object_names = sorted( + { + str(item.get("object_name") or "").strip() + for item in (*policies, *catalog) + if str(item.get("object_name") or "").strip() + } + ) + + st.markdown( + '
조회 조건
', + unsafe_allow_html=True, + ) + with st.container(key="poc4_fga_filters"): + filter_policy, filter_object = st.columns(2) + with filter_policy: + selected_policy = st.selectbox( + "FGA 정책", + options=("", *policy_names), + format_func=lambda value: "전체 정책" if not value else value, + key="poc4_fga_policy_filter", + ) + with filter_object: + selected_object = st.selectbox( + "감사 객체", + options=("", *object_names), + format_func=lambda value: "전체 객체" if not value else value, + key="poc4_fga_object_filter", + ) + filter_days, filter_limit, refresh_column = st.columns([1.5, 1, 0.8]) + with filter_days: + days = st.slider( + "조회 기간", + min_value=1, + max_value=90, + value=7, + format="%d일", + key="poc4_fga_days", + ) + with filter_limit: + row_limit = st.number_input( + "최대 건수", + min_value=10, + max_value=500, + value=100, + step=10, + key="poc4_fga_row_limit", + ) + with refresh_column: + st.markdown( + '
', + unsafe_allow_html=True, + ) + if st.button( + "새로고침", + icon=":material/refresh:", + width="stretch", + key="poc4_fga_refresh", + ): + _load_fga_inventory.clear() + _load_fga_audit_events.clear() + st.rerun() + + try: + events = _load_fga_audit_events( + int(days), + int(row_limit), + selected_policy, + selected_object, + ) + except AuditLogError as exc: + st.error(str(exc)) + return + + success_count = sum( + 1 for item in events if int(item.get("return_code") or 0) == 0 + ) + failure_count = len(events) - success_count + with st.container(key="poc4_fga_metrics"): + metric_policy, metric_event, metric_success, metric_failure = st.columns(4) + metric_policy.metric("등록 FGA 정책", len(policies)) + metric_event.metric("조회 이벤트", len(events)) + metric_success.metric("성공", success_count) + metric_failure.metric("실패", failure_count) + + if not policies: + if catalog: + st.warning( + "FGA 정책 카탈로그는 존재하지만 현재 DB에 활성 정책이 등록되어 있지 않습니다." + ) + else: + st.warning( + f"현재 {AUDIT_SCHEMA} 스키마에 등록된 DBMS_FGA 정책이 없습니다." + ) + + st.markdown( + '
정책 상태
' + f'
현재 활성 정책 {len(policies)}건 · ' + '감사 대상 컬럼과 적용 조건을 확인합니다.
', + unsafe_allow_html=True, + ) + with st.container(key="poc4_fga_policy_panel"): + with st.expander("FGA 정책 상태", expanded=True): + if policies: + st.dataframe( + [ + { + "객체": str(item.get("object_name") or ""), + "정책": str(item.get("policy_name") or ""), + "감사 컬럼": str( + item.get("policy_column") or "전체" + ), + "조건": str(item.get("policy_text") or "항상"), + "활성": str(item.get("enabled") or ""), + "SELECT": str(item.get("sel") or ""), + } + for item in policies + ], + column_config={ + "객체": st.column_config.TextColumn(width="medium"), + "정책": st.column_config.TextColumn(width="large"), + "감사 컬럼": st.column_config.TextColumn(width="large"), + "조건": st.column_config.TextColumn(width="large"), + "활성": st.column_config.TextColumn(width="small"), + "SELECT": st.column_config.TextColumn(width="small"), + }, + hide_index=True, + width="stretch", + height=min(360, 72 + 36 * len(policies)), + ) + elif catalog: + st.dataframe( + [ + { + "객체": str(item.get("object_name") or ""), + "정책": str(item.get("policy_name") or ""), + "대상 컬럼": str(item.get("column_name") or "전체"), + "카탈로그 상태": str(item.get("enabled_yn") or ""), + "설명": str(item.get("description") or ""), + } + for item in catalog + ], + hide_index=True, + width="stretch", + height=min(360, 72 + 36 * len(catalog)), + ) + else: + st.caption("등록된 FGA 정책 정보가 없습니다.") + + st.markdown( + '
감사 이벤트
' + '
최신 이벤트부터 표시합니다. ' + '성공 여부와 감사 컬럼, 실행 사용자를 먼저 확인하세요.
', + unsafe_allow_html=True, + ) + with st.container(key="poc4_fga_event_toolbar"): + show_sql = st.toggle( + "SQL 원문 표시", + value=True, + key="poc4_fga_show_sql", + ) + if not events: + st.info("선택한 조건에 해당하는 FGA 감사 이벤트가 없습니다.") + return + + display_rows: list[dict[str, Any]] = [] + for event in events: + return_code = int(event.get("return_code") or 0) + actor = str(event.get("client_identifier") or "").strip() + if not actor: + actor = str(event.get("dbusername") or "") + display_row: dict[str, Any] = { + "발생시각(KST)": str(event.get("event_time") or ""), + "정책": str(event.get("fga_policy_name") or ""), + "감사 컬럼": str(event.get("audit_column") or "전체"), + "사용자": actor, + "DB 사용자": str(event.get("dbusername") or ""), + "접속 호스트": str(event.get("userhost") or ""), + "객체": ( + f"{event.get('object_schema')}.{event.get('object_name')}" + ), + "작업": str(event.get("action_name") or ""), + "결과": "성공" if return_code == 0 else f"ORA-{return_code:05d}", + } + if show_sql: + display_row["SQL 원문"] = " ".join( + str(event.get("sql_text") or "").split() + ) + display_rows.append(display_row) + + def initial_column_width( + column_name: str, + minimum: int, + maximum: int, + ) -> int: + values = [column_name] + values.extend(str(row.get(column_name) or "") for row in display_rows) + text_units = max( + sum(2 if ord(character) > 127 else 1 for character in value) + for value in values + ) + return max(minimum, min(maximum, 36 + text_units * 8)) + + event_column_config: dict[str, Any] = { + "발생시각(KST)": st.column_config.TextColumn( + width=initial_column_width("발생시각(KST)", 180, 220) + ), + "정책": st.column_config.TextColumn( + width=initial_column_width("정책", 180, 320) + ), + "감사 컬럼": st.column_config.TextColumn( + width=initial_column_width("감사 컬럼", 150, 300) + ), + "사용자": st.column_config.TextColumn( + width=initial_column_width("사용자", 110, 180) + ), + "DB 사용자": st.column_config.TextColumn( + width=initial_column_width("DB 사용자", 120, 180) + ), + "접속 호스트": st.column_config.TextColumn( + width=initial_column_width("접속 호스트", 150, 240) + ), + "객체": st.column_config.TextColumn( + width=initial_column_width("객체", 180, 300) + ), + "작업": st.column_config.TextColumn( + width=initial_column_width("작업", 90, 140) + ), + "결과": st.column_config.TextColumn( + width=initial_column_width("결과", 90, 140) + ), + } + if show_sql: + event_column_config["SQL 원문"] = st.column_config.TextColumn( + width=initial_column_width("SQL 원문", 420, 720) + ) + with st.container(key="poc4_fga_event_panel"): + st.dataframe( + display_rows, + column_config=event_column_config, + hide_index=True, + width="stretch", + height=min(640, 104 + 38 * len(display_rows)), + ) + + +def _render_vpd_operations_tab() -> None: + st.markdown( + f""" +
+
+ RLS / CLS 설정 ( 오라클 VPD / 마스킹 ) +
+
+ 업무 사용자와 데이터 접근 기준을 관리하고, + DB가 적용한 결과까지 한 흐름에서 확인합니다. +
+ +

보안 설정 업무 프로세스

+
+ 권한을 먼저 만들고 보호 대상을 연결한 뒤, 실제 사용자 토큰으로 결과를 검증합니다. + 권한은 토큰에 복사되지 않아 이후 변경도 다음 요청부터 반영됩니다. +
+
+
+
1
+ 사용자·그룹 + 업무 대상을 등록합니다. +
+
+
2
+ 역할 + 직접·그룹 역할을 부여합니다. +
+
+
3
+ 접근 규칙 + 객체·행·컬럼 접근을 설정합니다. +
+
+
4
+ 보호·연결 + VPD·ORDS 대상을 확인합니다. +
+
+
5
+ 유효 권한·접근 검증 + 토큰으로 실제 결과를 확인합니다. +
+
+
+ 보안 운영 포털 +

+ 권한 등록, 접근 규칙 관리와 검증 결과 확인은 별도 운영 화면에서 수행합니다. +

+ + 권한 운영 화면 열기 ↗ + +
+
+ """, + 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] = {} + 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 ""), + } + 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 ""), + } + 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, + "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: + st.set_page_config(page_title="KB손해보험 AI 콘솔", page_icon="🟨", layout="wide") + _apply_kb_theme() + if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): + _render_portal_login() + return + questions = tuple( + question + for question in COMMON_DEMO_QUESTIONS + if str(question.question_id).strip().upper() != "S5" + ) + 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) + if ( + str(getattr(selected_scenario_state, "question_id", "")).strip().upper() + == "S5" + ): + 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 + 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_kb_header() + with st.sidebar: + st.caption( + f"포털 사용자 · {st.session_state.get(PORTAL_AUTH_USER_KEY, '')}" + ) + if st.button("로그아웃", use_container_width=True): + _logout_portal() + st.divider() + st.markdown('
AI 사용자 설정
', 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( + '
VPD 사용자
', + 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( + "VPD 사용자 선택", + 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 + if 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="선택한 VPD 사용자의 Bearer Token입니다.", + ) + else: + manual_bearer_token = st.text_input( + "Bearer token", + type="default", + key="poc4_manual_bearer_token", + ) + bearer_token = ( + selected_token_preset.token + if selected_token_preset is not None + else manual_bearer_token + ) + if selected_token_preset is not None: + _render_vpd_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( + '
MCP Servers
', + 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"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( + '
', + 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( + '
' + "질문 입력" + "
", + 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( + """ + + """, + height=0, + ) + + st.markdown( + '
', + 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( + """ + + """, + height=0, + ) + + with audit_tab: + _render_fga_audit_tab() + + 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() diff --git a/poc4_active_source_20260714/apps/poc4/ui_theme.py b/poc4_active_source_20260714/apps/poc4/ui_theme.py new file mode 100644 index 0000000..b75056d --- /dev/null +++ b/poc4_active_source_20260714/apps/poc4/ui_theme.py @@ -0,0 +1,226 @@ +"""Cross-browser light theme primitives shared by the PoC_4 Streamlit UIs. + +This module is presentation-only. It does not import or call runtime adapters, +MCP clients, databases, retrieval code, or model providers. +""" + +from __future__ import annotations + + +POC4_LIGHT_THEME_CSS = """ + +""" + + +def apply_poc4_light_theme(st: object, *, additional_css: str = "") -> None: + """Inject optional layout CSS followed by the authoritative light theme.""" + + st.markdown( + additional_css + POC4_LIGHT_THEME_CSS, + unsafe_allow_html=True, + ) + + +__all__ = ["POC4_LIGHT_THEME_CSS", "apply_poc4_light_theme"] diff --git a/poc4_active_source_20260714/config/mcp_servers.json b/poc4_active_source_20260714/config/mcp_servers.json new file mode 100644 index 0000000..80efeaf --- /dev/null +++ b/poc4_active_source_20260714/config/mcp_servers.json @@ -0,0 +1,35 @@ +{ + "default_server_id": "kb_mcp", + "servers": [ + { + "id": "kb_mcp", + "enabled": true, + "provider": "custom_python", + "transport": "http", + "endpoint_url": "https://kb.cloud-handson.com/mcp", + "base_url_env": "KB_MCP_BASE_URL", + "auth_mode_env": "KB_MCP_AUTH_MODE", + "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS", + "default_tool": "ords.query.kb_select_ai_vpd", + "router_model_profile": "gpt55_oci", + "tool_allowlist": [ + "ords.query.kb_select_ai_vpd" + ], + "description": "KB MCP Server used by PoC_4 UI" + }, + { + "id": "kb_vector_mcp", + "enabled": true, + "provider": "custom_python", + "transport": "http", + "base_url_env": "KB_VECTOR_MCP_BASE_URL", + "endpoint_url": "http://127.0.0.1:9978/mcp", + "auth_mode_env": "KB_VECTOR_MCP_AUTH_MODE", + "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS", + "tool_allowlist": [ + "hybrid_rerank_search" + ], + "description": "KB 보험 약관 검색 MCP Server used by PoC_4 UI" + } + ] +} diff --git a/poc4_active_source_20260714/config/poc3_model_profiles.json b/poc4_active_source_20260714/config/poc3_model_profiles.json new file mode 100644 index 0000000..2883a08 --- /dev/null +++ b/poc4_active_source_20260714/config/poc3_model_profiles.json @@ -0,0 +1,88 @@ +{ + "schema_version": 1, + "registry_name": "POC3_8512_8513_MODEL_PROFILES", + "default_model_profile": "gpt55_oci", + "source_commit": "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f", + "profiles": [ + { + "model_key": "gpt55_oci", + "display_name": "GPT-5.5 (OCI GenAI)", + "provider": "oci", + "model_id": "openai.gpt-5.5", + "answer_model_id_alias": "OPENAI_GPT_5_5_CHAT", + "answer_model_region": "us-chicago-1", + "answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT", + "poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2", + "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT55_OCI_V2", + "verification_status": "VERIFIED_WITH_WARNINGS", + "default_for_poc3": true, + "source_tag": "poc_2-gpt55-oci-partial", + "notes": "PoC_2 Select AI S1~S5와 승인된 read-only 실행은 5/5 PASS. 8503 Native Agent는 S1/S2/R1/H1 PASS이나 S3 safe-conversion marker 미확인 WARN으로 PARTIAL이다.", + "display_order": 0 + }, + { + "model_key": "gpt54_mini_oci", + "display_name": "GPT-5.4 Mini (OCI GenAI)", + "provider": "oci", + "model_id": "openai.gpt-5.4-mini", + "answer_model_id_alias": "OPENAI_GPT_5_4_MINI_CHAT", + "answer_model_region": "us-chicago-1", + "answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT", + "poc2_select_ai_profile": "KB_AIDP_SELECTAI_GPT54_MINI_OCI_PROFILE_V1", + "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GPT54_MINI_OCI_V1", + "verification_status": "PARTIAL_VERIFIED", + "default_for_poc3": false, + "source_tag": "poc_2-gpt55-oci-partial", + "notes": "PoC4 MCP 실행 방식(single/agent) 판단용 경량 planner profile. Select AI/Agent Team 본 처리 기본값은 gpt55_oci를 유지한다.", + "display_order": 1 + }, + { + "model_key": "grok43", + "display_name": "Grok 4.3", + "provider": "oci", + "model_id": "xai.grok-4.3", + "answer_model_id_alias": "XAI_GROK_4_3_CHAT", + "answer_model_region": "us-chicago-1", + "answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT", + "poc2_select_ai_profile": "KB_AIDP_SELECTAI_GROK43_PROFILE_V2", + "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_GROK43_V3", + "verification_status": "VERIFIED", + "default_for_poc3": false, + "source_tag": "poc_2-gpt55-oci-partial", + "notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.", + "display_order": 2 + }, + { + "model_key": "llama4_maverick", + "display_name": "Llama 4 Maverick", + "provider": "oci", + "model_id": "meta.llama-4-maverick-17b-128e-instruct-fp8", + "answer_model_id_alias": "META_LLAMA_4_MAVERICK_CHAT", + "answer_model_region": "us-chicago-1", + "answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT", + "poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA4_MAVERICK_PROFILE_V2", + "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA4_MAVERICK_V3", + "verification_status": "VERIFIED", + "default_for_poc3": false, + "source_tag": "poc_2-gpt55-oci-partial", + "notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.", + "display_order": 3 + }, + { + "model_key": "llama33_70b", + "display_name": "Llama 3.3 70B", + "provider": "oci", + "model_id": "meta.llama-3.3-70b-instruct", + "answer_model_id_alias": "META_LLAMA_3_3_70B_CHAT", + "answer_model_region": "us-chicago-1", + "answer_model_endpoint_mode": "OCI_REGIONAL_DEFAULT", + "poc2_select_ai_profile": "KB_AIDP_SELECTAI_LLAMA33_PROFILE_V2", + "poc2_native_agent_team": "KB_AIDP_AGENT_TEAM_LLAMA33_V3", + "verification_status": "VERIFIED", + "default_for_poc3": false, + "source_tag": "poc_2-gpt55-oci-partial", + "notes": "PoC_2 기존 모델 회귀에서 S1/S3 SHOWSQL, profile 확인 및 S3 safe conversion PASS.", + "display_order": 4 + } + ] +} diff --git a/poc4_active_source_20260714/config/vpd_token_presets.json b/poc4_active_source_20260714/config/vpd_token_presets.json new file mode 100644 index 0000000..494a426 --- /dev/null +++ b/poc4_active_source_20260714/config/vpd_token_presets.json @@ -0,0 +1,14 @@ +{ + "presets": [ + { + "enabled": true, + "default": true, + "token": "vpd_live_REPLACE_WITH_USER_TOKEN", + "user_id": "FC00789", + "name": "김설계", + "role": "설계사", + "channel": "설계사", + "scope": "본인 담당 계약 고객" + } + ] +} diff --git a/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz b/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz new file mode 100644 index 0000000..9df851e Binary files /dev/null and b/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz differ diff --git a/poc4_active_source_20260714/requirements-langgraph.txt b/poc4_active_source_20260714/requirements-langgraph.txt new file mode 100644 index 0000000..85167e8 --- /dev/null +++ b/poc4_active_source_20260714/requirements-langgraph.txt @@ -0,0 +1,4 @@ +-r requirements.txt +fastmcp==3.4.3 +langgraph>=1.2,<2 +oci>=2.180,<3 diff --git a/poc4_active_source_20260714/requirements.txt b/poc4_active_source_20260714/requirements.txt new file mode 100644 index 0000000..e2cf71c --- /dev/null +++ b/poc4_active_source_20260714/requirements.txt @@ -0,0 +1,5 @@ +openpyxl>=3.1,<4 +oracledb>=2,<4 +pandas>=2,<3 +streamlit>=1.35,<2 + diff --git a/poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh b/poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh new file mode 100755 index 0000000..58b03e4 --- /dev/null +++ b/poc4_active_source_20260714/scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +source "/home/opc/poc_4/scripts/poc4/runtime_ui_lib.sh" +poc4_start_service "8622_langgraph_tc_ui" "/home/opc/poc_4/scripts/poc4/run_8622_langgraph_tc_ui.sh" "apps/poc4/langgraph_tc_ui.py" diff --git a/poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh b/poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh new file mode 100755 index 0000000..565daad --- /dev/null +++ b/poc4_active_source_20260714/scripts/poc4/status_8622_langgraph_tc_ui_nohup.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +source "/home/opc/poc_4/scripts/poc4/runtime_ui_lib.sh" +poc4_status_service "8622_langgraph_tc_ui" "apps/poc4/langgraph_tc_ui.py" diff --git a/poc4_active_source_20260714/src/mcp_tool_router.py b/poc4_active_source_20260714/src/mcp_tool_router.py new file mode 100644 index 0000000..3e09962 --- /dev/null +++ b/poc4_active_source_20260714/src/mcp_tool_router.py @@ -0,0 +1,190 @@ +"""LLM-based MCP tool routing. + +The router receives only the user question and the discovered MCP tool +descriptors. It never receives MCP bearer tokens or provider credentials. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import Any, Mapping + +from src.oci_genai_sdk import ( + build_oci_genai_completion_client, + temperature_for_model_profile, +) +from src.poc3.model_registry import resolve_model_profile + + +@dataclass(frozen=True) +class McpTool: + name: str + description: str + schema: Mapping[str, Any] + read_only: bool + + +@dataclass(frozen=True) +class RoutedMcpTool: + server_id: str + tool: McpTool + + +class McpToolRouterError(RuntimeError): + """Safe routing error. Must not contain secrets or provider traces.""" + + +def route_mcp_tool_across_servers_with_llm( + tools: list[RoutedMcpTool], + question: str, + *, + router_model_profile: str, +) -> RoutedMcpTool: + """Select one discovered MCP server/tool pair with OCI GenAI.""" + + candidates = list(tools) + if not candidates: + raise McpToolRouterError("라우팅 가능한 MCP tool이 없습니다.") + + by_key = { + "{}::{}".format(candidate.server_id, candidate.tool.name): candidate + for candidate in candidates + } + route_keys = list(by_key) + try: + profile = resolve_model_profile(router_model_profile) + client = build_oci_genai_completion_client( + profile.model_id, + profile.answer_model_region, + profile.answer_model_endpoint, + ) + tool_catalog = [ + { + "route_key": "{}::{}".format(candidate.server_id, candidate.tool.name), + "server_id": candidate.server_id, + "tool_name": candidate.tool.name, + "description": candidate.tool.description[:1000], + "input_properties": sorted( + ( + candidate.tool.schema.get("properties", {}) + if isinstance( + candidate.tool.schema.get("properties"), Mapping + ) + else {} + ).keys() + ), + "read_only": candidate.tool.read_only, + } + for candidate in candidates + ] + text = client.complete( + system_prompt=( + "You are an MCP server and tool router. Choose exactly one " + "server/tool route for the user question from the discovered " + "routes. Return only JSON that matches the schema. Never " + "request or expose bearer tokens. Do not invent server ids or " + "tool names." + ), + user_prompt=json.dumps( + { + "question": question, + "routes": tool_catalog, + }, + ensure_ascii=False, + ), + response_schema={ + "type": "object", + "additionalProperties": False, + "required": ["route_key"], + "properties": { + "route_key": { + "type": "string", + "enum": route_keys, + } + }, + }, + max_tokens=256, + temperature=temperature_for_model_profile(profile), + ) + routed = json.loads(text) + except Exception: + raise McpToolRouterError("LLM tool router 호출에 실패했습니다.") from None + + if not isinstance(routed, Mapping): + raise McpToolRouterError("LLM tool router 응답 형식이 올바르지 않습니다.") + selected_key = str(routed.get("route_key") or "").strip() + selected = by_key.get(selected_key) + if selected is None: + raise McpToolRouterError("LLM tool router가 허용되지 않은 route를 선택했습니다.") + return selected + + +def route_mcp_tool_with_llm( + tools: list[McpTool], + question: str, + *, + preferred_tool: str, + tool_allowlist: tuple[str, ...], + router_model_profile: str, +) -> McpTool: + """Backward-compatible single-server routing helper.""" + + candidates = [ + tool for tool in tools if not tool_allowlist or tool.name in tool_allowlist + ] + routed = route_mcp_tool_across_servers_with_llm( + [RoutedMcpTool(server_id="default", tool=tool) for tool in candidates], + question, + router_model_profile=router_model_profile, + ) + return routed.tool + + +def build_mcp_tool_arguments( + tool: McpTool, + question: str, + limit: int, + *, + preferred_tool: str, +) -> dict[str, Any]: + """Build bounded tool arguments from the selected tool schema.""" + + properties = tool.schema.get("properties") + if not isinstance(properties, Mapping): + properties = {} + + if tool.name == preferred_tool: + return {"prompt": question, "limit": limit} + if "prompt" in properties: + args: dict[str, Any] = {"prompt": question} + if "limit" in properties: + args["limit"] = limit + elif "max_rows" in properties: + args["max_rows"] = limit + return args + if "question" in properties: + args = {"question": question} + if "max_rows" in properties: + args["max_rows"] = limit + elif "limit" in properties: + args["limit"] = limit + return args + if "query" in properties: + args = {"query": question} + if "max_evidence" in properties: + args["max_evidence"] = min(limit, 10) + elif "limit" in properties: + args["limit"] = limit + return args + return {"prompt": question, "limit": limit} + + +__all__ = [ + "McpTool", + "McpToolRouterError", + "RoutedMcpTool", + "build_mcp_tool_arguments", + "route_mcp_tool_across_servers_with_llm", + "route_mcp_tool_with_llm", +] diff --git a/poc4_active_source_20260714/src/oci_genai_sdk.py b/poc4_active_source_20260714/src/oci_genai_sdk.py new file mode 100644 index 0000000..347b250 --- /dev/null +++ b/poc4_active_source_20260714/src/oci_genai_sdk.py @@ -0,0 +1,274 @@ +"""Common OCI Generative AI chat-completion SDK boundary. + +This module is intentionally small: callers provide a validated model route +and a JSON schema, and this boundary performs one OCI GenAI chat call. It +does not know about MCP, Streamlit, business payloads, or bearer tokens. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from functools import lru_cache +import os +from pathlib import Path +import re +from typing import Dict, Optional, Protocol + + +ROOT = Path(__file__).resolve().parents[1] +DOTENV_PATH = ROOT / ".env" +ALLOWED_OCI_SETTINGS = frozenset( + { + "OCI_AUTH_TYPE", + "OCI_CONFIG_FILE", + "OCI_GENAI_COMPARTMENT_ID", + "OCI_PROFILE", + } +) +_COMPARTMENT_ID = re.compile(r"^ocid1\.compartment\.[A-Za-z0-9._-]+$") + + +class CompletionClient(Protocol): + """Minimal completion client contract shared by app layers.""" + + def complete( + self, + system_prompt: str, + user_prompt: str, + response_schema: Mapping[str, object], + max_tokens: int, + temperature: Optional[float], + ) -> str: + """Return the assistant message text.""" + + +def read_allowed_dotenv(path: Optional[Path] = None) -> Dict[str, str]: + """Read only non-secret OCI routing/auth-mode settings from .env.""" + + selected_path = DOTENV_PATH if path is None else path + try: + lines = selected_path.read_text(encoding="utf-8").splitlines() + except OSError: + return {} + values: Dict[str, str] = {} + for raw_line in lines: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + key, separator, raw_value = line.partition("=") + key = key.strip() + if not separator or key not in ALLOWED_OCI_SETTINGS: + continue + value = raw_value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + if "\x00" not in value and "\n" not in value and "\r" not in value: + values[key] = value + return values + + +@dataclass(frozen=True, repr=False) +class OCISettings: + auth_type: str + config_file: str + profile: str + compartment_id: str + + +def load_oci_settings() -> OCISettings: + """Resolve OCI GenAI settings from safe .env keys and environment.""" + + values = read_allowed_dotenv() + for key in ALLOWED_OCI_SETTINGS: + value = os.environ.get(key) + if isinstance(value, str) and value.strip(): + values[key] = value.strip() + + auth_type = values.get("OCI_AUTH_TYPE", "config_file").strip().casefold() + auth_type = auth_type.replace("-", "_") + if auth_type in {"api_key", "config", "config_file"}: + auth_type = "config_file" + elif auth_type not in {"instance_principal", "resource_principal"}: + raise ValueError("unsupported OCI authentication mode") + + compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip() + if not _COMPARTMENT_ID.fullmatch(compartment_id): + raise ValueError("OCI Generative AI compartment is not configured") + return OCISettings( + auth_type=auth_type, + config_file=values.get("OCI_CONFIG_FILE", "~/.oci/config").strip(), + profile=values.get("OCI_PROFILE", "DEFAULT").strip() or "DEFAULT", + compartment_id=compartment_id, + ) + + +class OCICompletionClient: + """Minimal OCI GenericChatRequest adapter.""" + + def __init__( + self, + settings: OCISettings, + model_id: str, + region: str, + endpoint: str, + ) -> None: + try: + import oci + from oci.generative_ai_inference import GenerativeAiInferenceClient + except (ImportError, AttributeError): + raise RuntimeError("OCI SDK is unavailable") from None + + kwargs: Dict[str, object] = {} + if settings.auth_type == "config_file": + try: + config = oci.config.from_file( + file_location=os.path.expandvars( + os.path.expanduser(settings.config_file) + ), + profile_name=settings.profile, + ) + except Exception: + raise RuntimeError("OCI SDK configuration is unavailable") from None + config["region"] = region + elif settings.auth_type == "instance_principal": + try: + signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner() + except Exception: + raise RuntimeError("OCI signer is unavailable") from None + config = {"region": region} + kwargs["signer"] = signer + else: + try: + signer = oci.auth.signers.get_resource_principals_signer() + except Exception: + raise RuntimeError("OCI signer is unavailable") from None + config = {"region": region} + kwargs["signer"] = signer + if not config.get("region"): + raise RuntimeError("OCI region is unavailable") + kwargs["service_endpoint"] = endpoint + try: + self._client = GenerativeAiInferenceClient(config, **kwargs) + except Exception: + raise RuntimeError("OCI Generative AI client is unavailable") from None + self._compartment_id = settings.compartment_id + self._model_id = model_id + + def complete( + self, + system_prompt: str, + user_prompt: str, + response_schema: Mapping[str, object], + max_tokens: int, + temperature: Optional[float], + ) -> str: + try: + from oci.generative_ai_inference.models import ( + ChatDetails, + GenericChatRequest, + JsonSchemaResponseFormat, + OnDemandServingMode, + ResponseJsonSchema, + SystemMessage, + TextContent, + UserMessage, + ) + + schema = ResponseJsonSchema( + name="oci_genai_json_response", + description="Strict JSON response", + schema=dict(response_schema), + is_strict=True, + ) + request_options: Dict[str, object] = { + "api_format": "GENERIC", + "messages": [ + SystemMessage(content=[TextContent(text=system_prompt)]), + UserMessage(content=[TextContent(text=user_prompt)]), + ], + "max_completion_tokens": max_tokens, + "is_stream": False, + "response_format": JsonSchemaResponseFormat(json_schema=schema), + } + if temperature is not None: + request_options["temperature"] = temperature + request = GenericChatRequest(**request_options) + details = ChatDetails( + compartment_id=self._compartment_id, + serving_mode=OnDemandServingMode(model_id=self._model_id), + chat_request=request, + ) + response = self._client.chat(details) + data = getattr(response, "data", None) + chat_response = getattr(data, "chat_response", None) + choices = getattr(chat_response, "choices", None) + if not isinstance(choices, Sequence) or not choices: + raise RuntimeError("OCI response has no choice") + message = getattr(choices[0], "message", None) + content = getattr(message, "content", None) + if not isinstance(content, Sequence) or isinstance( + content, (str, bytes, bytearray) + ) or not content: + raise RuntimeError("OCI response has no content") + text = getattr(content[0], "text", None) + if not isinstance(text, str): + raise RuntimeError("OCI response content is invalid") + return text + except Exception: + raise RuntimeError("OCI GenAI completion call failed") from None + + +def build_oci_genai_completion_client( + model_id: str, + region: str, + endpoint: str, +) -> CompletionClient: + """Build a completion client for one validated model route.""" + + return _cached_oci_genai_completion_client( + load_oci_settings(), + model_id, + region, + endpoint, + ) + + +@lru_cache(maxsize=16) +def _cached_oci_genai_completion_client( + settings: OCISettings, + model_id: str, + region: str, + endpoint: str, +) -> CompletionClient: + """Reuse OCI GenAI clients within one Python process.""" + + return OCICompletionClient(settings, model_id, region, endpoint) + + +def temperature_for_model_key(model_key: object) -> Optional[float]: + """Return provider-compatible temperature for one registered model key.""" + + key = str(model_key or "").strip().lower() + if key.startswith("gpt"): + return None + return 0.1 + + +def temperature_for_model_profile(profile: object) -> Optional[float]: + return temperature_for_model_key(getattr(profile, "model_key", profile)) + + +__all__ = [ + "ALLOWED_OCI_SETTINGS", + "CompletionClient", + "OCICompletionClient", + "OCISettings", + "build_oci_genai_completion_client", + "load_oci_settings", + "read_allowed_dotenv", + "temperature_for_model_key", + "temperature_for_model_profile", +] diff --git a/poc4_active_source_20260714/src/poc3/model_registry.py b/poc4_active_source_20260714/src/poc3/model_registry.py new file mode 100644 index 0000000..ee9adf6 --- /dev/null +++ b/poc4_active_source_20260714/src/poc3/model_registry.py @@ -0,0 +1,434 @@ +"""8512/8513 전용 PoC_3 model profile registry. + +이 registry는 모델 metadata만 관리한다. ``provider=oci``는 모델의 출처를 뜻하며 +``POC3_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재 +MCP 실행 경로는 계속 ``mock``일 수 있다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +import json +import os +from pathlib import Path +import re +from typing import Any, Mapping, Optional + + +ROOT = Path(__file__).resolve().parents[2] +REGISTRY_PATH = ROOT / "config" / "poc3_model_profiles.json" +DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci" +MODEL_PROFILE_ENV = "POC3_MODEL_PROFILE" +MODEL_PROFILE_DEFAULT_ENV = "POC3_MODEL_PROFILE_DEFAULT" +EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b") +MODEL_PROFILE_ALIASES = { + "gpt54_mini": "gpt54_mini_oci", + "llama33": "llama33_70b", +} +EXPECTED_SOURCE_TAG = "poc_2-gpt55-oci-partial" +EXPECTED_SOURCE_COMMIT = "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f" +_EXPECTED_ANSWER_MODEL_ROUTES = { + "gpt55_oci": ( + "openai.gpt-5.5", + "us-chicago-1", + "OPENAI_GPT_5_5_CHAT", + "OCI_REGIONAL_DEFAULT", + ), + "gpt54_mini_oci": ( + "openai.gpt-5.4-mini", + "us-chicago-1", + "OPENAI_GPT_5_4_MINI_CHAT", + "OCI_REGIONAL_DEFAULT", + ), + "grok43": ( + "xai.grok-4.3", + "us-chicago-1", + "XAI_GROK_4_3_CHAT", + "OCI_REGIONAL_DEFAULT", + ), + "llama4_maverick": ( + "meta.llama-4-maverick-17b-128e-instruct-fp8", + "us-chicago-1", + "META_LLAMA_4_MAVERICK_CHAT", + "OCI_REGIONAL_DEFAULT", + ), + "llama33_70b": ( + "meta.llama-3.3-70b-instruct", + "us-chicago-1", + "META_LLAMA_3_3_70B_CHAT", + "OCI_REGIONAL_DEFAULT", + ), +} + +_MODEL_KEY = re.compile(r"^[a-z][a-z0-9_]{1,63}$") +_MODEL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$") +_ANSWER_MODEL_ID_ALIAS = re.compile(r"^[A-Z][A-Z0-9_]{1,127}$") +_OCI_REGION = re.compile(r"^[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*$") +_OCI_REGIONAL_ENDPOINT = re.compile( + r"^https://inference\.generativeai\." + r"(?P[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*)\.oci\.oraclecloud\.com$" +) +_ORACLE_IDENTIFIER = re.compile(r"^[A-Z][A-Z0-9_$#]{0,127}$") +_SOURCE_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$") +_VERIFICATION_STATUSES = frozenset( + {"VERIFIED", "PARTIAL_VERIFIED", "VERIFIED_WITH_WARNINGS"} +) +_REQUIRED_PROFILE_FIELDS = ( + "model_key", + "display_name", + "provider", + "model_id", + "answer_model_id_alias", + "answer_model_region", + "answer_model_endpoint_mode", + "poc2_select_ai_profile", + "poc2_native_agent_team", + "verification_status", + "default_for_poc3", + "source_tag", + "notes", +) + +_PROFILE_ROUTE_ENV_KEYS = { + "gpt55_oci": ( + "POC3_LLM_GPT55_OCI_MODEL_ID", + "POC3_LLM_GPT55_OCI_REGION", + "POC3_LLM_GPT55_OCI_ENDPOINT", + ), + "gpt54_mini_oci": ( + "POC3_LLM_GPT54_MINI_OCI_MODEL_ID", + "POC3_LLM_GPT54_MINI_OCI_REGION", + "POC3_LLM_GPT54_MINI_OCI_ENDPOINT", + ), + "grok43": ( + "POC3_LLM_GROK43_MODEL_ID", + "POC3_LLM_GROK43_REGION", + "POC3_LLM_GROK43_ENDPOINT", + ), + "llama4_maverick": ( + "POC3_LLM_LLAMA4_MAVERICK_MODEL_ID", + "POC3_LLM_LLAMA4_MAVERICK_REGION", + "POC3_LLM_LLAMA4_MAVERICK_ENDPOINT", + ), + "llama33_70b": ( + "POC3_LLM_LLAMA33_70B_MODEL_ID", + "POC3_LLM_LLAMA33_70B_REGION", + "POC3_LLM_LLAMA33_70B_ENDPOINT", + ), +} + + +def _regional_endpoint(region: str) -> str: + return "https://inference.generativeai.%s.oci.oraclecloud.com" % region + + +def _endpoint_host_alias(region: str) -> str: + return "OCI_GENAI_INFERENCE_%s" % region.upper().replace("-", "_") + + +def _env_override( + environ: Mapping[str, str], + key: str, + default: str, +) -> str: + if key not in environ: + return default + value = environ.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError("answer model route override is invalid") + return value.strip() + + +@dataclass(frozen=True) +class ModelProfile: + """UI와 workflow가 공유하는 비밀값 없는 model metadata.""" + + model_key: str + display_name: str + provider: str + model_id: str + answer_model_id_alias: str + answer_model_region: str + answer_model_endpoint_mode: str + answer_model_endpoint: str = field(repr=False) + poc2_select_ai_profile: str + poc2_native_agent_team: str + verification_status: str + default_for_poc3: bool + source_tag: str + notes: str + display_order: int = 999 + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile": + missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value] + if missing: + raise ValueError("PoC_3 model profile fields are missing") + if not isinstance(value.get("default_for_poc3"), bool): + raise ValueError("default_for_poc3 must be boolean") + order = value.get("display_order", 999) + if isinstance(order, bool) or not isinstance(order, int) or order < 0: + raise ValueError("model profile display_order is invalid") + + answer_model_region = str(value["answer_model_region"]).strip().lower() + profile = cls( + model_key=str(value["model_key"]).strip().lower(), + display_name=str(value["display_name"]).strip(), + provider=str(value["provider"]).strip().lower(), + model_id=str(value["model_id"]).strip(), + answer_model_id_alias=str(value["answer_model_id_alias"]) + .strip() + .upper(), + answer_model_region=answer_model_region, + answer_model_endpoint_mode=str( + value["answer_model_endpoint_mode"] + ).strip().upper(), + answer_model_endpoint=_regional_endpoint(answer_model_region), + poc2_select_ai_profile=str(value["poc2_select_ai_profile"]) + .strip() + .upper(), + poc2_native_agent_team=str(value["poc2_native_agent_team"]) + .strip() + .upper(), + verification_status=str(value["verification_status"]).strip().upper(), + default_for_poc3=value["default_for_poc3"], + source_tag=str(value["source_tag"]).strip(), + notes=str(value["notes"]).strip(), + display_order=order, + ) + if not _MODEL_KEY.fullmatch(profile.model_key): + raise ValueError("model profile key is invalid") + if not profile.display_name or len(profile.display_name) > 128: + raise ValueError("model profile display name is invalid") + if profile.provider != "oci": + raise ValueError("unsupported model provider") + if not _MODEL_ID.fullmatch(profile.model_id): + raise ValueError("model id is invalid") + if not _ANSWER_MODEL_ID_ALIAS.fullmatch(profile.answer_model_id_alias): + raise ValueError("answer model id alias is invalid") + if not _OCI_REGION.fullmatch(profile.answer_model_region): + raise ValueError("answer model region is invalid") + if profile.answer_model_endpoint_mode != "OCI_REGIONAL_DEFAULT": + raise ValueError("answer model endpoint mode is invalid") + endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch( + profile.answer_model_endpoint + ) + if ( + endpoint_match is None + or endpoint_match.group("region") != profile.answer_model_region + ): + raise ValueError("answer model endpoint is invalid") + if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_select_ai_profile): + raise ValueError("PoC_2 Select AI profile mapping is invalid") + if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_native_agent_team): + raise ValueError("PoC_2 Native Agent team mapping is invalid") + if profile.verification_status not in _VERIFICATION_STATUSES: + raise ValueError("model verification status is invalid") + if not _SOURCE_TAG.fullmatch(profile.source_tag): + raise ValueError("model profile source tag is invalid") + if not profile.notes or len(profile.notes) > 1_000: + raise ValueError("model profile notes are invalid") + return profile + + def with_answer_route_overrides( + self, + environ: Mapping[str, str], + ) -> "ModelProfile": + """Apply only this profile's validated, non-secret OCI route settings.""" + + keys = _PROFILE_ROUTE_ENV_KEYS.get(self.model_key) + if keys is None: + raise ValueError("answer model route is not registered") + model_id = _env_override(environ, keys[0], self.model_id) + region = _env_override(environ, keys[1], self.answer_model_region).lower() + endpoint = _env_override( + environ, + keys[2], + _regional_endpoint(region), + ) + if not _MODEL_ID.fullmatch(model_id): + raise ValueError("answer model route override is invalid") + if not _OCI_REGION.fullmatch(region): + raise ValueError("answer model route override is invalid") + endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch(endpoint) + if endpoint_match is None or endpoint_match.group("region") != region: + raise ValueError("answer model route override is invalid") + return replace( + self, + model_id=model_id, + answer_model_region=region, + answer_model_endpoint=endpoint, + ) + + def public_metadata(self) -> dict[str, object]: + """System Details에 투영 가능한 비밀값 없는 metadata를 반환한다.""" + + return { + "model_key": self.model_key, + "display_name": self.display_name, + "provider": self.provider, + "answer_model_id_alias": self.answer_model_id_alias, + "answer_model_region": self.answer_model_region, + "answer_model_endpoint_mode": self.answer_model_endpoint_mode, + "answer_model_endpoint_host_alias": _endpoint_host_alias( + self.answer_model_region + ), + "poc2_select_ai_profile": self.poc2_select_ai_profile, + "poc2_native_agent_team": self.poc2_native_agent_team, + "verification_status": self.verification_status, + "default_for_poc3": self.default_for_poc3, + "source_tag": self.source_tag, + "notes": self.notes, + } + + +@dataclass(frozen=True) +class ModelProfileRegistry: + """검증된 PoC_3 model profile 집합.""" + + profiles: tuple[ModelProfile, ...] + default_model_profile: str + registry_name: str + source_commit: str + schema_version: int = 1 + + def by_key(self, model_key: object) -> ModelProfile: + candidate = str(model_key or "").strip().lower() + candidate = MODEL_PROFILE_ALIASES.get(candidate, candidate) + for profile in self.profiles: + if profile.model_key == candidate: + return profile + # 사용자 입력이나 환경변수 원문을 오류에 반사하지 않는다. + raise ValueError("model profile is not registered") + + @property + def default_profile(self) -> ModelProfile: + return self.by_key(self.default_model_profile) + + def selector_options(self) -> tuple[ModelProfile, ...]: + return tuple(sorted(self.profiles, key=lambda item: item.display_order)) + + def resolve( + self, + requested: object = None, + *, + environ: Optional[Mapping[str, str]] = None, + ) -> ModelProfile: + """명시 요청은 엄격히 검증하고 환경 기본값은 안전하게 fallback한다.""" + + source = os.environ if environ is None else environ + if requested is not None and str(requested).strip(): + return self.by_key(requested).with_answer_route_overrides(source) + + for name in (MODEL_PROFILE_ENV, MODEL_PROFILE_DEFAULT_ENV): + candidate = source.get(name) + if not candidate or not candidate.strip(): + continue + try: + profile = self.by_key(candidate) + except ValueError: + continue + return profile.with_answer_route_overrides(source) + return self.default_profile.with_answer_route_overrides(source) + + +def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry: + """JSON registry를 매 호출마다 검증해 반환한다.""" + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("PoC_3 model profile registry cannot be loaded") from exc + if not isinstance(payload, Mapping): + raise ValueError("PoC_3 model profile registry must be an object") + raw_profiles = payload.get("profiles") + if not isinstance(raw_profiles, list) or not raw_profiles: + raise ValueError("PoC_3 model profile registry has no profiles") + profiles = tuple( + ModelProfile.from_mapping(item) + for item in raw_profiles + if isinstance(item, Mapping) + ) + if len(profiles) != len(raw_profiles): + raise ValueError("PoC_3 model profile registry contains an invalid profile") + keys = tuple(item.model_key for item in profiles) + if len(set(keys)) != len(keys): + raise ValueError("PoC_3 model profile keys must be unique") + if len({item.display_name for item in profiles}) != len(profiles): + raise ValueError("PoC_3 model profile display names must be unique") + defaults = tuple(item.model_key for item in profiles if item.default_for_poc3) + configured_default = str(payload.get("default_model_profile") or "").strip().lower() + if defaults != (configured_default,): + raise ValueError("PoC_3 model profile default is inconsistent") + if configured_default != DEFAULT_MODEL_PROFILE_KEY: + raise ValueError("PoC_3 GPT-5.5 default contract is not satisfied") + if not set(EXISTING_MODEL_PROFILE_KEYS).issubset(keys): + raise ValueError("existing PoC_3 selector models are missing") + actual_answer_routes = { + item.model_key: ( + item.model_id, + item.answer_model_region, + item.answer_model_id_alias, + item.answer_model_endpoint_mode, + ) + for item in profiles + } + if actual_answer_routes != _EXPECTED_ANSWER_MODEL_ROUTES: + raise ValueError("PoC_3 answer model route mapping is inconsistent") + if str(payload.get("source_commit") or "").strip() != EXPECTED_SOURCE_COMMIT: + raise ValueError("PoC_2 source commit is inconsistent") + if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles): + raise ValueError("PoC_2 source tag is inconsistent") + if payload.get("schema_version") != 1: + raise ValueError("unsupported PoC_3 model profile registry schema") + registry_name = str(payload.get("registry_name") or "").strip() + if not registry_name: + raise ValueError("PoC_3 model profile registry name is missing") + return ModelProfileRegistry( + profiles=profiles, + default_model_profile=configured_default, + registry_name=registry_name, + source_commit=EXPECTED_SOURCE_COMMIT, + ) + + +def resolve_model_profile( + requested: object = None, + *, + environ: Optional[Mapping[str, str]] = None, +) -> ModelProfile: + return load_model_registry().resolve(requested, environ=environ) + + +def resolve_model_profile_key( + requested: object = None, + *, + environ: Optional[Mapping[str, str]] = None, +) -> str: + return resolve_model_profile(requested, environ=environ).model_key + + +def is_registered_model_profile(value: object) -> bool: + try: + load_model_registry().by_key(value) + except ValueError: + return False + return True + + +__all__ = [ + "DEFAULT_MODEL_PROFILE_KEY", + "EXISTING_MODEL_PROFILE_KEYS", + "EXPECTED_SOURCE_COMMIT", + "EXPECTED_SOURCE_TAG", + "MODEL_PROFILE_DEFAULT_ENV", + "MODEL_PROFILE_ENV", + "MODEL_PROFILE_ALIASES", + "ModelProfile", + "ModelProfileRegistry", + "REGISTRY_PATH", + "is_registered_model_profile", + "load_model_registry", + "resolve_model_profile", + "resolve_model_profile_key", +] diff --git a/poc4_active_source_20260714/src/poc3/questions.py b/poc4_active_source_20260714/src/poc3/questions.py new file mode 100644 index 0000000..da01a1e --- /dev/null +++ b/poc4_active_source_20260714/src/poc3/questions.py @@ -0,0 +1,264 @@ +"""PoC_3 preset catalog와 현재 질문 기반의 결정적 intent router.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +import unicodedata + + +QUESTION_CATEGORIES = ("STRUCTURED", "RAG", "HYBRID") +GENERIC_RAG_QUESTION_ID = "R0" +MAX_QUESTION_CHARS = 2_000 + + +@dataclass(frozen=True) +class DemoQuestion: + """ID와 분류가 고정된 데모 질문.""" + + question_id: str + category: str + text: str + + def __post_init__(self) -> None: + if self.category not in QUESTION_CATEGORIES: + raise ValueError("unsupported demo question category") + if not self.question_id or not self.text.strip(): + raise ValueError("demo question id/text is required") + + +@dataclass(frozen=True) +class ResolvedQuestionIntent: + """현재 질문만으로 결정된 실행 intent. + + ``question_id``는 local route/fixture를 설명하는 분류 label이다. 8500 provider + 입력으로 전달되지 않으며 UI에서 선택한 scenario ID도 이 모델에 들어오지 + 않는다. + """ + + question_id: str + category: str + + def __post_init__(self) -> None: + if self.category not in QUESTION_CATEGORIES: + raise ValueError("unsupported resolved question category") + if self.question_id not in { + GENERIC_RAG_QUESTION_ID, + "S1", + "S2", + "S3", + "S4", + "S5", + "R1", + "R2", + "H1", + "H2", + "H3", + "H4", + }: + raise ValueError("unsupported resolved question id") + + +COMMON_DEMO_QUESTIONS = ( + DemoQuestion("S1", "STRUCTURED", "상품별 계약 건수를 보여줘."), + DemoQuestion("S2", "STRUCTURED", "총 지급보험금이 가장 큰 상품은?"), + DemoQuestion( + "S3", + "STRUCTURED", + "숫자로 계산 가능한 평균 보장금액이 높은 상품 10개를 보여줘.", + ), + DemoQuestion("S4", "STRUCTURED", "고객 등급별 평균 보험료를 보여줘."), + DemoQuestion("S5", "STRUCTURED", "이해관계자 역할별 인원 수를 보여줘."), + DemoQuestion("R1", "RAG", "자동차보험 약관의 면책 사항은?"), + DemoQuestion("R2", "RAG", "보험금 청구 시 필요한 서류는?"), + DemoQuestion( + "H1", + "HYBRID", + "보험금이 가장 큰 상품의 주요 면책 조항을 알려줘.", + ), + DemoQuestion( + "H2", + "HYBRID", + "청구가 많은 상품군의 보장 제외 조건을 알려줘.", + ), + DemoQuestion( + "H3", + "HYBRID", + "고객 등급별 보험료 수준을 보고, 관련 약관상 유의해야 할 보장 제외 조건도 함께 알려줘.", + ), +) + +_QUESTION_BY_ID = {item.question_id: item for item in COMMON_DEMO_QUESTIONS} +if len(_QUESTION_BY_ID) != len(COMMON_DEMO_QUESTIONS): + raise RuntimeError("duplicate PoC_3 demo question id") + + +def question_by_id(question_id: str) -> DemoQuestion: + """정규화된 ID로 질문을 찾되, 알 수 없는 ID는 거부한다.""" + + normalized = str(question_id).strip().upper() + try: + return _QUESTION_BY_ID[normalized] + except KeyError: + raise ValueError("unknown PoC_3 demo question id") from None + + +def normalize_scenario_id(value: object) -> str | None: + """선택적인 preset ID를 정규화하되 실행 routing에는 관여하지 않는다.""" + + if value is None: + return None + if not isinstance(value, str): + raise ValueError("scenario id must be a string") + normalized = value.strip().upper() + if not normalized: + return None + question_by_id(normalized) + return normalized + + +def _normalized_question(question: object) -> tuple[str, str]: + if not isinstance(question, str): + raise ValueError("question must be a non-empty string") + if len(question) > MAX_QUESTION_CHARS: + raise ValueError("question exceeds the supported length") + normalized = re.sub( + r"\s+", " ", unicodedata.normalize("NFKC", question).strip().lower() + ) + if not normalized: + raise ValueError("question must be a non-empty string") + return normalized, normalized.replace(" ", "") + + +def _contains_any(text: str, candidates: tuple[str, ...]) -> bool: + return any(candidate in text for candidate in candidates) + + +def resolve_question_intent(question: object) -> ResolvedQuestionIntent: + """현재 질문 텍스트만으로 S/R/H route intent를 결정한다. + + 명확한 structured/hybrid intent에 해당하지 않는 질문은 임의로 추측하지 않고 + ``R0`` generic RAG 검색으로 보낸다. 이 함수는 scenario 또는 + question ID hint를 받지 않으므로 preset metadata가 실행을 바꿀 수 없다. + """ + + normalized, compact = _normalized_question(question) + + # Canonical preset은 기존 10문항 동작을 byte-for-byte 보존한다. + for item in COMMON_DEMO_QUESTIONS: + candidate, _ = _normalized_question(item.text) + if normalized == candidate: + return ResolvedQuestionIntent(item.question_id, item.category) + + has_product = _contains_any(compact, ("상품", "상품군")) + has_exclusion = _contains_any( + compact, + ("면책", "보장제외", "제외조건", "보상하지않", "약관상유의"), + ) + has_top = _contains_any( + compact, ("가장큰", "최대", "최고", "1위", "상위", "제일많", "높은") + ) + + # 자유 질문에서 자동차보험 보유 규모와 타사 대비 강점을 함께 요구하면 + # generic structured 조회와 자유 evidence 검색을 로컬에서 합성하는 H4로 + # 보낸다. Label은 routing metadata일 뿐 provider query ID가 아니다. + has_auto_insurance = "자동차보험" in compact + has_count = _contains_any( + compact, + ( + "갯수", + "개수", + "건수", + "계약수", + "몇개", + "몇건", + "보유수", + "상품수", + ), + ) + has_competitor_comparison = _contains_any( + compact, ("타사", "경쟁사", "다른회사", "타보험사") + ) and _contains_any(compact, ("강점", "장점", "차별", "우위", "비교")) + if has_auto_insurance and has_count and has_competitor_comparison: + return ResolvedQuestionIntent("H4", "HYBRID") + + # Hybrid를 먼저 판별해 정형 키워드가 포함된 복합 질문이 S/R 단일 route로 + # 축소되지 않도록 한다. + if ( + _contains_any(compact, ("고객등급", "등급별")) + and "보험료" in compact + and has_exclusion + ): + return ResolvedQuestionIntent("H3", "HYBRID") + if ( + "청구" in compact + and _contains_any(compact, ("많은", "빈도", "건수", "상위")) + and has_product + and has_exclusion + ): + return ResolvedQuestionIntent("H2", "HYBRID") + if ( + has_product + and _contains_any(compact, ("지급보험금", "보험금")) + and has_top + and has_exclusion + ): + return ResolvedQuestionIntent("H1", "HYBRID") + + if ( + has_product + and "계약" in compact + and _contains_any(compact, ("건수", "계약수", "몇건", "집계")) + ): + return ResolvedQuestionIntent("S1", "STRUCTURED") + if ( + has_product + and _contains_any(compact, ("지급보험금", "보험금총액", "총보험금")) + and has_top + ): + return ResolvedQuestionIntent("S2", "STRUCTURED") + if ( + has_product + and _contains_any(compact, ("보장금액", "가입금액")) + and "평균" in compact + and has_top + ): + return ResolvedQuestionIntent("S3", "STRUCTURED") + if ( + _contains_any(compact, ("고객등급", "등급별")) + and "보험료" in compact + and "평균" in compact + ): + return ResolvedQuestionIntent("S4", "STRUCTURED") + if ( + "이해관계자" in compact + and "역할" in compact + and _contains_any(compact, ("인원", "사람수", "몇명", "명수", "수")) + ): + return ResolvedQuestionIntent("S5", "STRUCTURED") + + if ( + "자동차보험" in compact + and _contains_any(compact, ("면책", "보상하지않", "보장제외", "제외사항")) + ): + return ResolvedQuestionIntent("R1", "RAG") + if ( + _contains_any(compact, ("보험금", "청구")) + and _contains_any(compact, ("서류", "문서", "증빙", "제출자료")) + ): + return ResolvedQuestionIntent("R2", "RAG") + + return ResolvedQuestionIntent(GENERIC_RAG_QUESTION_ID, "RAG") + + +__all__ = [ + "COMMON_DEMO_QUESTIONS", + "DemoQuestion", + "GENERIC_RAG_QUESTION_ID", + "MAX_QUESTION_CHARS", + "QUESTION_CATEGORIES", + "ResolvedQuestionIntent", + "normalize_scenario_id", + "question_by_id", + "resolve_question_intent", +]