refs #699: configure HMM demo users and OCI runtime

This commit is contained in:
devmrko
2026-07-22 16:33:11 +09:00
parent 536938895a
commit ee355a9282
9 changed files with 269 additions and 36 deletions

View File

@@ -62,7 +62,7 @@ from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
LOG = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2025-11-25"
PREFERRED_TOOL = "ords.query.kb_select_ai_vpd"
PREFERRED_TOOL = "search_hr_data"
DEFAULT_QUESTION = ""
MAX_RESPONSE_BYTES = 1_000_000
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"
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
DEFAULT_VPD_USER_ID = "FC00789"
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/"
DEFAULT_VPD_USER_ID = "E1001"
VPD_OPERATIONS_URL = "https://hmm-backoffice.cloud-handson.com/"
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
@@ -1471,6 +1471,7 @@ class VpdTokenPreset:
scope: str
token: str = field(repr=False, compare=False)
is_default: bool = False
team: str = ""
@property
def display_label(self) -> str:
@@ -1480,7 +1481,7 @@ class VpdTokenPreset:
self.user_id,
self.name,
self.role,
self.channel,
self.team or self.channel,
self.scope,
)
if item
@@ -1493,6 +1494,7 @@ class VpdTokenPreset:
for item in (
self.user_id,
self.name,
self.role,
)
if item
)
@@ -2047,7 +2049,7 @@ def _render_app_header(profile: AppProfile) -> None:
render_console_header(st, profile)
def _render_vpd_user_card(preset: VpdTokenPreset) -> None:
def _render_demo_user_card(preset: VpdTokenPreset) -> None:
st.markdown(
f"""
<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)}
</div>
<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 class="kb-vpd-scope">
권한 범위: {html.escape(preset.scope)}
테스트 문맥: {html.escape(preset.scope)}
</div>
</div>
""",
@@ -3625,19 +3627,24 @@ def load_vpd_token_presets(
except FileNotFoundError:
return ()
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
if not isinstance(raw_presets, list):
raise PublicMcpError("VPD 토큰 preset 설정에 presets 배열이 필요합니다.")
raise PublicMcpError("데모 사용자 preset 설정에 presets 배열이 필요합니다.")
presets: list[VpdTokenPreset] = []
seen: set[str] = set()
for item in raw_presets:
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
continue
token = _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()
if not token or not user_id or user_id in seen:
if not user_id or user_id in seen:
continue
presets.append(
VpdTokenPreset(
@@ -3645,9 +3652,10 @@ def load_vpd_token_presets(
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(),
channel=str(item.get("channel") or item.get("team") or "").strip(),
scope=str(item.get("scope") or "").strip(),
is_default=item.get("default") is True,
team=str(item.get("team") or "").strip(),
)
)
seen.add(user_id)
@@ -5264,6 +5272,16 @@ def prepare_tool_query_for_mcp(
fallback = str(question or "").strip()
if not fallback:
return fallback
if server.server_id == "hmm_hr_mcp":
return _prepare_hmm_hr_tool_query(
question=fallback,
tool=tool,
model_profile_key=model_profile_key,
selected_user_id=selected_user_id,
selected_user_role=selected_user_role,
selected_user_team=selected_user_channel,
selected_user_scope=selected_user_scope,
)
normalized_question = " ".join(fallback.casefold().split())
individual_scope_terms = (
"내 담당이 아닌",
@@ -5414,6 +5432,98 @@ def prepare_tool_query_for_mcp(
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(
*,
question: str,
@@ -7037,7 +7147,7 @@ def main() -> None:
current_vpd_user_id
].select_label
st.markdown(
'<div class="kb-sidebar-section-title">VPD 사용자</div>',
'<div class="kb-sidebar-section-title">데모 사용자</div>',
unsafe_allow_html=True,
)
vpd_popover_key = "poc4_vpd_user_popover_open"
@@ -7052,7 +7162,7 @@ def main() -> None:
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[
@@ -7068,10 +7178,13 @@ def main() -> None:
)
else:
selected_token_preset = None
if configured_mcp_bearer:
# The HMM MCP gateway uses its own server-side credential. VPD user
# tokens remain UI context only and must never be forwarded to it.
st.caption("MCP 인증: 서버 관리 토큰 적용")
selected_preset_bearer = (
selected_token_preset.token if selected_token_preset is not None else ""
)
if configured_mcp_bearer or selected_preset_bearer:
# The HMM MCP gateway credential is never rendered. Each demo-user
# preset refers to its runtime env key, allowing secure profile swaps.
st.caption("MCP 인증: 선택 사용자 preset의 서버 관리 토큰 적용")
manual_bearer_token = ""
elif selected_token_preset is not None:
manual_bearer_token = st.text_input(
@@ -7080,7 +7193,7 @@ def main() -> None:
type="default",
disabled=True,
key=f"poc4_bearer_token_{selected_token_preset.user_id}",
help="선택한 VPD 사용자의 Bearer Token입니다.",
help="선택한 데모 사용자의 MCP gateway token입니다.",
)
else:
manual_bearer_token = st.text_input(
@@ -7088,13 +7201,9 @@ def main() -> None:
type="default",
key="poc4_manual_bearer_token",
)
bearer_token = configured_mcp_bearer or (
selected_token_preset.token
if selected_token_preset is not None
else manual_bearer_token
)
bearer_token = selected_preset_bearer or configured_mcp_bearer or manual_bearer_token
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)
current_row = next(