refs #732: preserve Select AI results in HMM reports

This commit is contained in:
devmrko
2026-08-10 15:51:29 +09:00
parent c0c5a36ba0
commit b13b913939
8 changed files with 105 additions and 31 deletions

View File

@@ -17,3 +17,12 @@
6. README의 문서 지도와 각 상세 문서의 상호 링크를 변경 후 반드시 확인한다. 6. README의 문서 지도와 각 상세 문서의 상호 링크를 변경 후 반드시 확인한다.
문서의 독자는 운영자·개발자·검토자다. 제품명과 전문 용어는 필요할 때만 쓰고, 처음 한 번은 평이한 말로 역할을 정의한다. 문서의 독자는 운영자·개발자·검토자다. 제품명과 전문 용어는 필요할 때만 쓰고, 처음 한 번은 평이한 말로 역할을 정의한다.
## HMM Select AI·HTML 리포트 보호 기준
1. 사용자 권한이 적용되는 HMM MCP의 기준 endpoint는 `https://hmm-backoffice.cloud-handson.com/mcp`다.
2. MCP route `search_carrier_performance`는 기존 DB Agent Tool `HMM_CARRIER_FEDERATION_SEARCH`를 호출한다. 이 Tool은 자연어를 Select AI Federation으로 처리하며, 고정 SQL·고정 데이터·대체 package로 바꾸지 않는다.
3. `hmm-mcp.cloud-handson.com`은 별도 호환성·무 VPD 시험 환경이다. 기준 endpoint나 운영 Select AI 경로의 대체재로 사용하지 않는다.
4. HTML 기능 추가 범위는 후속 표현 Tool `HMM_CARRIER_REPORT_RENDERER`와 포털의 범용 이전 결과 전달뿐이다. 렌더러는 사용자·권한·업무 데이터를 다시 조회하거나 보충하지 않는다.
5. 조회 Tool, target, package 또는 endpoint를 생성·삭제·교체하기 전에는 현재 MCP discovery, `BACKOFFICE_MCP_TOOLS`, DB Agent Tool metadata를 먼저 대조한다. 기존 조회 경로 변경은 사용자가 명시적으로 요청한 경우에만 한다.
6. 회귀 검증은 같은 Bearer 문맥에서 `search_carrier_performance`의 원본 행 수와 renderer에 전달된 `rows` 수가 같은지 확인한다. 대표 시나리오의 현재 기준은 E1001 팀장 질문에 대한 8건이지만, 코드는 E1001이나 8을 조건으로 사용하지 않는다.

View File

@@ -2670,6 +2670,28 @@ def _mcp_items(mcp_result: Any) -> list[Any]:
return items if isinstance(items, list) else [] return items if isinstance(items, list) else []
def _mcp_structured_rows(value: Any) -> list[Any]:
"""Extract row arrays from common MCP wrappers, including JSON strings."""
if isinstance(value, str):
try:
return _mcp_structured_rows(json.loads(value))
except ValueError:
return []
if isinstance(value, list):
return value
if not isinstance(value, Mapping):
return []
for key in ("items", "results", "rows", "response", "result"):
if key not in value:
continue
rows = _mcp_structured_rows(value.get(key))
if rows:
return rows
return []
def _mcp_summary(mcp_result: Any) -> dict[str, Any]: def _mcp_summary(mcp_result: Any) -> dict[str, Any]:
if not isinstance(mcp_result, Mapping): if not isinstance(mcp_result, Mapping):
return {"type": type(mcp_result).__name__} return {"type": type(mcp_result).__name__}
@@ -2876,14 +2898,7 @@ def _presentation_payload(question: str, steps: list[Mapping[str, Any]]) -> dict
{}, {},
) )
raw_result = source.get("mcp_result", {}) if isinstance(source, Mapping) else {} raw_result = source.get("mcp_result", {}) if isinstance(source, Mapping) else {}
payload = _mcp_response_payload(raw_result) source_rows = _mcp_structured_rows(raw_result)
source_rows = _mcp_items(raw_result)
if not source_rows:
for key in ("results", "rows"):
candidate = payload.get(key) if isinstance(payload, Mapping) else None
if isinstance(candidate, list):
source_rows = candidate
break
rows = [ rows = [
_normalize_presentation_value(row) _normalize_presentation_value(row)
for row in source_rows for row in source_rows

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import ast
import json import json
from pathlib import Path from pathlib import Path
import tempfile import tempfile
from typing import Any, Mapping
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -140,6 +142,38 @@ class DemoScenarioConfigTest(unittest.TestCase):
self.assertIn("render_hmm_carrier_report", server["tool_allowlist"]) self.assertIn("render_hmm_carrier_report", server["tool_allowlist"])
self.assertNotIn('tool.name == "render_hmm_carrier_report"', source) self.assertNotIn('tool.name == "render_hmm_carrier_report"', source)
def test_hmm_report_rows_accept_nested_select_ai_json_array(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 == "_mcp_structured_rows"
)
namespace: dict[str, Any] = {
"Any": Any,
"Mapping": Mapping,
"json": json,
}
exec(compile(ast.Module(body=[helper], type_ignores=[]), "app.py", "exec"), namespace)
rows = namespace["_mcp_structured_rows"](
{
"response": {
"result": json.dumps(
[
{"EMPLOYEE_CODE": "E9001", "CARRIER_CODE": "C901"},
{"EMPLOYEE_CODE": "E9002", "CARRIER_CODE": "C902"},
]
)
}
}
)
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["CARRIER_CODE"], "C901")
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

@@ -11,7 +11,7 @@ BACKOFFICE_PRODUCT_DATA_LABEL='HMM HR 데이터'
BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp' BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp'
BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice' BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice'
BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"},{"name":"render_hmm_carrier_report","label":"HMM 선사 실적 HTML 리포트","description":"이미 권한이 적용된 조회 결과를 HMM HTML 리포트로 표현합니다. 데이터를 조회하거나 값을 변경하지 않는 후속 처리 전용 Tool입니다.","argumentName":"reportJson","argumentDescription":"앞 단계의 구조화된 조회 결과와 리포트 메타데이터를 담은 JSON입니다.","executionType":"AGENT_TOOL","targetName":"HMM_CARRIER_REPORT_RENDERER","targetParameterName":"P_REPORT_JSON"}]' BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_carrier_performance","label":"HMM 선사 실적 Federation 조회","description":"팀원별 담당 선사의 최신 매출, 매출총이익, 정시 운항률과 위험 등급을 Select AI Federation으로 조회합니다.","argumentName":"query","argumentDescription":"선사 실적에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_CARRIER_FEDERATION_SEARCH","targetParameterName":"P_QUERY"},{"name":"render_hmm_carrier_report","label":"HMM 선사 실적 HTML 리포트","description":"이미 권한이 적용된 조회 결과를 HMM HTML 리포트로 표현합니다. 데이터를 조회하거나 값을 변경하지 않는 후속 처리 전용 Tool입니다.","argumentName":"reportJson","argumentDescription":"앞 단계의 구조화된 조회 결과와 리포트 메타데이터를 담은 JSON입니다.","executionType":"AGENT_TOOL","targetName":"HMM_CARRIER_REPORT_RENDERER","targetParameterName":"P_REPORT_JSON"}]'
BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]' BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]'

View File

@@ -7,6 +7,8 @@ DB를 다시 조회하거나 자연어를 해석하지 않는다.
## 결정사항 ## 결정사항
- 기준 MCP endpoint는 `https://hmm-backoffice.cloud-handson.com/mcp` 하나이며, 별도 호환성 시험 endpoint로 교체하지 않는다.
- 기존 조회 route `search_carrier_performance`는 DB Agent Tool `HMM_CARRIER_FEDERATION_SEARCH`를 그대로 호출한다. 이 경로가 자연어를 Select AI Federation으로 처리한다.
- MCP 도구 `render_hmm_carrier_report``reportJson` 문자열 하나를 입력으로 받는다. - MCP 도구 `render_hmm_carrier_report``reportJson` 문자열 하나를 입력으로 받는다.
- 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다. - 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다.
- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다. - 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
@@ -33,7 +35,7 @@ Oracle DB의 custom Agent Tool과 템플릿 CLOB은 적용됐다. 포털은 리
도구 이름을 고정하지 않고, 발견한 도구의 설명·입력 스키마를 기준으로 데이터 조회 뒤 렌더링을 도구 이름을 고정하지 않고, 발견한 도구의 설명·입력 스키마를 기준으로 데이터 조회 뒤 렌더링을
순차 호출하도록 운영 배포한다. 순차 호출하도록 운영 배포한다.
2026-08-10 운영 검증에서 `tools/list`, `reportJson` 입력, 64KB 입력 한도, 동적 JSON 매핑과 2026-08-10 운영 검증에서 같은 endpoint와 Bearer 문맥으로 기존 Select AI 조회 8건, renderer 입력
Agent 2단계 실행을 확인했다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며, 호출 시 전달된 8건, HTML 18,670자를 연속 호출해 확인했다. 조회 응답은 `response.result` 안의 JSON 배열 문자열로
`report``rows`만 표시한다. 선행 조회가 0건을 반환하면 빈 리포트를 생성하는 것이 정상이며, 반환되므로 포털이 이를 범용적으로 구조화한다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며,
조회 결과나 권한 설정은 이 기능의 변경 범위가 아니다. 호출 시 전달된 `report``rows`만 표시한다.

View File

@@ -4,7 +4,7 @@
| 구성요소 | 책임 | DB 접근 | | 구성요소 | 책임 | DB 접근 |
|---|---|---| |---|---|---|
| `search_carrier_performance` | Bearer 기반 사용자 식별 및 VPD 적용 선사 조회 | 허용 | | `search_carrier_performance` | 기존 `HMM_CARRIER_FEDERATION_SEARCH`를 통한 Bearer/VPD 기반 Select AI Federation 조회 | 허용 |
| AI 웹 콘솔 | 도구 계약을 판별해 조회 결과를 정규화하고 렌더러를 후속 호출 | 없음 | | AI 웹 콘솔 | 도구 계약을 판별해 조회 결과를 정규화하고 렌더러를 후속 호출 | 없음 |
| `render_hmm_carrier_report` | DB CLOB 템플릿에 허용된 JSON을 매핑 | 템플릿만 읽음 | | `render_hmm_carrier_report` | DB CLOB 템플릿에 허용된 JSON을 매핑 | 템플릿만 읽음 |
| 브라우저 | 반환 HTML 표시·다운로드 | 없음 | | 브라우저 | 반환 HTML 표시·다운로드 | 없음 |
@@ -36,9 +36,17 @@ HMM portal ──► /mcp (Bearer) ──► Backoffice MCP facade ──► DB
사용한다는 의도가 있다. 사용한다는 의도가 있다.
사용자가 리포트·보고서·대시보드·차트·HTML을 요청하고 이 렌더러가 발견되면, 기존 LLM 라우터는 사용자가 리포트·보고서·대시보드·차트·HTML을 요청하고 이 렌더러가 발견되면, 기존 LLM 라우터는
렌더러를 제외한 데이터 도구 중 하나를 먼저 선택한다. 첫 응답의 `items` 또는 `results` 렌더러를 제외한 데이터 도구 중 하나를 먼저 선택한다. 첫 응답의 `items`, `results`, `rows` 또는
camelCase JSON으로 정규화해 두 번째 도구에 전달한다. 포털은 렌더러의 `html` 응답을 iframe으로 `response.result` 안의 JSON 배열 문자열을 같은 방식으로 구조화하고, camelCase JSON으로 정규화해
표시한다. 이 규칙은 동일 계약을 선언하는 다른 업무 리포트 도구에도 적용된다. 두 번째 도구에 전달한다. 포털은 렌더러의 `html` 응답을 iframe으로 표시한다. 이 규칙은 특정
Tool 이름, 사용자 코드나 예상 행 수를 조건으로 사용하지 않는다.
## 변경 금지 경계
- `search_carrier_performance`의 DB target은 기존 `HMM_CARRIER_FEDERATION_SEARCH`다.
- 별도 호환성 시험 서버 `hmm-mcp.cloud-handson.com`은 이 운영 경로를 대신하지 않는다.
- HTML 추가를 이유로 조회 Agent Tool, Select AI profile, VPD 정책 또는 조회 package를 생성·교체하지 않는다.
- renderer는 선행 결과만 표현하며 누락 행을 조회하거나 고정 샘플로 채우지 않는다.
## `reportJson` 계약 ## `reportJson` 계약

View File

@@ -3,7 +3,7 @@
## 1. 준비 ## 1. 준비
- `vpd-backoffice` 배포본에 HTML 템플릿과 HMM CI 리소스가 포함되어야 한다. - `vpd-backoffice` 배포본에 HTML 템플릿과 HMM CI 리소스가 포함되어야 한다.
- 조회 도구 `search_carrier_performance`구조화된 `items` 배열을 반환해야 한다. - 조회 도구 `search_carrier_performance`기존 `HMM_CARRIER_FEDERATION_SEARCH`를 가리켜야 한다.
- 배포 환경의 `BACKOFFICE_MCP_TOOLS``AGENT_TOOL` 도구 계약을 추가한다. - 배포 환경의 `BACKOFFICE_MCP_TOOLS``AGENT_TOOL` 도구 계약을 추가한다.
먼저 `database/adb/83_hmm_carrier_html_report_tool.sql`을 실행한 뒤 다음 명령으로 현재 먼저 `database/adb/83_hmm_carrier_html_report_tool.sql`을 실행한 뒤 다음 명령으로 현재
@@ -17,11 +17,12 @@
1. 동일 Bearer token으로 `tools/list`를 호출한다. 1. 동일 Bearer token으로 `tools/list`를 호출한다.
2. `render_hmm_carrier_report`와 입력 속성 `reportJson`이 보이는지 확인한다. 2. `render_hmm_carrier_report`와 입력 속성 `reportJson`이 보이는지 확인한다.
3. 먼저 `search_carrier_performance`를 호출`items`를 얻는다. 3. 먼저 `search_carrier_performance`를 호출한다. 현재 Agent Tool 응답의 실제 행은 `response.result` 안의 JSON 배열 문자열이다.
4. 질문·답변 근거·items를 reportJson으로 구성해 리포트 도구를 호출한다. 4. 포털이 해당 배열을 행으로 구조화했는지 확인하고 질문·답변 근거·행을 `reportJson`으로 구성해 리포트 도구를 호출한다.
5. 응답 `response.html`을 새 탭 또는 sandboxed iframe에서 연다. 5. 응답 `response.html`을 새 탭 또는 sandboxed iframe에서 연다.
성공 판정은 제목, 담당자별 막대 차트, 위험 분포, 상세 표가 `rows`와 일치하는 것이다. 성공 판정은 조회 원본 행 수와 `reportJson.rows` 수가 같고, 제목, 담당자별 막대 차트, 위험 분포,
상세 표가 `rows`와 일치하는 것이다.
템플릿 내부에 `__REPORT_DATA__`가 남지 않고, 검증 payload의 담당자·선사 식별자가 반환 HTML에 템플릿 내부에 `__REPORT_DATA__`가 남지 않고, 검증 payload의 담당자·선사 식별자가 반환 HTML에
포함되는지도 확인한다. 포함되는지도 확인한다.
@@ -34,8 +35,9 @@
4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지 4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
확인한다. 확인한다.
첫 단계가 0건이면 두 번째 단계의 `reportJson.rows`도 빈 배열이어야 한다. 이 경우 순차 실행과 대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
HTML 생성은 성공한 것이며, 데이터 조회 문제는 리포트 Tool과 분리해 확인한다. 인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이
아니며, [문제 해결](troubleshooting.md)의 중첩 응답 항목을 확인한다.
실패 시 [문제 해결](troubleshooting.md)의 `텍스트만 답하고 리포트가 생성되지 않음`을 따른다. 실패 시 [문제 해결](troubleshooting.md)의 `텍스트만 답하고 리포트가 생성되지 않음`을 따른다.

View File

@@ -37,14 +37,18 @@
## 리포트는 생성됐지만 행이 0건임 ## 리포트는 생성됐지만 행이 0건임
**원인**: 렌더러 앞에서 실행된 데이터 조회 Tool이 0건을 반환했다. 렌더러는 없는 행을 만들거나 **원인**: 다음 둘 중 하나다.
기본 샘플을 채우지 않는다.
**확인**: Agent 첫 단계의 결과 건수와 두 번째 단계 `reportJson.rows`를 비교한다. 둘 다 0건이 1. 선행 조회가 실제로 0건이다.
HTML 생성 경로는 정상이다. 2. 선행 Select AI Tool은 행을 반환했지만 포털이 `response.result` 안의 JSON 배열 문자열을
`reportJson.rows`로 구조화하지 못했다.
**해결**: 같은 사용자와 질문으로 데이터 조회 Tool만 별도 호출해 권한 범위와 조회 결과 **확인**: Agent 첫 단계의 원본 `response.result` 배열 건수와 두 번째 단계 `reportJson.rows`
확인한다. HTML 템플릿, renderer 함수 또는 Agent 순차 실행 규칙은 변경하지 않는다. 비교한다. 원본이 8건이고 `rows`가 0건이면 권한이나 Select AI 문제가 아니라 결과 전달 문제다.
**재발 방지**: HTML Tool 검증에는 별도의 합성 payload를 사용하고, 데이터 조회 검증과 판정을 **해결**: 포털의 범용 결과 해석기가 `items`, `results`, `rows`, `response`, `result` wrapper와
분리한다. JSON 배열 문자열을 재귀적으로 처리하는지 확인한다. 조회 Tool, endpoint, VPD 정책, DB package를
교체하지 않는다.
**재발 방지**: 중첩 JSON 배열 회귀 테스트와 운영 연속 호출 검증을 유지한다. 같은 Bearer 문맥에서
조회 원본 행 수와 renderer 입력 행 수가 같아야 배포를 완료한다.