From 66fcb5d149e56014506201e4f0139bfecba3146d Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 10 Aug 2026 16:06:17 +0900 Subject: [PATCH] refs #732: polish HMM report title and response --- ai-web-agent-console/app.py | 167 +++++++++++++++++- ai-web-agent-console/tests/test_scenarios.py | 37 ++++ docs/design/hmm-html-report-mcp/README.md | 2 + .../hmm-html-report-mcp/architecture.md | 18 ++ docs/design/hmm-html-report-mcp/cookbook.md | 3 + .../hmm-html-report-mcp/troubleshooting.md | 25 +++ 6 files changed, 247 insertions(+), 5 deletions(-) diff --git a/ai-web-agent-console/app.py b/ai-web-agent-console/app.py index 1bbbc09..4d9da9e 100644 --- a/ai-web-agent-console/app.py +++ b/ai-web-agent-console/app.py @@ -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, diff --git a/ai-web-agent-console/tests/test_scenarios.py b/ai-web-agent-console/tests/test_scenarios.py index 2ab9c62..63b6b8a 100644 --- a/ai-web-agent-console/tests/test_scenarios.py +++ b/ai-web-agent-console/tests/test_scenarios.py @@ -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"]( + "E1001 팀 포트폴리오" + ) + + 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] diff --git a/docs/design/hmm-html-report-mcp/README.md b/docs/design/hmm-html-report-mcp/README.md index ec61e43..ae81734 100644 --- a/docs/design/hmm-html-report-mcp/README.md +++ b/docs/design/hmm-html-report-mcp/README.md @@ -13,6 +13,8 @@ DB를 다시 조회하거나 자연어를 해석하지 않는다. - 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다. - 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다. - 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다. +- 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다. +- HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다. ## 전체 흐름 diff --git a/docs/design/hmm-html-report-mcp/architecture.md b/docs/design/hmm-html-report-mcp/architecture.md index f077d53..1dec743 100644 --- a/docs/design/hmm-html-report-mcp/architecture.md +++ b/docs/design/hmm-html-report-mcp/architecture.md @@ -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에서 ``를 이스케이프한다. payload는 저장하지 않는다. diff --git a/docs/design/hmm-html-report-mcp/cookbook.md b/docs/design/hmm-html-report-mcp/cookbook.md index c496eb5..76f32c9 100644 --- a/docs/design/hmm-html-report-mcp/cookbook.md +++ b/docs/design/hmm-html-report-mcp/cookbook.md @@ -34,6 +34,9 @@ 3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다. 4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지 확인한다. +5. 일반 답변에는 ``, `
` 또는 Markdown 표가 반복되지 않고, 생성 완료·제목·조회 건수만 + 표시되는지 확인한다. +6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다. 대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool 인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이 diff --git a/docs/design/hmm-html-report-mcp/troubleshooting.md b/docs/design/hmm-html-report-mcp/troubleshooting.md index 905998f..55efe42 100644 --- a/docs/design/hmm-html-report-mcp/troubleshooting.md +++ b/docs/design/hmm-html-report-mcp/troubleshooting.md @@ -35,6 +35,31 @@ **해결**: DB 템플릿 활성 상태와 custom Agent Tool의 반환 형식을 확인한다. 포털은 유효한 `html` 또는 `rendered_html`만 sandboxed iframe으로 표시한다. +## 일반 답변에 HTML 표가 반복되거나 글자가 잘 보이지 않음 + +**원인**: 최종 답변 모델이 사용자의 `HTML로 보여줘`를 artifact 생성이 아니라 답변 본문 형식 +요청으로 해석해 `
` 또는 Markdown 표를 다시 작성했다. + +**확인**: MCP 마지막 응답에 유효한 `html`이 있으면서 저장된 `answer`에도 HTML 태그나 표 행이 +있는지 확인한다. + +**해결**: 최종 답변 지침에 HTML artifact 우선 규칙을 적용하고, 출력 후 검사에서 일반 답변을 +생성 완료·제목·조회 건수 안내로 정규화한다. iframe CSS나 조회 Tool은 변경하지 않는다. + +**재발 방지**: renderer가 반환된 시나리오에서 일반 답변에 `
`과 Markdown 표가 없는지 +회귀 테스트한다. + +## 리포트 제목에 질문 전체 문장이 표시됨 + +**원인**: `report.title`에 사용자 질문을 그대로 복사했다. + +**확인**: renderer 호출의 `reportJson.report.title`과 `question`이 완전히 같은지 확인한다. + +**해결**: 제목 생성 지침으로 출력 형식·행동 문구를 제거한 짧은 명사형 제목을 만들고, +`report.question`에는 원문을 유지한다. + +**재발 방지**: 제목과 원문 질문이 역할상 분리되고 제목 길이 제한이 적용되는지 검증한다. + ## 리포트는 생성됐지만 행이 0건임 **원인**: 다음 둘 중 하나다.