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. 일반 답변에는 `