fix: normalize scaffold pod and tool generation
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 35s

This commit is contained in:
jade
2026-09-03 10:10:21 +09:00
parent e6bcfe8934
commit 693ddea18d
7 changed files with 544 additions and 77 deletions

View File

@@ -78,7 +78,8 @@ public class ScaffoldingController {
}
System.setProperty("AXHUB_SOURCE_DIR", workspacePath);
return PodScaffolder.scaffoldPod(moduleName, port, shortName, author, date, req.get("toolServiceManifest"));
return PodScaffolder.scaffoldPod(moduleName, port, shortName, author, date,
req.get("toolServiceManifest"), targetModules(req.get("targetModules")));
} catch (Exception e) {
return "오류 발생: " + e.getMessage();
}
@@ -89,6 +90,9 @@ public class ScaffoldingController {
String description = req.getOrDefault("description", "").trim();
if (description.isBlank()) return ResponseEntity.badRequest().body(Map.of("error", "Pod 업무 설명을 입력해주세요."));
try {
String moduleName = req.getOrDefault("moduleName", "dat-was-cus").trim();
if (!moduleName.startsWith("dat-was-")) moduleName = "dat-was-" + moduleName;
List<String> targetModules = targetModules(req.get("targetModules"));
String prompt = """
Generate only YAML for an MCP tool service manifest.
The root must be mcp.manifest.routing-functions with one routing function.
@@ -105,8 +109,9 @@ public class ScaffoldingController {
Use valid YAML only, without Markdown fences or explanations.
Pod module: %s
Business description: %s
""".formatted(req.getOrDefault("moduleName", "dat-was-cus"), description);
String content = stripCodeFence(generateAiContent(prompt, req.get("model")));
""".formatted(moduleName, description);
String content = PodScaffolder.normalizeToolServiceManifest(
stripCodeFence(generateAiContent(prompt, req.get("model"))), moduleName, targetModules);
return ResponseEntity.ok(Map.of("toolServiceManifest", content));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", "AI Manifest 초안 생성 실패: " + safeMessage(e)));
@@ -140,7 +145,7 @@ public class ScaffoldingController {
if (title == null || title.isBlank()) title = baseName;
String description = req.get("description");
String group = req.getOrDefault("categoryKey", req.getOrDefault("group", "COMMON"));
String routingType = req.getOrDefault("routingType", "HTTP");
String routingType = req.getOrDefault("routingType", "MCI");
String moduleName = req.getOrDefault("moduleName", "dat-was-cus");
String author = req.get("author");
if (author == null || author.trim().isEmpty()) author = System.getProperty("user.name");
@@ -290,9 +295,9 @@ public class ScaffoldingController {
Generate an MCP Tool scaffold from the user request.
Return JSON only. Do not add Markdown, explanations, or code fences.
The response must have this exact shape:
{"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"HTTP","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","examples":["example1","example2"],"pattern":"^regex$","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","examples":["SUCCESS"],"pattern":"","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
{"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"MCI","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","examples":["example1","example2"],"pattern":"^regex$","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","examples":["SUCCESS"],"pattern":"","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
categoryKey must be exactly three lowercase letters or digits.
routingType must be either HTTP or MCI. httpApiName can contain only letters, digits, hyphens, and underscores.
routingType must be either MCI or HTTP. Default to MCI. Use HTTP only when the user explicitly requests a REST or HTTP integration. httpApiName can contain only letters, digits, hyphens, and underscores.
Write every V17 metadata field for its distinct purpose; do not copy the same sentence into all fields.
Generate 3 to 10 realistic exampleQueries and concise search tags. Use MCP_TOOL for ownerOrg unless the user names an owner.
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal, List. Finite values should be enforced by populating enumValues. List must include itemType and object lists include itemFields.
@@ -529,6 +534,19 @@ public class ScaffoldingController {
return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
}
private List<String> targetModules(String rawTargetModules) throws Exception {
if (rawTargetModules == null || rawTargetModules.isBlank()) {
return List.of();
}
List<String> modules = objectMapper.readValue(rawTargetModules, new TypeReference<List<String>>() { });
return modules.stream()
.filter(java.util.Objects::nonNull)
.map(String::trim)
.filter(name -> name.matches("^dat-was-[a-z0-9-]+$"))
.distinct()
.toList();
}
private record FieldDraft(List<ToolScaffolder.FieldDefinition> fields) {
}

View File

@@ -944,7 +944,7 @@
<div class="col-md-6 mt-3 mt-md-0">
<label class="form-label">Protocol</label>
<select class="form-select" name="routingType">
<option value="MCI">MCI (Legacy)</option>
<option value="MCI" selected>MCI (Legacy)</option>
<option value="HTTP">HTTP (REST)</option>
</select>
</div>
@@ -1579,6 +1579,9 @@
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
if (formId === 'podForm') {
data.targetModules = JSON.stringify(currentTargetModules());
}
const btn = this.querySelector('button[type="submit"]');
const originalText = btn.innerHTML;
@@ -1644,6 +1647,12 @@
handleFormSubmit('podForm', '/api/v1/scaffold/pod');
function currentTargetModules() {
return Array.from(document.querySelectorAll('#targetModuleSelect option'))
.map(option => option.value.trim())
.filter(moduleName => /^dat-was-[a-z0-9-]+$/.test(moduleName));
}
async function createPodManifestDraft(button) {
const description = document.getElementById('podDescription').value.trim();
if (!description) { alert('Pod 업무 설명을 입력해주세요.'); return; }
@@ -1651,7 +1660,12 @@
try {
const response = await fetch('/api/v1/scaffold/pod-draft', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({description, moduleName: document.querySelector('#podForm [name="moduleName"]').value, model: document.getElementById('podAiModelSelect').value})
body: JSON.stringify({
description,
moduleName: document.querySelector('#podForm [name="moduleName"]').value,
targetModules: JSON.stringify(currentTargetModules()),
model: document.getElementById('podAiModelSelect').value
})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'AI Manifest 생성 실패');
@@ -2281,7 +2295,14 @@
form.elements.categoryKey.value = result.categoryKey || '';
if (typeof loadUseCasesForSelection === 'function') loadUseCasesForSelection();
}
form.elements.routingType.value = result.routingType || 'HTTP';
const routingType = String(result.routingType || 'MCI').trim().toUpperCase();
form.elements.routingType.value = routingType === 'HTTP' ? 'HTTP' : 'MCI';
const useCaseSelect = document.getElementById('toolGroupUseCaseSelect');
const useCaseNameInput = document.getElementById('toolGroupUseCaseName');
if (useCaseSelect.value === '' && !useCaseNameInput.value.trim() && result.baseName) {
useCaseNameInput.value = result.baseName;
useCaseNameInput.readOnly = false;
}
form.elements.httpApiName.value = result.httpApiName || '';
form.elements.functionDescription.value = result.functionDescription || '';
form.elements.displayDescription.value = result.displayDescription || '';

View File

@@ -12,11 +12,14 @@ import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.nio.file.Files;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -83,7 +86,7 @@ class ScaffoldingControllerToolDraftTest {
when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(responseSpec);
when(responseSpec.content()).thenReturn("""
{"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"HTTP","httpApiName":"contract-status","functionDescription":"고객 계약의 현재 상태를 조회한다.","displayDescription":"고객 계약 상태 조회","whenToUse":"고객번호로 계약 상태 확인을 요청할 때 사용한다.","whenNotToUse":"계약 변경 또는 해지를 요청할 때는 사용하지 않는다.","ioLimits":"고객번호 한 건을 입력받아 계약 상태 한 건을 반환한다.","exampleQueries":["고객 C123의 계약 상태를 알려줘","C123 계약이 정상인지 확인해줘","고객번호 C123 계약 조회해줘"],"tags":["contract","status","search"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"customerId","type":"String","description":"고객번호","examples":["C123"],"required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","examples":["SUCCESS"],"required":true}]}
{"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"MCI","httpApiName":"contract-status","functionDescription":"고객 계약의 현재 상태를 조회한다.","displayDescription":"고객 계약 상태 조회","whenToUse":"고객번호로 계약 상태 확인을 요청할 때 사용한다.","whenNotToUse":"계약 변경 또는 해지를 요청할 때는 사용하지 않는다.","ioLimits":"고객번호 한 건을 입력받아 계약 상태 한 건을 반환한다.","exampleQueries":["고객 C123의 계약 상태를 알려줘","C123 계약이 정상인지 확인해줘","고객번호 C123 계약 조회해줘"],"tags":["contract","status","search"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"customerId","type":"String","description":"고객번호","examples":["C123"],"required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","examples":["SUCCESS"],"required":true}]}
""");
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new ScaffoldingController(builder, new ObjectMapper()))
@@ -95,6 +98,7 @@ class ScaffoldingControllerToolDraftTest {
.content("{\"description\":\"고객번호로 계약 상태를 조회\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.baseName").value("CustomerContractStatus"))
.andExpect(jsonPath("$.routingType").value("MCI"))
.andExpect(jsonPath("$.functionDescription").value("고객 계약의 현재 상태를 조회한다."))
.andExpect(jsonPath("$.displayDescription").value("고객 계약 상태 조회"))
.andExpect(jsonPath("$.whenToUse").value("고객번호로 계약 상태 확인을 요청할 때 사용한다."))
@@ -104,5 +108,49 @@ class ScaffoldingControllerToolDraftTest {
.andExpect(jsonPath("$.tags[0]").value("contract"))
.andExpect(jsonPath("$.ownerOrg").value("MCP_TOOL"))
.andExpect(jsonPath("$.inputFields[0].name").value("customerId"));
org.mockito.ArgumentCaptor<String> promptCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
verify(requestSpec).user(promptCaptor.capture());
assertTrue(promptCaptor.getValue().contains("\"routingType\":\"MCI\""));
assertTrue(promptCaptor.getValue().contains("Default to MCI"));
}
@Test
void podDraftUsesTheCurrentTargetModuleOptionsForConfusableServers() {
ChatClient.Builder builder = mock(ChatClient.Builder.class);
ChatClient chatClient = mock(ChatClient.class);
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class);
when(builder.build()).thenReturn(chatClient);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user(anyString())).thenReturn(requestSpec);
when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(responseSpec);
when(responseSpec.content()).thenReturn("""
mcp:
manifest:
routing-functions:
- name: route_to_dat-was-pro
server-id: dat-was-pro
category-key: pro
confusable-servers: [dat-was-hrd, dat-was-pay, dat-was-att]
""");
var response = new ScaffoldingController(builder, new ObjectMapper()).generatePodManifestDraft(java.util.Map.of(
"description", "상품 업무를 처리합니다.",
"moduleName", "dat-was-pro",
"targetModules", "[\"dat-was-cus\",\"dat-was-hr\",\"dat-was-sal\",\"dat-was-pro\",\"dat-was-sys\"]"));
assertEquals(org.springframework.http.HttpStatus.OK, response.getStatusCode());
String manifest = (String) ((java.util.Map<?, ?>) response.getBody()).get("toolServiceManifest");
List<String> confusableServers = manifest.lines()
.map(String::trim)
.filter(line -> line.startsWith("- \"dat-was-"))
.map(line -> line.substring(3, line.length() - 1))
.toList();
assertEquals(List.of("dat-was-cus", "dat-was-hr", "dat-was-sal", "dat-was-sys"), confusableServers);
assertFalse(manifest.contains("dat-was-hrd"), manifest);
assertFalse(manifest.contains("dat-was-pay"), manifest);
assertFalse(manifest.contains("dat-was-att"), manifest);
}
}