diff --git a/AGENTS.md b/AGENTS.md index faa06c3..b47b66c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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을 조건으로 사용하지 않는다. diff --git a/ai-web-agent-console/app.py b/ai-web-agent-console/app.py index 275d3f6..1bbbc09 100644 --- a/ai-web-agent-console/app.py +++ b/ai-web-agent-console/app.py @@ -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 diff --git a/ai-web-agent-console/tests/test_scenarios.py b/ai-web-agent-console/tests/test_scenarios.py index 39fff54..2ab9c62 100644 --- a/ai-web-agent-console/tests/test_scenarios.py +++ b/ai-web-agent-console/tests/test_scenarios.py @@ -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] diff --git a/deploy/vpd-backoffice/backoffice.env.example b/deploy/vpd-backoffice/backoffice.env.example index d2f2f6b..351c8f5 100644 --- a/deploy/vpd-backoffice/backoffice.env.example +++ b/deploy/vpd-backoffice/backoffice.env.example @@ -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"}]' diff --git a/docs/design/hmm-html-report-mcp/README.md b/docs/design/hmm-html-report-mcp/README.md index 8739a90..ec61e43 100644 --- a/docs/design/hmm-html-report-mcp/README.md +++ b/docs/design/hmm-html-report-mcp/README.md @@ -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`만 표시한다. diff --git a/docs/design/hmm-html-report-mcp/architecture.md b/docs/design/hmm-html-report-mcp/architecture.md index 3d663f9..f077d53 100644 --- a/docs/design/hmm-html-report-mcp/architecture.md +++ b/docs/design/hmm-html-report-mcp/architecture.md @@ -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` 계약 diff --git a/docs/design/hmm-html-report-mcp/cookbook.md b/docs/design/hmm-html-report-mcp/cookbook.md index d4ef6c3..c496eb5 100644 --- a/docs/design/hmm-html-report-mcp/cookbook.md +++ b/docs/design/hmm-html-report-mcp/cookbook.md @@ -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)의 `텍스트만 답하고 리포트가 생성되지 않음`을 따른다. diff --git a/docs/design/hmm-html-report-mcp/troubleshooting.md b/docs/design/hmm-html-report-mcp/troubleshooting.md index 7531437..905998f 100644 --- a/docs/design/hmm-html-report-mcp/troubleshooting.md +++ b/docs/design/hmm-html-report-mcp/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 입력 행 수가 같아야 배포를 완료한다.