refs #732: add dynamic HMM HTML report MCP tool
This commit is contained in:
@@ -2633,6 +2633,14 @@ def _mcp_response_payload(mcp_result: Any) -> Mapping[str, Any]:
|
||||
if isinstance(mcp_result, Mapping):
|
||||
response = mcp_result.get("response")
|
||||
if isinstance(response, Mapping):
|
||||
nested_result = response.get("result")
|
||||
if isinstance(nested_result, str):
|
||||
try:
|
||||
parsed = json.loads(nested_result)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if isinstance(parsed, Mapping):
|
||||
return parsed
|
||||
return response
|
||||
return mcp_result
|
||||
return {}
|
||||
@@ -2643,6 +2651,19 @@ def _mcp_generated_sql(mcp_result: Any) -> str:
|
||||
return str(payload.get("generatedSql") or payload.get("generated_sql") or "").strip()
|
||||
|
||||
|
||||
def _mcp_rendered_html(mcp_result: Any) -> str:
|
||||
"""Extract an HTML artifact without coupling the UI to a tool name."""
|
||||
|
||||
payload = _mcp_response_payload(mcp_result)
|
||||
candidate = payload.get("html") or payload.get("rendered_html")
|
||||
if not isinstance(candidate, str):
|
||||
return ""
|
||||
html_text = candidate.strip()
|
||||
if len(html_text) > 1_000_000 or "<" not in html_text or ">" not in html_text:
|
||||
return ""
|
||||
return html_text
|
||||
|
||||
|
||||
def _mcp_items(mcp_result: Any) -> list[Any]:
|
||||
payload = _mcp_response_payload(mcp_result)
|
||||
items = payload.get("items")
|
||||
@@ -2692,11 +2713,91 @@ def _agent_tool_catalog(
|
||||
if isinstance(properties, Mapping)
|
||||
else [],
|
||||
"read_only": route.tool.read_only,
|
||||
"consumes_previous_result": _is_presentation_route(route),
|
||||
}
|
||||
)
|
||||
return catalog, routes
|
||||
|
||||
|
||||
def _is_presentation_route(route: RoutedMcpTool) -> bool:
|
||||
"""Identify a renderer from its discovered description and input schema."""
|
||||
|
||||
properties = route.tool.schema.get("properties")
|
||||
names = (
|
||||
[str(name).casefold() for name in properties]
|
||||
if isinstance(properties, Mapping)
|
||||
else []
|
||||
)
|
||||
has_payload_input = any(
|
||||
any(marker in name for marker in ("report", "payload", "render_data"))
|
||||
and any(marker in name for marker in ("json", "data", "payload"))
|
||||
for name in names
|
||||
)
|
||||
description = f"{route.tool.name} {route.tool.description}".casefold()
|
||||
has_presentation_intent = any(
|
||||
marker in description
|
||||
for marker in ("render", "html", "dashboard", "presentation", "report")
|
||||
)
|
||||
has_prior_result_contract = any(
|
||||
marker in description
|
||||
for marker in (
|
||||
"supplied",
|
||||
"already-authorized",
|
||||
"previous result",
|
||||
"input data",
|
||||
"후속 처리",
|
||||
"조회 결과",
|
||||
"선행 결과",
|
||||
)
|
||||
)
|
||||
return has_payload_input and has_presentation_intent and has_prior_result_contract
|
||||
|
||||
|
||||
def _question_requests_presentation(question: str) -> bool:
|
||||
text = str(question or "").casefold()
|
||||
return any(
|
||||
marker in text
|
||||
for marker in (
|
||||
"리포트",
|
||||
"보고서",
|
||||
"대시보드",
|
||||
"차트",
|
||||
"그래프",
|
||||
"html",
|
||||
"화면으로 만들어",
|
||||
"문서로 만들어",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _presentation_route(
|
||||
routes_by_key: Mapping[str, RoutedMcpTool],
|
||||
attempted_route_keys: set[str],
|
||||
) -> tuple[str, RoutedMcpTool] | None:
|
||||
return next(
|
||||
(
|
||||
(key, route)
|
||||
for key, route in routes_by_key.items()
|
||||
if key not in attempted_route_keys and _is_presentation_route(route)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _primary_data_route(
|
||||
routes_by_key: Mapping[str, RoutedMcpTool],
|
||||
attempted_route_keys: set[str],
|
||||
) -> tuple[str, RoutedMcpTool] | None:
|
||||
return next(
|
||||
(
|
||||
(key, route)
|
||||
for key, route in routes_by_key.items()
|
||||
if key not in attempted_route_keys and not _is_presentation_route(route)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _complex_reasoning_model_profile(default_model_profile: str) -> str:
|
||||
configured = (
|
||||
os.environ.get(COMPLEX_REASONING_MODEL_PROFILE_ENV)
|
||||
@@ -2740,6 +2841,120 @@ def _clean_agent_tool_query(value: object, fallback: str) -> str:
|
||||
return " ".join(cleaned).strip() or fallback
|
||||
|
||||
|
||||
def _camel_case_key(value: object) -> str:
|
||||
text = str(value or "")
|
||||
if "_" not in text:
|
||||
return text[:1].lower() + text[1:]
|
||||
parts = [part for part in text.casefold().split("_") if part]
|
||||
return (
|
||||
parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:])
|
||||
if parts
|
||||
else text
|
||||
)
|
||||
|
||||
|
||||
def _normalize_presentation_value(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
_camel_case_key(key): _normalize_presentation_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_normalize_presentation_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _presentation_payload(question: str, steps: list[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
"""Build a renderer payload only from the preceding authorized tool result."""
|
||||
|
||||
source = next(
|
||||
(
|
||||
step
|
||||
for step in reversed(steps)
|
||||
if not bool(step.get("consumes_previous_result"))
|
||||
),
|
||||
{},
|
||||
)
|
||||
raw_result = source.get("mcp_result", {}) if isinstance(source, Mapping) else {}
|
||||
payload = _mcp_response_payload(raw_result)
|
||||
source_rows = _mcp_items(raw_result)
|
||||
if not source_rows:
|
||||
for key in ("results", "rows"):
|
||||
candidate = payload.get(key) if isinstance(payload, Mapping) else None
|
||||
if isinstance(candidate, list):
|
||||
source_rows = candidate
|
||||
break
|
||||
rows = [
|
||||
_normalize_presentation_value(row)
|
||||
for row in source_rows
|
||||
if isinstance(row, Mapping)
|
||||
]
|
||||
requester = re.search(r"\bE\d{4,}\b", str(question or ""), flags=re.IGNORECASE)
|
||||
source_tool = str(source.get("tool_name") or "MCP data query")
|
||||
return {
|
||||
"report": {
|
||||
"id": "MCP-REPORT",
|
||||
"category": "MCP Business Intelligence",
|
||||
"title": str(question or "MCP 결과 리포트"),
|
||||
"question": str(question or ""),
|
||||
"requestedBy": requester.group(0).upper() if requester else "-",
|
||||
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"answer": (
|
||||
f"{source_tool}에서 권한 범위 내 결과 {len(rows)}건을 받아 "
|
||||
"리포트로 구성했습니다."
|
||||
),
|
||||
"execution": {
|
||||
"server": str(source.get("server_id") or ""),
|
||||
"tool": source_tool,
|
||||
"calls": len(steps) + 1,
|
||||
},
|
||||
"evidence": [f"MCP {source_tool} 결과 {len(rows)}건 반환"],
|
||||
"limitation": "표시 값은 선행 MCP 데이터 조회 결과만 사용했습니다.",
|
||||
},
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def _build_mcp_arguments(
|
||||
*,
|
||||
route: RoutedMcpTool,
|
||||
tool_query: str,
|
||||
limit: int,
|
||||
preferred_tool: str,
|
||||
steps: list[Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
if not _is_presentation_route(route):
|
||||
return build_mcp_tool_arguments(
|
||||
route.tool,
|
||||
tool_query,
|
||||
int(limit),
|
||||
preferred_tool=preferred_tool,
|
||||
)
|
||||
properties = route.tool.schema.get("properties")
|
||||
if not isinstance(properties, Mapping):
|
||||
raise McpToolRouterError("렌더링 MCP tool의 입력 스키마가 없습니다.")
|
||||
input_name = next(
|
||||
(
|
||||
str(name)
|
||||
for name in properties
|
||||
if "report" in str(name).casefold()
|
||||
and any(
|
||||
marker in str(name).casefold()
|
||||
for marker in ("json", "payload", "data")
|
||||
)
|
||||
),
|
||||
"",
|
||||
)
|
||||
if not input_name:
|
||||
raise McpToolRouterError("렌더링 MCP tool이 report payload 입력을 선언하지 않았습니다.")
|
||||
return {
|
||||
input_name: json.dumps(
|
||||
_presentation_payload(tool_query, steps),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _mcp_has_actionable_result(mcp_result: Any) -> bool:
|
||||
if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result):
|
||||
return True
|
||||
@@ -3231,6 +3446,29 @@ def plan_mcp_execution_mode(
|
||||
"reason": f"사용자 선택: {override}",
|
||||
"model_profile": "",
|
||||
}
|
||||
presentation_routes = [route for route in routed_tools if _is_presentation_route(route)]
|
||||
if _question_requests_presentation(question) and presentation_routes:
|
||||
data_routes = [route for route in routed_tools if not _is_presentation_route(route)]
|
||||
primary = data_routes[0] if data_routes else fallback
|
||||
if len(data_routes) > 1:
|
||||
try:
|
||||
primary = route_mcp_tool_across_servers_with_llm(
|
||||
data_routes,
|
||||
question,
|
||||
router_model_profile=model_profile_key,
|
||||
)
|
||||
except McpToolRouterError:
|
||||
pass
|
||||
if primary is not fallback or len(routed_tools) > 1:
|
||||
return {
|
||||
"mode": "agent",
|
||||
"route_key": _route_key(primary.server_id, primary.tool.name),
|
||||
"reason": (
|
||||
"리포트·차트·HTML 요청이며, 발견된 렌더링 도구가 이전 구조화 "
|
||||
"결과를 입력으로 선언했습니다. 데이터 조회 후 렌더링을 순차 실행합니다."
|
||||
),
|
||||
"model_profile": model_profile_key,
|
||||
}
|
||||
if len(routed_tools) <= 1:
|
||||
return {
|
||||
"mode": "single",
|
||||
@@ -3276,7 +3514,10 @@ def plan_mcp_execution_mode(
|
||||
"the user's Korean question should be answered with one MCP "
|
||||
"tool call or with a multi-tool ReAct loop. Prefer mode=single "
|
||||
"unless the question explicitly requires comparing, combining, "
|
||||
"or validating evidence across different MCP tools/sources. "
|
||||
"or validating evidence across different MCP tools/sources. A route "
|
||||
"whose consumes_previous_result flag is true is a presentation renderer: "
|
||||
"when the user asks for a report, dashboard, chart, document, or HTML, "
|
||||
"choose mode=agent so a data route runs first and that renderer runs next. "
|
||||
"Return only JSON matching the schema. Never request or expose "
|
||||
"bearer tokens."
|
||||
),
|
||||
@@ -3290,7 +3531,8 @@ def plan_mcp_execution_mode(
|
||||
),
|
||||
"agent_policy": (
|
||||
"use agent only for cross-source comparison, contract "
|
||||
"plus terms/document search, or multi-step validation"
|
||||
"plus terms/document search, multi-step validation, or a "
|
||||
"data-result-to-presentation rendering sequence"
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -3360,6 +3602,10 @@ def _plan_agent_step(
|
||||
"If no tool has been called yet, choose action=call_tool. "
|
||||
"After each observation, decide whether another tool call is needed "
|
||||
"or action=final_answer is enough. Do not expose or request bearer tokens. "
|
||||
"A route with consumes_previous_result=true is a renderer. For a report, "
|
||||
"dashboard, chart, document, or HTML request, call a data route first, "
|
||||
"then call that renderer using the previous structured result; do not "
|
||||
"finish with text only before the renderer has been attempted. "
|
||||
"tool_query must be plain natural-language query text only; do not include "
|
||||
"argument labels such as limit:, prompt:, query:, top_k:, or candidate_k:. "
|
||||
"Do not write SQL. Preserve identifiers exactly. If the user says "
|
||||
@@ -3417,6 +3663,7 @@ def run_mcp_agent_loop(
|
||||
bearer_token: str,
|
||||
limit: int,
|
||||
model_profile_key: str,
|
||||
initial_route_key: str = "",
|
||||
progress_callback: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
tool_catalog, routes_by_key = _agent_tool_catalog(routed_tools)
|
||||
@@ -3431,8 +3678,21 @@ def run_mcp_agent_loop(
|
||||
|
||||
for step_no in range(1, MAX_AGENT_TOOL_STEPS + 1):
|
||||
forced_plan = None
|
||||
wants_presentation = _question_requests_presentation(question)
|
||||
if wants_presentation and _presentation_route(routes_by_key, attempted_route_keys):
|
||||
forced_plan = (
|
||||
(
|
||||
(initial_route_key, routes_by_key[initial_route_key])
|
||||
if not observations
|
||||
and initial_route_key in routes_by_key
|
||||
and not _is_presentation_route(routes_by_key[initial_route_key])
|
||||
else _primary_data_route(routes_by_key, attempted_route_keys)
|
||||
)
|
||||
if not observations
|
||||
else _presentation_route(routes_by_key, attempted_route_keys)
|
||||
)
|
||||
if step_no == 1 and _question_needs_cross_source(question):
|
||||
forced_plan = _select_unvisited_route(
|
||||
forced_plan = forced_plan or _select_unvisited_route(
|
||||
question,
|
||||
routes_by_key,
|
||||
attempted_route_keys,
|
||||
@@ -3522,36 +3782,48 @@ def run_mcp_agent_loop(
|
||||
tool_query = _clean_agent_tool_query(plan.get("tool_query"), question)
|
||||
|
||||
if action == "final_answer" and observations:
|
||||
forced = _select_unvisited_route(
|
||||
question,
|
||||
routes_by_key,
|
||||
attempted_route_keys,
|
||||
renderer = (
|
||||
_presentation_route(routes_by_key, attempted_route_keys)
|
||||
if wants_presentation
|
||||
else None
|
||||
)
|
||||
if forced is None or not _question_needs_cross_source(question):
|
||||
stop_reason = "planner가 추가 MCP 호출이 불필요하다고 판단했습니다."
|
||||
break
|
||||
route_key, route = forced
|
||||
action = "call_tool"
|
||||
tool_query = _fallback_tool_query_for_route(
|
||||
question,
|
||||
route,
|
||||
observations,
|
||||
)
|
||||
thought = (
|
||||
f"{thought} / 비교·약관 질문인데 미호출 MCP route가 있어 "
|
||||
f"{route_key} 호출로 전환합니다."
|
||||
).strip(" /")
|
||||
if renderer is not None:
|
||||
route_key, route = renderer
|
||||
action = "call_tool"
|
||||
tool_query = question
|
||||
thought = "사용자 요청에 따른 이전 구조화 결과의 리포트 렌더링"
|
||||
else:
|
||||
forced = _select_unvisited_route(
|
||||
question,
|
||||
routes_by_key,
|
||||
attempted_route_keys,
|
||||
)
|
||||
if forced is None or not _question_needs_cross_source(question):
|
||||
stop_reason = "planner가 추가 MCP 호출이 불필요하다고 판단했습니다."
|
||||
break
|
||||
route_key, route = forced
|
||||
action = "call_tool"
|
||||
tool_query = _fallback_tool_query_for_route(
|
||||
question,
|
||||
route,
|
||||
observations,
|
||||
)
|
||||
thought = (
|
||||
f"{thought} / 비교·약관 질문인데 미호출 MCP route가 있어 "
|
||||
f"{route_key} 호출로 전환합니다."
|
||||
).strip(" /")
|
||||
if action != "call_tool" and not observations:
|
||||
action = "call_tool"
|
||||
route = routes_by_key.get(route_key)
|
||||
if route is None:
|
||||
route = next(iter(routes_by_key.values()))
|
||||
route_key = _route_key(route.server_id, route.tool.name)
|
||||
tool_query = append_query_contract_guidance(
|
||||
tool_query,
|
||||
original_question=question,
|
||||
tool_name=route.tool.name,
|
||||
)
|
||||
if not _is_presentation_route(route):
|
||||
tool_query = append_query_contract_guidance(
|
||||
tool_query,
|
||||
original_question=question,
|
||||
tool_name=route.tool.name,
|
||||
)
|
||||
is_distinct_vector_query = (
|
||||
_is_vector_route(route)
|
||||
and tool_query not in completed_vector_queries
|
||||
@@ -3576,11 +3848,12 @@ def run_mcp_agent_loop(
|
||||
route,
|
||||
observations,
|
||||
)
|
||||
tool_query = append_query_contract_guidance(
|
||||
tool_query,
|
||||
original_question=question,
|
||||
tool_name=route.tool.name,
|
||||
)
|
||||
if not _is_presentation_route(route):
|
||||
tool_query = append_query_contract_guidance(
|
||||
tool_query,
|
||||
original_question=question,
|
||||
tool_name=route.tool.name,
|
||||
)
|
||||
thought = (
|
||||
f"{thought} / 중복 MCP route {repeated_route_key} 대신 "
|
||||
f"미호출 route {route_key}로 전환합니다."
|
||||
@@ -3590,11 +3863,12 @@ def run_mcp_agent_loop(
|
||||
raise PublicMcpError("선택된 MCP 서버 설정을 찾지 못했습니다.")
|
||||
|
||||
started = perf_counter()
|
||||
arguments = build_mcp_tool_arguments(
|
||||
route.tool,
|
||||
tool_query,
|
||||
int(limit),
|
||||
arguments = _build_mcp_arguments(
|
||||
route=route,
|
||||
tool_query=question if _is_presentation_route(route) else tool_query,
|
||||
limit=int(limit),
|
||||
preferred_tool=server.default_tool,
|
||||
steps=steps,
|
||||
)
|
||||
raw_result = call_tool(
|
||||
base_url=server.endpoint_url,
|
||||
@@ -3618,6 +3892,7 @@ def run_mcp_agent_loop(
|
||||
"mcp_result": mcp_result,
|
||||
"result_summary": summary,
|
||||
"elapsed_seconds": round(elapsed, 3),
|
||||
"consumes_previous_result": _is_presentation_route(route),
|
||||
}
|
||||
steps.append(step)
|
||||
observations.append(
|
||||
@@ -3642,6 +3917,9 @@ def run_mcp_agent_loop(
|
||||
actionable_route_keys.add(route_key)
|
||||
if progress_callback:
|
||||
progress_callback(step)
|
||||
if _is_presentation_route(route):
|
||||
stop_reason = "이전 구조화 결과를 렌더링 MCP tool로 전달했습니다."
|
||||
break
|
||||
|
||||
if last is None:
|
||||
raise PublicMcpError("MCP agent가 실행한 tool 호출이 없습니다.")
|
||||
@@ -3666,6 +3944,11 @@ def _render_mcp_result_sections(
|
||||
execution_events = details.get("execution_events")
|
||||
generated_sql = _mcp_generated_sql(mcp_result)
|
||||
items = _mcp_items(mcp_result)
|
||||
rendered_html = _mcp_rendered_html(mcp_result)
|
||||
|
||||
if rendered_html:
|
||||
st.markdown("#### 생성된 리포트")
|
||||
components.html(rendered_html, height=1420, scrolling=True)
|
||||
|
||||
if isinstance(execution_events, list) and execution_events:
|
||||
with st.expander(f"처리 시간 로그 · {len(execution_events)}건"):
|
||||
@@ -5031,6 +5314,10 @@ def _process_submitted_question(
|
||||
bearer_token=bearer_token,
|
||||
limit=int(limit),
|
||||
model_profile_key=reasoning_model_profile,
|
||||
initial_route_key=_route_key(
|
||||
selected_route.server_id,
|
||||
selected_route.tool.name,
|
||||
),
|
||||
progress_callback=on_agent_step,
|
||||
)
|
||||
selected_server = agent_result["server"]
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HMM | 팀 포트폴리오 선사 실적</title>
|
||||
<style>
|
||||
:root { --hmm:#0065ad; --hmm-deep:#004a84; --ink:#102c43; --copy:#3d5363; --muted:#71818c; --line:#0d78c4; --pale:#edf7fc; --canvas:#f4f5f6; --green:#16815d; --amber:#b66b00; --red:#c5413c; font-family:"Noto Sans KR","Malgun Gothic",Arial,sans-serif; color:var(--ink); }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; min-width:320px; background:var(--canvas); }
|
||||
.page { min-height:100vh; max-width:1480px; margin:0 auto; background:#fff; box-shadow:0 0 40px rgba(12,38,60,.08); }
|
||||
.wordmark { display:flex; align-items:flex-end; gap:9px; font-size:18px; font-weight:900; letter-spacing:-.03em; }
|
||||
.wordmark::before { content:""; width:24px; height:21px; display:inline-block; background:linear-gradient(155deg,transparent 37%,#0571bc 38% 47%,transparent 48%),linear-gradient(26deg,transparent 42%,#0571bc 43% 52%,transparent 53%); border-left:2px solid #0571bc; border-bottom:2px solid #0571bc; }
|
||||
.report-name { margin:24px 0 31px; font-weight:800; font-size:13px; line-height:1.3; }
|
||||
.report-name span { display:block; color:var(--hmm); }
|
||||
.side-tools { padding:12px 0 22px; border-bottom:1px solid #8f969a; color:#056bad; font-size:16px; letter-spacing:6px; }
|
||||
nav { margin-top:22px; } .nav-group { padding:15px 0; border-bottom:1px solid #8f969a; }
|
||||
.nav-group-title { margin:0 0 11px; font-size:12px; font-weight:850; text-transform:uppercase; }
|
||||
.nav-item { display:block; padding:5px 9px; color:#69747b; font-size:11px; line-height:1.3; text-decoration:none; }
|
||||
.nav-item.active { color:#fff; background:linear-gradient(90deg,#0066ae,#52a9d9); font-weight:800; }
|
||||
.side-foot { margin-top:28px; color:#748087; font-size:10px; line-height:1.6; }
|
||||
.content { padding:42px min(6vw,92px) 45px; }
|
||||
.brand-banner { display:flex; align-items:center; justify-content:space-between; gap:18px; padding-bottom:18px; }
|
||||
.brand-banner img { display:block; width:128px; height:auto; }
|
||||
.brand-banner span { color:#627887; font-size:10px; font-weight:750; letter-spacing:.12em; text-transform:uppercase; }
|
||||
.top-line { height:4px; background:#045fa5; margin-bottom:32px; }
|
||||
.report-head { display:flex; justify-content:space-between; align-items:flex-start; gap:28px; padding-bottom:35px; border-bottom:1px solid var(--line); }
|
||||
.kicker { margin:0 0 10px; color:#0070ba; font-size:12px; font-weight:850; letter-spacing:.025em; }
|
||||
h1 { margin:0; color:#075b9f; font-size:clamp(30px,3.25vw,50px); line-height:1.16; letter-spacing:-.07em; font-weight:700; }
|
||||
h1 b { font-weight:850; }
|
||||
.head-meta { min-width:195px; padding-left:22px; border-left:1px solid #96c8e7; color:#647682; font-size:11px; line-height:1.65; }
|
||||
.head-meta strong { display:block; color:#075b9f; font-size:12px; }
|
||||
.intro { padding:18px 0 23px; color:var(--copy); font-size:15px; line-height:1.75; border-bottom:1px solid #8ac5e8; }
|
||||
.section-heading { margin:0 0 16px; color:#005fa9; font-size:21px; letter-spacing:-.05em; }
|
||||
.section-heading small { margin-left:8px; color:#71818c; font-size:11px; letter-spacing:0; font-weight:500; }
|
||||
.executive { display:grid; grid-template-columns:1.45fr 1fr; gap:42px; padding:29px 0 30px; border-bottom:1px solid var(--line); }
|
||||
.metrics { display:grid; grid-template-columns:repeat(2,1fr); border-top:2px solid #1478bb; border-left:1px solid #c8dce9; }
|
||||
.metric { min-height:111px; padding:17px 18px; border-right:1px solid #c8dce9; border-bottom:1px solid #c8dce9; }
|
||||
.metric-label { color:#5b6f7e; font-size:11px; font-weight:700; }.metric-value { margin:8px 0 5px; color:#075b9f; font-size:27px; letter-spacing:-.055em; font-weight:850; font-variant-numeric:tabular-nums; }.metric-value.money { font-size:23px; }.metric-note { color:#75848c; font-size:10px; }
|
||||
.risk-panel { padding:2px 0 0 25px; border-left:1px dotted #1478bb; }.risk-panel h2 { margin:0 0 15px; color:#005fa9; font-size:20px; letter-spacing:-.05em; }
|
||||
.risk-message { margin:0 0 17px; color:var(--copy); font-size:12px; line-height:1.65; }.risk-list { display:grid; gap:9px; }.risk-item { display:flex; align-items:center; gap:10px; padding-bottom:8px; border-bottom:1px solid #d7e8f3; font-size:11px; }.risk-item:last-child { border-bottom:0; }.risk-item-name { flex:1; font-weight:750; }.pill { padding:3px 7px; border-radius:2px; font-size:10px; font-weight:850; }.pill.red { color:var(--red); background:#fff1f0; }.pill.amber { color:var(--amber); background:#fff6e7; }.pill.green { color:var(--green); background:#eaf7f0; }
|
||||
.portfolio { padding:31px 0 28px; border-bottom:1px solid var(--line); }.portfolio-grid { display:grid; grid-template-columns:minmax(0,1.65fr) minmax(260px,.8fr); gap:43px; }.chart-note { margin:-8px 0 22px; color:#73838e; font-size:11px; }.bar-chart { display:grid; gap:12px; }.bar-row { display:grid; grid-template-columns:146px minmax(100px,1fr) 112px; align-items:center; gap:12px; }.bar-person { font-size:11px; }.bar-person b { display:block; color:#134f7f; font-size:12px; }.bar-person span { color:#788791; }.bar-lane { height:24px; background:#ebf2f6; overflow:hidden; }.bar { height:100%; min-width:4px; background:linear-gradient(90deg,#0073bb,#1aa2d8); position:relative; }.bar.critical { background:linear-gradient(90deg,#0073bb 0 84%,#d75349 84%); }.bar-value { color:#15486f; text-align:right; font-size:11px; font-weight:800; font-variant-numeric:tabular-nums; }.bar-value span { display:block; color:#72828c; font-size:9px; font-weight:500; }
|
||||
.chart-aside { padding:14px 0 0 22px; border-left:1px dotted #1478bb; }.chart-aside h3 { margin:0 0 14px; color:#005fa9; font-size:16px; }.legend { display:flex; justify-content:space-between; align-items:end; padding:10px 0; border-bottom:1px solid #d5e4ed; }.legend:last-child { border-bottom:0; }.legend-label { display:flex; align-items:center; gap:8px; font-size:11px; }.dot { width:10px; height:10px; border-radius:50%; }.dot.green { background:var(--green); }.dot.amber { background:var(--amber); }.dot.red { background:var(--red); }.legend strong { color:#075b9f; font-size:20px; }
|
||||
.detail { padding:31px 0 28px; }.detail-head { display:flex; justify-content:space-between; align-items:end; gap:16px; }.detail-head p { margin:0 0 16px; color:#75848c; font-size:11px; }.detail-table { width:100%; border-collapse:collapse; border-top:2px solid #1478bb; }.detail-table th { padding:11px 10px; background:#eff8fd; color:#176aa4; text-align:left; font-size:10px; font-weight:800; }.detail-table td { padding:12px 10px; border-bottom:1px solid #d4e3eb; color:#334f62; font-size:11px; }.detail-table .code { display:block; margin-bottom:2px; color:#75848c; font-size:9px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.detail-table .name { font-weight:750; }.number { text-align:right; font-variant-numeric:tabular-nums; }.negative { color:var(--red)!important; }.empty { padding:35px; color:#77868f; text-align:center; border:1px solid #d5e4ed; }
|
||||
.footnotes { display:grid; grid-template-columns:1.4fr 1fr; gap:25px; padding-top:19px; border-top:1px solid var(--line); color:#697d89; font-size:10px; line-height:1.7; }.footnotes h3 { margin:0 0 5px; color:#075b9f; font-size:11px; }.footnotes p,.footnotes ul { margin:0; padding-left:15px; }.footnotes p { padding-left:0; }
|
||||
@media(max-width:1000px){.content{padding:32px 35px 42px}.executive,.portfolio-grid{gap:25px}.bar-row{grid-template-columns:118px minmax(80px,1fr) 95px}}
|
||||
@media(max-width:760px){.content{padding:24px 18px 35px}.top-line{margin-bottom:20px}.report-head,.executive,.portfolio-grid,.footnotes{grid-template-columns:1fr;display:grid}.report-head{gap:17px;padding-bottom:24px}.head-meta{padding-left:0;border-left:0;border-top:1px solid #96c8e7;padding-top:10px}.intro{font-size:13px}.risk-panel,.chart-aside{padding:24px 0 0;border-left:0;border-top:1px dotted #1478bb}.bar-row{grid-template-columns:105px minmax(30px,1fr) 80px;gap:7px}.detail{overflow-x:auto}.detail-table{min-width:690px}.footnotes{gap:14px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="report-root" class="page" aria-live="polite"></main>
|
||||
<script>
|
||||
// 서버가 이미 권한 검사를 마친 조회 결과 JSON을 이 위치에 주입한다.
|
||||
const reportData = __REPORT_DATA__;
|
||||
const esc=(v)=>String(v??"").replace(/[&<>'"]/g,c=>({"&":"&","<":"<",">":">","'":"'","\"":"""}[c]));
|
||||
const n=(v,d=2)=>new Intl.NumberFormat("en-US",{maximumFractionDigits:d}).format(Number(v||0));
|
||||
const usd=(v)=>`${n(v)} USD`; const pct=(v)=>`${n(v)}%`; const when=(v)=>new Intl.DateTimeFormat("ko-KR",{dateStyle:"medium",timeStyle:"short",timeZone:"Asia/Seoul"}).format(new Date(v));
|
||||
const riskClass=(v)=>String(v||"").toLowerCase(); const riskLabel=(v)=>({GREEN:"정상",AMBER:"주의",RED:"위험"}[v]||v||"미분류");
|
||||
function aggregate(rows){ const risks={GREEN:0,AMBER:0,RED:0}; const people=new Map(); rows.forEach(row=>{const revenue=Number(row.latestRevenueUsd||0); const margin=Number(row.latestGrossMarginUsd||0); risks[row.latestRiskLevel]=(risks[row.latestRiskLevel]||0)+1; if(!people.has(row.employeeCode)) people.set(row.employeeCode,{code:row.employeeCode,name:row.employeeName,revenue:0,carriers:0,hasRisk:false}); const p=people.get(row.employeeCode);p.revenue+=revenue;p.carriers+=1;p.hasRisk||=row.latestRiskLevel==="RED"||row.latestRiskLevel==="AMBER";}); const totalRevenue=rows.reduce((s,r)=>s+Number(r.latestRevenueUsd||0),0); const totalMargin=rows.reduce((s,r)=>s+Number(r.latestGrossMarginUsd||0),0); return {risks,people:[...people.values()].sort((a,b)=>b.revenue-a.revenue),totalRevenue,totalMargin,avgReliability:rows.length?rows.reduce((s,r)=>s+Number(r.latestScheduleReliabilityPct||0),0)/rows.length:0}; }
|
||||
function badge(value){return `<span class="pill ${riskClass(value)}">${esc(riskLabel(value))}</span>`;}
|
||||
function detailRow(row){return `<tr><td><span class="code">${esc(row.employeeCode)}</span><span class="name">${esc(row.employeeName)}</span></td><td><span class="code">${esc(row.carrierCode)}</span><span class="name">${esc(row.carrierName)}</span></td><td class="number">${usd(row.latestRevenueUsd)}</td><td class="number ${Number(row.latestGrossMarginUsd)<0?"negative":""}">${usd(row.latestGrossMarginUsd)}</td><td class="number">${pct(row.latestScheduleReliabilityPct)}</td><td>${badge(row.latestRiskLevel)}</td></tr>`;}
|
||||
function renderHmmCarrierPerformanceReport(payload,target=document.getElementById("report-root")){const report=payload?.report||{};const rows=Array.isArray(payload?.rows)?payload.rows:[];const s=aggregate(rows);const max=Math.max(...s.people.map(p=>p.revenue),1);const watch=rows.filter(r=>r.latestRiskLevel!=="GREEN").sort((a,b)=>a.latestRiskLevel.localeCompare(b.latestRiskLevel));target.innerHTML=`
|
||||
<section class="content"><div class="brand-banner"><img src="https://eu-images.contentstack.com/v3/assets/bltdcfe6aab5515629e/bltaf50776e73f3149f/668ea7b97dc26754645e1830/hmmci.png?width=1400&auto=webp&quality=80&disable=upscale" alt="HMM"><span>HMM Management Report · Internal Demo</span></div><div class="top-line"></div><header class="report-head"><div><p class="kicker">${esc(report.id||"FEDERATION")} · ${esc(report.category||"HMM Business Intelligence")}</p><h1><b>${esc(report.title||"팀 포트폴리오")}</b> — 최신 선사 실적</h1></div><div class="head-meta"><strong>${esc(report.requestedBy||"-")} 팀장 조회</strong>생성 ${esc(when(report.generatedAt))}<br>MCP ${esc(report.execution?.calls||0)}회 호출<br>${esc(report.execution?.tool||"-")}</div></header>
|
||||
<p class="intro">${esc(report.answer||report.question||"조회된 선사 실적입니다.")} 전체 ${n(rows.length,0)}개 담당 선사의 매출·수익성·운항 지표를 담당자별 포트폴리오 관점에서 요약했습니다.</p>
|
||||
<section id="summary" class="executive"><div><h2 class="section-heading">핵심 요약 <small>Latest performance snapshot</small></h2><div class="metrics"><article class="metric"><div class="metric-label">담당 선사</div><div class="metric-value">${n(rows.length,0)}<small>개</small></div><div class="metric-note">팀원 ${n(s.people.length,0)}명 기준</div></article><article class="metric"><div class="metric-label">최신 매출 합계</div><div class="metric-value money">${usd(s.totalRevenue)}</div><div class="metric-note">담당 선사별 최신 기준월 합산</div></article><article class="metric"><div class="metric-label">매출총이익 합계</div><div class="metric-value money ${s.totalMargin<0?"negative":""}">${usd(s.totalMargin)}</div><div class="metric-note">음수 마진 선사 포함</div></article><article class="metric"><div class="metric-label">평균 정시 운항률</div><div class="metric-value">${pct(s.avgReliability)}</div><div class="metric-note">담당 선사 단순 평균</div></article></div></div><aside id="risk" class="risk-panel"><h2>위험 신호</h2><p class="risk-message">주의·위험 등급 선사 ${n(s.risks.AMBER+s.risks.RED,0)}개를 우선 점검 대상으로 표시합니다.</p><div class="risk-list">${watch.length?watch.map(r=>`<div class="risk-item">${badge(r.latestRiskLevel)}<span class="risk-item-name">${esc(r.carrierName)}</span><span>${esc(r.employeeName)}</span></div>`).join(""):'<div class="risk-item">현재 주의·위험 선사가 없습니다.</div>'}</div></aside></section>
|
||||
<section id="portfolio" class="portfolio"><h2 class="section-heading">담당자별 포트폴리오 매출 <small>Latest revenue by employee</small></h2><div class="portfolio-grid"><div><p class="chart-note">각 막대는 담당 선사 최신 매출의 합계입니다. 막대 끝의 색상은 해당 담당자 포트폴리오에 주의·위험 선사가 있는 경우를 나타냅니다.</p><div class="bar-chart">${s.people.map(p=>`<div class="bar-row"><div class="bar-person"><b>${esc(p.name)}</b><span>${esc(p.code)} · ${n(p.carriers,0)}개 선사</span></div><div class="bar-lane"><div class="bar ${p.hasRisk?"critical":""}" style="width:${(p.revenue/max*100).toFixed(2)}%"></div></div><div class="bar-value">${usd(p.revenue)}<span>팀 매출 ${(p.revenue/s.totalRevenue*100).toFixed(1)}%</span></div></div>`).join("")||'<div class="empty">차트 데이터가 없습니다.</div>'}</div></div><aside class="chart-aside"><h3>위험 등급 분포</h3><div class="legend"><span class="legend-label"><i class="dot green"></i>정상</span><strong>${n(s.risks.GREEN,0)}</strong></div><div class="legend"><span class="legend-label"><i class="dot amber"></i>주의</span><strong>${n(s.risks.AMBER,0)}</strong></div><div class="legend"><span class="legend-label"><i class="dot red"></i>위험</span><strong>${n(s.risks.RED,0)}</strong></div></aside></div></section>
|
||||
<section id="detail" class="detail"><div class="detail-head"><h2 class="section-heading">선사별 최신 지표 <small>Carrier detail</small></h2><p>총 ${n(rows.length,0)}건 · 최신순 1~${n(rows.length,0)}건 표시</p></div>${rows.length?`<table class="detail-table"><thead><tr><th>담당자</th><th>선사</th><th class="number">최신 매출</th><th class="number">매출총이익</th><th class="number">정시 운항률</th><th>위험 등급</th></tr></thead><tbody>${rows.map(detailRow).join("")}</tbody></table>`:'<div class="empty">표시할 상세 데이터가 없습니다.</div>'}</section>
|
||||
<footer id="notes" class="footnotes"><section><h3>답변 근거</h3><ul>${(report.evidence||[]).map(e=>`<li>${esc(e)}</li>`).join("")||'<li>제공된 근거가 없습니다.</li>'}</ul></section><section><h3>제약 및 주의</h3><p>${esc(report.limitation||"제약 정보가 제공되지 않았습니다.")}</p></section></footer></section>`;}
|
||||
window.renderHmmCarrierPerformanceReport=renderHmmCarrierPerformanceReport;renderHmmCarrierPerformanceReport(reportData);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,7 +6,7 @@
|
||||
"enabled": true,
|
||||
"provider": "hmm_compat_mcp",
|
||||
"transport": "http",
|
||||
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
|
||||
"endpoint_url": "https://hmm-backoffice.cloud-handson.com/mcp",
|
||||
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||
"timeout_seconds_env": "AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS",
|
||||
"default_tool": "search_hr_data",
|
||||
@@ -15,7 +15,8 @@
|
||||
"search_hr_data",
|
||||
"resolve_hr_term",
|
||||
"search_hr_policy",
|
||||
"search_carrier_performance"
|
||||
"search_carrier_performance",
|
||||
"render_hmm_carrier_report"
|
||||
],
|
||||
"description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server"
|
||||
}
|
||||
|
||||
@@ -123,6 +123,34 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
||||
server["tool_allowlist"],
|
||||
)
|
||||
|
||||
def test_hmm_mcp_allows_dynamic_html_renderer(self) -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
payload = json.loads(
|
||||
(root / "config" / "mcp_servers.json").read_text(encoding="utf-8")
|
||||
)
|
||||
server = next(
|
||||
item for item in payload["servers"] if item["id"] == "hmm_hr_mcp"
|
||||
)
|
||||
source = (root / "app.py").read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(
|
||||
server["endpoint_url"],
|
||||
"https://hmm-backoffice.cloud-handson.com/mcp",
|
||||
)
|
||||
self.assertIn("render_hmm_carrier_report", server["tool_allowlist"])
|
||||
self.assertNotIn('tool.name == "render_hmm_carrier_report"', source)
|
||||
|
||||
def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
|
||||
template = (
|
||||
Path(__file__).parents[1]
|
||||
/ "assets"
|
||||
/ "hmm-carrier-performance-report.html"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(template.count("__REPORT_DATA__"), 1)
|
||||
self.assertNotIn("Bluewave Maritime", template)
|
||||
self.assertNotIn("Southern Cross Marine", template)
|
||||
|
||||
def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None:
|
||||
path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
72
database/adb/83_hmm_carrier_html_report_tool.sql
Normal file
72
database/adb/83_hmm_carrier_html_report_tool.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
-- HMM carrier report template storage and DBMS_CLOUD_AI_AGENT custom tool.
|
||||
-- Run as ADMIN. Load the approved HTML template into HMM_REPORT_TEMPLATES
|
||||
-- through the deployment loader before enabling the MCP tool.
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE ROLLBACK
|
||||
SET DEFINE OFF
|
||||
|
||||
CREATE TABLE hmm_report_templates (
|
||||
template_key VARCHAR2(64) PRIMARY KEY,
|
||||
template_version VARCHAR2(32) NOT NULL,
|
||||
html_template CLOB NOT NULL,
|
||||
active_yn CHAR(1) DEFAULT 'Y' NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
CONSTRAINT hmm_report_templates_active_ck CHECK (active_yn IN ('Y', 'N'))
|
||||
);
|
||||
|
||||
CREATE OR REPLACE PACKAGE hmm_report_render_pkg AUTHID DEFINER AS
|
||||
FUNCTION render_carrier_report(p_report_json IN CLOB) RETURN CLOB;
|
||||
END hmm_report_render_pkg;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE PACKAGE BODY hmm_report_render_pkg AS
|
||||
FUNCTION render_carrier_report(p_report_json IN CLOB) RETURN CLOB IS
|
||||
l_template CLOB;
|
||||
l_data CLOB;
|
||||
l_html VARCHAR2(32767);
|
||||
l_result CLOB;
|
||||
BEGIN
|
||||
IF p_report_json IS NULL OR dbms_lob.getlength(p_report_json) > 64000 THEN
|
||||
raise_application_error(-20101, 'Invalid report payload size.');
|
||||
END IF;
|
||||
IF NOT json_exists(p_report_json, '$.report') OR NOT json_exists(p_report_json, '$.rows') THEN
|
||||
raise_application_error(-20102, 'Report payload requires report and rows.');
|
||||
END IF;
|
||||
SELECT html_template INTO l_template
|
||||
FROM hmm_report_templates
|
||||
WHERE template_key = 'hmm-carrier-performance'
|
||||
AND active_yn = 'Y';
|
||||
l_data := replace(p_report_json, '</', '<\/');
|
||||
l_html := dbms_lob.substr(replace(l_template, '__REPORT_DATA__', l_data), 32767, 1);
|
||||
SELECT json_object(
|
||||
'status' VALUE 'ok',
|
||||
'template' VALUE 'hmm-carrier-performance',
|
||||
'html' VALUE l_html
|
||||
RETURNING CLOB
|
||||
) INTO l_result FROM dual;
|
||||
RETURN l_result;
|
||||
EXCEPTION
|
||||
WHEN no_data_found THEN
|
||||
raise_application_error(-20103, 'Active report template is not installed.');
|
||||
END render_carrier_report;
|
||||
END hmm_report_render_pkg;
|
||||
/
|
||||
|
||||
BEGIN
|
||||
DBMS_CLOUD_AI_AGENT.DROP_TOOL('HMM_CARRIER_REPORT_RENDERER', force => TRUE);
|
||||
DBMS_CLOUD_AI_AGENT.CREATE_TOOL(
|
||||
tool_name => 'HMM_CARRIER_REPORT_RENDERER',
|
||||
attributes => q'~{
|
||||
"instruction": "Render the supplied carrier-performance payload with the approved HMM HTML template. Do not query data and do not alter the supplied values.",
|
||||
"function": "HMM_REPORT_RENDER_PKG.RENDER_CARRIER_REPORT",
|
||||
"tool_inputs": [{"name":"P_REPORT_JSON","description":"Normalized carrier performance report JSON."}]
|
||||
}~',
|
||||
status => 'ENABLED',
|
||||
description => 'Renders approved HMM carrier-performance HTML from already-authorized query results.'
|
||||
);
|
||||
END;
|
||||
/
|
||||
|
||||
SELECT tool_name, status
|
||||
FROM user_ai_agent_tools
|
||||
WHERE tool_name = 'HMM_CARRIER_REPORT_RENDERER';
|
||||
@@ -11,7 +11,7 @@ BACKOFFICE_PRODUCT_DATA_LABEL='HMM HR 데이터'
|
||||
|
||||
BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp'
|
||||
BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice'
|
||||
BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"}]'
|
||||
BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"},{"name":"render_hmm_carrier_report","label":"HMM 선사 실적 HTML 리포트","description":"이미 권한이 적용된 조회 결과를 HMM HTML 리포트로 표현합니다. 데이터를 조회하거나 값을 변경하지 않는 후속 처리 전용 Tool입니다.","argumentName":"reportJson","argumentDescription":"앞 단계의 구조화된 조회 결과와 리포트 메타데이터를 담은 JSON입니다.","executionType":"AGENT_TOOL","targetName":"HMM_CARRIER_REPORT_RENDERER","targetParameterName":"P_REPORT_JSON"}]'
|
||||
|
||||
BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]'
|
||||
|
||||
|
||||
39
docs/design/hmm-html-report-mcp/README.md
Normal file
39
docs/design/hmm-html-report-mcp/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# HMM HTML 리포트 MCP
|
||||
|
||||
## 목적
|
||||
|
||||
선사 실적 Federation 조회의 구조화 결과를 HMM 기업 리포트 HTML로 표현한다. 리포트 도구는
|
||||
DB를 다시 조회하거나 자연어를 해석하지 않는다.
|
||||
|
||||
## 결정사항
|
||||
|
||||
- MCP 도구 `render_hmm_carrier_report`는 `reportJson` 문자열 하나를 입력으로 받는다.
|
||||
- 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다.
|
||||
- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
|
||||
- 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다.
|
||||
|
||||
## 전체 흐름
|
||||
|
||||
```text
|
||||
HMM 포털 → `https://hmm-backoffice.cloud-handson.com/mcp` → 데이터 조회 도구
|
||||
→ 포털의 범용 결과 정규화 → 이전 결과 입력형 렌더링 도구 → HTML 미리보기
|
||||
```
|
||||
|
||||
리포트에 보이는 값은 조회 결과 행에서 계산되므로, 자연어 답변과 별개로 재해석되지 않는다.
|
||||
|
||||
## 문서 지도
|
||||
|
||||
- [아키텍처와 입력 계약](architecture.md)
|
||||
- [적용·검증 절차](cookbook.md)
|
||||
- [문제 해결](troubleshooting.md)
|
||||
|
||||
## 현재 상태
|
||||
|
||||
Oracle DB의 custom Agent Tool과 템플릿 CLOB은 적용됐다. 포털은 리포트·차트·HTML 요청에서
|
||||
도구 이름을 고정하지 않고, 발견한 도구의 설명·입력 스키마를 기준으로 데이터 조회 뒤 렌더링을
|
||||
순차 호출하도록 운영 배포한다.
|
||||
|
||||
2026-08-10 운영 검증에서 `tools/list`, `reportJson` 입력, 64KB 입력 한도, 동적 JSON 매핑과
|
||||
Agent 2단계 실행을 확인했다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며, 호출 시 전달된
|
||||
`report`와 `rows`만 표시한다. 선행 조회가 0건을 반환하면 빈 리포트를 생성하는 것이 정상이며,
|
||||
조회 결과나 권한 설정은 이 기능의 변경 범위가 아니다.
|
||||
72
docs/design/hmm-html-report-mcp/architecture.md
Normal file
72
docs/design/hmm-html-report-mcp/architecture.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 아키텍처와 입력 계약
|
||||
|
||||
## 책임 경계
|
||||
|
||||
| 구성요소 | 책임 | DB 접근 |
|
||||
|---|---|---|
|
||||
| `search_carrier_performance` | Bearer 기반 사용자 식별 및 VPD 적용 선사 조회 | 허용 |
|
||||
| AI 웹 콘솔 | 도구 계약을 판별해 조회 결과를 정규화하고 렌더러를 후속 호출 | 없음 |
|
||||
| `render_hmm_carrier_report` | DB CLOB 템플릿에 허용된 JSON을 매핑 | 템플릿만 읽음 |
|
||||
| 브라우저 | 반환 HTML 표시·다운로드 | 없음 |
|
||||
|
||||
```text
|
||||
HMM portal ──► /mcp (Bearer) ──► Backoffice MCP facade ──► DB Agent Tool
|
||||
│
|
||||
VPD applied rows
|
||||
│
|
||||
Console
|
||||
│
|
||||
report MCP tool call
|
||||
│
|
||||
HMM_REPORT_TEMPLATES
|
||||
│
|
||||
HTML 응답
|
||||
```
|
||||
|
||||
리포트 MCP도 같은 Bearer 인증을 통과해야 하지만, 권한 판단은 조회 MCP에서 끝난다. 리포트
|
||||
입력의 임의 사용자 ID를 신뢰하거나 DB를 재조회하지 않는다.
|
||||
|
||||
## 포털의 범용 순차 호출 규칙
|
||||
|
||||
포털은 `render_hmm_carrier_report`라는 이름을 조건문에 넣지 않는다. 발견한 MCP 도구가 아래
|
||||
계약을 동시에 보이면 **이전 결과 입력형 렌더러**로 분류한다.
|
||||
|
||||
- 입력 스키마에 `reportJson`·`report_payload`처럼 리포트 JSON/payload를 받는 문자열이 있다.
|
||||
- 설명에 HTML/리포트/렌더링 의도와 `조회 결과`, `후속 처리`, `already-authorized`처럼 선행 결과를
|
||||
사용한다는 의도가 있다.
|
||||
|
||||
사용자가 리포트·보고서·대시보드·차트·HTML을 요청하고 이 렌더러가 발견되면, 기존 LLM 라우터는
|
||||
렌더러를 제외한 데이터 도구 중 하나를 먼저 선택한다. 첫 응답의 `items` 또는 `results`만
|
||||
camelCase JSON으로 정규화해 두 번째 도구에 전달한다. 포털은 렌더러의 `html` 응답을 iframe으로
|
||||
표시한다. 이 규칙은 동일 계약을 선언하는 다른 업무 리포트 도구에도 적용된다.
|
||||
|
||||
## `reportJson` 계약
|
||||
|
||||
최상위에는 `report` 객체와 `rows` 배열만 허용한다. `report`에는 `id`, `category`, `title`,
|
||||
`generatedAt`, `requestedBy`, `question`, `answer`, `execution`, `evidence`, `limitation`을
|
||||
넣는다. 행에는 담당자·선사 식별자와 최신 KPI만 넣는다.
|
||||
|
||||
서버는 JSON 크기, 행 수, 문자열 길이, 숫자 형식을 제한하고, 템플릿에 주입할 JSON에서
|
||||
`</script>`를 이스케이프한다. payload는 저장하지 않는다.
|
||||
|
||||
## 배포 도구 계약
|
||||
|
||||
`BACKOFFICE_MCP_TOOLS`에 다음과 같이 등록한다. 실제 환경 변수에는 비밀값을 넣지 않는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "render_hmm_carrier_report",
|
||||
"label": "HMM 선사 실적 HTML 리포트",
|
||||
"description": "VPD 적용 선사 실적 조회 결과를 HMM HTML 리포트로 표현합니다.",
|
||||
"argumentName": "reportJson",
|
||||
"argumentDescription": "정규화된 선사 실적 리포트 JSON입니다.",
|
||||
"executionType": "AGENT_TOOL",
|
||||
"targetName": "HMM_CARRIER_REPORT_RENDERER",
|
||||
"targetParameterName": "P_REPORT_JSON"
|
||||
}
|
||||
```
|
||||
|
||||
`targetName`은 DBMS_CLOUD_AI_AGENT custom tool `HMM_CARRIER_REPORT_RENDERER`다.
|
||||
|
||||
템플릿에는 `const reportData = __REPORT_DATA__;` 자리만 둔다. DB 함수는 호출 시 전달받은 JSON을
|
||||
이 자리에 삽입하고, 조회 Tool을 호출하거나 기본 업무 행을 보충하지 않는다.
|
||||
45
docs/design/hmm-html-report-mcp/cookbook.md
Normal file
45
docs/design/hmm-html-report-mcp/cookbook.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 적용·검증 절차
|
||||
|
||||
## 1. 준비
|
||||
|
||||
- `vpd-backoffice` 배포본에 HTML 템플릿과 HMM CI 리소스가 포함되어야 한다.
|
||||
- 조회 도구 `search_carrier_performance`가 구조화된 `items` 배열을 반환해야 한다.
|
||||
- 배포 환경의 `BACKOFFICE_MCP_TOOLS`에 `AGENT_TOOL` 도구 계약을 추가한다.
|
||||
|
||||
먼저 `database/adb/83_hmm_carrier_html_report_tool.sql`을 실행한 뒤 다음 명령으로 현재
|
||||
승인 템플릿을 CLOB에 적재한다. 스크립트는 템플릿을 UTF-8 Base64로 복원한다.
|
||||
|
||||
```bash
|
||||
./scripts/load-hmm-carrier-report-template.sh
|
||||
```
|
||||
|
||||
## 2. 확인
|
||||
|
||||
1. 동일 Bearer token으로 `tools/list`를 호출한다.
|
||||
2. `render_hmm_carrier_report`와 입력 속성 `reportJson`이 보이는지 확인한다.
|
||||
3. 먼저 `search_carrier_performance`를 호출해 `items`를 얻는다.
|
||||
4. 질문·답변 근거·items를 reportJson으로 구성해 리포트 도구를 호출한다.
|
||||
5. 응답 `response.html`을 새 탭 또는 sandboxed iframe에서 연다.
|
||||
|
||||
성공 판정은 제목, 담당자별 막대 차트, 위험 분포, 상세 표가 `rows`와 일치하는 것이다.
|
||||
템플릿 내부에 `__REPORT_DATA__`가 남지 않고, 검증 payload의 담당자·선사 식별자가 반환 HTML에
|
||||
포함되는지도 확인한다.
|
||||
|
||||
## 3. 포털 순차 실행 확인
|
||||
|
||||
1. 포털 MCP 설정의 endpoint를 `https://hmm-backoffice.cloud-handson.com/mcp`로 설정하고,
|
||||
허용 목록에 조회 도구와 리포트 렌더링 도구를 모두 넣는다.
|
||||
2. 포털에서 예를 들어 `E1001 팀의 선사 최신 실적을 HMM 리포트로 만들어줘`라고 요청한다.
|
||||
3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다.
|
||||
4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
|
||||
확인한다.
|
||||
|
||||
첫 단계가 0건이면 두 번째 단계의 `reportJson.rows`도 빈 배열이어야 한다. 이 경우 순차 실행과
|
||||
HTML 생성은 성공한 것이며, 데이터 조회 문제는 리포트 Tool과 분리해 확인한다.
|
||||
|
||||
실패 시 [문제 해결](troubleshooting.md)의 `텍스트만 답하고 리포트가 생성되지 않음`을 따른다.
|
||||
|
||||
## 4. 롤백
|
||||
|
||||
`BACKOFFICE_MCP_TOOLS`에서 리포트 도구 항목을 제거하고 서비스를 재기동하면 기존 조회 MCP에는
|
||||
영향 없이 리포트 후속 호출만 중단된다. 템플릿은 DB나 사용자 데이터에 변경을 만들지 않는다.
|
||||
50
docs/design/hmm-html-report-mcp/troubleshooting.md
Normal file
50
docs/design/hmm-html-report-mcp/troubleshooting.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 문제 해결
|
||||
|
||||
## 텍스트만 답하고 리포트가 생성되지 않음
|
||||
|
||||
**원인**: 포털이 단일 조회만 실행했거나, MCP discovery 결과에 이전 결과 입력형 렌더러가 없다.
|
||||
|
||||
**확인**:
|
||||
|
||||
1. 포털 설정 endpoint가 `https://hmm-backoffice.cloud-handson.com/mcp`인지 확인한다.
|
||||
2. `tools/list` 결과에 조회 도구와 `reportJson` 입력을 가진 HTML/리포트 렌더러가 모두 있는지 확인한다.
|
||||
3. 포털 실행 상세에서 실행 방식이 `agent`, Agent 단계가 두 번인지 확인한다.
|
||||
|
||||
**해결**: endpoint와 허용 목록을 함께 갱신한 뒤 포털 서비스를 재기동한다. 렌더러 설명에는
|
||||
`조회 결과 후속 처리`처럼 선행 결과를 받는다는 문구를 유지한다.
|
||||
|
||||
**재발 방지**: 새 업무 리포트 도구도 `reportJson`류 입력과 후속 처리 의도를 설명에 선언한다.
|
||||
포털 코드에 특정 도구명을 추가하지 않는다.
|
||||
|
||||
## 렌더러가 입력 형식 오류를 반환함
|
||||
|
||||
**원인**: 조회 도구가 `items`/`results` 배열을 반환하지 않거나, 렌더러가 요구하는 payload 계약과
|
||||
템플릿 계약이 다르다.
|
||||
|
||||
**확인**: 첫 Agent 단계의 응답에 행 배열이 있는지, 두 번째 단계 arguments에 `report`와 `rows`가
|
||||
있는지 확인한다. 비밀값·Bearer token은 실행 상세에 남기지 않는다.
|
||||
|
||||
**해결**: 조회 도구는 구조화 행 배열을 반환하고, 렌더러 함수는 `report`와 `rows` 계약을 유지한다.
|
||||
|
||||
## 생성된 HTML이 화면에 표시되지 않음
|
||||
|
||||
**원인**: 렌더러 응답에 `html` 문자열이 없거나, HTML이 유효하지 않다.
|
||||
|
||||
**확인**: 두 번째 MCP 응답의 `response.html` 존재와 길이를 확인한다.
|
||||
|
||||
**해결**: DB 템플릿 활성 상태와 custom Agent Tool의 반환 형식을 확인한다. 포털은 유효한 `html`
|
||||
또는 `rendered_html`만 sandboxed iframe으로 표시한다.
|
||||
|
||||
## 리포트는 생성됐지만 행이 0건임
|
||||
|
||||
**원인**: 렌더러 앞에서 실행된 데이터 조회 Tool이 0건을 반환했다. 렌더러는 없는 행을 만들거나
|
||||
기본 샘플을 채우지 않는다.
|
||||
|
||||
**확인**: Agent 첫 단계의 결과 건수와 두 번째 단계 `reportJson.rows`를 비교한다. 둘 다 0건이면
|
||||
HTML 생성 경로는 정상이다.
|
||||
|
||||
**해결**: 같은 사용자와 질문으로 데이터 조회 Tool만 별도 호출해 권한 범위와 조회 결과를
|
||||
확인한다. HTML 템플릿, renderer 함수 또는 Agent 순차 실행 규칙은 변경하지 않는다.
|
||||
|
||||
**재발 방지**: HTML Tool 검증에는 별도의 합성 payload를 사용하고, 데이터 조회 검증과 판정을
|
||||
분리한다.
|
||||
42
scripts/load-hmm-carrier-report-template.sh
Executable file
42
scripts/load-hmm-carrier-report-template.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Load the approved UTF-8 HTML template into Oracle without SQLcl literal mojibake.
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="${HMM_REPORT_ENV_FILE:-$ROOT/.env}"
|
||||
TEMPLATE_FILE="${HMM_REPORT_TEMPLATE_FILE:-$ROOT/ai-web-agent-console/assets/hmm-carrier-performance-report.html}"
|
||||
WALLET_DIR="${HMM_REPORT_WALLET_DIR:-/Users/joungminko/devkit/db_conn/Wallet_HMMAIPOC}"
|
||||
[[ -f "$ENV_FILE" ]] && { set -a; . "$ENV_FILE"; set +a; }
|
||||
SQLCL_BIN="${SQLCL_BIN:-$(command -v sql || true)}"
|
||||
DB_USER="${BACKOFFICE_DB_USERNAME:-${ADB_USER:-}}"
|
||||
DB_PASSWORD="${BACKOFFICE_DB_PASSWORD:-${ADB_PASSWORD:-}}"
|
||||
DB_TNS="${ADB_TNS:-}"
|
||||
DB_URL="${BACKOFFICE_DB_URL:-}"
|
||||
if [[ -z "$DB_TNS" && "$DB_URL" =~ ^jdbc:oracle:thin:@([^?]+) ]]; then
|
||||
DB_TNS="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
[[ -x "$SQLCL_BIN" && -f "$TEMPLATE_FILE" && -d "$WALLET_DIR" && -n "$DB_USER" && -n "$DB_PASSWORD" && -n "$DB_TNS" ]] || {
|
||||
echo "SQLCL_BIN, DB credentials, ADB_TNS, and template file are required." >&2; exit 1;
|
||||
}
|
||||
REPORT_B64="$(base64 < "$TEMPLATE_FILE" | tr -d '\n')"
|
||||
[[ ${#REPORT_B64} -le 30000 ]] || { echo "Template is too large for the single-chunk loader." >&2; exit 1; }
|
||||
"$SQLCL_BIN" -thin -tnsadmin "$WALLET_DIR" -s "$DB_USER/$DB_PASSWORD@$DB_TNS" <<SQL
|
||||
WHENEVER SQLERROR EXIT SQL.SQLCODE ROLLBACK
|
||||
DECLARE
|
||||
l_html CLOB := to_clob(utl_i18n.raw_to_char(
|
||||
utl_encode.base64_decode(utl_raw.cast_to_raw('$REPORT_B64')), 'AL32UTF8'));
|
||||
BEGIN
|
||||
MERGE INTO hmm_report_templates dst
|
||||
USING (SELECT 'hmm-carrier-performance' template_key FROM dual) src
|
||||
ON (dst.template_key = src.template_key)
|
||||
WHEN MATCHED THEN UPDATE SET html_template = l_html, template_version = '1.0.0',
|
||||
active_yn = 'Y', updated_at = SYSTIMESTAMP
|
||||
WHEN NOT MATCHED THEN INSERT (template_key, template_version, html_template, active_yn)
|
||||
VALUES ('hmm-carrier-performance', '1.0.0', l_html, 'Y');
|
||||
COMMIT;
|
||||
END;
|
||||
/
|
||||
SELECT template_key, template_version, active_yn, dbms_lob.getlength(html_template) html_chars
|
||||
FROM hmm_report_templates WHERE template_key = 'hmm-carrier-performance';
|
||||
EXIT
|
||||
SQL
|
||||
@@ -122,7 +122,7 @@ public class McpSseService {
|
||||
ObjectNode argument = objectMapper.createObjectNode();
|
||||
argument.put("type", "string");
|
||||
argument.put("description", tool.argumentDescription());
|
||||
argument.put("maxLength", 4000);
|
||||
argument.put("maxLength", 64000);
|
||||
properties.set(tool.argumentName(), argument);
|
||||
schema.set("properties", properties);
|
||||
ArrayNode required = objectMapper.createArrayNode();
|
||||
|
||||
Reference in New Issue
Block a user