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 "업무 현황" 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( def _plan_presentation_title(
*, *,
question: str, question: str,
@@ -3995,6 +4093,12 @@ def run_mcp_agent_loop(
started = perf_counter() started = perf_counter()
presentation_title = "" 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): if _is_presentation_route(route):
source_step = next( source_step = next(
( (
@@ -4025,6 +4129,14 @@ def run_mcp_agent_loop(
) )
parsed = _content_text_json(raw_result) parsed = _content_text_json(raw_result)
mcp_result = parsed if parsed is not None else 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 elapsed = perf_counter() - started
summary = _mcp_summary(mcp_result) summary = _mcp_summary(mcp_result)
step = { step = {

View File

@@ -211,6 +211,30 @@ class DemoScenarioConfigTest(unittest.TestCase):
source, 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: def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
template = ( template = (
Path(__file__).parents[1] Path(__file__).parents[1]

View File

@@ -15,6 +15,7 @@ DB를 다시 조회하거나 자연어를 해석하지 않는다.
- 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다. - 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다.
- 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다. - 제목은 질문 전체 문장을 복사하지 않는다. 포털의 제목 생성 지침이 요청 대상과 업무 범위만 남긴 짧은 보고서 제목을 만들고 renderer에 전달한다.
- HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다. - HTML artifact가 반환되면 일반 답변은 HTML 표나 코드를 반복하지 않고 생성 완료와 조회 건수만 안내한다. 실제 표현은 `생성된 리포트` 영역 하나에서 담당한다.
- 포털은 사용자 질문을 그대로 두 Tool에 재사용하지 않는다. 첫 조회에는 데이터 조건만 남긴 질문을 전달하고, `HTML로 보여줘` 같은 표현 요청은 renderer 선택과 제목 생성에만 사용한다.
## 전체 흐름 ## 전체 흐름

View File

@@ -72,6 +72,22 @@ renderer 응답에 유효한 `html` 또는 `rendered_html`이 있으면 HTML art
조회 건수, 아래 리포트 확인 안내만 반환한다. 포털은 같은 조건을 출력 후에도 검사해 모델이 조회 건수, 아래 리포트 확인 안내만 반환한다. 포털은 같은 조건을 출력 후에도 검사해 모델이
HTML을 반환하더라도 안전한 짧은 안내문으로 정규화한다. HTML을 반환하더라도 안전한 짧은 안내문으로 정규화한다.
## 조회 질문과 표현 요청 분리
사용자의 한 문장에는 데이터 요구와 표현 요구가 함께 있을 수 있다. 포털은 Agent instruction으로
두 의도를 분리한다.
```text
원문: E1001 팀 선사 KPI를 HTML로 보여줘
조회 단계: E1001 팀 선사 KPI를 보여줘
표현 단계: 조회된 구조화 행을 HTML 리포트로 렌더링
```
조회 단계 재작성은 직원·팀·기간·지표·필터를 모두 유지하고 `HTML`, `리포트`, `차트`, `대시보드`
같은 출력 형식과 생성 행동만 제거한다. 원문은 `report.question`에 보존한다. Select AI가 만든
`htmlRow`, HTML 태그 또는 Markdown 표는 구조화 업무 행으로 간주하지 않으며 renderer 입력으로
전달하지 않는다.
서버는 JSON 크기, 행 수, 문자열 길이, 숫자 형식을 제한하고, 템플릿에 주입할 JSON에서 서버는 JSON 크기, 행 수, 문자열 길이, 숫자 형식을 제한하고, 템플릿에 주입할 JSON에서
`</script>`를 이스케이프한다. payload는 저장하지 않는다. `</script>`를 이스케이프한다. payload는 저장하지 않는다.

View File

@@ -32,11 +32,14 @@
허용 목록에 조회 도구와 리포트 렌더링 도구를 모두 넣는다. 허용 목록에 조회 도구와 리포트 렌더링 도구를 모두 넣는다.
2. 포털에서 예를 들어 `E1001 팀의 선사 최신 실적을 HMM 리포트로 만들어줘`라고 요청한다. 2. 포털에서 예를 들어 `E1001 팀의 선사 최신 실적을 HMM 리포트로 만들어줘`라고 요청한다.
3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다. 3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다.
첫 단계 MCP argument에는 `HTML로 보여줘`, `리포트로 만들어줘` 같은 표현 요청이 없어야 한다.
4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지 4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
확인한다. 확인한다.
5. 일반 답변에는 `<table>`, `<div>` 또는 Markdown 표가 반복되지 않고, 생성 완료·제목·조회 건수만 5. 일반 답변에는 `<table>`, `<div>` 또는 Markdown 표가 반복되지 않고, 생성 완료·제목·조회 건수만
표시되는지 확인한다. 표시되는지 확인한다.
6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다. 6. 리포트 머리글이 질문 전체 문장이 아니라 출력 형식 문구를 제거한 짧은 업무 제목인지 확인한다.
7. `reportJson.rows`의 각 행이 `employeeCode`, `carrierCode`, KPI처럼 업무 필드를 가지며,
`htmlRow``<tr>` 문자열을 포함하지 않는지 확인한다.
대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool 대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이 인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이

View File

@@ -60,6 +60,20 @@
**재발 방지**: 제목과 원문 질문이 역할상 분리되고 제목 길이 제한이 적용되는지 검증한다. **재발 방지**: 제목과 원문 질문이 역할상 분리되고 제목 길이 제한이 적용되는지 검증한다.
## `reportJson.rows`가 `htmlRow`와 `<tr>`만 포함함
**원인**: 포털이 `HTML로 보여줘`가 포함된 원문을 첫 Select AI 데이터 조회에 그대로 전달해,
조회 Tool이 컬럼별 구조화 행 대신 HTML 조각을 반환했다.
**확인**: 첫 Agent 단계의 MCP argument와 원본 결과를 확인한다. 조회 질문에 표현 형식이 남아 있고
행 key가 `htmlRow`뿐이면 이 문제다.
**해결**: 포털의 데이터 질문 재작성 instruction이 업무 대상·필터·지표는 유지하면서 HTML·리포트·
차트 생성 요청만 제거하도록 한다. renderer나 DB 조회 Tool을 변경하지 않는다.
**재발 방지**: `HTML로 보여줘`가 포함된 통합 질문으로 실제 연속 호출하고, 조회 결과와
`reportJson.rows` 모두 업무 필드를 가지며 HTML 태그가 없는지 검증한다.
## 리포트는 생성됐지만 행이 0건임 ## 리포트는 생성됐지만 행이 0건임
**원인**: 다음 둘 중 하나다. **원인**: 다음 둘 중 하나다.