refs #732: separate report data and rendering requests
This commit is contained in:
@@ -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 = {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 선택과 제목 생성에만 사용한다.
|
||||||
|
|
||||||
## 전체 흐름
|
## 전체 흐름
|
||||||
|
|
||||||
|
|||||||
@@ -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는 저장하지 않는다.
|
||||||
|
|
||||||
|
|||||||
@@ -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건이면 순차 실행 성공이
|
||||||
|
|||||||
@@ -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건임
|
||||||
|
|
||||||
**원인**: 다음 둘 중 하나다.
|
**원인**: 다음 둘 중 하나다.
|
||||||
|
|||||||
Reference in New Issue
Block a user