Import PoC4 MCP test UI source snapshot

This commit is contained in:
devmrko
2026-07-14 14:09:31 +09:00
parent b48d9a0792
commit 5bdd24299e
17 changed files with 8971 additions and 3 deletions

View File

@@ -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 <current-user-vpd-token>
- 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을 구현 완료했다는 의미는 아니다.

View File

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

View File

@@ -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`는 개인정보/대화 내용이 포함될 수 있어 복사하지 않았습니다.

File diff suppressed because it is too large Load Diff

View File

@@ -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 = """
<style id="poc4-cross-browser-light-theme">
:root,
html,
body,
#root,
.stApp,
[data-testid="stAppViewContainer"],
[data-testid="stMain"] {
color-scheme: light !important;
background-color: #f7f8fa !important;
color: #172033 !important;
}
/* Keep native form controls independent of the browser/OS dark-mode palette. */
input,
textarea,
select,
[data-baseweb="input"] input,
[data-baseweb="textarea"] textarea {
color-scheme: light !important;
background-color: #ffffff !important;
color: #172033 !important;
-webkit-text-fill-color: #172033 !important;
caret-color: #172033 !important;
border-color: #aab3c2 !important;
opacity: 1 !important;
}
input::placeholder,
textarea::placeholder,
[data-baseweb="input"] input::placeholder,
[data-baseweb="textarea"] textarea::placeholder {
color: #687386 !important;
-webkit-text-fill-color: #687386 !important;
opacity: 1 !important;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus {
-webkit-text-fill-color: #172033 !important;
-webkit-box-shadow: 0 0 0 1000px #ffffff inset !important;
caret-color: #172033 !important;
}
select,
select option,
option {
background-color: #ffffff !important;
color: #172033 !important;
-webkit-text-fill-color: #172033 !important;
}
/* Streamlit selectbox is rendered by BaseWeb; its menu is portaled to body. */
[data-baseweb="select"],
[data-baseweb="select"] > div {
color-scheme: light !important;
background-color: #ffffff !important;
color: #172033 !important;
border-color: #aab3c2 !important;
}
[data-baseweb="select"] input,
[data-baseweb="select"] span,
[data-baseweb="select"] [aria-selected] {
color: #172033 !important;
-webkit-text-fill-color: #172033 !important;
opacity: 1 !important;
}
[data-baseweb="select"] input::placeholder,
[data-baseweb="select"] [aria-placeholder="true"],
[data-baseweb="select"] [data-baseweb="placeholder"] {
color: #687386 !important;
-webkit-text-fill-color: #687386 !important;
opacity: 1 !important;
}
[data-baseweb="select"] svg {
fill: #526075 !important;
color: #526075 !important;
}
[data-baseweb="popover"],
[data-baseweb="menu"],
[role="listbox"] {
color-scheme: light !important;
background-color: #ffffff !important;
color: #172033 !important;
border-color: #aab3c2 !important;
}
[role="option"],
[role="option"] *,
[data-baseweb="menu"] li,
[data-baseweb="menu"] li * {
background-color: #ffffff !important;
color: #172033 !important;
-webkit-text-fill-color: #172033 !important;
}
[role="option"]:hover,
[role="option"][aria-selected="true"],
[role="option"]:hover *,
[role="option"][aria-selected="true"] * {
background-color: #ffffff !important;
color: #172033 !important;
-webkit-text-fill-color: #172033 !important;
}
input:disabled,
textarea:disabled,
select:disabled,
[data-baseweb="select"][aria-disabled="true"],
[data-baseweb="select"] > div[aria-disabled="true"],
[data-baseweb="select"] [aria-disabled="true"] {
background-color: #f1f3f6 !important;
color: #5f6b7a !important;
-webkit-text-fill-color: #5f6b7a !important;
opacity: 1 !important;
}
div[data-testid="stButton"] > button,
button[data-testid="stBaseButton-primary"],
button[kind="primary"] {
color-scheme: light !important;
background-color: #2256c7 !important;
border-color: #2256c7 !important;
color: #ffffff !important;
-webkit-text-fill-color: #ffffff !important;
}
div[data-testid="stButton"] > button *,
button[data-testid="stBaseButton-primary"] *,
button[kind="primary"] * {
color: #ffffff !important;
-webkit-text-fill-color: #ffffff !important;
}
div[data-testid="stButton"] > button:hover,
button[data-testid="stBaseButton-primary"]:hover,
button[kind="primary"]:hover {
background-color: #1746a2 !important;
border-color: #1746a2 !important;
}
div[data-testid="stButton"] > button:disabled,
button[data-testid="stBaseButton-primary"]:disabled,
button[kind="primary"]:disabled {
background-color: #e4e8ee !important;
border-color: #c3cad4 !important;
color: #5f6b7a !important;
-webkit-text-fill-color: #5f6b7a !important;
opacity: 1 !important;
}
div[data-testid="stButton"] > button:disabled *,
button[data-testid="stBaseButton-primary"]:disabled *,
button[kind="primary"]:disabled * {
color: #5f6b7a !important;
-webkit-text-fill-color: #5f6b7a !important;
}
/* Outrank the legacy primary-label selector for non-WebKit engines too. */
div[data-testid="stButton"]
> button[data-testid="stBaseButton-primary"]:disabled
[data-testid="stMarkdownContainer"],
div[data-testid="stButton"]
> button[data-testid="stBaseButton-primary"]:disabled
[data-testid="stMarkdownContainer"] p,
div[data-testid="stButton"]
> button[data-testid="stBaseButton-primary"]:disabled
[data-testid="stMarkdownContainer"] span {
color: #5f6b7a !important;
-webkit-text-fill-color: #5f6b7a !important;
}
label,
h1,
h2,
h3,
h4,
p,
[data-testid="stMarkdownContainer"],
[data-testid="stWidgetLabel"],
[data-testid="stExpander"] summary,
[data-testid="stExpander"] summary *,
[data-testid="stExpanderDetails"],
[data-testid="stExpanderDetails"] * {
color: #172033;
}
[data-testid="stExpander"],
[data-testid="stVerticalBlockBorderWrapper"] {
color-scheme: light !important;
background-color: #ffffff !important;
color: #172033 !important;
border-color: #dce1e8 !important;
}
</style>
"""
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"]

View File

@@ -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"
}
]
}

View File

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

View File

@@ -0,0 +1,14 @@
{
"presets": [
{
"enabled": true,
"default": true,
"token": "vpd_live_REPLACE_WITH_USER_TOKEN",
"user_id": "FC00789",
"name": "김설계",
"role": "설계사",
"channel": "설계사",
"scope": "본인 담당 계약 고객"
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
]

View File

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

View File

@@ -0,0 +1,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<region>[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*)\.oci\.oraclecloud\.com$"
)
_ORACLE_IDENTIFIER = re.compile(r"^[A-Z][A-Z0-9_$#]{0,127}$")
_SOURCE_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$")
_VERIFICATION_STATUSES = frozenset(
{"VERIFIED", "PARTIAL_VERIFIED", "VERIFIED_WITH_WARNINGS"}
)
_REQUIRED_PROFILE_FIELDS = (
"model_key",
"display_name",
"provider",
"model_id",
"answer_model_id_alias",
"answer_model_region",
"answer_model_endpoint_mode",
"poc2_select_ai_profile",
"poc2_native_agent_team",
"verification_status",
"default_for_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",
]

View File

@@ -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",
]