Compare commits
2 Commits
536938895a
...
d6a558e45a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6a558e45a | ||
|
|
ee355a9282 |
45
docs/design/699-hmm-mcp-demo-users/README.md
Normal file
45
docs/design/699-hmm-mcp-demo-users/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# HMM MCP 데모 사용자 preset (#699)
|
||||||
|
|
||||||
|
> 상태: Implementing
|
||||||
|
> 추적: Redmine #699 / Git 브랜치: `hmm-backoffice`
|
||||||
|
> 대상: `hmm.cloud-handson.com` / `/opt/hmm-poc4`
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
HMM AI 업무 에이전트에서 HMM 가상 HR 조직의 팀장·팀원을 선택해 같은 질문을 사용자 관점으로
|
||||||
|
시험한다. MCP 접속 토큰과 데모 사용자 정보를 분리하여 토큰 원문을 화면·JSON·Git에 남기지 않는다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
```text
|
||||||
|
vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_env)
|
||||||
|
→ Streamlit 데모 사용자 선택
|
||||||
|
→ HMM MCP gateway bearer token (HMM_MCP_BEARER_TOKEN, 서버 .env 전용)
|
||||||
|
→ hmm-mcp.cloud-handson.com/mcp
|
||||||
|
→ HMM HR Select AI Agent Tool
|
||||||
|
```
|
||||||
|
|
||||||
|
- JSON 파일명은 이전 배포 호환성을 위해 유지한다. 내용은 KB VPD 사용자가 아닌 HMM HR 데모 사용자다.
|
||||||
|
- 각 preset은 `mcp_token_env`로 서버 환경변수만 참조한다. token 원문은 절대 JSON에 쓰지 않는다.
|
||||||
|
- 선택한 사용자는 자연어의 ‘나’, ‘내’, ‘우리 팀’을 해석하는 데모 문맥이다. 현재 HMM MCP의 gateway
|
||||||
|
token은 공용이므로 이 선택 자체가 DB 행 수준 권한을 강제한다고 표시하지 않는다.
|
||||||
|
- HMM MCP 허용 도구는 `resolve_hr_term`, `search_hr_data`, `search_hr_policy` 세 개다.
|
||||||
|
- 호출 인자는 `tools/list`의 schema를 기준으로 생성한다. 기본 도구에도 레거시 `prompt`/`limit`를
|
||||||
|
강제하지 않으며 `search_hr_data`·`search_hr_policy`는 `query`, `resolve_hr_term`은 `term`을 전달한다.
|
||||||
|
|
||||||
|
## 런타임 구성
|
||||||
|
|
||||||
|
- Streamlit은 `requirements.txt`의 OCI SDK를 `/opt/hmm-poc4/.venv`에 설치해 사용한다.
|
||||||
|
- OCI Generative AI 인증은 `/home/opc/.oci/config`의 `DEFAULT` profile과 해당 private key를 사용한다.
|
||||||
|
- `OCI_AUTH_TYPE=config_file`, `OCI_CONFIG_FILE`, `OCI_PROFILE`, `OCI_GENAI_COMPARTMENT_ID`는
|
||||||
|
배포 서버 `/opt/hmm-poc4/.env`에서 관리하고 저장소에는 값을 기록하지 않는다.
|
||||||
|
- MCP 호출 상세 JSON/code 영역은 공통 `presentation.py`에서 배경·글자색을 함께 고정한다.
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
1. 팀장 E1001과 팀원 preset이 사이드바에서 선택된다.
|
||||||
|
2. MCP bearer token은 화면·대화 JSON·Git에 노출되지 않는다.
|
||||||
|
3. E1001의 ‘우리 팀 대기 휴가’와 E1005/E1007의 ‘내 휴가’가 HMM MCP로 정상 호출된다.
|
||||||
|
4. 기존 KB MCP·KB VPD 안내가 HMM HR 질문 생성 경로에 사용되지 않는다.
|
||||||
|
5. OCI SDK가 `DEFAULT` profile로 GPT-5.4-mini를 실제 호출한다.
|
||||||
|
6. MCP 호출 상세의 JSON·code가 밝은 테마에서 읽힌다.
|
||||||
@@ -31,6 +31,8 @@ POC3_MCP_PROVIDER=custom_python
|
|||||||
POC3_MCP_BASE_URL=http://127.0.0.1:8500
|
POC3_MCP_BASE_URL=http://127.0.0.1:8500
|
||||||
POC3_MCP_AUTH_MODE=bearer
|
POC3_MCP_AUTH_MODE=bearer
|
||||||
POC3_MCP_TOKEN=
|
POC3_MCP_TOKEN=
|
||||||
|
# HMM MCP gateway token. 실제 값은 배포 서버 .env에만 둡니다.
|
||||||
|
HMM_MCP_BEARER_TOKEN=
|
||||||
POC3_MCP_TIMEOUT_SECONDS=30
|
POC3_MCP_TIMEOUT_SECONDS=30
|
||||||
POC3_MCP_FALLBACK_TO_MOCK=false
|
POC3_MCP_FALLBACK_TO_MOCK=false
|
||||||
POC3_MCP_LIVE_SMOKE=false
|
POC3_MCP_LIVE_SMOKE=false
|
||||||
@@ -39,7 +41,7 @@ POC3_MCP_LIVE_SMOKE=false
|
|||||||
# 실제 서버 URL/token 값은 JSON에 직접 넣지 않고 위 환경변수 이름을 참조합니다.
|
# 실제 서버 URL/token 값은 JSON에 직접 넣지 않고 위 환경변수 이름을 참조합니다.
|
||||||
# 사용 전 config/mcp_servers.sample.json을 아래 파일명으로 복사해 조정합니다.
|
# 사용 전 config/mcp_servers.sample.json을 아래 파일명으로 복사해 조정합니다.
|
||||||
POC4_MCP_SERVERS_FILE=config/mcp_servers.json
|
POC4_MCP_SERVERS_FILE=config/mcp_servers.json
|
||||||
POC4_MCP_DEFAULT_SERVER_ID=local_adb_mcp
|
POC4_MCP_DEFAULT_SERVER_ID=hmm_hr_mcp
|
||||||
|
|
||||||
# MCP discovery UI conversation history store
|
# MCP discovery UI conversation history store
|
||||||
# 기본값: /home/opc/poc_4/data/poc4_mcp_chat.sqlite3
|
# 기본값: /home/opc/poc_4/data/poc4_mcp_chat.sqlite3
|
||||||
|
|||||||
@@ -13,3 +13,15 @@
|
|||||||
The PoC4 MCP registry defaults to `hmm_hr_mcp` (`https://hmm-mcp.cloud-handson.com/mcp`).
|
The PoC4 MCP registry defaults to `hmm_hr_mcp` (`https://hmm-mcp.cloud-handson.com/mcp`).
|
||||||
Its gateway credential is supplied only at runtime as `HMM_MCP_BEARER_TOKEN` in the
|
Its gateway credential is supplied only at runtime as `HMM_MCP_BEARER_TOKEN` in the
|
||||||
deployed `.env`; it is intentionally not committed and is not the selected VPD user's token.
|
deployed `.env`; it is intentionally not committed and is not the selected VPD user's token.
|
||||||
|
|
||||||
|
## HMM demo user presets
|
||||||
|
|
||||||
|
`config/vpd_token_presets.json` is retained as a legacy deployment filename, but its content is
|
||||||
|
an HMM HR demo-user list. The Streamlit sidebar uses it to select a manager or team-member
|
||||||
|
persona and resolves pronouns such as “내” and “우리 팀” in the test question. Each preset uses
|
||||||
|
`mcp_token_env: HMM_MCP_BEARER_TOKEN`; the bearer value stays in `/opt/hmm-poc4/.env` and is
|
||||||
|
never rendered, downloaded, or committed. The selector is a test context, not a claim of
|
||||||
|
row-level authorization enforcement.
|
||||||
|
|
||||||
|
The security-operation link in the Streamlit console points to
|
||||||
|
`https://hmm-backoffice.cloud-handson.com/`, the HMM access-management backoffice.
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.po
|
|||||||
|
|
||||||
- Python 3.11 이상을 사용합니다.
|
- Python 3.11 이상을 사용합니다.
|
||||||
- 이 개발 서버의 기본 `python3`가 3.6 계열이면 문법 검증이 실패합니다.
|
- 이 개발 서버의 기본 `python3`가 3.6 계열이면 문법 검증이 실패합니다.
|
||||||
- 배포 서버 검증 런타임: `/home/opc/poc_4/.python-runtime/cpython-3.11.15+20260610/bin/python3.11`
|
- 배포 서버는 `/opt/hmm-poc4/.venv`와 Python 3.11을 사용합니다.
|
||||||
|
- OCI Generative AI SDK 인증은 배포 서버의 `/home/opc/.oci/config` `DEFAULT` profile을 사용합니다.
|
||||||
|
private key와 compartment OCID는 Git에 저장하지 않고 서버의 `~/.oci`와 `.env`에서만 관리합니다.
|
||||||
|
|
||||||
## Deployment DB reference
|
## Deployment DB reference
|
||||||
|
|
||||||
@@ -46,12 +48,14 @@ streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.po
|
|||||||
## Security note
|
## Security note
|
||||||
|
|
||||||
- 실제 `.env`는 복사하지 않았습니다. `.env.sample`을 기준으로 새로 만드세요.
|
- 실제 `.env`는 복사하지 않았습니다. `.env.sample`을 기준으로 새로 만드세요.
|
||||||
- 실제 VPD 토큰 원문은 복사하지 않았습니다. `config/vpd_token_presets.json`의 `token` 값을 배포 환경에서 교체하세요.
|
- `config/vpd_token_presets.json`은 파일명 호환성을 유지한 HMM **데모 사용자** 목록입니다.
|
||||||
|
사용자별 `mcp_token_env`는 배포 환경의 `HMM_MCP_BEARER_TOKEN`만 참조하며, token 원문은 JSON·Git에 저장하지 않습니다.
|
||||||
- 대화 DB `data/poc4_mcp_chat.sqlite3`는 개인정보/대화 내용이 포함될 수 있어 복사하지 않았습니다.
|
- 대화 DB `data/poc4_mcp_chat.sqlite3`는 개인정보/대화 내용이 포함될 수 있어 복사하지 않았습니다.
|
||||||
|
- MCP 호출 상세의 JSON/code 영역은 공통 presentation CSS에서 밝은 배경과 어두운 글자를 함께 지정합니다.
|
||||||
|
|
||||||
## 구성 원칙
|
## 구성 원칙
|
||||||
|
|
||||||
- `config/`: 운영자가 바꿀 수 있는 MCP, 모델, 사용자 preset, 데모 질문 JSON
|
- `config/`: 운영자가 바꿀 수 있는 MCP, 모델, HMM 데모 사용자 preset, 데모 질문 JSON
|
||||||
- `src/poc4/`: Streamlit과 분리 가능한 화면 보조 모듈 및 도메인 로직
|
- `src/poc4/`: Streamlit과 분리 가능한 화면 보조 모듈 및 도메인 로직
|
||||||
- `apps/poc4/`: 레거시 호환 entrypoint. 화면 조립과 사용자 상호작용만 담당하도록 점진적으로 축소
|
- `apps/poc4/`: 레거시 호환 entrypoint. 화면 조립과 사용자 상호작용만 담당하도록 점진적으로 축소
|
||||||
- `tests/`: 설정 파일과 순수 Python 모듈의 회귀 검증
|
- `tests/`: 설정 파일과 순수 Python 모듈의 회귀 검증
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
|
|||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
MCP_PROTOCOL_VERSION = "2025-11-25"
|
MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||||
PREFERRED_TOOL = "ords.query.kb_select_ai_vpd"
|
PREFERRED_TOOL = "search_hr_data"
|
||||||
DEFAULT_QUESTION = ""
|
DEFAULT_QUESTION = ""
|
||||||
MAX_RESPONSE_BYTES = 1_000_000
|
MAX_RESPONSE_BYTES = 1_000_000
|
||||||
MAX_CONVERSATION_MESSAGES = 8
|
MAX_CONVERSATION_MESSAGES = 8
|
||||||
@@ -80,8 +80,8 @@ VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
|
|||||||
DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json"
|
DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json"
|
||||||
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
|
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
|
||||||
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
|
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
|
||||||
DEFAULT_VPD_USER_ID = "FC00789"
|
DEFAULT_VPD_USER_ID = "E1001"
|
||||||
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/"
|
VPD_OPERATIONS_URL = "https://hmm-backoffice.cloud-handson.com/"
|
||||||
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
|
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
|
||||||
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
|
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
|
||||||
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
|
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
|
||||||
@@ -1471,6 +1471,7 @@ class VpdTokenPreset:
|
|||||||
scope: str
|
scope: str
|
||||||
token: str = field(repr=False, compare=False)
|
token: str = field(repr=False, compare=False)
|
||||||
is_default: bool = False
|
is_default: bool = False
|
||||||
|
team: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_label(self) -> str:
|
def display_label(self) -> str:
|
||||||
@@ -1480,7 +1481,7 @@ class VpdTokenPreset:
|
|||||||
self.user_id,
|
self.user_id,
|
||||||
self.name,
|
self.name,
|
||||||
self.role,
|
self.role,
|
||||||
self.channel,
|
self.team or self.channel,
|
||||||
self.scope,
|
self.scope,
|
||||||
)
|
)
|
||||||
if item
|
if item
|
||||||
@@ -1493,6 +1494,7 @@ class VpdTokenPreset:
|
|||||||
for item in (
|
for item in (
|
||||||
self.user_id,
|
self.user_id,
|
||||||
self.name,
|
self.name,
|
||||||
|
self.role,
|
||||||
)
|
)
|
||||||
if item
|
if item
|
||||||
)
|
)
|
||||||
@@ -2047,7 +2049,7 @@ def _render_app_header(profile: AppProfile) -> None:
|
|||||||
render_console_header(st, profile)
|
render_console_header(st, profile)
|
||||||
|
|
||||||
|
|
||||||
def _render_vpd_user_card(preset: VpdTokenPreset) -> None:
|
def _render_demo_user_card(preset: VpdTokenPreset) -> None:
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
f"""
|
||||||
<div class="kb-vpd-card">
|
<div class="kb-vpd-card">
|
||||||
@@ -2055,10 +2057,10 @@ def _render_vpd_user_card(preset: VpdTokenPreset) -> None:
|
|||||||
{html.escape(preset.user_id)} · {html.escape(preset.name)}
|
{html.escape(preset.user_id)} · {html.escape(preset.name)}
|
||||||
</div>
|
</div>
|
||||||
<div class="kb-vpd-meta">
|
<div class="kb-vpd-meta">
|
||||||
{html.escape(preset.role)} · {html.escape(preset.channel)}
|
{html.escape(preset.role)} · {html.escape(preset.team or preset.channel)}
|
||||||
</div>
|
</div>
|
||||||
<div class="kb-vpd-scope">
|
<div class="kb-vpd-scope">
|
||||||
권한 범위: {html.escape(preset.scope)}
|
테스트 문맥: {html.escape(preset.scope)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
@@ -3625,19 +3627,24 @@ def load_vpd_token_presets(
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return ()
|
return ()
|
||||||
except (OSError, UnicodeError, ValueError):
|
except (OSError, UnicodeError, ValueError):
|
||||||
raise PublicMcpError(f"VPD 토큰 preset 설정을 읽지 못했습니다: {path}") from None
|
raise PublicMcpError(f"데모 사용자 preset 설정을 읽지 못했습니다: {path}") from None
|
||||||
raw_presets = payload.get("presets") if isinstance(payload, Mapping) else None
|
raw_presets = payload.get("presets") if isinstance(payload, Mapping) else None
|
||||||
if not isinstance(raw_presets, list):
|
if not isinstance(raw_presets, list):
|
||||||
raise PublicMcpError("VPD 토큰 preset 설정에 presets 배열이 필요합니다.")
|
raise PublicMcpError("데모 사용자 preset 설정에 presets 배열이 필요합니다.")
|
||||||
|
|
||||||
presets: list[VpdTokenPreset] = []
|
presets: list[VpdTokenPreset] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for item in raw_presets:
|
for item in raw_presets:
|
||||||
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
|
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
|
||||||
continue
|
continue
|
||||||
token = _normalized_bearer(item.get("token"))
|
token_env = str(
|
||||||
|
item.get("mcp_token_env") or item.get("token_env") or ""
|
||||||
|
).strip()
|
||||||
|
token = _normalized_bearer(
|
||||||
|
_runtime_env_value(token_env) if token_env else item.get("token")
|
||||||
|
)
|
||||||
user_id = str(item.get("user_id") or "").strip()
|
user_id = str(item.get("user_id") or "").strip()
|
||||||
if not token or not user_id or user_id in seen:
|
if not user_id or user_id in seen:
|
||||||
continue
|
continue
|
||||||
presets.append(
|
presets.append(
|
||||||
VpdTokenPreset(
|
VpdTokenPreset(
|
||||||
@@ -3645,9 +3652,10 @@ def load_vpd_token_presets(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=str(item.get("name") or "").strip(),
|
name=str(item.get("name") or "").strip(),
|
||||||
role=str(item.get("role") or "").strip(),
|
role=str(item.get("role") or "").strip(),
|
||||||
channel=str(item.get("channel") or "").strip(),
|
channel=str(item.get("channel") or item.get("team") or "").strip(),
|
||||||
scope=str(item.get("scope") or "").strip(),
|
scope=str(item.get("scope") or "").strip(),
|
||||||
is_default=item.get("default") is True,
|
is_default=item.get("default") is True,
|
||||||
|
team=str(item.get("team") or "").strip(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
seen.add(user_id)
|
seen.add(user_id)
|
||||||
@@ -5264,6 +5272,16 @@ def prepare_tool_query_for_mcp(
|
|||||||
fallback = str(question or "").strip()
|
fallback = str(question or "").strip()
|
||||||
if not fallback:
|
if not fallback:
|
||||||
return fallback
|
return fallback
|
||||||
|
if server.server_id == "hmm_hr_mcp":
|
||||||
|
return _prepare_hmm_hr_tool_query(
|
||||||
|
question=fallback,
|
||||||
|
tool=tool,
|
||||||
|
model_profile_key=model_profile_key,
|
||||||
|
selected_user_id=selected_user_id,
|
||||||
|
selected_user_role=selected_user_role,
|
||||||
|
selected_user_team=selected_user_channel,
|
||||||
|
selected_user_scope=selected_user_scope,
|
||||||
|
)
|
||||||
normalized_question = " ".join(fallback.casefold().split())
|
normalized_question = " ".join(fallback.casefold().split())
|
||||||
individual_scope_terms = (
|
individual_scope_terms = (
|
||||||
"내 담당이 아닌",
|
"내 담당이 아닌",
|
||||||
@@ -5414,6 +5432,98 @@ def prepare_tool_query_for_mcp(
|
|||||||
return _append_query_guidance(rewritten or fallback, query_guidance)
|
return _append_query_guidance(rewritten or fallback, query_guidance)
|
||||||
|
|
||||||
|
|
||||||
|
def _hmm_demo_user_context(
|
||||||
|
question: str,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
role: str,
|
||||||
|
team: str,
|
||||||
|
scope: str,
|
||||||
|
) -> str:
|
||||||
|
"""Make a selected HR persona useful without claiming row-level enforcement."""
|
||||||
|
|
||||||
|
normalized = str(question or "").strip()
|
||||||
|
if not user_id:
|
||||||
|
return normalized
|
||||||
|
profile = " · ".join(item for item in (user_id, role, team) if item)
|
||||||
|
purpose = f" 테스트 목적: {scope}." if scope else ""
|
||||||
|
return (
|
||||||
|
f"현재 HMM HR 데모 사용자: {profile}.{purpose} "
|
||||||
|
"질문의 ‘나’, ‘내’, ‘우리 팀’은 이 데모 사용자를 기준으로 해석하고, "
|
||||||
|
"실제 행 수준 권한이 적용됐다고 주장하지 마세요.\n"
|
||||||
|
f"질문: {normalized}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_hmm_hr_tool_query(
|
||||||
|
*,
|
||||||
|
question: str,
|
||||||
|
tool: McpTool,
|
||||||
|
model_profile_key: str,
|
||||||
|
selected_user_id: str,
|
||||||
|
selected_user_role: str,
|
||||||
|
selected_user_team: str,
|
||||||
|
selected_user_scope: str,
|
||||||
|
) -> str:
|
||||||
|
"""Prepare an HMM HR query without inheriting retired KB/VPD prompt rules."""
|
||||||
|
|
||||||
|
fallback = str(question or "").strip()
|
||||||
|
if tool.name == "resolve_hr_term":
|
||||||
|
return fallback
|
||||||
|
contextual_question = _hmm_demo_user_context(
|
||||||
|
fallback,
|
||||||
|
user_id=selected_user_id.strip(),
|
||||||
|
role=selected_user_role.strip(),
|
||||||
|
team=selected_user_team.strip(),
|
||||||
|
scope=selected_user_scope.strip(),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
profile = resolve_model_profile(model_profile_key)
|
||||||
|
client = build_oci_genai_completion_client(
|
||||||
|
profile.model_id,
|
||||||
|
profile.answer_model_region,
|
||||||
|
profile.answer_model_endpoint,
|
||||||
|
)
|
||||||
|
text = client.complete(
|
||||||
|
system_prompt=(
|
||||||
|
"You prepare one Korean natural-language query for an HMM HR MCP tool. "
|
||||||
|
"Return only JSON matching the schema. Do not answer the user, write SQL, "
|
||||||
|
"or expose/request tokens. Preserve employee codes exactly. "
|
||||||
|
"The HMM HR data tool can query organization, employees, leave balances, "
|
||||||
|
"leave requests, attendance, and standardized HR terms. The policy tool searches "
|
||||||
|
"HR policy PDF abstracts and chunks. A selected demo user only resolves pronouns "
|
||||||
|
"such as 'my' or 'our team'; do not claim that it enforces database access control."
|
||||||
|
),
|
||||||
|
user_prompt=json.dumps(
|
||||||
|
{
|
||||||
|
"question": contextual_question,
|
||||||
|
"selected_tool_name": tool.name,
|
||||||
|
"selected_tool_description": tool.description[:1200],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
response_schema={
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"required": ["tool_query"],
|
||||||
|
"properties": {"tool_query": {"type": "string"}},
|
||||||
|
},
|
||||||
|
max_tokens=500,
|
||||||
|
temperature=temperature_for_model_profile(profile),
|
||||||
|
)
|
||||||
|
parsed = json.loads(text)
|
||||||
|
rewritten = _clean_agent_tool_query(parsed.get("tool_query"), fallback)
|
||||||
|
except Exception:
|
||||||
|
return contextual_question
|
||||||
|
return _hmm_demo_user_context(
|
||||||
|
rewritten or fallback,
|
||||||
|
user_id=selected_user_id.strip(),
|
||||||
|
role=selected_user_role.strip(),
|
||||||
|
team=selected_user_team.strip(),
|
||||||
|
scope=selected_user_scope.strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def synthesize_answer(
|
def synthesize_answer(
|
||||||
*,
|
*,
|
||||||
question: str,
|
question: str,
|
||||||
@@ -7037,7 +7147,7 @@ def main() -> None:
|
|||||||
current_vpd_user_id
|
current_vpd_user_id
|
||||||
].select_label
|
].select_label
|
||||||
st.markdown(
|
st.markdown(
|
||||||
'<div class="kb-sidebar-section-title">VPD 사용자</div>',
|
'<div class="kb-sidebar-section-title">데모 사용자</div>',
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
vpd_popover_key = "poc4_vpd_user_popover_open"
|
vpd_popover_key = "poc4_vpd_user_popover_open"
|
||||||
@@ -7052,7 +7162,7 @@ def main() -> None:
|
|||||||
on_change="rerun",
|
on_change="rerun",
|
||||||
):
|
):
|
||||||
selected_vpd_user_id = st.radio(
|
selected_vpd_user_id = st.radio(
|
||||||
"VPD 사용자 선택",
|
"데모 사용자 선택",
|
||||||
options=token_preset_ids,
|
options=token_preset_ids,
|
||||||
key=vpd_user_key,
|
key=vpd_user_key,
|
||||||
format_func=lambda user_id: token_preset_by_id[
|
format_func=lambda user_id: token_preset_by_id[
|
||||||
@@ -7068,10 +7178,13 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
selected_token_preset = None
|
selected_token_preset = None
|
||||||
if configured_mcp_bearer:
|
selected_preset_bearer = (
|
||||||
# The HMM MCP gateway uses its own server-side credential. VPD user
|
selected_token_preset.token if selected_token_preset is not None else ""
|
||||||
# tokens remain UI context only and must never be forwarded to it.
|
)
|
||||||
st.caption("MCP 인증: 서버 관리 토큰 적용")
|
if configured_mcp_bearer or selected_preset_bearer:
|
||||||
|
# The HMM MCP gateway credential is never rendered. Each demo-user
|
||||||
|
# preset refers to its runtime env key, allowing secure profile swaps.
|
||||||
|
st.caption("MCP 인증: 선택 사용자 preset의 서버 관리 토큰 적용")
|
||||||
manual_bearer_token = ""
|
manual_bearer_token = ""
|
||||||
elif selected_token_preset is not None:
|
elif selected_token_preset is not None:
|
||||||
manual_bearer_token = st.text_input(
|
manual_bearer_token = st.text_input(
|
||||||
@@ -7080,7 +7193,7 @@ def main() -> None:
|
|||||||
type="default",
|
type="default",
|
||||||
disabled=True,
|
disabled=True,
|
||||||
key=f"poc4_bearer_token_{selected_token_preset.user_id}",
|
key=f"poc4_bearer_token_{selected_token_preset.user_id}",
|
||||||
help="선택한 VPD 사용자의 Bearer Token입니다.",
|
help="선택한 데모 사용자의 MCP gateway token입니다.",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
manual_bearer_token = st.text_input(
|
manual_bearer_token = st.text_input(
|
||||||
@@ -7088,13 +7201,9 @@ def main() -> None:
|
|||||||
type="default",
|
type="default",
|
||||||
key="poc4_manual_bearer_token",
|
key="poc4_manual_bearer_token",
|
||||||
)
|
)
|
||||||
bearer_token = configured_mcp_bearer or (
|
bearer_token = selected_preset_bearer or configured_mcp_bearer or manual_bearer_token
|
||||||
selected_token_preset.token
|
|
||||||
if selected_token_preset is not None
|
|
||||||
else manual_bearer_token
|
|
||||||
)
|
|
||||||
if selected_token_preset is not None:
|
if selected_token_preset is not None:
|
||||||
_render_vpd_user_card(selected_token_preset)
|
_render_demo_user_card(selected_token_preset)
|
||||||
|
|
||||||
all_conversation_rows = list_conversations(limit=200)
|
all_conversation_rows = list_conversations(limit=200)
|
||||||
current_row = next(
|
current_row = next(
|
||||||
|
|||||||
@@ -1,14 +1,52 @@
|
|||||||
{
|
{
|
||||||
|
"version": 2,
|
||||||
|
"description": "HMM HR 데모 사용자 선택 목록입니다. 파일명은 기존 배포 호환성을 위해 유지합니다. token 원문은 저장하지 않고 mcp_token_env의 서버 환경변수만 참조합니다.",
|
||||||
"presets": [
|
"presets": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"default": true,
|
"default": true,
|
||||||
"token": "vpd_live_REPLACE_WITH_USER_TOKEN",
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||||
"user_id": "FC00789",
|
"user_id": "E1001",
|
||||||
"name": "김설계",
|
"name": "Kim Minseo",
|
||||||
"role": "설계사",
|
"role": "HR Team Manager",
|
||||||
"channel": "설계사",
|
"team": "HMM HR Demo Team",
|
||||||
"scope": "본인 담당 계약 고객"
|
"scope": "팀원 6명의 휴가·근태 현황을 확인하는 관리자 데모"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||||
|
"user_id": "E1002",
|
||||||
|
"name": "Lee Jiwon",
|
||||||
|
"role": "HR Operations Specialist",
|
||||||
|
"team": "HMM HR Demo Team",
|
||||||
|
"scope": "본인 휴가 잔여·신청·근태를 확인하는 팀원 데모"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||||
|
"user_id": "E1003",
|
||||||
|
"name": "Park Dohyun",
|
||||||
|
"role": "People Analytics Analyst",
|
||||||
|
"team": "HMM HR Demo Team",
|
||||||
|
"scope": "본인 휴가·근태와 팀 인력 현황을 확인하는 분석 담당 데모"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||||
|
"user_id": "E1005",
|
||||||
|
"name": "Han Seojun",
|
||||||
|
"role": "Recruiting Specialist",
|
||||||
|
"team": "HMM HR Demo Team",
|
||||||
|
"scope": "대기 중인 2일 연차 신청을 확인하는 팀원 데모"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"mcp_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||||
|
"user_id": "E1007",
|
||||||
|
"name": "Kang Minho",
|
||||||
|
"role": "HR Coordinator",
|
||||||
|
"team": "HMM HR Demo Team",
|
||||||
|
"scope": "대기 중인 1일 연차 신청과 휴가 근태를 확인하는 팀원 데모"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
openpyxl>=3.1,<4
|
openpyxl>=3.1,<4
|
||||||
|
oci>=2.180,<3
|
||||||
oracledb>=2,<4
|
oracledb>=2,<4
|
||||||
pandas>=2,<3
|
pandas>=2,<3
|
||||||
streamlit>=1.35,<2
|
streamlit>=1.35,<2
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
|||||||
div[data-testid="stButton"] > button[kind="primary"] *,
|
div[data-testid="stButton"] > button[kind="primary"] *,
|
||||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {{
|
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {{
|
||||||
color:#fff !important; -webkit-text-fill-color:#fff !important; }}
|
color:#fff !important; -webkit-text-fill-color:#fff !important; }}
|
||||||
|
/* Streamlit JSON/code components otherwise retain a dark-surface theme while
|
||||||
|
the console text is forced dark, making MCP details unreadable. */
|
||||||
|
[data-testid="stJson"], [data-testid="stJson"] > div,
|
||||||
|
[data-testid="stCodeBlock"], [data-testid="stCodeBlock"] > div,
|
||||||
|
[data-testid="stCode"], [data-testid="stCode"] > div {{
|
||||||
|
background:#f6f8fa !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; }}
|
||||||
|
[data-testid="stJson"] *, [data-testid="stCodeBlock"] *, [data-testid="stCode"] *,
|
||||||
|
[data-testid="stJson"] pre, [data-testid="stCodeBlock"] pre, [data-testid="stCode"] pre {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
[data-testid="stJson"] button, [data-testid="stCodeBlock"] button, [data-testid="stCode"] button {{
|
||||||
|
background:#fff !important; border-color:var(--console-border) !important; }}
|
||||||
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
|
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
|
||||||
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
|
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
|
||||||
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
|
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
|
||||||
|
|||||||
@@ -154,26 +154,32 @@ def build_mcp_tool_arguments(
|
|||||||
if not isinstance(properties, Mapping):
|
if not isinstance(properties, Mapping):
|
||||||
properties = {}
|
properties = {}
|
||||||
|
|
||||||
if tool.name == preferred_tool:
|
# A server's default/preferred tool still has to obey its discovered schema.
|
||||||
return {"prompt": question, "limit": limit}
|
# HMM tools use `query` and `term`; forcing the legacy `prompt`/`limit` shape
|
||||||
if "prompt" in properties:
|
# makes an otherwise valid tool fail argument validation.
|
||||||
args: dict[str, Any] = {"prompt": question}
|
del preferred_tool
|
||||||
if "limit" in properties:
|
input_name = next(
|
||||||
args["limit"] = limit
|
(name for name in ("prompt", "question", "query", "term", "text") if name in properties),
|
||||||
elif "max_rows" in properties:
|
"",
|
||||||
args["max_rows"] = limit
|
)
|
||||||
return args
|
if not input_name:
|
||||||
if "question" in properties:
|
required = tool.schema.get("required")
|
||||||
args = {"question": question}
|
if isinstance(required, list):
|
||||||
if "max_rows" in properties:
|
input_name = next(
|
||||||
args["max_rows"] = limit
|
(
|
||||||
elif "limit" in properties:
|
str(name)
|
||||||
args["limit"] = limit
|
for name in required
|
||||||
return args
|
if isinstance(properties.get(str(name)), Mapping)
|
||||||
if "query" in properties:
|
and properties[str(name)].get("type") == "string"
|
||||||
args = {"query": question}
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if input_name:
|
||||||
|
args: dict[str, Any] = {input_name: question}
|
||||||
if "max_evidence" in properties:
|
if "max_evidence" in properties:
|
||||||
args["max_evidence"] = min(limit, 10)
|
args["max_evidence"] = min(limit, 10)
|
||||||
|
elif "max_rows" in properties:
|
||||||
|
args["max_rows"] = limit
|
||||||
elif "limit" in properties:
|
elif "limit" in properties:
|
||||||
args["limit"] = limit
|
args["limit"] = limit
|
||||||
return args
|
return args
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
|
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
|
||||||
from src.agent_console.profile import load_app_profile
|
from src.agent_console.profile import load_app_profile
|
||||||
|
from src.mcp_tool_router import McpTool, build_mcp_tool_arguments
|
||||||
|
|
||||||
|
|
||||||
class DemoScenarioConfigTest(unittest.TestCase):
|
class DemoScenarioConfigTest(unittest.TestCase):
|
||||||
@@ -45,6 +46,18 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios}))
|
self.assertEqual(len(scenarios), len({item.scenario_id for item in scenarios}))
|
||||||
self.assertTrue(all(item.question.strip() for item in scenarios))
|
self.assertTrue(all(item.question.strip() for item in scenarios))
|
||||||
|
|
||||||
|
def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None:
|
||||||
|
path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json"
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
presets = payload["presets"]
|
||||||
|
|
||||||
|
self.assertEqual(payload["version"], 2)
|
||||||
|
self.assertEqual({item["user_id"] for item in presets}, {
|
||||||
|
"E1001", "E1002", "E1003", "E1005", "E1007"
|
||||||
|
})
|
||||||
|
self.assertTrue(all(item["mcp_token_env"] == "HMM_MCP_BEARER_TOKEN" for item in presets))
|
||||||
|
self.assertTrue(all("token" not in item for item in presets))
|
||||||
|
|
||||||
def test_duplicate_id_is_rejected(self) -> None:
|
def test_duplicate_id_is_rejected(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
path = Path(temp_dir) / "scenarios.json"
|
path = Path(temp_dir) / "scenarios.json"
|
||||||
@@ -62,6 +75,42 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
|||||||
with self.assertRaises(ScenarioConfigError):
|
with self.assertRaises(ScenarioConfigError):
|
||||||
load_demo_scenarios(path)
|
load_demo_scenarios(path)
|
||||||
|
|
||||||
|
def test_default_mcp_tool_arguments_follow_discovered_query_schema(self) -> None:
|
||||||
|
tool = McpTool(
|
||||||
|
name="search_hr_data",
|
||||||
|
description="",
|
||||||
|
schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
arguments = build_mcp_tool_arguments(
|
||||||
|
tool, "직원 E1005의 휴가 신청 내역", 50, preferred_tool="search_hr_data"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(arguments, {"query": "직원 E1005의 휴가 신청 내역"})
|
||||||
|
|
||||||
|
def test_term_tool_arguments_follow_discovered_term_schema(self) -> None:
|
||||||
|
tool = McpTool(
|
||||||
|
name="resolve_hr_term",
|
||||||
|
description="",
|
||||||
|
schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"term": {"type": "string"}},
|
||||||
|
"required": ["term"],
|
||||||
|
},
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
arguments = build_mcp_tool_arguments(
|
||||||
|
tool, "반차", 50, preferred_tool="search_hr_data"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(arguments, {"term": "반차"})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user