refs #699: preserve HMM policy MCP evidence
This commit is contained in:
@@ -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