feat: implement MCI session fetching using employee number
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 1m3s

This commit is contained in:
jade
2026-08-19 16:41:13 +09:00
parent 9cdf313a77
commit e998127fa5
171 changed files with 17018 additions and 3 deletions

View File

@@ -0,0 +1,196 @@
# AX HUB Tool 개발 환경 가이드
## 1. 문서 목적
이 문서는 AX HUB의 **Tool Pod 개발자**가 로컬 PC와 신한라이프 내부망에서 동일한 방식으로 소스를 빌드하고 실행하기 위한 기준을 정리한다.
이 문서의 범위는 `dap-was-lib`와 Tool Pod(`dap-was-cus`, `dap-was-sal`, `dap-was-pro`, `dap-was-sys`)이다. 외부 라우팅 계층의 구성과 운영 방법은 다루지 않는다.
## 2. 기준 기술 환경
| 항목 | 현재 기준 | 용도 |
|---|---:|---|
| Java | 21 | 컴파일 및 Tool Pod 실행 |
| Spring Boot | 3.5.11 | Tool Pod 애플리케이션 기반 |
| Gradle Wrapper | 8.14.3 | 빌드 표준화 |
| MCP Java SDK | 2.0.0 | MCP 서버와 JSON 전송 처리 |
| Spring AI BOM | 1.1.8 | Boot 3.5 계열 의존성 정렬 |
| MCP Annotations | 0.9.0 | `@McpTool`, `@McpToolParam` 제공 |
| MapStruct | 1.5.5.Final | Tool DTO와 연계 전문 변환 |
| Lombok | 1.18.32 | DTO·생성자 반복 코드 축소 |
| JSON Schema Validator | 2.0.0 | MCP SDK 2.0.0 호환 Schema 검증 |
> `com.networknt:json-schema-validator`는 반드시 현재 지정된 `2.0.0`을 유지한다. 3.x를 혼용하면 MCP SDK가 기대하는 `Schema.validate(JsonNode)` 규격과 달라 `NoSuchMethodError`가 발생할 수 있다.
## 3. Tool 모듈 구성
| 모듈 | 기본 포트 | 역할 |
|---|---:|---|
| `dap-was-lib` | 해당 없음 | MCP 서버 설정, Tool 탐색·실행, Schema, V17 메타데이터, MCI·HTTP 연동, 공통 로그·헤더 처리 |
| `dap-was-cus` | 8084 | 고객 관련 Tool Pod |
| `dap-was-sal` | 8082 | 영업 관련 Tool Pod |
| `dap-was-pro` | 8085 | 상품 관련 Tool Pod |
| `dap-was-sys` | 8086 | 시스템 관련 Tool Pod |
각 실행 모듈은 `dap-was-lib`를 의존하고 다음 패키지를 스캔한다.
```java
@SpringBootApplication(scanBasePackages = {
"io.shinhanlife.dap.mcc",
"io.shinhanlife.dap.lib"
})
@Import(ToolMcpServerConfiguration.class)
```
따라서 업무 Tool은 `io.shinhanlife.dap.mcc` 아래에, 공통 기능은 `io.shinhanlife.dap.lib` 아래에 둔다.
## 4. 개발 PC 준비
### 4.1 필수 설치 항목
1. JDK 21
2. IntelliJ IDEA 또는 Eclipse 기반 개발도구
3. Git
4. Docker Desktop 또는 사내 표준 컨테이너 실행 환경(통합 테스트 시)
별도 Gradle 설치는 필요하지 않다. 저장소에 포함된 `gradlew.bat`를 사용한다.
### 4.2 IntelliJ 설정
- Project SDK: JDK 21
- Gradle JVM: JDK 21
- Build and run using: Gradle 권장
- File Encoding: UTF-8
- Annotation Processing: 활성화
- 줄바꿈: Git 정책에 맞춰 유지
한글 주석이나 문자열이 깨지는 경우 파일을 UTF-8로 다시 저장하고, Java 파일 첫 바이트에 BOM(`\ufeff`)이 들어가지 않았는지 확인한다. Java 소스는 **UTF-8 without BOM**을 사용한다.
## 5. 프로파일과 설정 파일
각 Tool Pod는 다음 구조를 사용한다.
```text
src/main/resources/
├─ application.yml
├─ application-local.yml
├─ application-dev.yml
├─ application-test.yml
└─ application-prod.yml
```
프로파일 파일은 공통 라이브러리의 Glow 설정을 가져온다.
```yaml
spring:
config:
activate:
on-profile: local
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-local.yml
```
| 프로파일 | 용도 |
|---|---|
| `local` | 개발자 PC, H2 및 로컬 Mock 사용 |
| `dev` | 개발계 MCI·HTTP 연계 |
| `test` | 테스트 환경 연계 |
| `prod` | 운영 환경 연계 |
실행 프로파일은 다음 중 하나로 지정한다.
```powershell
$env:SPRING_PROFILES_ACTIVE = "local"
.\gradlew.bat :dap-was-cus:bootRun
```
또는 IntelliJ Run Configuration의 `Active profiles``local`을 입력한다.
## 6. 주요 환경 변수
환경별 주소와 보안 값은 Java 코드에 직접 작성하지 않고 환경 변수로 주입한다.
| 환경 변수 | 의미 | local 기본값 예시 |
|---|---|---|
| `SPRING_PROFILES_ACTIVE` | 실행 프로파일 | `local` |
| `PORT` | Tool Pod 포트 | 모듈별 기본 포트 |
| `AXHUB_TOOL_URL` | 현재 Tool Pod의 공개 기준 URL | `http://localhost:${server.port}` |
| `GLOW_COMMUNICATION_MCI_HOST` | MCI 호스트 | `http://localhost` |
| `GLOW_COMMUNICATION_MCI_PORT` | MCI 포트 | `8080` |
| `AXHUB_{API_NAME}_HTTP_DOMAIN` | HTTP API 도메인 | Tool별 설정값 |
| `AXHUB_{API_NAME}_HTTP_URL` | HTTP API 경로 | Tool별 설정값 |
비밀번호, 인증키, 암호화키는 YAML 기본값으로 커밋하지 않는다.
## 7. 빌드와 실행
### 7.1 공통 라이브러리 검증
```powershell
.\gradlew.bat :dap-was-lib:clean :dap-was-lib:test
```
### 7.2 Tool Pod 컴파일
```powershell
.\gradlew.bat :dap-was-cus:compileJava
.\gradlew.bat :dap-was-sal:compileJava
.\gradlew.bat :dap-was-pro:compileJava
.\gradlew.bat :dap-was-sys:compileJava
```
### 7.3 Tool Pod 실행
```powershell
.\gradlew.bat :dap-was-cus:bootRun
```
실행 후 확인할 Tool Pod 자체 주소는 다음과 같다.
| 기능 | 주소 예시 |
|---|---|
| MCP 표준 엔드포인트 | `http://localhost:8084/mcp` |
| Tool Manifest | `http://localhost:8084/tool-manifest` |
| Tool 테스트 콘솔 | `http://localhost:8084/tool-test-console.html` |
| Tool 직접 실행 | `POST http://localhost:8084/mcp/{toolName}` |
포트는 실행한 Pod에 맞게 바꾼다.
## 8. Redis와 Docker 사용 기준
- Tool DTO 작성, 컴파일, 단위 테스트에는 Docker가 필요하지 않다.
- 로컬 HTTP Mock이나 MCI Mock을 사용할 때만 관련 컨테이너를 실행한다.
- Redis가 없어도 Tool 목록과 Tool 자체 실행을 확인할 수 있도록 개발한다.
- Redis를 사용하는 부가 기능을 시험할 때만 Redis를 실행한다.
즉, Tool 개발의 최소 실행 단위는 **JDK 21 + 해당 Tool Pod**이다.
## 9. 내부망 반입 준비
신한라이프 내부망에는 다음 항목을 사전에 준비한다.
1. JDK 21 또는 Java 21 컨테이너 이미지
2. Gradle Wrapper 8.14.3 배포 파일
3. 사내 Nexus의 전체 Maven 의존성
4. Glow Framework 사내 JAR와 그 전이 의존성
5. MCP SDK 2.0.0 및 JSON Schema Validator 2.0.0
6. 사내 인증서와 JVM TrustStore
7. MCI·HTTP 대상 주소, 포트, ACL
8. 환경별 설정값과 Secret 주입 방식
컨테이너 빌드 시 사내 Registry에 Java 21 빌드·실행 이미지가 미러링되어 있어야 한다. 이미지 경로는 Dockerfile의 `FROM`만 사내 경로로 치환하고, Java 버전은 21로 유지한다.
## 10. 환경 점검 체크리스트
- [ ] `java -version`이 21이다.
- [ ] Gradle JVM이 JDK 21이다.
- [ ] `gradlew.bat --version`이 정상 실행된다.
- [ ] `:dap-was-lib:test`가 성공한다.
- [ ] 대상 Tool Pod의 `compileJava`가 성공한다.
- [ ] local 프로파일로 Tool Pod가 기동된다.
- [ ] `/tool-manifest`에서 Tool 목록이 조회된다.
- [ ] `/tool-test-console.html`에서 샘플 Tool이 실행된다.
- [ ] MCI·HTTP 연계 주소와 ACL이 환경별로 준비되어 있다.
- [ ] 소스와 설정 파일의 한글이 UTF-8로 정상 표시된다.

View File

@@ -0,0 +1,342 @@
# AX HUB Tool 설계 가이드
## 1. 설계 목표
Tool은 AI가 기능을 정확히 선택하고, 입력값을 안전하게 전달하며, 레거시 응답을 사람이 이해할 수 있는 형태로 반환하도록 설계한다.
핵심 원칙은 다음과 같다.
1. Tool의 업무 목적과 호출 조건을 명확히 작성한다.
2. AI용 DTO와 MCI·HTTP 전문 DTO를 분리한다.
3. 입력과 출력의 JSON Schema를 구체적으로 정의한다.
4. endpoint, timeout, 인증 값은 설정으로 분리한다.
5. 동일한 Tool명은 빌드 단계에서 차단한다.
## 2. Tool명 규칙
현재 V17 검증 규칙은 다음과 같다.
```regex
^[a-z][a-z0-9_]{2,63}$
```
권장 형식은 `{업무도메인}_{업무기능}_{행위}`이다.
```text
cmm_claim_search
cmm_comcode_lookup
smp_exchange_inquiry
ins_insurance_processor
```
설계 규칙:
- 소문자 영문, 숫자, 언더스코어만 사용한다.
- Pod명(`cus`, `sal`, `pro`, `sys`)은 Tool명에 넣지 않는다.
- 마침표와 공백을 사용하지 않는다.
- 구현 클래스명이나 인터페이스 ID만으로 이름을 만들지 않는다.
- 이름은 한 번 배포한 뒤 호환성을 위해 가급적 변경하지 않는다.
`mcp.namespace`를 설정하면 런타임 등록명 앞에 namespace가 붙을 수 있으므로, 기본값인 빈 문자열 사용 여부를 Pod 운영 기준과 함께 확정한다.
## 3. 업무 패키지 구조
```text
io.shinhanlife.dap.mcc
├─ biz.{category}
│ ├─ dto
│ ├─ converter
│ └─ usecase
│ └─ impl
└─ infra.itrf
├─ mci.{clientSystemCode}
│ └─ io
└─ http.{httpApiName}
└─ io
```
각 계층의 책임은 다음과 같다.
| 계층 | 책임 |
|---|---|
| `biz.*.dto` | AI가 이해하는 Tool 요청·응답 모델 |
| `biz.*.converter` | Tool DTO와 연계 전문 DTO 변환 |
| `biz.*.usecase` | `@McpTool`로 노출할 업무 계약 |
| `biz.*.usecase.impl` | 변환, Client 호출, 응답 해석 |
| `infra.itrf.mci.*` | MCI 인터페이스별 Client와 요청·응답 전문 |
| `infra.itrf.http.*` | HTTP API별 Client와 요청·응답 전문 |
한 UseCase 인터페이스에는 관련된 Tool 함수를 여러 개 선언할 수 있다. 이때 구현체에도 동일한 메서드를 추가하고, 각 메서드마다 독립된 `@McpTool``@GrowToolHint`를 지정한다. 공통 추상 UseCase 상속은 필수가 아니다.
## 4. Tool 선언
```java
@McpTool(
name = "cmm_claim_search",
title = "보험금 청구 상태 조회",
description = "청구번호 또는 계약번호로 보험금 청구 상태를 조회합니다."
)
@GrowToolHint(
register = true,
categoryKey = "cmm",
mappingId = "CLCNNB00001"
)
ClaimSearchResponse searchClaim(ClaimSearchRequest request);
```
| 항목 | 작성 기준 |
|---|---|
| `name` | 시스템 식별용 영문 Tool명 |
| `title` | 사용자 화면에 표시할 짧은 업무명 |
| `description` | Tool이 실제로 수행하는 기능 설명 |
| `categoryKey` | 업무 분류 코드 |
| `mappingId` | MCI 인터페이스 ID 또는 업무 연계 식별자 |
| `register` | `false`이면 개발 중인 Tool을 외부 노출 대상에서 제외 |
`title``description`은 같은 문장을 반복하지 않는다. `title`은 짧은 명칭, `description`은 대상·조건·결과를 포함한 한두 문장으로 작성한다.
## 5. Tool Schema V17 메타데이터
각 Tool에는 다음 경로의 정의 파일을 둔다.
```text
src/main/resources/tool-definitions/{categoryKey}/{toolName}.yml
```
필수 구성 예시는 다음과 같다.
```yaml
name: cmm_claim_search
display_name: 보험금 청구 상태 조회
version: 1.0.0
category_key: cmm
description:
function: 청구번호 또는 계약번호를 기준으로 청구 상태와 심사 결과를 조회한다.
when_to_use: 기존 청구 건의 진행 상태나 지급 결과를 확인할 때 사용한다.
when_not_to_use: 신규 보험금 청구 접수나 기존 청구 변경에는 사용하지 않는다.
io_limits: 청구번호 또는 계약번호 중 하나 이상이 필요하며 조회 기능만 제공한다.
display_description: 보험금 청구 상태와 심사 결과를 조회합니다.
example_queries:
- 내 보험금 청구가 어디까지 진행됐는지 알려줘
- 계약번호로 최근 청구 상태를 확인해줘
- 청구 심사 결과가 나왔는지 조회해줘
read_only: true
destructive: false
idempotent: true
parameters_schema:
type: object
properties:
claimNo:
type: string
description: 조회할 보험금 청구번호
additionalProperties: false
tags: [보험금, 청구조회]
legacy_interface_id: CLCNNB00001
required_env_keys: []
owner_org: MCP_TOOL
```
V17 검증 기준:
- `name`은 Tool명 정규식을 만족해야 한다.
- 표시명, 버전, 분류, 4종 설명은 비어 있으면 안 된다.
- 자연어 예시 질의는 3~10개이며 Tool명을 직접 포함하지 않는다.
- `read_only`, `destructive`, `idempotent`를 명시한다.
- Schema 최상위 타입은 `object`이다.
- 모든 속성에 `description`이 있어야 한다.
- `additionalProperties``false`이다.
## 6. Input Schema 설계
### 6.1 단순한 요청
필드 수가 적고 조건이 단순하면 DTO의 `@Schema`, `@McpToolParam`과 타입 정보를 이용해 생성한다.
```java
@Schema(
description = "보험금 청구번호",
example = "CLM202608100001",
requiredMode = Schema.RequiredMode.REQUIRED
)
private String claimNo;
```
### 6.2 복잡한 요청
중첩 객체, 조건부 필드, 정규식, 배열 제약처럼 복잡한 규칙은 JSON 리소스로 관리한다.
```text
src/main/resources/tool-schemas/{categoryKey}/{schema-name}-input-schema.json
```
```java
@GrowToolHint(
categoryKey = "cmm",
inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json"
)
```
명시한 `inputSchemaResource`가 있으면 리소스 Schema를 우선 사용한다. 리소스가 없을 때 DTO 기반 Schema를 사용한다.
## 7. Output Schema 설계
단순한 응답 DTO는 클래스에 `@McpOutputSchema`를 붙인다.
```java
@McpOutputSchema
public class ClaimSearchResponse {
// fields
}
```
복잡한 응답은 다음 리소스를 사용한다.
```text
src/main/resources/tool-schemas/{categoryKey}/{schema-name}-output-schema.json
```
```java
@GrowToolHint(
categoryKey = "cmm",
outputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-output-schema.json"
)
```
우선순위는 **명시 JSON 리소스 → `@McpOutputSchema` DTO 생성 → 미사용** 순서다. Output Schema를 명시한 Tool만 실행 결과를 해당 Schema로 검증한다.
## 8. 응답 DTO 작성 규칙
1. 코드와 라벨을 함께 제공한다: `status`, `statusLabel`.
2. null의 의미를 필드 설명에 명시한다: “심사 전이면 null이며 0원으로 해석하지 않는다.”
3. 조건부 필드는 조건을 설명한다: “status가 REJECTED일 때만 값이 있다.”
4. 배열에는 정렬 기준을 설명한다: “접수일 내림차순.”
5. 결과가 잘릴 수 있으면 `hasMore` 같은 필드를 제공한다.
6. 민감정보는 가능한 한 응답에 포함하지 않는다. 마스킹은 생략이 불가능할 때의 보조 수단이다.
7. 레거시 원문 응답을 그대로 반환하지 않고 Tool 응답 DTO에 필요한 값만 매핑한다.
배열 요소가 복합 객체이면 응답 DTO 안에 의미 있는 inner class를 정의할 수 있다.
```java
public class ActivityStatusResponse {
private List<ActivityItem> items;
public static class ActivityItem {
private String status;
private String statusLabel;
}
}
```
## 9. MCI 연동 설계
권장 호출 흐름은 다음과 같다.
```text
Tool Request
→ UseCaseImpl
→ Converter.toMciRequest()
→ Mci{System}Client
→ AxhubMciComponent
→ GlowMciComponent
→ Converter.toResponse()
→ Tool Response
```
파일 구성 예:
```text
Onnba3011Request.java
Onnba3011Response.java
Onnba3011Converter.java
Onnba3011UseCase.java
Onnba3011UseCaseImpl.java
MciCfpaClient.java
CLCNNB00001_I.java
CLCNNB00001_O.java
```
UseCaseImpl에서 직접 MCI 전문 필드를 하나씩 조립하지 않는다. 변환은 Converter에 두고, MCI Client는 변환이 끝난 전문을 받는다.
## 10. Glow HTTP 연동 설계
권장 호출 흐름은 다음과 같다.
```text
Tool Request
→ UseCaseImpl
→ Converter.toHttpRequest()
→ {HttpApiName}Client
→ AxhubHttpComponent
→ GlowHttpComponent
→ Converter.toResponse()
→ Tool Response
```
Client는 설정의 API 이름만 참조한다.
```java
@Component
@RequiredArgsConstructor
public class InsuranceClient {
private static final String API_NAME = "insurance";
private final AxhubHttpComponent http;
public <I, O> O call(I request, Class<O> responseType) {
return http.call(API_NAME, request, responseType);
}
}
```
URL과 HTTP 메서드는 `glow.communication.http.api-list`에 정의한다.
```yaml
glow:
communication:
http:
api-list:
- name: insurance
domain: ${AXHUB_INSURANCE_HTTP_DOMAIN:http://localhost:${server.port}}
url: ${AXHUB_INSURANCE_HTTP_URL:/api/mock/http/ins_insurance_processor}
method: POST
content-type: application/json;charset=UTF-8
biz-pod: false
```
`name`은 Client의 `API_NAME`과 같아야 한다. 업무 Java 코드에는 실제 host, port, path를 하드코딩하지 않는다.
## 11. 공통 헤더와 추적성
Tool 호출 시 다음 헤더는 선택적으로 받을 수 있다.
| 헤더 | 의미 |
|---|---|
| `trace-id` | 전체 업무 흐름 추적 ID. 연속 호출 동안 유지 |
| `request-id` | 개별 요청 ID. HTTP 호출 단위로 새 값 사용 가능 |
| `employee-id` | 암호화된 사번. 현재 필수 아님 |
| `X-Request-Id` | 호환용 요청 ID |
`AxhubHttpComponent`는 현재 요청 Context의 `trace-id`, `request-id`, 암호화 사번을 Glow HTTP 요청 헤더로 전달한다. 민감 헤더와 DTO 원문은 일반 로그에 그대로 남기지 않는다.
## 12. 빌드 시 자동 검증
```powershell
.\gradlew.bat validateMcpToolNames
.\gradlew.bat validateToolSchemaV17
```
- `validateMcpToolNames`: 모든 Tool 모듈의 `@McpTool(name)` 중복 검사
- `validateToolSchemaV17`: `@McpTool`과 V17 정의 파일의 필수 규칙 검사
- 각 Tool Pod의 `bootJar`는 두 검증 Task에 의존하므로 오류가 있으면 배포 JAR이 생성되지 않는다.
## 13. Tool 설계 완료 조건
- [ ] Tool명이 규칙에 맞고 중복되지 않는다.
- [ ] title, description, 사용/비사용 조건이 구체적이다.
- [ ] V17 Tool 정의 파일이 존재한다.
- [ ] AI DTO와 MCI·HTTP 전문 DTO가 분리되어 있다.
- [ ] Converter가 요청과 응답을 담당한다.
- [ ] endpoint와 timeout이 설정으로 분리되어 있다.
- [ ] Input/Output Schema의 단순·복잡 기준을 적용했다.
- [ ] 응답의 코드·라벨, null 의미, 조건, 배열 정렬, 잘림 여부를 설명했다.
- [ ] 불필요한 개인정보를 입출력에서 제거했다.

View File

@@ -0,0 +1,344 @@
# AX HUB Tool 개발 가이드 1차본
## 1. 목적과 적용 범위
이 문서는 신규 Tool을 설계한 뒤 실제 소스에 구현하고 단위·연계 테스트까지 완료하는 절차를 설명한다.
대상은 다음 Tool 모듈이다.
- `dap-was-cus`
- `dap-was-sal`
- `dap-was-pro`
- `dap-was-sys`
- 공통 기능이 필요한 경우 `dap-was-lib`
## 2. 개발 전 준비 자료
Tool 개발 착수 전에 다음 내용을 확보한다.
1. Tool 업무명과 자연어 사용 예시
2. 입력·출력 항목 정의
3. 필수값, 형식, 길이, 코드값
4. 조회/변경 여부와 사용자 승인 필요 여부
5. MCI 인터페이스 ID 또는 HTTP API명
6. 요청·응답 전문 및 오류 코드 정의
7. 개발·테스트 환경 endpoint와 ACL
8. 개인정보 포함 여부와 처리 기준
## 3. 대상 Tool Pod 선택
| Pod | 선택 기준 | 기본 포트 |
|---|---|---:|
| CUS | 고객 업무 | 8084 |
| SAL | 영업 업무 | 8082 |
| PRO | 상품 업무 | 8085 |
| SYS | 시스템·공통 시스템 업무 | 8086 |
Tool명에는 Pod명을 넣지 않는다. Pod는 배포와 장애 격리 단위이고 Tool명은 업무 기능 식별자다.
## 4. 구현 방식 선택
| 방식 | 선택 기준 | 핵심 Client |
|---|---|---|
| MCI | 인터페이스 ID와 정형 전문으로 연계 | `Mci{SystemCode}Client` |
| HTTP | JSON 기반 사내 API로 연계 | `{HttpApiName}Client` |
| 내부 로직 | 외부 연계 없이 계산·조회 가능 | UseCaseImpl 내부 서비스 |
외부 연계가 있어도 Tool UseCase의 입출력은 항상 AI가 이해할 수 있는 업무 DTO로 유지한다.
## 5. Scaffold로 기본 소스 생성
Scaffold 입력 시 최소한 다음 값을 정확히 지정한다.
| 항목 | 예시 | 설명 |
|---|---|---|
| Target Module | `dap-was-sal` | 소스가 생성될 Tool Pod |
| Domain Category | `cmm` | 업무 패키지 및 Schema 경로 |
| Base Name | `ClaimSearch` | Java 클래스명 기준 |
| Tool Name | `cmm_claim_search` | MCP Tool 식별자 |
| Title | `보험금 청구 상태 조회` | 화면 표시용 짧은 명칭 |
| Description | `청구번호 또는 계약번호로 상태를 조회합니다.` | 기능 설명 |
| Routing Type | `MCI` 또는 `HTTP` | 연계 방식 |
| Interface ID | `CLCNNB00001` | MCI 선택 시 |
| Client System Code | `CFPA` | MCI Client 패키지/이름 기준 |
| HTTP API Name | `insurance` | HTTP 설정과 Client 연결 키 |
기존 UseCase에 함수를 추가할 경우 대상 UseCase를 선택한다. Scaffold는 UseCase 인터페이스와 UseCaseImpl 양쪽에 같은 메서드를 추가해야 한다. MCI뿐 아니라 HTTP와 내부 로직도 여러 함수 구성을 지원해야 한다.
## 6. 생성 결과 확인
### 6.1 공통 업무 파일
```text
biz/{category}/dto/{BaseName}Request.java
biz/{category}/dto/{BaseName}Response.java
biz/{category}/converter/{BaseName}Converter.java
biz/{category}/usecase/{BaseName}UseCase.java
biz/{category}/usecase/impl/{BaseName}UseCaseImpl.java
```
### 6.2 MCI 선택 시
```text
infra/itrf/mci/{clientSystemCode}/Mci{ClientSystemCode}Client.java
infra/itrf/mci/{clientSystemCode}/io/{InterfaceId}_I.java
infra/itrf/mci/{clientSystemCode}/io/{InterfaceId}_O.java
```
### 6.3 HTTP 선택 시
```text
infra/itrf/http/{httpApiName}/{HttpApiName}Client.java
infra/itrf/http/{httpApiName}/io/{BaseName}HttpRequest.java
infra/itrf/http/{httpApiName}/io/{BaseName}HttpResponse.java
```
### 6.4 리소스
```text
tool-definitions/{categoryKey}/{toolName}.yml
tool-schemas/{categoryKey}/*-input-schema.json
tool-schemas/{categoryKey}/*-output-schema.json
mock-responses/{toolName}.json
```
복잡한 Schema를 사용하지 않으면 JSON Schema 파일은 생략할 수 있다.
## 7. DTO 작성
### 7.1 Request DTO
- 자연어에서 추출할 수 있는 업무 용어로 필드명을 정한다.
- 필수값과 예시를 명시한다.
- 날짜, 금액, 코드의 형식을 description에 적는다.
- 중첩 구조는 의미 있는 inner class 또는 별도 DTO로 만든다.
- `List<String>``List<Object>`를 구분하고 복합 배열은 요소 필드를 정의한다.
### 7.2 Response DTO
- 레거시 전문 전체가 아닌 사용자에게 필요한 정보만 반환한다.
- 상태 코드와 상태명을 같이 제공한다.
- null과 빈 문자열을 구분한다.
- 배열 정렬 기준과 추가 결과 존재 여부를 설명한다.
- Output Schema를 사용할 때 실제 반환값이 Schema의 타입과 required 조건을 만족해야 한다.
## 8. Converter 구현
Converter는 다음 두 방향을 담당한다.
```text
Tool Request → MCI/HTTP Request
MCI/HTTP Response → Tool Response
```
MapStruct를 기본으로 사용하되, 날짜·코드·중첩 객체처럼 자동 매핑이 어려운 항목은 명시적으로 변환한다.
```java
@Mapper(componentModel = "spring")
public interface ClaimSearchConverter {
CLCNNB00001_I toMciRequest(ClaimSearchRequest request);
ClaimSearchResponse toResponse(CLCNNB00001_O response);
}
```
필드명이 같더라도 중요한 업무 값은 테스트로 매핑 결과를 확인한다.
## 9. MCI Tool 구현 절차
1. 인터페이스 ID와 요청·응답 전문을 확정한다.
2. `{InterfaceId}_I`, `{InterfaceId}_O`에 전문 구조를 구현한다.
3. Converter에서 Tool DTO와 전문 DTO를 변환한다.
4. `Mci{SystemCode}Client``AxhubMciComponent`를 호출하도록 한다.
5. UseCaseImpl은 변환된 전문을 Client에 전달한다.
6. 응답 전문을 Tool Response로 변환한다.
7. 전문 오류 코드와 통신 예외를 사용자용 오류로 매핑한다.
권장 UseCaseImpl 형태:
```java
public ClaimSearchResponse searchClaim(ClaimSearchRequest request) {
CLCNNB00001_I mciRequest = converter.toMciRequest(request);
CLCNNB00001_O mciResponse = mciClient.call(mciRequest);
return converter.toResponse(mciResponse);
}
```
## 10. HTTP Tool 구현 절차
1. HTTP API Name, domain, url, method, content type을 확정한다.
2. HTTP 요청·응답 DTO를 작성한다.
3. `{HttpApiName}Client`를 작성한다.
4. Converter에서 Tool DTO와 HTTP DTO를 변환한다.
5. UseCaseImpl이 Client를 호출하고 응답을 변환한다.
6. 환경별 `application-glow-*.yml`에 API 설정을 반영한다.
```yaml
glow:
communication:
http:
connection-timeout: 5
read-timeout: 5
api-list:
- name: insurance
domain: ${AXHUB_INSURANCE_HTTP_DOMAIN:http://localhost:${server.port}}
url: ${AXHUB_INSURANCE_HTTP_URL:/api/mock/http/ins_insurance_processor}
method: POST
content-type: application/json;charset=UTF-8
biz-pod: false
```
`domain + url`이 최종 호출 주소가 된다. `name`은 Java Client의 `API_NAME`과 반드시 일치해야 한다.
## 11. V17 메타데이터 작성
`tool-definitions/{categoryKey}/{toolName}.yml`에서 다음 항목을 업무 기준으로 수정한다.
- `display_name`
- `description.function`
- `description.when_to_use`
- `description.when_not_to_use`
- `description.io_limits`
- `display_description`
- `example_queries` 3~10개
- `read_only`, `destructive`, `idempotent`
- `parameters_schema`
- `tags`
- `legacy_interface_id`
- `required_env_keys`
- `owner_org`
AI 자동 채움 결과는 초안으로만 사용하고, Tool 개발자가 인터페이스 정의서와 실제 소스를 기준으로 검수한다.
## 12. Input/Output Schema 적용
### 단순 Tool
- Request 필드에 `@Schema` 또는 `@McpToolParam`
- Response 클래스에 `@McpOutputSchema`
- DTO 기반 자동 Schema 생성
### 복잡한 Tool
- `tool-schemas/{categoryKey}`에 JSON 파일 작성
- `@GrowToolHint.inputSchemaResource` 지정
- `@GrowToolHint.outputSchemaResource` 지정
- 조건부 필드, 배열 요소, null 허용 여부까지 명시
JSON Schema에서 null을 허용하려면 타입 규칙에 명시해야 한다. Java 응답이 null을 반환하는데 Schema가 `string`이나 `integer`만 허용하면 실행 결과 검증에서 실패한다.
## 13. 로컬 Mock 테스트
local 프로파일에서는 공통 Mock HTTP endpoint를 사용할 수 있다.
```text
POST /api/mock/http/{toolName}
```
Mock 응답 파일은 다음 경로에서 Tool명과 맞춰 관리한다.
```text
src/main/resources/mock-responses/{toolName}.json
```
파일명이 다르거나 classpath에 포함되지 않으면 `Not found in blob store`와 같은 오류가 발생할 수 있다. 파일명, 대소문자, 리소스 경로를 확인한다.
## 14. Tool 테스트 콘솔
Tool Pod 실행 후 다음 주소로 접속한다.
```text
http://localhost:{podPort}/tool-test-console.html
```
테스트 순서:
1. Tool 목록 새로고침
2. 대상 Tool 선택
3. Schema 기반 샘플 요청 확인
4. 정상 요청 실행
5. 필수값 누락·형식 오류·경계값 실행
6. 응답 결과와 Output Schema 검증 확인
7. `trace-id`, `request-id` 및 처리 시간 확인
8. 테스트 케이스 저장 후 회귀 테스트
## 15. 자동 검증 명령
```powershell
# 공통 단위 테스트
.\gradlew.bat :dap-was-lib:test
# 대상 Pod 테스트
.\gradlew.bat :dap-was-cus:test
# Tool명 중복
.\gradlew.bat validateMcpToolNames
# Tool Schema V17
.\gradlew.bat validateToolSchemaV17
# 배포 산출물 생성: 위 두 검증이 자동 선행됨
.\gradlew.bat :dap-was-cus:bootJar
```
Docker는 컴파일과 일반 단위 테스트에 필요하지 않다. 실제 HTTP/MCI Mock 컨테이너를 사용하는 연계 테스트에서만 실행한다.
## 16. 장애 확인 순서
### Tool이 목록에 없을 때
1. UseCase 메서드에 `@McpTool`이 있는지 확인한다.
2. UseCase 구현체가 Spring Bean인지 확인한다.
3. `@GrowToolHint.register` 값을 확인한다.
4. Tool명과 V17 정의 파일의 `name`이 같은지 확인한다.
5. 애플리케이션의 component scan 범위를 확인한다.
### HTTP 호출이 실패할 때
1. Client의 `API_NAME`을 확인한다.
2. `glow.communication.http.api-list[].name`과 비교한다.
3. `domain + url`을 확인한다.
4. method와 content-type을 확인한다.
5. timeout, 인증서, DNS, ACL을 확인한다.
### MCI 호출이 실패할 때
1. 인터페이스 ID와 수신 서비스 ID를 확인한다.
2. host, port, uri를 확인한다.
3. 요청 전문 필드와 인코딩을 확인한다.
4. 공통 헤더와 세션성 값의 필요 여부를 확인한다.
5. MCI 응답 헤더의 결과 코드와 메시지를 확인한다.
### Output Schema 검증이 실패할 때
1. 실제 응답 JSON을 확인한다.
2. null 허용 여부를 확인한다.
3. integer/string/array/object 타입을 비교한다.
4. required 필드가 실제로 항상 존재하는지 확인한다.
5. JSON Schema와 `@McpOutputSchema` 중 어떤 방식이 선택됐는지 확인한다.
## 17. 개발 완료 체크리스트
- [ ] 올바른 Tool Pod와 category를 선택했다.
- [ ] Tool명이 정규식과 업무 명명 규칙을 만족한다.
- [ ] `@McpTool`의 title과 description을 구분해 작성했다.
- [ ] V17 정의 파일을 업무 내용으로 검수했다.
- [ ] Request/Response DTO와 연계 DTO를 분리했다.
- [ ] Converter 테스트를 작성했다.
- [ ] MCI 또는 HTTP Client가 공통 Component를 사용한다.
- [ ] endpoint와 Secret을 코드에 하드코딩하지 않았다.
- [ ] 정상·필수값 누락·타입 오류·경계값을 테스트했다.
- [ ] 응답에 불필요한 개인정보가 없다.
- [ ] Tool명 중복 검사와 V17 검증이 성공한다.
- [ ] 대상 Pod의 단위 테스트와 `bootJar`가 성공한다.
- [ ] Tool 테스트 콘솔에서 실제 실행 결과를 확인했다.
## 18. 1차본 이후 보완 대상
다음 항목은 운영 표준이 확정되면 2차본에 반영한다.
- 업무 도메인별 Pod 배치 기준의 상세화
- 인증·암호화 사번 처리 최종 규격
- 연계 오류 코드의 공통 사용자 메시지 표준
- Tool별 SLA, Retry, Circuit Breaker 기준
- 성능·부하·보안 테스트 기준
- 운영 모니터링과 장애 대응 절차

View File

@@ -0,0 +1,633 @@
from __future__ import annotations
import re
from pathlib import Path
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor
ROOT = Path(__file__).resolve().parent
OUTPUT_DIR = ROOT / "docx-professional"
SOURCES = [
(
ROOT / "01-Tool-개발환경-가이드.md",
"개발 환경 가이드",
"Tool Pod 개발 환경과 내부망 반입 기준",
("개발 환경", "실행·검증", "내부망 반입"),
),
(
ROOT / "02-Tool-설계-가이드.md",
"Tool 설계 가이드",
"명명·구조·Schema·MCI·Glow HTTP 설계 기준",
("명명·패키지", "Input·Output Schema", "MCI·Glow HTTP"),
),
(
ROOT / "03-Tool-개발가이드-1차.md",
"Tool 개발 가이드 1차본",
"신규 Tool 구현·검증·완료 절차",
("Scaffold", "구현·연계", "테스트·완료"),
),
]
FONT_BODY = "맑은 고딕"
FONT_CODE = "Consolas"
BLUE = "2E74B5"
DARK_BLUE = "1F4D78"
NAVY = "0B2545"
INK = "24364B"
MUTED = "667085"
LIGHT_BLUE = "E8EEF5"
LIGHT_GRAY = "F4F6F9"
SOFT_BLUE = "F1F6FB"
TABLE_ALT = "F8FAFC"
BORDER = "C9D2DE"
WHITE = "FFFFFF"
GOLD = "B88928"
CONTENT_DXA = 9360
TABLE_INDENT_DXA = 120
def set_run_font(run, name=FONT_BODY, size=None, color=None, bold=None, italic=None):
run.font.name = name
run._element.get_or_add_rPr().rFonts.set(qn("w:ascii"), name)
run._element.get_or_add_rPr().rFonts.set(qn("w:hAnsi"), name)
run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
if size is not None:
run.font.size = Pt(size)
if color is not None:
run.font.color.rgb = RGBColor.from_string(color)
if bold is not None:
run.bold = bold
if italic is not None:
run.italic = italic
def set_cell_shading(cell, fill):
tc_pr = cell._tc.get_or_add_tcPr()
shd = tc_pr.find(qn("w:shd"))
if shd is None:
shd = OxmlElement("w:shd")
tc_pr.append(shd)
shd.set(qn("w:fill"), fill)
def set_cell_margins(cell, top=80, start=120, bottom=80, end=120):
tc = cell._tc
tc_pr = tc.get_or_add_tcPr()
tc_mar = tc_pr.first_child_found_in("w:tcMar")
if tc_mar is None:
tc_mar = OxmlElement("w:tcMar")
tc_pr.append(tc_mar)
for edge, value in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
node = tc_mar.find(qn(f"w:{edge}"))
if node is None:
node = OxmlElement(f"w:{edge}")
tc_mar.append(node)
node.set(qn("w:w"), str(value))
node.set(qn("w:type"), "dxa")
def set_table_geometry(table, widths):
total = sum(widths)
if total != CONTENT_DXA:
widths[-1] += CONTENT_DXA - total
table.alignment = WD_TABLE_ALIGNMENT.LEFT
table.autofit = False
tbl_pr = table._tbl.tblPr
tbl_w = tbl_pr.find(qn("w:tblW"))
if tbl_w is None:
tbl_w = OxmlElement("w:tblW")
tbl_pr.append(tbl_w)
tbl_w.set(qn("w:w"), str(CONTENT_DXA))
tbl_w.set(qn("w:type"), "dxa")
tbl_ind = tbl_pr.find(qn("w:tblInd"))
if tbl_ind is None:
tbl_ind = OxmlElement("w:tblInd")
tbl_pr.append(tbl_ind)
tbl_ind.set(qn("w:w"), str(TABLE_INDENT_DXA))
tbl_ind.set(qn("w:type"), "dxa")
grid = table._tbl.tblGrid
for child in list(grid):
grid.remove(child)
for width in widths:
col = OxmlElement("w:gridCol")
col.set(qn("w:w"), str(width))
grid.append(col)
for row in table.rows:
for idx, cell in enumerate(row.cells):
cell.width = Inches(widths[idx] / 1440)
tc_w = cell._tc.get_or_add_tcPr().find(qn("w:tcW"))
if tc_w is None:
tc_w = OxmlElement("w:tcW")
cell._tc.get_or_add_tcPr().append(tc_w)
tc_w.set(qn("w:w"), str(widths[idx]))
tc_w.set(qn("w:type"), "dxa")
set_cell_margins(cell)
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
def repeat_table_header(row):
tr_pr = row._tr.get_or_add_trPr()
header = OxmlElement("w:tblHeader")
header.set(qn("w:val"), "true")
tr_pr.append(header)
def add_page_number(paragraph):
run = paragraph.add_run()
begin = OxmlElement("w:fldChar")
begin.set(qn("w:fldCharType"), "begin")
instruction = OxmlElement("w:instrText")
instruction.set(qn("xml:space"), "preserve")
instruction.text = " PAGE "
separate = OxmlElement("w:fldChar")
separate.set(qn("w:fldCharType"), "separate")
text = OxmlElement("w:t")
text.text = "1"
end = OxmlElement("w:fldChar")
end.set(qn("w:fldCharType"), "end")
run._r.extend([begin, instruction, separate, text, end])
set_run_font(run, size=9, color=MUTED)
def add_bottom_border(paragraph, color=BORDER, size="6"):
p_pr = paragraph._p.get_or_add_pPr()
p_bdr = p_pr.find(qn("w:pBdr"))
if p_bdr is None:
p_bdr = OxmlElement("w:pBdr")
p_pr.append(p_bdr)
bottom = OxmlElement("w:bottom")
bottom.set(qn("w:val"), "single")
bottom.set(qn("w:sz"), size)
bottom.set(qn("w:space"), "1")
bottom.set(qn("w:color"), color)
p_bdr.append(bottom)
def add_top_border(paragraph, color=BORDER, size="6"):
p_pr = paragraph._p.get_or_add_pPr()
p_bdr = p_pr.find(qn("w:pBdr"))
if p_bdr is None:
p_bdr = OxmlElement("w:pBdr")
p_pr.append(p_bdr)
top = OxmlElement("w:top")
top.set(qn("w:val"), "single")
top.set(qn("w:sz"), size)
top.set(qn("w:space"), "1")
top.set(qn("w:color"), color)
p_bdr.append(top)
def add_left_border(paragraph, color=BLUE, size="18"):
p_pr = paragraph._p.get_or_add_pPr()
p_bdr = p_pr.find(qn("w:pBdr"))
if p_bdr is None:
p_bdr = OxmlElement("w:pBdr")
p_pr.append(p_bdr)
left = OxmlElement("w:left")
left.set(qn("w:val"), "single")
left.set(qn("w:sz"), size)
left.set(qn("w:space"), "8")
left.set(qn("w:color"), color)
p_bdr.append(left)
def configure_styles(doc):
normal = doc.styles["Normal"]
normal.font.name = FONT_BODY
normal._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_BODY)
normal.font.size = Pt(11)
normal.font.color.rgb = RGBColor.from_string(INK)
normal.paragraph_format.space_before = Pt(0)
normal.paragraph_format.space_after = Pt(6)
normal.paragraph_format.line_spacing = 1.25
specs = {
"Title": (30, NAVY, 0, 8),
"Subtitle": (13.5, MUTED, 0, 18),
"Heading 1": (16, NAVY, 18, 10),
"Heading 2": (13, BLUE, 14, 7),
"Heading 3": (12, DARK_BLUE, 10, 5),
}
for style_name, (size, color, before, after) in specs.items():
style = doc.styles[style_name]
style.font.name = FONT_BODY
style._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_BODY)
style.font.size = Pt(size)
style.font.color.rgb = RGBColor.from_string(color)
style.font.bold = style_name != "Subtitle"
style.paragraph_format.space_before = Pt(before)
style.paragraph_format.space_after = Pt(after)
style.paragraph_format.keep_with_next = True
for style_name in ("List Bullet", "List Number"):
style = doc.styles[style_name]
style.font.name = FONT_BODY
style._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_BODY)
style.font.size = Pt(11)
style.paragraph_format.left_indent = Inches(0.375)
style.paragraph_format.first_line_indent = Inches(-0.188)
style.paragraph_format.space_after = Pt(4)
style.paragraph_format.line_spacing = 1.25
def configure_section_geometry(section):
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1)
section.right_margin = Inches(1)
section.header_distance = Inches(0.492)
section.footer_distance = Inches(0.492)
def clear_paragraph(paragraph):
for child in list(paragraph._p):
if child.tag != qn("w:pPr"):
paragraph._p.remove(child)
def set_page_number_start(section, start=1):
sect_pr = section._sectPr
page_num = sect_pr.find(qn("w:pgNumType"))
if page_num is None:
page_num = OxmlElement("w:pgNumType")
sect_pr.append(page_num)
page_num.set(qn("w:start"), str(start))
def configure_cover_section(section):
configure_section_geometry(section)
section.header.is_linked_to_previous = False
section.footer.is_linked_to_previous = False
clear_paragraph(section.header.paragraphs[0])
clear_paragraph(section.footer.paragraphs[0])
def configure_content_section(section, short_title):
configure_section_geometry(section)
section.header.is_linked_to_previous = False
section.footer.is_linked_to_previous = False
set_page_number_start(section, 1)
header_p = section.header.paragraphs[0]
clear_paragraph(header_p)
header_p.paragraph_format.space_after = Pt(4)
header_p.paragraph_format.tab_stops.add_tab_stop(Inches(6.5))
left = header_p.add_run(short_title)
set_run_font(left, size=8.5, color=NAVY, bold=True)
right = header_p.add_run("\tAX HUB · TOOL DEVELOPMENT STANDARD")
set_run_font(right, size=8.2, color=MUTED)
add_bottom_border(header_p, "D7DEE8", "4")
footer_p = section.footer.paragraphs[0]
clear_paragraph(footer_p)
footer_p.paragraph_format.tab_stops.add_tab_stop(Inches(6.5))
left = footer_p.add_run("INTERNAL USE · TOOL POD DEVELOPMENT")
set_run_font(left, size=8.2, color=MUTED)
right = footer_p.add_run("\t")
set_run_font(right, size=8.2, color=MUTED)
add_page_number(footer_p)
def add_inline(paragraph, text, default_size=11, default_color=None):
token_pattern = re.compile(r"(\*\*.+?\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))")
cursor = 0
for match in token_pattern.finditer(text):
if match.start() > cursor:
run = paragraph.add_run(text[cursor:match.start()])
set_run_font(run, size=default_size, color=default_color)
token = match.group(0)
if token.startswith("**"):
run = paragraph.add_run(token[2:-2])
set_run_font(run, size=default_size, color=default_color, bold=True)
elif token.startswith("`"):
run = paragraph.add_run(token[1:-1])
set_run_font(run, name=FONT_CODE, size=max(8.5, default_size - 1), color=DARK_BLUE)
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), "EEF2F6")
run._r.get_or_add_rPr().append(shd)
else:
label, url = re.match(r"\[([^\]]+)\]\(([^)]+)\)", token).groups()
run = paragraph.add_run(label)
set_run_font(run, size=default_size, color=BLUE)
run.underline = True
cursor = match.end()
if cursor < len(text):
run = paragraph.add_run(text[cursor:])
set_run_font(run, size=default_size, color=default_color)
def add_cover(doc, title, subtitle, doc_type, highlights):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Pt(88)
p.paragraph_format.space_after = Pt(20)
r = p.add_run("AX HUB | TOOL DEVELOPMENT STANDARD")
set_run_font(r, size=9.5, color=GOLD, bold=True)
title_p = doc.add_paragraph(style="Title")
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
title_p.paragraph_format.space_after = Pt(10)
add_inline(title_p, title, default_size=30, default_color=NAVY)
subtitle_p = doc.add_paragraph(style="Subtitle")
subtitle_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
add_inline(subtitle_p, subtitle, default_size=13.5, default_color=DARK_BLUE)
rule = doc.add_paragraph()
rule.paragraph_format.left_indent = Inches(1.55)
rule.paragraph_format.right_indent = Inches(1.55)
rule.paragraph_format.space_after = Pt(24)
add_bottom_border(rule, GOLD, "10")
focus = doc.add_paragraph()
focus.alignment = WD_ALIGN_PARAGRAPH.CENTER
focus.paragraph_format.space_after = Pt(86)
focus_run = focus.add_run(" · ".join(highlights))
set_run_font(focus_run, size=10.5, color=BLUE, bold=True)
rows = [
("문서 구분", doc_type),
("기준 버전", "1차본 | 2026-08-14"),
("적용 범위", "dap-was-lib 및 Tool Pod (CUS · SAL · PRO · SYS)"),
]
for label, value in rows:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.first_line_indent = Inches(0)
p.paragraph_format.space_after = Pt(5)
label_run = p.add_run(f"{label} | ")
set_run_font(label_run, size=9.2, color=MUTED, bold=True)
value_run = p.add_run(value)
set_run_font(value_run, size=9.5, color=INK)
def extract_section_titles(markdown_text):
return [
line.removeprefix("## ").strip()
for line in markdown_text.splitlines()
if line.startswith("## ")
]
def add_document_overview(doc, subtitle, markdown_text):
heading = doc.add_heading("문서 안내", level=1)
add_bottom_border(heading, LIGHT_BLUE, "8")
lead = doc.add_paragraph()
lead.paragraph_format.space_after = Pt(10)
run = lead.add_run(subtitle)
set_run_font(run, size=11.5, color=NAVY, bold=True)
p = doc.add_paragraph()
p.paragraph_format.left_indent = Inches(0.12)
p.paragraph_format.right_indent = Inches(0.12)
p.paragraph_format.space_before = Pt(2)
p.paragraph_format.space_after = Pt(12)
p_pr = p._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), SOFT_BLUE)
p_pr.append(shd)
add_left_border(p, BLUE, "20")
marker = p.add_run("READING GUIDE ")
set_run_font(marker, size=9, color=BLUE, bold=True)
body = p.add_run("이 문서는 Tool Pod 개발자가 설계·구현·검증 과정에서 바로 참고할 수 있도록 현재 소스 기준으로 정리했습니다.")
set_run_font(body, size=10.2, color=INK)
section_heading = doc.add_heading("주요 구성", level=2)
section_heading.paragraph_format.space_after = Pt(6)
for title in extract_section_titles(markdown_text):
item = doc.add_paragraph(style="List Bullet")
add_inline(item, title, default_size=10.2, default_color=INK)
rule = doc.add_paragraph()
rule.paragraph_format.space_before = Pt(6)
rule.paragraph_format.space_after = Pt(4)
add_bottom_border(rule, "D7DEE8", "4")
def is_table_separator(line):
cells = [c.strip() for c in line.strip().strip("|").split("|")]
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells)
def parse_table(lines, start):
rows = []
idx = start
while idx < len(lines) and lines[idx].strip().startswith("|"):
if not is_table_separator(lines[idx]):
rows.append([c.strip() for c in lines[idx].strip().strip("|").split("|")])
idx += 1
return rows, idx
def table_widths(rows):
col_count = max(len(row) for row in rows)
weights = []
for col in range(col_count):
max_len = max(len(row[col]) if col < len(row) else 0 for row in rows)
weights.append(max(8, min(max_len, 48)))
total = sum(weights)
widths = [max(900, round(CONTENT_DXA * weight / total)) for weight in weights]
scale = CONTENT_DXA / sum(widths)
widths = [round(width * scale) for width in widths]
widths[-1] += CONTENT_DXA - sum(widths)
return widths
def add_markdown_table(doc, rows):
if not rows:
return
col_count = max(len(row) for row in rows)
table = doc.add_table(rows=len(rows), cols=col_count)
table.style = "Table Grid"
for row_idx, values in enumerate(rows):
for col_idx in range(col_count):
cell = table.cell(row_idx, col_idx)
cell.text = ""
p = cell.paragraphs[0]
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.15
value = values[col_idx] if col_idx < len(values) else ""
add_inline(p, value, default_size=8.8 if col_count >= 4 else 9.2)
if row_idx == 0:
set_cell_shading(cell, NAVY)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in p.runs:
run.bold = True
run.font.color.rgb = RGBColor.from_string(WHITE)
elif row_idx % 2 == 0:
set_cell_shading(cell, TABLE_ALT)
if row_idx > 0:
column_values = [row[col_idx] if col_idx < len(row) else "" for row in rows[1:]]
if column_values and max(len(value) for value in column_values) <= 12:
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
repeat_table_header(table.rows[0])
set_table_geometry(table, table_widths(rows))
after = doc.add_paragraph()
after.paragraph_format.space_after = Pt(2)
def add_code_block(doc, code_lines):
if not code_lines:
return
p = doc.add_paragraph()
p.paragraph_format.left_indent = Inches(0.12)
p.paragraph_format.right_indent = Inches(0.12)
p.paragraph_format.space_before = Pt(3)
p.paragraph_format.space_after = Pt(8)
p.paragraph_format.line_spacing = 1.0
p.paragraph_format.keep_together = len(code_lines) <= 12
p_pr = p._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), "F5F7FA")
p_pr.append(shd)
add_left_border(p, BLUE, "16")
for index, line in enumerate(code_lines):
run = p.add_run(line)
set_run_font(run, name=FONT_CODE, size=8.2, color=NAVY)
if index < len(code_lines) - 1:
run.add_break()
def add_callout(doc, text):
p = doc.add_paragraph()
p.paragraph_format.left_indent = Inches(0.12)
p.paragraph_format.right_indent = Inches(0.12)
p.paragraph_format.space_before = Pt(3)
p.paragraph_format.space_after = Pt(8)
p_pr = p._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), SOFT_BLUE)
p_pr.append(shd)
add_left_border(p)
marker = p.add_run("NOTE ")
set_run_font(marker, size=9.5, color=BLUE, bold=True)
add_inline(p, text, default_size=10)
def add_markdown_body(doc, markdown_text):
lines = markdown_text.splitlines()
first_h1_seen = False
in_code = False
code_lines = []
idx = 0
while idx < len(lines):
raw = lines[idx]
line = raw.rstrip()
stripped = line.strip()
if stripped.startswith("```"):
if in_code:
add_code_block(doc, code_lines)
code_lines = []
in_code = False
else:
in_code = True
idx += 1
continue
if in_code:
code_lines.append(line)
idx += 1
continue
if not stripped:
idx += 1
continue
if stripped.startswith("# ") and not first_h1_seen:
first_h1_seen = True
idx += 1
continue
if stripped.startswith("### "):
doc.add_heading(stripped[4:], level=2)
idx += 1
continue
if stripped.startswith("## "):
heading = doc.add_heading(stripped[3:], level=1)
add_bottom_border(heading, LIGHT_BLUE, "8")
idx += 1
continue
if stripped.startswith("# "):
heading = doc.add_heading(stripped[2:], level=1)
add_bottom_border(heading, LIGHT_BLUE, "8")
idx += 1
continue
if stripped.startswith("|") and idx + 1 < len(lines) and is_table_separator(lines[idx + 1]):
rows, idx = parse_table(lines, idx)
add_markdown_table(doc, rows)
continue
if stripped.startswith("> "):
add_callout(doc, stripped[2:])
idx += 1
continue
match_ordered = re.match(r"^\d+\.\s+(.*)$", stripped)
if match_ordered:
p = doc.add_paragraph(style="List Number")
add_inline(p, match_ordered.group(1))
idx += 1
continue
if stripped.startswith("- [ ] "):
p = doc.add_paragraph(style="List Bullet")
add_inline(p, "" + stripped[6:])
idx += 1
continue
if stripped.startswith("- "):
p = doc.add_paragraph(style="List Bullet")
add_inline(p, stripped[2:])
idx += 1
continue
p = doc.add_paragraph()
add_inline(p, stripped)
idx += 1
if code_lines:
add_code_block(doc, code_lines)
def set_document_properties(doc, title):
props = doc.core_properties
props.title = title
props.subject = "AX HUB Tool 개발 표준"
props.author = "AX HUB MCP & TOOL"
props.keywords = "AX HUB, Tool Pod, MCP, Glow, MCI, HTTP"
props.comments = "Tool Pod 개발자용 문서"
def build(source_path, short_title, subtitle, highlights):
markdown = source_path.read_text(encoding="utf-8")
first_line = markdown.splitlines()[0]
full_title = first_line.removeprefix("# ").strip()
doc = Document()
configure_styles(doc)
set_document_properties(doc, full_title)
configure_cover_section(doc.sections[0])
add_cover(doc, full_title, subtitle, short_title, highlights)
content_section = doc.add_section(WD_SECTION.NEW_PAGE)
configure_content_section(content_section, short_title)
add_document_overview(doc, subtitle, markdown)
add_markdown_body(doc, markdown)
output = OUTPUT_DIR / f"{source_path.stem}.docx"
doc.save(output)
return output
def main():
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
for source, short_title, subtitle, highlights in SOURCES:
output = build(source, short_title, subtitle, highlights)
print(output)
if __name__ == "__main__":
main()

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import re
import zipfile
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
ROOT = Path(__file__).resolve().parent
DOCX_DIR = ROOT / "docx-professional"
def dxa(value):
return round(value.inches * 1440)
def all_text(doc):
values = [p.text for p in doc.paragraphs]
for table in doc.tables:
for row in table.rows:
values.extend(cell.text for cell in row.cells)
return "\n".join(values)
def table_geometry_errors(table, index):
errors = []
tbl_pr = table._tbl.tblPr
tbl_w = tbl_pr.find(qn("w:tblW"))
if tbl_w is None or tbl_w.get(qn("w:w")) != "9360":
errors.append(f"table {index}: tblW != 9360")
tbl_ind = tbl_pr.find(qn("w:tblInd"))
if tbl_ind is None or tbl_ind.get(qn("w:w")) != "120":
errors.append(f"table {index}: tblInd != 120")
grid_widths = [int(c.get(qn("w:w"))) for c in table._tbl.tblGrid]
if sum(grid_widths) != 9360:
errors.append(f"table {index}: grid sum={sum(grid_widths)}")
for row_index, row in enumerate(table.rows):
widths = []
for cell in row.cells:
tc_w = cell._tc.get_or_add_tcPr().find(qn("w:tcW"))
if tc_w is None:
errors.append(f"table {index} row {row_index}: missing tcW")
continue
widths.append(int(tc_w.get(qn("w:w"))))
if widths and sum(widths) != 9360:
errors.append(f"table {index} row {row_index}: cell sum={sum(widths)}")
return errors
def verify(source, output):
errors = []
with zipfile.ZipFile(output) as package:
bad = package.testzip()
if bad:
errors.append(f"broken ZIP member: {bad}")
doc = Document(output)
text = all_text(doc)
source_text = source.read_text(encoding="utf-8")
expected_title = source_text.splitlines()[0].removeprefix("# ").strip()
if expected_title not in text:
errors.append("title missing")
for heading in re.findall(r"^##\s+(.+)$", source_text, re.MULTILINE):
if heading not in text:
errors.append(f"heading missing: {heading}")
if "<EFBFBD>" in text or "\ufeff" in text:
errors.append("invalid/replacement Unicode character")
if re.search(r"\b(gateway|portal|router)\b", text, re.IGNORECASE):
errors.append("out-of-scope routing term found")
if len(doc.paragraphs) < 35:
errors.append(f"too few paragraphs: {len(doc.paragraphs)}")
if len(doc.tables) < 2:
errors.append(f"too few tables: {len(doc.tables)}")
for section_index, section in enumerate(doc.sections):
values = {
"page_width": dxa(section.page_width),
"page_height": dxa(section.page_height),
"top_margin": dxa(section.top_margin),
"bottom_margin": dxa(section.bottom_margin),
"left_margin": dxa(section.left_margin),
"right_margin": dxa(section.right_margin),
}
expected = {
"page_width": 12240,
"page_height": 15840,
"top_margin": 1440,
"bottom_margin": 1440,
"left_margin": 1440,
"right_margin": 1440,
}
for key, expected_value in expected.items():
if abs(values[key] - expected_value) > 2:
errors.append(f"section {section_index}: {key}={values[key]}")
footer_xml = section.footer._element.xml
if section_index > 0 and " PAGE " not in footer_xml:
errors.append(f"section {section_index}: page field missing")
for index, table in enumerate(doc.tables):
errors.extend(table_geometry_errors(table, index))
return {
"file": output.name,
"paragraphs": len(doc.paragraphs),
"tables": len(doc.tables),
"sections": len(doc.sections),
"errors": errors,
}
def main():
pairs = [
(ROOT / "01-Tool-개발환경-가이드.md", DOCX_DIR / "01-Tool-개발환경-가이드.docx"),
(ROOT / "02-Tool-설계-가이드.md", DOCX_DIR / "02-Tool-설계-가이드.docx"),
(ROOT / "03-Tool-개발가이드-1차.md", DOCX_DIR / "03-Tool-개발가이드-1차.docx"),
]
failed = False
for source, output in pairs:
result = verify(source, output)
print(f"{result['file']}: paragraphs={result['paragraphs']}, tables={result['tables']}, sections={result['sections']}")
for error in result["errors"]:
failed = True
print(f" ERROR: {error}")
if failed:
raise SystemExit(1)
print("DOCX structural QA passed")
if __name__ == "__main__":
main()