fix: tolerate AI manifest YAML colons
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 37s
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 37s
This commit is contained in:
55
README.md
55
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user