diff --git a/docs/design/699-hmm-mcp-demo-users/README.md b/docs/design/699-hmm-mcp-demo-users/README.md index 950ffad..5ad35de 100644 --- a/docs/design/699-hmm-mcp-demo-users/README.md +++ b/docs/design/699-hmm-mcp-demo-users/README.md @@ -26,6 +26,10 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e - 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`을 전달한다. +- MCP 호환 서버의 `status/result` 응답에서 `result` 문자열은 정책 본문 근거다. 화면 요약에는 + 길이와 앞부분을 표시하고, 최종 답변 합성에는 제한된 길이의 원문을 전달한다. +- 사용자 ID·역할·팀 문맥은 `search_hr_data`의 ‘나/우리 팀’ 해석에만 사용한다. + `search_hr_policy`와 `resolve_hr_term`에는 원 질문/원 용어만 전달해 벡터·lexical 검색어를 오염시키지 않는다. ## 런타임 구성 @@ -34,6 +38,8 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e - `OCI_AUTH_TYPE=config_file`, `OCI_CONFIG_FILE`, `OCI_PROFILE`, `OCI_GENAI_COMPARTMENT_ID`는 배포 서버 `/opt/hmm-poc4/.env`에서 관리하고 저장소에는 값을 기록하지 않는다. - MCP 호출 상세 JSON/code 영역은 공통 `presentation.py`에서 배경·글자색을 함께 고정한다. +- 브라우저의 dark color-scheme과 관계없이 답변 목록, expander header, 보조 버튼도 밝은 배경과 + 어두운 글자색을 사용한다. ## 완료 기준 @@ -54,3 +60,7 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e - systemd `ExecStart`를 `/opt/hmm-poc4/.venv/bin/python -m streamlit ...`로 고정 - Chromium dark color-scheme에서 MCP JSON 계산값 확인: 배경 `rgb(246, 248, 250)`, 글자 `rgb(23, 43, 58)` +- 정책 질문 브라우저 검증: 12월 31일 기준 사용촉진조치 시 미사용 연차 이월 없음과 + 입사 2년차부터 차기 발생연차 50% 선사용 제한을 답변했다. +- dark color-scheme 계산값: expander 배경 `rgb(246, 248, 250)`, 보조 버튼 배경 + `rgb(255, 255, 255)`, 답변 목록·expander·버튼 글자 `rgb(23, 43, 58)` diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py index b7adfbd..a6a014e 100644 --- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py +++ b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py @@ -46,6 +46,12 @@ from src.mcp_tool_router import ( build_mcp_tool_arguments, route_mcp_tool_across_servers_with_llm, ) +from src.mcp_result import ( + has_actionable_text_result, + status_result_evidence, + status_result_summary, + text_result, +) from src.oci_genai_sdk import ( build_oci_genai_completion_client, temperature_for_model_profile, @@ -4108,7 +4114,7 @@ def _mcp_summary(mcp_result: Any) -> dict[str, Any]: return {"type": type(mcp_result).__name__} payload = _mcp_response_payload(mcp_result) items = _mcp_items(mcp_result) - summary: dict[str, Any] = {} + summary: dict[str, Any] = status_result_summary(mcp_result) for key in ("toolName", "profile", "ordsPath", "generatedSql"): value = mcp_result.get(key) if key in mcp_result else payload.get(key) if value: @@ -4195,6 +4201,8 @@ def _clean_agent_tool_query(value: object, fallback: str) -> str: def _mcp_has_actionable_result(mcp_result: Any) -> bool: if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result): return True + if has_actionable_text_result(mcp_result): + return True payload = _mcp_response_payload(mcp_result) results = payload.get("results") if isinstance(results, list) and results: @@ -4259,7 +4267,10 @@ def _mcp_answer_evidence( if not isinstance(mcp_result, Mapping): return mcp_result payload = _mcp_response_payload(mcp_result) - evidence: dict[str, Any] = {} + evidence: dict[str, Any] = status_result_evidence( + mcp_result, + max_chars=max(3500, min(7000, max_text * 15)), + ) for key in ( "toolName", "profile", @@ -5468,7 +5479,7 @@ def _prepare_hmm_hr_tool_query( """Prepare an HMM HR query without inheriting retired KB/VPD prompt rules.""" fallback = str(question or "").strip() - if tool.name == "resolve_hr_term": + if tool.name in {"resolve_hr_term", "search_hr_policy"}: return fallback contextual_question = _hmm_demo_user_context( fallback, @@ -5646,6 +5657,8 @@ def synthesize_answer( "say that clearly. Treat items_count=0 or results_count=0 as an actual " "zero-row result, not as missing tool delivery. Never claim that an audit " "event exists unless its identifier or audit result is in the evidence. " + "For compatibility MCP tools, the result string contains the factual " + "DOC and EVIDENCE lines; read it as evidence rather than treating it as metadata. " "For cross-source questions, keep structured contract/product facts separate " "from vector clause evidence, then combine only matching identifiers. If one " "source is missing, list confirmed and unconfirmed points separately instead " @@ -5781,6 +5794,11 @@ def fallback_answer_from_mcp( lines.append(f"- {title or '검색 결과'}{suffix}: {body}") else: lines.append(f"- {compact}") + elif text_result(mcp_result): + result = text_result(mcp_result) + lines.append("") + lines.append("MCP 반환 근거:") + lines.append(result[:6000] + ("..." if len(result) > 6000 else "")) elif agent_steps: lines.append(f"Agent는 MCP tool을 {len(agent_steps)}회 호출했습니다.") else: diff --git a/poc4_active_source_20260714/src/agent_console/presentation.py b/poc4_active_source_20260714/src/agent_console/presentation.py index f561028..2014f3c 100644 --- a/poc4_active_source_20260714/src/agent_console/presentation.py +++ b/poc4_active_source_20260714/src/agent_console/presentation.py @@ -27,6 +27,8 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: [data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span, [data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1, [data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3, + [data-testid="stAppViewContainer"] li, [data-testid="stAppViewContainer"] dt, + [data-testid="stAppViewContainer"] dd, [data-testid="stAppViewContainer"] blockquote, [data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea, section[data-testid="stSidebar"] * {{ color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }} @@ -34,7 +36,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: background:#fff !important; border:1px solid var(--console-border) !important; border-radius:4px !important; box-shadow:none !important; }} div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{ + background:#fff !important; color:var(--console-text) !important; + border:1px solid var(--console-border) !important; border-radius:4px !important; box-shadow:none !important; }} + div[data-testid="stButton"] > button *, + div[data-testid="stFormSubmitButton"] > button * {{ + color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }} div[data-testid="stButton"] > button[kind="primary"], div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{ background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }} @@ -54,6 +61,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None: -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; }} + [data-testid="stExpander"] summary {{ + background:#f6f8fa !important; color:var(--console-text) !important; + border-color:var(--console-border) !important; }} + [data-testid="stExpander"] summary * {{ + color:var(--console-text) !important; + -webkit-text-fill-color:var(--console-text) !important; }} .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-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }} diff --git a/poc4_active_source_20260714/src/mcp_result.py b/poc4_active_source_20260714/src/mcp_result.py new file mode 100644 index 0000000..a35ec02 --- /dev/null +++ b/poc4_active_source_20260714/src/mcp_result.py @@ -0,0 +1,80 @@ +"""Pure helpers for MCP result envelopes used by the Streamlit console.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +def response_payload(value: Any) -> Mapping[str, Any]: + """Return the business payload from a direct or nested MCP response.""" + + if not isinstance(value, Mapping): + return {} + nested = value.get("response") + return nested if isinstance(nested, Mapping) else value + + +def text_result(value: Any) -> str: + """Return a textual `result` field without stringifying other structures.""" + + result = response_payload(value).get("result") + return result.strip() if isinstance(result, str) else "" + + +def status_result_summary(value: Any, *, excerpt_chars: int = 900) -> dict[str, Any]: + """Build a safe UI summary for status/result-style compatibility tools.""" + + payload = response_payload(value) + summary: dict[str, Any] = {} + for key in ("status", "success", "error", "errorCode", "errorMessage"): + item = payload.get(key) + if item not in (None, "", []): + summary[key] = item + result = text_result(value) + if result: + summary["result_chars"] = len(result) + summary["result_excerpt"] = result[:excerpt_chars] + ( + "..." if len(result) > excerpt_chars else "" + ) + return summary + + +def status_result_evidence(value: Any, *, max_chars: int = 7000) -> dict[str, Any]: + """Preserve bounded textual policy/data evidence for final answer synthesis.""" + + payload = response_payload(value) + evidence: dict[str, Any] = {} + for key in ("status", "success", "error", "errorCode", "errorMessage"): + item = payload.get(key) + if item not in (None, "", []): + evidence[key] = item + result = text_result(value) + if result: + evidence["result"] = result[:max_chars] + ( + "..." if len(result) > max_chars else "" + ) + evidence["result_chars"] = len(result) + return evidence + + +def has_actionable_text_result(value: Any) -> bool: + """Return whether a textual result contains evidence worth stopping on.""" + + result = text_result(value) + if not result: + return False + normalized = " ".join(result.casefold().split()) + return not any( + marker in normalized + for marker in ("no data found", "no evidence found", "error:") + ) + + +__all__ = [ + "has_actionable_text_result", + "response_payload", + "status_result_evidence", + "status_result_summary", + "text_result", +] diff --git a/poc4_active_source_20260714/tests/test_scenarios.py b/poc4_active_source_20260714/tests/test_scenarios.py index 5ca60be..402982c 100644 --- a/poc4_active_source_20260714/tests/test_scenarios.py +++ b/poc4_active_source_20260714/tests/test_scenarios.py @@ -9,6 +9,21 @@ from unittest.mock import patch from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios from src.agent_console.profile import load_app_profile from src.mcp_tool_router import McpTool, build_mcp_tool_arguments +from src.mcp_result import ( + has_actionable_text_result, + status_result_evidence, + status_result_summary, +) + + +def _load_poc4_query_helpers(): + """Load the Streamlit entrypoint only when its optional runtime is installed.""" + + try: + from apps.poc4.mcp_discovery_ui import _prepare_hmm_hr_tool_query + except ModuleNotFoundError: + return None + return _prepare_hmm_hr_tool_query class DemoScenarioConfigTest(unittest.TestCase): @@ -38,6 +53,14 @@ class DemoScenarioConfigTest(unittest.TestCase): self.assertEqual(profile.short_name, "HMM") + def test_common_theme_covers_lists_expanders_and_secondary_buttons(self) -> None: + path = Path(__file__).parents[1] / "src" / "agent_console" / "presentation.py" + source = path.read_text(encoding="utf-8") + + self.assertIn('[data-testid="stAppViewContainer"] li', source) + self.assertIn('[data-testid="stExpander"] summary', source) + self.assertIn('div[data-testid="stButton"] > button', source) + def test_hmm_scenarios_are_enabled_and_unique(self) -> None: path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json" scenarios = load_demo_scenarios(path) @@ -111,6 +134,51 @@ class DemoScenarioConfigTest(unittest.TestCase): self.assertEqual(arguments, {"term": "반차"}) + def test_status_result_policy_text_is_preserved_as_answer_evidence(self) -> None: + result = { + "status": "success", + "result": ( + "HR_POLICY_SEARCH_RESULT\n" + "EVIDENCE|file=KR_Leave_Policy.pdf|chunk=13|text=이월 기준" + ), + } + + summary = status_result_summary(result, excerpt_chars=40) + evidence = status_result_evidence(result) + + self.assertEqual(summary["status"], "success") + self.assertGreater(summary["result_chars"], 40) + self.assertIn("KR_Leave_Policy.pdf", evidence["result"]) + self.assertTrue(has_actionable_text_result(result)) + + def test_no_data_text_is_not_actionable(self) -> None: + self.assertFalse( + has_actionable_text_result({"status": "success", "result": "No data found"}) + ) + + @unittest.skipIf(_load_poc4_query_helpers() is None, "Streamlit runtime is optional") + def test_policy_query_does_not_include_demo_user_context(self) -> None: + prepare = _load_poc4_query_helpers() + assert prepare is not None + tool = McpTool( + name="search_hr_policy", + description="Search policy documents", + schema={"properties": {"query": {"type": "string"}}}, + read_only=True, + ) + + query = prepare( + question="연차 휴가 이월 기준과 제한을 알려줘", + tool=tool, + model_profile_key="gpt54_mini_oci", + selected_user_id="E1001", + selected_user_role="HR Team Manager", + selected_user_team="HMM HR Demo Team", + selected_user_scope="팀원 6명 관리", + ) + + self.assertEqual(query, "연차 휴가 이월 기준과 제한을 알려줘") + if __name__ == "__main__": unittest.main()