refs #732: preserve Select AI results in HMM reports
This commit is contained in:
@@ -17,3 +17,12 @@
|
||||
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을 조건으로 사용하지 않는다.
|
||||
|
||||
@@ -2670,6 +2670,28 @@ def _mcp_items(mcp_result: Any) -> list[Any]:
|
||||
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]:
|
||||
if not isinstance(mcp_result, Mapping):
|
||||
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 {}
|
||||
payload = _mcp_response_payload(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
|
||||
source_rows = _mcp_structured_rows(raw_result)
|
||||
rows = [
|
||||
_normalize_presentation_value(row)
|
||||
for row in source_rows
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -140,6 +142,38 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
||||
self.assertIn("render_hmm_carrier_report", server["tool_allowlist"])
|
||||
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:
|
||||
template = (
|
||||
Path(__file__).parents[1]
|
||||
|
||||
@@ -11,7 +11,7 @@ BACKOFFICE_PRODUCT_DATA_LABEL='HMM HR 데이터'
|
||||
|
||||
BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp'
|
||||
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"}]'
|
||||
|
||||
|
||||
@@ -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` 문자열 하나를 입력으로 받는다.
|
||||
- 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다.
|
||||
- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
|
||||
@@ -33,7 +35,7 @@ Oracle DB의 custom Agent Tool과 템플릿 CLOB은 적용됐다. 포털은 리
|
||||
도구 이름을 고정하지 않고, 발견한 도구의 설명·입력 스키마를 기준으로 데이터 조회 뒤 렌더링을
|
||||
순차 호출하도록 운영 배포한다.
|
||||
|
||||
2026-08-10 운영 검증에서 `tools/list`, `reportJson` 입력, 64KB 입력 한도, 동적 JSON 매핑과
|
||||
Agent 2단계 실행을 확인했다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며, 호출 시 전달된
|
||||
`report`와 `rows`만 표시한다. 선행 조회가 0건을 반환하면 빈 리포트를 생성하는 것이 정상이며,
|
||||
조회 결과나 권한 설정은 이 기능의 변경 범위가 아니다.
|
||||
2026-08-10 운영 검증에서 같은 endpoint와 Bearer 문맥으로 기존 Select AI 조회 8건, renderer 입력
|
||||
8건, HTML 18,670자를 연속 호출해 확인했다. 조회 응답은 `response.result` 안의 JSON 배열 문자열로
|
||||
반환되므로 포털이 이를 범용적으로 구조화한다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며,
|
||||
호출 시 전달된 `report`와 `rows`만 표시한다.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
| 구성요소 | 책임 | DB 접근 |
|
||||
|---|---|---|
|
||||
| `search_carrier_performance` | Bearer 기반 사용자 식별 및 VPD 적용 선사 조회 | 허용 |
|
||||
| `search_carrier_performance` | 기존 `HMM_CARRIER_FEDERATION_SEARCH`를 통한 Bearer/VPD 기반 Select AI Federation 조회 | 허용 |
|
||||
| AI 웹 콘솔 | 도구 계약을 판별해 조회 결과를 정규화하고 렌더러를 후속 호출 | 없음 |
|
||||
| `render_hmm_carrier_report` | DB CLOB 템플릿에 허용된 JSON을 매핑 | 템플릿만 읽음 |
|
||||
| 브라우저 | 반환 HTML 표시·다운로드 | 없음 |
|
||||
@@ -36,9 +36,17 @@ HMM portal ──► /mcp (Bearer) ──► Backoffice MCP facade ──► DB
|
||||
사용한다는 의도가 있다.
|
||||
|
||||
사용자가 리포트·보고서·대시보드·차트·HTML을 요청하고 이 렌더러가 발견되면, 기존 LLM 라우터는
|
||||
렌더러를 제외한 데이터 도구 중 하나를 먼저 선택한다. 첫 응답의 `items` 또는 `results`만
|
||||
camelCase JSON으로 정규화해 두 번째 도구에 전달한다. 포털은 렌더러의 `html` 응답을 iframe으로
|
||||
표시한다. 이 규칙은 동일 계약을 선언하는 다른 업무 리포트 도구에도 적용된다.
|
||||
렌더러를 제외한 데이터 도구 중 하나를 먼저 선택한다. 첫 응답의 `items`, `results`, `rows` 또는
|
||||
`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` 계약
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## 1. 준비
|
||||
|
||||
- `vpd-backoffice` 배포본에 HTML 템플릿과 HMM CI 리소스가 포함되어야 한다.
|
||||
- 조회 도구 `search_carrier_performance`가 구조화된 `items` 배열을 반환해야 한다.
|
||||
- 조회 도구 `search_carrier_performance`가 기존 `HMM_CARRIER_FEDERATION_SEARCH`를 가리켜야 한다.
|
||||
- 배포 환경의 `BACKOFFICE_MCP_TOOLS`에 `AGENT_TOOL` 도구 계약을 추가한다.
|
||||
|
||||
먼저 `database/adb/83_hmm_carrier_html_report_tool.sql`을 실행한 뒤 다음 명령으로 현재
|
||||
@@ -17,11 +17,12 @@
|
||||
|
||||
1. 동일 Bearer token으로 `tools/list`를 호출한다.
|
||||
2. `render_hmm_carrier_report`와 입력 속성 `reportJson`이 보이는지 확인한다.
|
||||
3. 먼저 `search_carrier_performance`를 호출해 `items`를 얻는다.
|
||||
4. 질문·답변 근거·items를 reportJson으로 구성해 리포트 도구를 호출한다.
|
||||
3. 먼저 `search_carrier_performance`를 호출한다. 현재 Agent Tool 응답의 실제 행은 `response.result` 안의 JSON 배열 문자열이다.
|
||||
4. 포털이 해당 배열을 행으로 구조화했는지 확인하고 질문·답변 근거·행을 `reportJson`으로 구성해 리포트 도구를 호출한다.
|
||||
5. 응답 `response.html`을 새 탭 또는 sandboxed iframe에서 연다.
|
||||
|
||||
성공 판정은 제목, 담당자별 막대 차트, 위험 분포, 상세 표가 `rows`와 일치하는 것이다.
|
||||
성공 판정은 조회 원본 행 수와 `reportJson.rows` 수가 같고, 제목, 담당자별 막대 차트, 위험 분포,
|
||||
상세 표가 `rows`와 일치하는 것이다.
|
||||
템플릿 내부에 `__REPORT_DATA__`가 남지 않고, 검증 payload의 담당자·선사 식별자가 반환 HTML에
|
||||
포함되는지도 확인한다.
|
||||
|
||||
@@ -34,8 +35,9 @@
|
||||
4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
|
||||
확인한다.
|
||||
|
||||
첫 단계가 0건이면 두 번째 단계의 `reportJson.rows`도 빈 배열이어야 한다. 이 경우 순차 실행과
|
||||
HTML 생성은 성공한 것이며, 데이터 조회 문제는 리포트 Tool과 분리해 확인한다.
|
||||
대표 E1001 팀장 질의의 운영 회귀 기준은 현재 8건이다. 이 숫자는 검증 기준일 뿐 코드나 Tool
|
||||
인수에 고정하지 않는다. 첫 단계 원본에 행이 있는데 `reportJson.rows`가 0건이면 순차 실행 성공이
|
||||
아니며, [문제 해결](troubleshooting.md)의 중첩 응답 항목을 확인한다.
|
||||
|
||||
실패 시 [문제 해결](troubleshooting.md)의 `텍스트만 답하고 리포트가 생성되지 않음`을 따른다.
|
||||
|
||||
|
||||
@@ -37,14 +37,18 @@
|
||||
|
||||
## 리포트는 생성됐지만 행이 0건임
|
||||
|
||||
**원인**: 렌더러 앞에서 실행된 데이터 조회 Tool이 0건을 반환했다. 렌더러는 없는 행을 만들거나
|
||||
기본 샘플을 채우지 않는다.
|
||||
**원인**: 다음 둘 중 하나다.
|
||||
|
||||
**확인**: Agent 첫 단계의 결과 건수와 두 번째 단계 `reportJson.rows`를 비교한다. 둘 다 0건이면
|
||||
HTML 생성 경로는 정상이다.
|
||||
1. 선행 조회가 실제로 0건이다.
|
||||
2. 선행 Select AI Tool은 행을 반환했지만 포털이 `response.result` 안의 JSON 배열 문자열을
|
||||
`reportJson.rows`로 구조화하지 못했다.
|
||||
|
||||
**해결**: 같은 사용자와 질문으로 데이터 조회 Tool만 별도 호출해 권한 범위와 조회 결과를
|
||||
확인한다. HTML 템플릿, renderer 함수 또는 Agent 순차 실행 규칙은 변경하지 않는다.
|
||||
**확인**: Agent 첫 단계의 원본 `response.result` 배열 건수와 두 번째 단계 `reportJson.rows`를
|
||||
비교한다. 원본이 8건이고 `rows`가 0건이면 권한이나 Select AI 문제가 아니라 결과 전달 문제다.
|
||||
|
||||
**재발 방지**: HTML Tool 검증에는 별도의 합성 payload를 사용하고, 데이터 조회 검증과 판정을
|
||||
분리한다.
|
||||
**해결**: 포털의 범용 결과 해석기가 `items`, `results`, `rows`, `response`, `result` wrapper와
|
||||
JSON 배열 문자열을 재귀적으로 처리하는지 확인한다. 조회 Tool, endpoint, VPD 정책, DB package를
|
||||
교체하지 않는다.
|
||||
|
||||
**재발 방지**: 중첩 JSON 배열 회귀 테스트와 운영 연속 호출 검증을 유지한다. 같은 Bearer 문맥에서
|
||||
조회 원본 행 수와 renderer 입력 행 수가 같아야 배포를 완료한다.
|
||||
|
||||
Reference in New Issue
Block a user