feat: implement MCI session fetching using employee number
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 1m3s
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 1m3s
This commit is contained in:
196
docs/tool-guide/01-Tool-개발환경-가이드.md
Normal file
196
docs/tool-guide/01-Tool-개발환경-가이드.md
Normal 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로 정상 표시된다.
|
||||
|
||||
342
docs/tool-guide/02-Tool-설계-가이드.md
Normal file
342
docs/tool-guide/02-Tool-설계-가이드.md
Normal 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 의미, 조건, 배열 정렬, 잘림 여부를 설명했다.
|
||||
- [ ] 불필요한 개인정보를 입출력에서 제거했다.
|
||||
|
||||
344
docs/tool-guide/03-Tool-개발가이드-1차.md
Normal file
344
docs/tool-guide/03-Tool-개발가이드-1차.md
Normal 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 기준
|
||||
- 성능·부하·보안 테스트 기준
|
||||
- 운영 모니터링과 장애 대응 절차
|
||||
Binary file not shown.
Binary file not shown.
633
docs/tool-guide/build_tool_guides_docx.py
Normal file
633
docs/tool-guide/build_tool_guides_docx.py
Normal 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()
|
||||
BIN
docs/tool-guide/docx-professional/01-Tool-개발환경-가이드.docx
Normal file
BIN
docs/tool-guide/docx-professional/01-Tool-개발환경-가이드.docx
Normal file
Binary file not shown.
BIN
docs/tool-guide/docx-professional/02-Tool-설계-가이드.docx
Normal file
BIN
docs/tool-guide/docx-professional/02-Tool-설계-가이드.docx
Normal file
Binary file not shown.
BIN
docs/tool-guide/docx-professional/03-Tool-개발가이드-1차.docx
Normal file
BIN
docs/tool-guide/docx-professional/03-Tool-개발가이드-1차.docx
Normal file
Binary file not shown.
BIN
docs/tool-guide/docx/01-Tool-개발환경-가이드.docx
Normal file
BIN
docs/tool-guide/docx/01-Tool-개발환경-가이드.docx
Normal file
Binary file not shown.
BIN
docs/tool-guide/docx/02-Tool-설계-가이드.docx
Normal file
BIN
docs/tool-guide/docx/02-Tool-설계-가이드.docx
Normal file
Binary file not shown.
BIN
docs/tool-guide/docx/03-Tool-개발가이드-1차.docx
Normal file
BIN
docs/tool-guide/docx/03-Tool-개발가이드-1차.docx
Normal file
Binary file not shown.
132
docs/tool-guide/verify_tool_guides_docx.py
Normal file
132
docs/tool-guide/verify_tool_guides_docx.py
Normal 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()
|
||||
Reference in New Issue
Block a user