refs #732: separate report data and rendering requests

This commit is contained in:
devmrko
2026-08-10 16:15:47 +09:00
parent 66fcb5d149
commit 2f5fc2bfbd
6 changed files with 170 additions and 0 deletions

View File

@@ -2894,6 +2894,104 @@ def _clean_presentation_title(value: Any) -> str:
return text or "업무 현황"
def _fallback_presentation_data_query(question: str) -> str:
text = " ".join(str(question or "").split()).strip()
text = re.sub(
r"\s*(?:그리고\s*)?(?:이걸|이를|그\s*결과를|결과를)\s*"
r"(?:HMM\s*)?(?:HTML|리포트|보고서|대시보드|차트)\s*"
r"(?:형식)?(?:으로|로)?\s*(?:만들어|생성해|작성해|보여)\s*"
r"(?:줘|주세요)?\s*[.!?]?\s*$",
"",
text,
flags=re.IGNORECASE,
)
text = re.sub(
r"(?i)HTML\s*(?:형식)?(?:으로|로)?\s*",
"",
text,
)
text = re.sub(
r"(?:HMM\s*)?(?:리포트|보고서|대시보드|차트)\s*"
r"(?:형식)?(?:으로|로)?\s*(?:만들어|생성해|작성해)\s*"
r"(?:줘|주세요)?",
"보여줘",
text,
flags=re.IGNORECASE,
)
return " ".join(text.split()).strip() or str(question or "").strip()
def _plan_presentation_data_query(
*,
question: str,
route: RoutedMcpTool,
model_profile_key: str,
) -> str:
"""Separate the data request from its presentation-format request."""
fallback = _fallback_presentation_data_query(question)
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=(
"Rewrite the Korean request as a data-retrieval question for the selected "
"MCP tool. Preserve every business subject, identifier, filter, period, and "
"requested metric. Remove only presentation-format and artifact-generation "
"instructions such as HTML, report, dashboard, chart, render, 보여주는 형식, "
"or 만들어줘. Do not answer the question, write SQL, or request a token. "
"The rewritten query must ask for ordinary structured data rows, never HTML "
"tags, Markdown, or preformatted table rows. Return only schema JSON."
),
user_prompt=json.dumps(
{
"question": question,
"selected_tool": route.tool.name,
"tool_description": route.tool.description[:1200],
},
ensure_ascii=False,
),
response_schema={
"type": "object",
"additionalProperties": False,
"required": ["data_query"],
"properties": {"data_query": {"type": "string"}},
},
max_tokens=300,
temperature=temperature_for_model_profile(profile),
)
parsed = json.loads(text)
if isinstance(parsed, Mapping):
planned = _clean_agent_tool_query(parsed.get("data_query"), fallback)
return _fallback_presentation_data_query(planned or fallback)
except Exception:
pass
return fallback
def _mcp_rows_contain_presentation_markup(value: Any) -> bool:
rows = _mcp_structured_rows(value)
if not rows:
return False
for row in rows:
if not isinstance(row, Mapping):
continue
keys = {str(key).casefold() for key in row}
if keys & {"htmlrow", "html", "markdown"}:
return True
if any(
isinstance(item, str)
and re.search(r"<(?:tr|td|table|div)\b", item, flags=re.IGNORECASE)
for item in row.values()
):
return True
return False
def _plan_presentation_title(
*,
question: str,
@@ -3995,6 +4093,12 @@ def run_mcp_agent_loop(
started = perf_counter()
presentation_title = ""
if wants_presentation and not observations and not _is_presentation_route(route):
tool_query = _plan_presentation_data_query(
question=question,
route=route,
model_profile_key=model_profile_key,
)
if _is_presentation_route(route):
source_step = next(
(
@@ -4025,6 +4129,14 @@ def run_mcp_agent_loop(
)
parsed = _content_text_json(raw_result)
mcp_result = parsed if parsed is not None else raw_result
if (
wants_presentation
and not _is_presentation_route(route)
and _mcp_rows_contain_presentation_markup(mcp_result)
):
raise PublicMcpError(
"데이터 조회 결과가 구조화 행이 아니라 표현용 마크업으로 반환됐습니다."
)
elapsed = perf_counter() - started
summary = _mcp_summary(mcp_result)
step = {

View File

@@ -211,6 +211,30 @@ class DemoScenarioConfigTest(unittest.TestCase):
source,
)
def test_hmm_report_data_query_drops_html_format_request(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 == "_fallback_presentation_data_query"
)
namespace: dict[str, Any] = {"re": re}
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
query = namespace["_fallback_presentation_data_query"](
"E1001 팀장의 담당 선사 최신 매출, 매출총이익, 정시 운항률, "
"위험 등급을 HTML로 보여줘"
)
self.assertNotIn("HTML", query.upper())
self.assertIn("E1001", query)
self.assertIn("매출총이익", query)
self.assertIn("정시 운항률", query)
self.assertIn("위험 등급", query)
self.assertIn("_mcp_rows_contain_presentation_markup(mcp_result)", source)
def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
template = (
Path(__file__).parents[1]