refs #699: preserve HMM policy MCP evidence
This commit is contained in:
@@ -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)`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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; }}
|
||||
|
||||
80
poc4_active_source_20260714/src/mcp_result.py
Normal file
80
poc4_active_source_20260714/src/mcp_result.py
Normal 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",
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user