From 84029ed633bb18de84db36bc660a6b012cc45218 Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 10 Aug 2026 17:37:32 +0900 Subject: [PATCH] refs #732: stabilize carrier report result schema --- docs/design/740-hmm-mcp-vpd-runtime/README.md | 3 ++ .../740-hmm-mcp-vpd-runtime/architecture.md | 15 ++++++++++ .../740-hmm-mcp-vpd-runtime/cookbook.md | 3 ++ .../hmm-html-report-mcp/troubleshooting.md | 13 +++++++++ .../config/hmm_carrier_query_contract.json | 28 +++++++++++++++++++ .../service/SelectAiServiceTest.java | 22 +++++++++++++++ 6 files changed, 84 insertions(+) create mode 100644 vpd-backoffice/config/hmm_carrier_query_contract.json diff --git a/docs/design/740-hmm-mcp-vpd-runtime/README.md b/docs/design/740-hmm-mcp-vpd-runtime/README.md index 4a35c87..e603975 100644 --- a/docs/design/740-hmm-mcp-vpd-runtime/README.md +++ b/docs/design/740-hmm-mcp-vpd-runtime/README.md @@ -14,6 +14,8 @@ Oracle VPD로 제한한다. 팀원은 본인 행, 팀장은 본인과 직속 팀 - `HMM_CARRIER_ASSIGNMENTS_V.EMPLOYEE_ID`에 `SELF`, `MANAGED_TEAM`, `ALL` 규칙을 적용한다. - 기존 MCP 이름 `search_carrier_performance`, 자연어 Select AI, 선사 `CARRIER_CODE` 조인과 HTML renderer는 유지한다. 고정 SQL이나 대체 조회 패키지를 만들지 않는다. +- 연속 호출에서도 renderer 입력이 달라지지 않도록 Select AI 질의 계약이 출력 컬럼의 ASCII + uppercase underscore 별칭을 고정한다. ## 전체 구성 @@ -43,6 +45,7 @@ Oracle VPD로 제한한다. 팀원은 본인 행, 팀장은 본인과 직속 팀 - MCP E1001: `vpdEnforced=true`, 8건 - MCP E1002: `vpdEnforced=true`, 2건, 직원 범위 E1002만 포함 - E1002 renderer: 입력 2건, HTML에 C001·C002 포함, E1003 미포함 +- 동일 E1002 조회와 renderer를 두 번 연속 호출해 두 호출 모두 표준 컬럼 9개와 HTML 2건을 확인 ## 문서 지도 diff --git a/docs/design/740-hmm-mcp-vpd-runtime/architecture.md b/docs/design/740-hmm-mcp-vpd-runtime/architecture.md index e3875de..a21f84e 100644 --- a/docs/design/740-hmm-mcp-vpd-runtime/architecture.md +++ b/docs/design/740-hmm-mcp-vpd-runtime/architecture.md @@ -20,6 +20,21 @@ 선사 조회의 자연어 처리와 `CARRIER_CODE` 조인은 기존 Select AI 프로필을 유지한다. 권한 조건을 질문이나 고정 SQL에 넣지 않고 Oracle VPD가 세션 사용자 ID로 자동 적용한다. +## 출력 스키마 계약 + +Select AI는 같은 의미의 컬럼도 호출마다 `CARRIER_CODE`, `carrier Code`, `선사코드`처럼 다른 +alias로 생성할 수 있다. `vpd-backoffice/config/hmm_carrier_query_contract.json`은 SQL을 고정하지 +않고 다음 결과 alias만 고정한다. + +```text +EMPLOYEE_CODE, EMPLOYEE_NAME, MANAGER_EMPLOYEE_CODE, +CARRIER_CODE, CARRIER_NAME, LATEST_REVENUE_USD, +LATEST_GROSS_MARGIN_USD, LATEST_SCHEDULE_RELIABILITY_PCT, +LATEST_RISK_LEVEL +``` + +따라서 권한에 따라 행 수는 달라져도 renderer에 전달되는 행 구조는 매 호출 동일하다. + ## 신뢰 경계 - 화면에 표시된 사용자 코드는 권한 근거가 아니다. 선택 preset의 전용 Bearer token이 근거다. diff --git a/docs/design/740-hmm-mcp-vpd-runtime/cookbook.md b/docs/design/740-hmm-mcp-vpd-runtime/cookbook.md index 442df96..ef786d4 100644 --- a/docs/design/740-hmm-mcp-vpd-runtime/cookbook.md +++ b/docs/design/740-hmm-mcp-vpd-runtime/cookbook.md @@ -7,6 +7,8 @@ 3. 포털 preset마다 `HMM_MCP_BEARER_TOKEN_` 전용 토큰을 발급한다. 4. 선사 조회 MCP는 기존 `HMM_RDS_FEDERATION_PROFILE`로 `SHOWSQL`을 생성하고 `CB_ORDS`에서 실행한다. 운영 도구 정의는 이름과 입력 계약을 유지하고 `executionType`만 `SELECT_AI`로 설정한다. + `BACKOFFICE_SELECT_AI_QUERY_CONTRACT_FILE`에는 배포한 + `config/hmm_carrier_query_contract.json`의 절대 경로를 설정한다. 5. 서비스를 재시작하고 동일 질문을 E1001과 E1002로 각각 호출한다. 성공 기준: @@ -20,6 +22,7 @@ 비밀번호는 명령·로그·문서에 출력하지 않는다. MCP 응답에서 `vpdEnforced=true`, `scopeEmployeeCode`가 선택 사용자와 같은지도 확인한다. +같은 E1002 질문을 두 번 실행해 두 응답 모두 표준 alias 9개와 C001·C002 두 행을 반환하는지 확인한다. 롤백할 때는 VPD 정책을 삭제하지 말고 disable하여 조사 가능 상태로 보존한다. 사용자별 token 환경변수도 공용 token으로 되돌리지 않고 사용자 선택 기능을 일시 비활성화한다. diff --git a/docs/design/hmm-html-report-mcp/troubleshooting.md b/docs/design/hmm-html-report-mcp/troubleshooting.md index ab69742..80ff38b 100644 --- a/docs/design/hmm-html-report-mcp/troubleshooting.md +++ b/docs/design/hmm-html-report-mcp/troubleshooting.md @@ -104,3 +104,16 @@ underscore만 camelCase로 변환했다. 조건문에 하드코딩하지 않는다. **재발 방지**: 서로 다른 컬럼 label 표기의 두 응답을 연속 정규화하는 회귀 테스트를 유지한다. + +## 두 번째 리포트의 컬럼이 한글 alias로 바뀌어 깨짐 + +**원인**: Select AI가 첫 호출에는 `CARRIER_CODE`, 두 번째 호출에는 `선사코드`처럼 의미는 같지만 +번역된 alias를 생성했다. 구분자 정규화만으로는 서로 다른 언어의 key를 같은 필드로 판단할 수 없다. + +**확인**: 선행 조회 2건은 정상인데 두 번째 `reportJson.rows`가 `선사명`, `선사코드`, +`최신매출Usd` 등을 가지는지 확인한다. + +**해결**: 선사 Select AI query contract에 renderer가 요구하는 ASCII uppercase underscore 출력 alias를 +선언한다. 실제 SQL, 직원 코드, 결과 값은 고정하지 않는다. + +**재발 방지**: 같은 팀원 질의를 두 번 호출해 두 응답의 key 집합과 renderer HTML을 비교한다. diff --git a/vpd-backoffice/config/hmm_carrier_query_contract.json b/vpd-backoffice/config/hmm_carrier_query_contract.json new file mode 100644 index 0000000..91eb25f --- /dev/null +++ b/vpd-backoffice/config/hmm_carrier_query_contract.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "purpose": "Stable structured output contract for HMM carrier performance reports", + "applies_to": "HMM_RDS_FEDERATION_PROFILE carrier assignment and performance questions", + "required_objects": [ + "HMM_CARRIER_ASSIGNMENTS_V", + "HMM_RDS_CARRIER_LATEST_V" + ], + "join_contract": "Join HMM_CARRIER_ASSIGNMENTS_V.CARRIER_CODE to HMM_RDS_CARRIER_LATEST_V.CARRIER_CODE.", + "required_output_aliases": [ + "EMPLOYEE_CODE", + "EMPLOYEE_NAME", + "MANAGER_EMPLOYEE_CODE", + "CARRIER_CODE", + "CARRIER_NAME", + "LATEST_REVENUE_USD", + "LATEST_GROSS_MARGIN_USD", + "LATEST_SCHEDULE_RELIABILITY_PCT", + "LATEST_RISK_LEVEL" + ], + "rules": [ + "Return one ordinary structured row per employee and assigned carrier.", + "Always include every required output alias, even when the natural-language question does not explicitly request the employee columns.", + "Use the required ASCII uppercase underscore aliases exactly as written. Never translate aliases, insert spaces in aliases, or return HTML and Markdown.", + "For latest or current metrics use HMM_RDS_CARRIER_LATEST_V.", + "Do not add, weaken, or emulate authorization predicates. Oracle VPD on HMM_CARRIER_ASSIGNMENTS_V enforces the authenticated employee scope." + ] +} diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java index 7943b02..3f8dab8 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/SelectAiServiceTest.java @@ -4,6 +4,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; import org.junit.jupiter.api.Test; class SelectAiServiceTest { @@ -32,4 +34,24 @@ class SelectAiServiceTest { service.validateReadOnlySql("SELECT * FROM x -- bypass")) .isInstanceOf(AppException.class); } + + @Test + void carrierQueryContractRequiresStableAsciiAliases() throws Exception { + var contract = new ObjectMapper().readTree(Files.readString( + Path.of("config/hmm_carrier_query_contract.json"))); + + assertThat(contract.path("required_output_aliases").toString()) + .contains( + "EMPLOYEE_CODE", + "CARRIER_CODE", + "CARRIER_NAME", + "LATEST_REVENUE_USD", + "LATEST_GROSS_MARGIN_USD", + "LATEST_SCHEDULE_RELIABILITY_PCT", + "LATEST_RISK_LEVEL" + ); + assertThat(contract.path("rules").toString()) + .contains("Never translate aliases") + .contains("Oracle VPD"); + } }