refs #732: stabilize carrier report result schema

This commit is contained in:
devmrko
2026-08-10 17:37:32 +09:00
parent 6394bbf078
commit 84029ed633
6 changed files with 84 additions and 0 deletions

View File

@@ -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건을 확인
## 문서 지도

View File

@@ -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이 근거다.

View File

@@ -7,6 +7,8 @@
3. 포털 preset마다 `HMM_MCP_BEARER_TOKEN_<EMPLOYEE_CODE>` 전용 토큰을 발급한다.
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으로 되돌리지 않고 사용자 선택 기능을 일시 비활성화한다.

View File

@@ -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을 비교한다.

View File

@@ -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."
]
}

View File

@@ -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");
}
}