fix: tolerate AI manifest YAML colons
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 37s

This commit is contained in:
jade
2026-09-03 14:16:57 +09:00
parent 82c087e2da
commit 69204cb6dd
4 changed files with 112 additions and 22 deletions

View File

@@ -36,10 +36,13 @@ Gateway (dat-gateway, 8081)
+-------------------------+
| |
v v
SMS Tool Pod (dat-was-sms, 8082) OTH Tool Pod (dat-was-oth, 8084)
- SMS 업무 Tool - 공통/업무/MCI 연계 Tool
Sales Tool Pod (dat-was-sal, 8082) Customer Tool Pod (dat-was-cus, 8084)
- 영업/알림 업무 Tool - 고객/공통 업무 Tool
- Tool Manifest - Tool Manifest
- Pod Test Console - Pod Test Console
|
+--> Process Tool Pod (dat-was-pro, 8085)
+--> System Tool Pod (dat-was-sys, 8086)
|
v
MCI / EAI / EIMS 등 레거시 연계 대상
@@ -53,10 +56,12 @@ Gateway는 외부 MCP 진입점과 Tool 등록·라우팅을 담당하고, 실
|---|---|---:|
| `dat-gateway` | MCP Gateway, Registry, 라우팅, Chat·Catalog·Tester·Scaffold 화면 | 8081 |
| `dat-was-lib` | Tool 공통 라이브러리: 어노테이션, Schema, Manifest, MCI/EAI 어댑터, 공통 Web·보안 기능 | - |
| `dat-was-sms` | SMS/알림 업무 Tool Pod | 8082 |
| `dat-was-oth` | 공통/샘플/MCI 연계 업무 Tool Pod | 8084 |
| `dat-was-sal` | 영업/알림 업무 Tool Pod | 8082 |
| `dat-was-cus` | 고객/공통 업무 Tool Pod | 8084 |
| `dat-was-pro` | 프로세스 업무 Tool Pod | 8085 |
| `dat-was-sys` | 시스템 업무 Tool Pod | 8086 |
> 기존 `dap-tool-core`, `dap-tool-sms`, `dap-tool-oth` 명칭은 현재 각각 `dat-was-lib`, `dat-was-sms`, `dat-was-oth`로 변경되었습니다.
> 기존 `dap-tool-core`, `dap-tool-sms`, `dap-tool-oth` 명칭은 현재 각각 `dat-was-lib`, `dat-was-sal`, `dat-was-cus`로 변경되었습니다.
## 3. 개발 환경
@@ -98,8 +103,10 @@ docker compose up -d --build
| 서비스 | 호스트 URL | 컨테이너 포트 |
|---|---|---:|
| Gateway | http://localhost:8281 | 8081 |
| SMS Tool Pod | http://localhost:8282 | 8082 |
| OTH Tool Pod | http://localhost:8284 | 8084 |
| Sales Tool Pod | http://localhost:8282 | 8082 |
| Customer Tool Pod | http://localhost:8284 | 8084 |
| Process Tool Pod | http://localhost:8285 | 8085 |
| System Tool Pod | http://localhost:8286 | 8086 |
| MCI Mock | http://localhost:8089 | 8080 |
| Dozzle 로그 화면 | http://localhost:8288 | 8080 |
@@ -124,8 +131,10 @@ Docker Compose로 실행한 경우 Gateway 화면은 포트 `8281`을 사용합
| 대상 Pod | 로컬 URL | Docker Compose URL |
|---|---|---|
| OTH | http://localhost:8084/tool-test-console.html | http://localhost:8284/tool-test-console.html |
| SMS | http://localhost:8082/tool-test-console.html | http://localhost:8282/tool-test-console.html |
| Customer | http://localhost:8084/tool-test-console.html | http://localhost:8284/tool-test-console.html |
| Sales | http://localhost:8082/tool-test-console.html | http://localhost:8282/tool-test-console.html |
| Process | http://localhost:8085/tool-test-console.html | http://localhost:8285/tool-test-console.html |
| System | http://localhost:8086/tool-test-console.html | http://localhost:8286/tool-test-console.html |
사용 방법은 다음과 같습니다.
@@ -214,15 +223,15 @@ Request DTO
-> Legacy System
```
- Request DTO에는 `@McpValidation`으로 단순 입력 검증을 선언합니다.
- Request DTO 필드에는 `@Schema(description = "...")`와 Bean Validation 어노테이션을 선언합니다.
- 복잡하거나 조건부 규칙이 필요한 입력은 `tool-schemas/{categoryKey}/...-input-schema.json` 리소스를 사용합니다.
- 응답 `@McpOutputSchema`로 간단한 설명을 제공하고, 복잡한 응답은 Output Schema 리소스로 명시합니다.
- 응답 Schema가 필요한 경우 Response DTO에 `@McpOutputSchema`를 선언하고, 복잡한 경우 Output Schema 리소스로 명시합니다.
- `null`의 업무 의미, 조건부 필드, 배열 정렬 기준, `hasMore` 여부, 민감 정보 제외 원칙을 Schema 설명에 명확히 씁니다.
### Schema 우선순위
1. `inputSchemaResource` 또는 `outputSchemaResource`가 지정된 경우: 해당 JSON Schema를 사용합니다.
2. 리소스가 없으면: DTO 어노테이션(`@McpValidation`, `@McpOutputSchema`)과 공통 Generator로 생성합니다.
2. 리소스가 없으면: DTO`@Schema` 정보와 공통 Generator로 생성합니다. 응답 Schema는 `@McpOutputSchema`가 선언된 DTO만 자동 생성합니다.
## 8. Manifest와 Registry
@@ -236,8 +245,10 @@ Gateway는 Registry 등록 및 Heartbeat 정보를 이용해 Tool을 라우팅
```text
dat-was-lib/src/main/resources/glow/application-glow*.yml
dat-was-sms/src/main/resources/application*.yml
dat-was-oth/src/main/resources/application*.yml
dat-was-sal/src/main/resources/application*.yml
dat-was-cus/src/main/resources/application*.yml
dat-was-pro/src/main/resources/application*.yml
dat-was-sys/src/main/resources/application*.yml
```
MCI 호출 주소는 일반적으로 `host + uri`로 구성합니다. 예를 들어 `host=https://dev-ichmci.shinhanlife.co.kr`, `uri=/ntl_mci/clc_rcv`이면 호출 대상은 다음과 같습니다.
@@ -285,8 +296,10 @@ https://dev-ichmci.shinhanlife.co.kr/ntl_mci/clc_rcv
| Manifest | `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/manifest` |
| Tool Scaffold | `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java` |
| Gateway 라우터 | `dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/McpRouterController.java` |
| OTH Tool | `dat-was-oth/src/main/java` |
| SMS Tool | `dat-was-sms/src/main/java` |
| Customer Tool | `dat-was-cus/src/main/java` |
| Sales Tool | `dat-was-sal/src/main/java` |
| Process Tool | `dat-was-pro/src/main/java` |
| System Tool | `dat-was-sys/src/main/java` |
## 13. 모듈명 전환 검증
Tool 관련 공통 기능은 `dat-was-*` 모듈명만 기준으로 동작합니다.
@@ -318,11 +331,13 @@ destructive, idempotent), `parameters_schema`입니다. 입력 Schema는 루트
입력 Schema 우선순위는 `inputSchemaResource` → V17 `parameters_schema` → DTO 자동 생성이고, 출력은
`outputSchemaResource` → 명시 Output Schema → `@McpOutputSchema` 기반 생성입니다. Output Schema를
명시한 Tool만 최종 응답 검증을 수행합니다.
명시한 Tool만 최종 응답 검증을 수행합니다. 현재 Scaffold는 Tool Definition V17 YAML과 응답 Mock JSON을
자동 생성하지 않고, 생성한 Java Tool의 `@GrowToolHint`를 기준으로 메타데이터를 제공합니다.
Scaffold 화면의 `Tool Schema V17 Metadata` 영역에서는 기능 설명, 사용/비사용 조건, 입출력 제한,
표시 설명, 예시 질의, 태그와 소유 조직을 입력합니다. Java 소스와 함께 V17 YAML이 생성되며, 기본값은
배포 전에 업무 담당자가 실제 의미에 맞게 검토해야 합니다.
Scaffold의 Pod Module 생성은 `local`, `dev`, `test`, `prod` 프로필 파일과
`src/main/resources/tool-service-manifest.yml`을 만듭니다. 이 파일은 라우팅용 설정이며 `/tool-manifest`
API 응답을 대체하지 않습니다. AI 초안은 한국어 YAML로 생성하며, 선택한 Target Module 목록에서 자기
자신을 제외해 `confusable-servers`에 반영합니다. Tool Function의 기본 Protocol은 MCI(Legacy)입니다.
```powershell
.\gradlew.bat validateMcpToolNames validateToolSchemaV17

View File

@@ -106,6 +106,7 @@ public class ScaffoldingController {
confusable-servers must contain only other plausible target server IDs and must never contain the current server-id.
descriptions, business domains, outcomes, entities, capabilities, selection/rejection rules,
and decision policy must all be natural and specific Korean text.
Enclose every scalar string value in double quotes so Korean text containing a colon (for example "예: 급여") remains valid YAML.
Use valid YAML only, without Markdown fences or explanations.
Pod module: %s
Business description: %s
@@ -471,7 +472,7 @@ public class ScaffoldingController {
if ("Qwen3-Coder".equalsIgnoreCase(resolvedModel) || "Gemma-4-31B".equalsIgnoreCase(resolvedModel)) {
String liteLlmBaseUrl = "https://dev-iam-litellm.shinhanlife.co.kr:18020";
String apiKey = "Gemma-4-31B".equalsIgnoreCase(resolvedModel) ? "sk-EH107wYBZuU6RUTqthR17A" : "sk-UJ2IRenvaMPbY3ozQdj6zw";
String apiKey = "Gemma-4-31B".equalsIgnoreCase(resolvedModel) ? "sk-EH107wYBZuu6RUTqthRl7A" : "sk-UJ2IRenvaMPbY3ozQdj6zw";
org.springframework.ai.openai.api.OpenAiApi openAiApi = org.springframework.ai.openai.api.OpenAiApi.builder()
.baseUrl(liteLlmBaseUrl)

View File

@@ -16,6 +16,7 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
@@ -475,7 +476,7 @@ public class PodScaffolder {
private static String normalizeToolServiceManifest(String source, Path rootDir, String moduleName, String categoryKey,
List<String> targetModules)
throws IOException {
Map<String, Object> root = YAML_MAPPER.readValue(source, Map.class);
Map<String, Object> root = parseManifestYaml(source);
if (!(root.get("mcp") instanceof Map<?, ?> mcp)
|| !(mcp.get("manifest") instanceof Map<?, ?> manifest)) {
throw new IOException("tool-service-manifest.yml의 mcp.manifest.routing-functions 형식이 올바르지 않습니다.");
@@ -526,6 +527,56 @@ public class PodScaffolder {
}
}
@SuppressWarnings("unchecked")
private static Map<String, Object> parseManifestYaml(String source) throws IOException {
try {
return YAML_MAPPER.readValue(source, Map.class);
} catch (IOException original) {
String normalized = quoteUnquotedManifestScalars(source);
if (normalized.equals(source)) {
throw original;
}
return YAML_MAPPER.readValue(normalized, Map.class);
}
}
private static String quoteUnquotedManifestScalars(String source) {
Set<String> stringKeys = Set.of(
"name", "server-id", "category-key", "product-boundary", "business-domain",
"business-outcome", "select-if", "reject-if", "decision-policy", "description-serialization");
Pattern property = Pattern.compile("^(\\s*)([a-z-]+):(\\s*)(.*)$");
Pattern listItem = Pattern.compile("^(\\s*-\\s+)(.*)$");
StringBuilder normalized = new StringBuilder();
for (String line : source.split("\\r?\\n", -1)) {
Matcher propertyMatcher = property.matcher(line);
if (propertyMatcher.matches() && stringKeys.contains(propertyMatcher.group(2))) {
String value = propertyMatcher.group(4).trim();
if (!value.isEmpty() && !isQuoted(value)) {
line = propertyMatcher.group(1) + propertyMatcher.group(2) + ": " + yamlQuoted(value);
}
} else {
Matcher listItemMatcher = listItem.matcher(line);
if (listItemMatcher.matches()) {
String value = listItemMatcher.group(2).trim();
if (value.contains(":") && !isQuoted(value) && !value.matches("^[a-z-]+:.*$")) {
line = listItemMatcher.group(1) + yamlQuoted(value);
}
}
}
normalized.append(line).append(System.lineSeparator());
}
return normalized.toString();
}
private static boolean isQuoted(String value) {
return (value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'"));
}
private static String yamlQuoted(String value) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
private static List<String> allowedTargetModules(Path rootDir, String moduleName, List<String> targetModules)
throws IOException {
List<String> candidates = targetModules == null || targetModules.isEmpty()

View File

@@ -114,6 +114,29 @@ class PodScaffolderTest {
assertTrue(compose.contains("SPRING_PROFILES_ACTIVE=${ACTIVE_PROFILE:-local}"));
}
@Test
void normalizesAiManifestWhenAnUnquotedKoreanDescriptionContainsColon() throws Exception {
String aiManifest = """
mcp:
manifest:
routing-functions:
- name: route_to_dat-was-ehr
server-id: dat-was-ehr
category-key: ehr
business-outcome: 인사 정보와 휴가 정보를 제공합니다.
reject-if: 인사 운영의 핵심 기능(예: 급여, 승진, 보상)과 관련된 경우 다른 서버로 전달합니다.
confusable-servers: [dat-was-hrd, dat-was-pay]
""";
String normalized = PodScaffolder.normalizeToolServiceManifest(aiManifest, "dat-was-ehr",
List.of("dat-was-cus", "dat-was-ehr", "dat-was-sal"));
Map<String, Object> routingFunction = routingFunction(normalized);
assertEquals("인사 운영의 핵심 기능(예: 급여, 승진, 보상)과 관련된 경우 다른 서버로 전달합니다.",
routingFunction.get("reject-if"));
assertEquals(List.of("dat-was-cus", "dat-was-sal"), routingFunction.get("confusable-servers"));
}
@Test
void generatesRedisOptionalPodComposeConfiguration() throws Exception {
Files.writeString(root.resolve("settings.gradle"), "rootProject.name = 'test'\n");