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
|
v v
|
||||||
SMS Tool Pod (dat-was-sms, 8082) OTH Tool Pod (dat-was-oth, 8084)
|
Sales Tool Pod (dat-was-sal, 8082) Customer Tool Pod (dat-was-cus, 8084)
|
||||||
- SMS 업무 Tool - 공통/업무/MCI 연계 Tool
|
- 영업/알림 업무 Tool - 고객/공통 업무 Tool
|
||||||
- Tool Manifest - Tool Manifest
|
- Tool Manifest - Tool Manifest
|
||||||
- Pod Test Console - Pod Test Console
|
- Pod Test Console - Pod Test Console
|
||||||
|
|
|
||||||
|
+--> Process Tool Pod (dat-was-pro, 8085)
|
||||||
|
+--> System Tool Pod (dat-was-sys, 8086)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
MCI / EAI / EIMS 등 레거시 연계 대상
|
MCI / EAI / EIMS 등 레거시 연계 대상
|
||||||
@@ -53,10 +56,12 @@ Gateway는 외부 MCP 진입점과 Tool 등록·라우팅을 담당하고, 실
|
|||||||
|---|---|---:|
|
|---|---|---:|
|
||||||
| `dat-gateway` | MCP Gateway, Registry, 라우팅, Chat·Catalog·Tester·Scaffold 화면 | 8081 |
|
| `dat-gateway` | MCP Gateway, Registry, 라우팅, Chat·Catalog·Tester·Scaffold 화면 | 8081 |
|
||||||
| `dat-was-lib` | Tool 공통 라이브러리: 어노테이션, Schema, Manifest, MCI/EAI 어댑터, 공통 Web·보안 기능 | - |
|
| `dat-was-lib` | Tool 공통 라이브러리: 어노테이션, Schema, Manifest, MCI/EAI 어댑터, 공통 Web·보안 기능 | - |
|
||||||
| `dat-was-sms` | SMS/알림 업무 Tool Pod | 8082 |
|
| `dat-was-sal` | 영업/알림 업무 Tool Pod | 8082 |
|
||||||
| `dat-was-oth` | 공통/샘플/MCI 연계 업무 Tool Pod | 8084 |
|
| `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. 개발 환경
|
## 3. 개발 환경
|
||||||
|
|
||||||
@@ -98,8 +103,10 @@ docker compose up -d --build
|
|||||||
| 서비스 | 호스트 URL | 컨테이너 포트 |
|
| 서비스 | 호스트 URL | 컨테이너 포트 |
|
||||||
|---|---|---:|
|
|---|---|---:|
|
||||||
| Gateway | http://localhost:8281 | 8081 |
|
| Gateway | http://localhost:8281 | 8081 |
|
||||||
| SMS Tool Pod | http://localhost:8282 | 8082 |
|
| Sales Tool Pod | http://localhost:8282 | 8082 |
|
||||||
| OTH Tool Pod | http://localhost:8284 | 8084 |
|
| 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 |
|
| MCI Mock | http://localhost:8089 | 8080 |
|
||||||
| Dozzle 로그 화면 | http://localhost:8288 | 8080 |
|
| Dozzle 로그 화면 | http://localhost:8288 | 8080 |
|
||||||
|
|
||||||
@@ -124,8 +131,10 @@ Docker Compose로 실행한 경우 Gateway 화면은 포트 `8281`을 사용합
|
|||||||
|
|
||||||
| 대상 Pod | 로컬 URL | Docker Compose URL |
|
| 대상 Pod | 로컬 URL | Docker Compose URL |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| OTH | http://localhost:8084/tool-test-console.html | http://localhost:8284/tool-test-console.html |
|
| Customer | 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 |
|
| 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
|
-> Legacy System
|
||||||
```
|
```
|
||||||
|
|
||||||
- Request DTO에는 `@McpValidation`으로 단순 입력 검증을 선언합니다.
|
- Request DTO 필드에는 `@Schema(description = "...")`와 Bean Validation 어노테이션을 선언합니다.
|
||||||
- 복잡하거나 조건부 규칙이 필요한 입력은 `tool-schemas/{categoryKey}/...-input-schema.json` 리소스를 사용합니다.
|
- 복잡하거나 조건부 규칙이 필요한 입력은 `tool-schemas/{categoryKey}/...-input-schema.json` 리소스를 사용합니다.
|
||||||
- 응답은 `@McpOutputSchema`로 간단한 설명을 제공하고, 복잡한 응답은 Output Schema 리소스로 명시합니다.
|
- 응답 Schema가 필요한 경우 Response DTO에 `@McpOutputSchema`를 선언하고, 복잡한 경우 Output Schema 리소스로 명시합니다.
|
||||||
- `null`의 업무 의미, 조건부 필드, 배열 정렬 기준, `hasMore` 여부, 민감 정보 제외 원칙을 Schema 설명에 명확히 씁니다.
|
- `null`의 업무 의미, 조건부 필드, 배열 정렬 기준, `hasMore` 여부, 민감 정보 제외 원칙을 Schema 설명에 명확히 씁니다.
|
||||||
|
|
||||||
### Schema 우선순위
|
### Schema 우선순위
|
||||||
|
|
||||||
1. `inputSchemaResource` 또는 `outputSchemaResource`가 지정된 경우: 해당 JSON Schema를 사용합니다.
|
1. `inputSchemaResource` 또는 `outputSchemaResource`가 지정된 경우: 해당 JSON Schema를 사용합니다.
|
||||||
2. 리소스가 없으면: DTO 어노테이션(`@McpValidation`, `@McpOutputSchema`)과 공통 Generator로 생성합니다.
|
2. 리소스가 없으면: DTO의 `@Schema` 정보와 공통 Generator로 생성합니다. 응답 Schema는 `@McpOutputSchema`가 선언된 DTO만 자동 생성합니다.
|
||||||
|
|
||||||
## 8. Manifest와 Registry
|
## 8. Manifest와 Registry
|
||||||
|
|
||||||
@@ -236,8 +245,10 @@ Gateway는 Registry 등록 및 Heartbeat 정보를 이용해 Tool을 라우팅
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
dat-was-lib/src/main/resources/glow/application-glow*.yml
|
dat-was-lib/src/main/resources/glow/application-glow*.yml
|
||||||
dat-was-sms/src/main/resources/application*.yml
|
dat-was-sal/src/main/resources/application*.yml
|
||||||
dat-was-oth/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`이면 호출 대상은 다음과 같습니다.
|
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` |
|
| 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` |
|
| 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` |
|
| Gateway 라우터 | `dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/McpRouterController.java` |
|
||||||
| OTH Tool | `dat-was-oth/src/main/java` |
|
| Customer Tool | `dat-was-cus/src/main/java` |
|
||||||
| SMS Tool | `dat-was-sms/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. 모듈명 전환 검증
|
## 13. 모듈명 전환 검증
|
||||||
|
|
||||||
Tool 관련 공통 기능은 `dat-was-*` 모듈명만 기준으로 동작합니다.
|
Tool 관련 공통 기능은 `dat-was-*` 모듈명만 기준으로 동작합니다.
|
||||||
@@ -318,11 +331,13 @@ destructive, idempotent), `parameters_schema`입니다. 입력 Schema는 루트
|
|||||||
|
|
||||||
입력 Schema 우선순위는 `inputSchemaResource` → V17 `parameters_schema` → DTO 자동 생성이고, 출력은
|
입력 Schema 우선순위는 `inputSchemaResource` → V17 `parameters_schema` → DTO 자동 생성이고, 출력은
|
||||||
`outputSchemaResource` → 명시 Output Schema → `@McpOutputSchema` 기반 생성입니다. Output Schema를
|
`outputSchemaResource` → 명시 Output Schema → `@McpOutputSchema` 기반 생성입니다. Output Schema를
|
||||||
명시한 Tool만 최종 응답 검증을 수행합니다.
|
명시한 Tool만 최종 응답 검증을 수행합니다. 현재 Scaffold는 Tool Definition V17 YAML과 응답 Mock JSON을
|
||||||
|
자동 생성하지 않고, 생성한 Java Tool의 `@GrowToolHint`를 기준으로 메타데이터를 제공합니다.
|
||||||
|
|
||||||
Scaffold 화면의 `Tool Schema V17 Metadata` 영역에서는 기능 설명, 사용/비사용 조건, 입출력 제한,
|
Scaffold의 Pod Module 생성은 `local`, `dev`, `test`, `prod` 프로필 파일과
|
||||||
표시 설명, 예시 질의, 태그와 소유 조직을 입력합니다. Java 소스와 함께 V17 YAML이 생성되며, 기본값은
|
`src/main/resources/tool-service-manifest.yml`을 만듭니다. 이 파일은 라우팅용 설정이며 `/tool-manifest`
|
||||||
배포 전에 업무 담당자가 실제 의미에 맞게 검토해야 합니다.
|
API 응답을 대체하지 않습니다. AI 초안은 한국어 YAML로 생성하며, 선택한 Target Module 목록에서 자기
|
||||||
|
자신을 제외해 `confusable-servers`에 반영합니다. Tool Function의 기본 Protocol은 MCI(Legacy)입니다.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\gradlew.bat validateMcpToolNames validateToolSchemaV17
|
.\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.
|
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,
|
descriptions, business domains, outcomes, entities, capabilities, selection/rejection rules,
|
||||||
and decision policy must all be natural and specific Korean text.
|
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.
|
Use valid YAML only, without Markdown fences or explanations.
|
||||||
Pod module: %s
|
Pod module: %s
|
||||||
Business description: %s
|
Business description: %s
|
||||||
@@ -471,7 +472,7 @@ public class ScaffoldingController {
|
|||||||
|
|
||||||
if ("Qwen3-Coder".equalsIgnoreCase(resolvedModel) || "Gemma-4-31B".equalsIgnoreCase(resolvedModel)) {
|
if ("Qwen3-Coder".equalsIgnoreCase(resolvedModel) || "Gemma-4-31B".equalsIgnoreCase(resolvedModel)) {
|
||||||
String liteLlmBaseUrl = "https://dev-iam-litellm.shinhanlife.co.kr:18020";
|
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()
|
org.springframework.ai.openai.api.OpenAiApi openAiApi = org.springframework.ai.openai.api.OpenAiApi.builder()
|
||||||
.baseUrl(liteLlmBaseUrl)
|
.baseUrl(liteLlmBaseUrl)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.util.LinkedHashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Scanner;
|
import java.util.Scanner;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
@@ -475,7 +476,7 @@ public class PodScaffolder {
|
|||||||
private static String normalizeToolServiceManifest(String source, Path rootDir, String moduleName, String categoryKey,
|
private static String normalizeToolServiceManifest(String source, Path rootDir, String moduleName, String categoryKey,
|
||||||
List<String> targetModules)
|
List<String> targetModules)
|
||||||
throws IOException {
|
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)
|
if (!(root.get("mcp") instanceof Map<?, ?> mcp)
|
||||||
|| !(mcp.get("manifest") instanceof Map<?, ?> manifest)) {
|
|| !(mcp.get("manifest") instanceof Map<?, ?> manifest)) {
|
||||||
throw new IOException("tool-service-manifest.yml의 mcp.manifest.routing-functions 형식이 올바르지 않습니다.");
|
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)
|
private static List<String> allowedTargetModules(Path rootDir, String moduleName, List<String> targetModules)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
List<String> candidates = targetModules == null || targetModules.isEmpty()
|
List<String> candidates = targetModules == null || targetModules.isEmpty()
|
||||||
|
|||||||
@@ -114,6 +114,29 @@ class PodScaffolderTest {
|
|||||||
assertTrue(compose.contains("SPRING_PROFILES_ACTIVE=${ACTIVE_PROFILE:-local}"));
|
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
|
@Test
|
||||||
void generatesRedisOptionalPodComposeConfiguration() throws Exception {
|
void generatesRedisOptionalPodComposeConfiguration() throws Exception {
|
||||||
Files.writeString(root.resolve("settings.gradle"), "rootProject.name = 'test'\n");
|
Files.writeString(root.resolve("settings.gradle"), "rootProject.name = 'test'\n");
|
||||||
|
|||||||
Reference in New Issue
Block a user