refs #699: preserve HMM policy MCP evidence

This commit is contained in:
devmrko
2026-07-23 09:40:09 +09:00
parent 62c5fb518c
commit eb5105fe72
5 changed files with 192 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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