From 69204cb6dd06308347d6f3fd5abb23877a23b940 Mon Sep 17 00:00:00 2001 From: jade Date: Thu, 3 Sep 2026 14:16:57 +0900 Subject: [PATCH] fix: tolerate AI manifest YAML colons --- README.md | 55 ++++++++++++------- .../presentation/ScaffoldingController.java | 3 +- .../dat/lib/util/PodScaffolder.java | 53 +++++++++++++++++- .../dat/lib/util/PodScaffolderTest.java | 23 ++++++++ 4 files changed, 112 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index d403d24a..aa00fd70 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java b/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java index 8f2f6e22..e91c57c2 100644 --- a/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java +++ b/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java @@ -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) diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java index 0ea09574..2e8b60ae 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/PodScaffolder.java @@ -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 targetModules) throws IOException { - Map root = YAML_MAPPER.readValue(source, Map.class); + Map 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 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 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 allowedTargetModules(Path rootDir, String moduleName, List targetModules) throws IOException { List candidates = targetModules == null || targetModules.isEmpty() diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/PodScaffolderTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/PodScaffolderTest.java index 41086354..43797cb4 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/PodScaffolderTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/PodScaffolderTest.java @@ -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 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");