refs #732: polish HMM report title and response

This commit is contained in:
devmrko
2026-08-10 16:06:17 +09:00
parent b13b913939
commit 66fcb5d149
6 changed files with 247 additions and 5 deletions

View File

@@ -2886,7 +2886,71 @@ def _normalize_presentation_value(value: Any) -> Any:
return value
def _presentation_payload(question: str, steps: list[Mapping[str, Any]]) -> dict[str, Any]:
def _clean_presentation_title(value: Any) -> str:
text = html.unescape(re.sub(r"<[^>]+>", " ", str(value or "")))
text = " ".join(text.split()).strip(" \t\r\n\"'`-–—:;,.!?·")
if len(text) > 48:
text = text[:47].rstrip() + ""
return text or "업무 현황"
def _plan_presentation_title(
*,
question: str,
source_step: Mapping[str, Any],
model_profile_key: str,
) -> str:
"""Generate a concise business title without coupling to a specific report."""
fallback = "업무 현황"
try:
profile = resolve_model_profile(model_profile_key)
client = build_oci_genai_completion_client(
profile.model_id,
profile.answer_model_region,
profile.answer_model_endpoint,
)
text = client.complete(
system_prompt=(
"Create one concise Korean enterprise report heading. Preserve the "
"business subject, team/user scope, and identifiers. Remove output-format "
"and action phrases such as HTML, report, dashboard, 보여줘, 만들어줘. "
"Use a noun phrase, not a sentence. The template separately appends a fixed "
"subtitle, so do not include '최신 선사 실적'. Return only schema JSON."
),
user_prompt=json.dumps(
{
"question": question,
"source_tool": str(source_step.get("tool_name") or ""),
"source_rows": len(
_mcp_structured_rows(source_step.get("mcp_result", {}))
),
},
ensure_ascii=False,
),
response_schema={
"type": "object",
"additionalProperties": False,
"required": ["title"],
"properties": {"title": {"type": "string"}},
},
max_tokens=120,
temperature=temperature_for_model_profile(profile),
)
parsed = json.loads(text)
if isinstance(parsed, Mapping):
return _clean_presentation_title(parsed.get("title"))
except Exception:
pass
return fallback
def _presentation_payload(
question: str,
steps: list[Mapping[str, Any]],
*,
title: str = "",
) -> dict[str, Any]:
"""Build a renderer payload only from the preceding authorized tool result."""
source = next(
@@ -2910,7 +2974,7 @@ def _presentation_payload(question: str, steps: list[Mapping[str, Any]]) -> dict
"report": {
"id": "MCP-REPORT",
"category": "MCP Business Intelligence",
"title": str(question or "MCP 결과 리포트"),
"title": _clean_presentation_title(title),
"question": str(question or ""),
"requestedBy": requester.group(0).upper() if requester else "-",
"generatedAt": datetime.now(timezone.utc).isoformat(),
@@ -2937,6 +3001,7 @@ def _build_mcp_arguments(
limit: int,
preferred_tool: str,
steps: list[Mapping[str, Any]],
presentation_title: str = "",
) -> dict[str, Any]:
if not _is_presentation_route(route):
return build_mcp_tool_arguments(
@@ -2964,12 +3029,63 @@ def _build_mcp_arguments(
raise McpToolRouterError("렌더링 MCP tool이 report payload 입력을 선언하지 않았습니다.")
return {
input_name: json.dumps(
_presentation_payload(tool_query, steps),
_presentation_payload(
tool_query,
steps,
title=presentation_title,
),
ensure_ascii=False,
)
}
def _presentation_artifact_summary(
mcp_result: Any,
agent_steps: list[Mapping[str, Any]] | None,
) -> dict[str, Any]:
if not _mcp_rendered_html(mcp_result):
return {}
title = "업무 현황"
row_count = 0
for step in reversed(agent_steps or []):
if not isinstance(step, Mapping):
continue
if not bool(step.get("consumes_previous_result")):
row_count = len(_mcp_structured_rows(step.get("mcp_result", {})))
continue
arguments = step.get("arguments")
if not isinstance(arguments, Mapping):
continue
for raw_payload in arguments.values():
if not isinstance(raw_payload, str):
continue
try:
parsed = json.loads(raw_payload)
except ValueError:
continue
report = parsed.get("report") if isinstance(parsed, Mapping) else None
if isinstance(report, Mapping):
title = _clean_presentation_title(report.get("title"))
rows = parsed.get("rows")
if isinstance(rows, list):
row_count = len(rows)
break
return {"title": title, "row_count": row_count}
def _presentation_completion_answer(
mcp_result: Any,
agent_steps: list[Mapping[str, Any]] | None,
) -> str:
artifact = _presentation_artifact_summary(mcp_result, agent_steps)
if not artifact:
return ""
return (
f"**{artifact['title']}** 리포트를 생성했습니다. "
f"아래 생성된 리포트에서 조회 결과 {artifact['row_count']}건을 확인할 수 있습니다."
)
def _mcp_has_actionable_result(mcp_result: Any) -> bool:
if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result):
return True
@@ -3878,12 +3994,28 @@ def run_mcp_agent_loop(
raise PublicMcpError("선택된 MCP 서버 설정을 찾지 못했습니다.")
started = perf_counter()
presentation_title = ""
if _is_presentation_route(route):
source_step = next(
(
step
for step in reversed(steps)
if not bool(step.get("consumes_previous_result"))
),
{},
)
presentation_title = _plan_presentation_title(
question=question,
source_step=source_step,
model_profile_key=model_profile_key,
)
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,
presentation_title=presentation_title,
)
raw_result = call_tool(
base_url=server.endpoint_url,
@@ -4526,6 +4658,10 @@ def synthesize_answer(
}
for step in (agent_steps or [])
],
"presentation_artifact": _presentation_artifact_summary(
mcp_result,
agent_steps,
),
}
return json.dumps(prompt_payload, ensure_ascii=False)
@@ -4609,7 +4745,11 @@ def synthesize_answer(
"Show audit_log_id when it is provided. Follow every required_answer_checks item in the user "
"payload. When the user requests a result list, include every "
"row provided in the MCP evidence, up to 20 rows, and state the total "
"returned row count. Do not arbitrarily stop at five rows. Keep the "
"returned row count. Do not arbitrarily stop at five rows. If "
"presentation_artifact is present, the HTML artifact is the final visual output: "
"do not reproduce HTML tags, Markdown tables, or individual data rows in answer. "
"Only confirm the artifact title and row count and direct the user to the rendered "
"report displayed below. Keep the "
"answer concise and business-readable."
)
for attempt in range(1, 3):
@@ -4909,8 +5049,18 @@ def _render_assistant_message(
message: Mapping[str, Any],
message_key: str,
) -> None:
st.markdown(str(message.get("content") or ""))
details = message.get("details")
content = str(message.get("content") or "")
if isinstance(details, Mapping):
presentation_answer = _presentation_completion_answer(
details.get("mcp_result", {}),
details.get("agent_steps")
if isinstance(details.get("agent_steps"), list)
else None,
)
if presentation_answer:
content = presentation_answer
st.markdown(content)
basis = message.get("basis")
if isinstance(basis, list) and basis:
display_records = _display_markdown_records(
@@ -5647,6 +5797,13 @@ def _process_submitted_question(
}
refresh_progress(96, "원본 MCP 응답으로 결과를 구성했습니다.")
presentation_answer = _presentation_completion_answer(
answer_source,
agent_steps,
)
if presentation_answer:
assistant_message["content"] = presentation_answer
pre_save_elapsed = perf_counter() - process_started
record_execution_event(
percent=98,

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
import ast
import html
import json
from pathlib import Path
import re
import tempfile
from typing import Any, Mapping
import unittest
@@ -174,6 +176,41 @@ class DemoScenarioConfigTest(unittest.TestCase):
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["CARRIER_CODE"], "C901")
def test_hmm_report_title_and_answer_follow_presentation_contract(self) -> None:
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
tree = ast.parse(source)
helper = next(
node
for node in tree.body
if isinstance(node, ast.FunctionDef)
and node.name == "_clean_presentation_title"
)
namespace: dict[str, Any] = {
"Any": Any,
"html": html,
"re": re,
}
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
title = namespace["_clean_presentation_title"](
"<b>E1001 팀 포트폴리오</b>"
)
self.assertEqual(title, "E1001 팀 포트폴리오")
self.assertIn('"title": _clean_presentation_title(title)', source)
self.assertIn(
'assistant_message["content"] = presentation_answer',
source,
)
self.assertIn(
"presentation_answer = _presentation_completion_answer(",
source,
)
self.assertIn(
"do not reproduce HTML tags, Markdown tables",
source,
)
def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
template = (
Path(__file__).parents[1]

View File

@@ -13,6 +13,8 @@ DB를 다시 조회하거나 자연어를 해석하지 않는다.
- 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다.
- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
- 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다.
- 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다.
- HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다.
## 전체 흐름

View File

@@ -54,6 +54,24 @@ Tool 이름, 사용자 코드나 예상 행 수를 조건으로 사용하지 않
`generatedAt`, `requestedBy`, `question`, `answer`, `execution`, `evidence`, `limitation`
넣는다. 행에는 담당자·선사 식별자와 최신 KPI만 넣는다.
`report.title`은 사용자 질문 원문이 아니다. 포털이 모델에 다음 제목 계약을 지시해 만든 짧은
업무 제목이다.
- `HTML로 보여줘`, `리포트로 만들어줘`와 같은 출력 형식·행동 문구는 제거한다.
- 사용자·팀·업무 대상처럼 범위를 구분하는 식별자는 유지한다.
- 문장형 답변이 아니라 화면 머리글에 맞는 명사형 제목으로 만든다.
- 템플릿이 붙이는 고정 부제와 같은 문구를 반복하지 않는다.
제목 생성이 실패하면 전체 질문을 제목으로 사용하지 않고 짧은 일반 업무 제목으로 안전하게
대체한다. 이 규칙은 특정 사용자 코드나 조회 행 수를 조건으로 사용하지 않는다.
## 포털 표시 계약
renderer 응답에 유효한 `html` 또는 `rendered_html`이 있으면 HTML artifact가 최종 표현물이다.
포털의 일반 답변 생성 지침은 HTML 태그, Markdown 표, 업무 행 전체를 다시 만들지 않고 제목과
조회 건수, 아래 리포트 확인 안내만 반환한다. 포털은 같은 조건을 출력 후에도 검사해 모델이
HTML을 반환하더라도 안전한 짧은 안내문으로 정규화한다.
서버는 JSON 크기, 행 수, 문자열 길이, 숫자 형식을 제한하고, 템플릿에 주입할 JSON에서
`</script>`를 이스케이프한다. payload는 저장하지 않는다.

View File

@@ -34,6 +34,9 @@
3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다.
4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
확인한다.
5. 일반 답변에는 `<table>`, `<div>` 또는 Markdown 표가 반복되지 않고, 생성 완료·제목·조회 건수만
표시되는지 확인한다.
6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다.
대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이

View File

@@ -35,6 +35,31 @@
**해결**: DB 템플릿 활성 상태와 custom Agent Tool의 반환 형식을 확인한다. 포털은 유효한 `html`
또는 `rendered_html`만 sandboxed iframe으로 표시한다.
## 일반 답변에 HTML 표가 반복되거나 글자가 잘 보이지 않음
**원인**: 최종 답변 모델이 사용자의 `HTML로 보여줘`를 artifact 생성이 아니라 답변 본문 형식
요청으로 해석해 `<table>` 또는 Markdown 표를 다시 작성했다.
**확인**: MCP 마지막 응답에 유효한 `html`이 있으면서 저장된 `answer`에도 HTML 태그나 표 행이
있는지 확인한다.
**해결**: 최종 답변 지침에 HTML artifact 우선 규칙을 적용하고, 출력 후 검사에서 일반 답변을
생성 완료·제목·조회 건수 안내로 정규화한다. iframe CSS나 조회 Tool은 변경하지 않는다.
**재발 방지**: renderer가 반환된 시나리오에서 일반 답변에 `<table>`과 Markdown 표가 없는지
회귀 테스트한다.
## 리포트 제목에 질문 전체 문장이 표시됨
**원인**: `report.title`에 사용자 질문을 그대로 복사했다.
**확인**: renderer 호출의 `reportJson.report.title``question`이 완전히 같은지 확인한다.
**해결**: 제목 생성 지침으로 출력 형식·행동 문구를 제거한 짧은 명사형 제목을 만들고,
`report.question`에는 원문을 유지한다.
**재발 방지**: 제목과 원문 질문이 역할상 분리되고 제목 길이 제한이 적용되는지 검증한다.
## 리포트는 생성됐지만 행이 0건임
**원인**: 다음 둘 중 하나다.