refs #732: polish HMM report title and response
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -13,6 +13,8 @@ DB를 다시 조회하거나 자연어를 해석하지 않는다.
|
||||
- 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다.
|
||||
- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
|
||||
- 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다.
|
||||
- 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다.
|
||||
- HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다.
|
||||
|
||||
## 전체 흐름
|
||||
|
||||
|
||||
@@ -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는 저장하지 않는다.
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다.
|
||||
4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
|
||||
확인한다.
|
||||
5. 일반 답변에는 `<table>`, `<div>` 또는 Markdown 표가 반복되지 않고, 생성 완료·제목·조회 건수만
|
||||
표시되는지 확인한다.
|
||||
6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다.
|
||||
|
||||
대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
|
||||
인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이
|
||||
|
||||
@@ -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건임
|
||||
|
||||
**원인**: 다음 둘 중 하나다.
|
||||
|
||||
Reference in New Issue
Block a user