feat: enhance tool scaffold and usecase naming
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 0s
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 0s
This commit is contained in:
@@ -39,7 +39,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
public class ScaffoldingController {
|
||||
|
||||
private static final Set<String> SUPPORTED_FIELD_TYPES = Set.of(
|
||||
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal");
|
||||
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal", "Enum", "List");
|
||||
private static final Set<String> SUPPORTED_AI_MODELS = Set.of(
|
||||
"inclusionai/ling-3.0-flash:free",
|
||||
"openai/gpt-oss-20b:free",
|
||||
@@ -116,6 +116,26 @@ public class ScaffoldingController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/tool-group")
|
||||
public String scaffoldToolGroup(@RequestBody ToolGroupRequest request) {
|
||||
try {
|
||||
if (request == null || request.useCaseName() == null
|
||||
|| !request.useCaseName().trim().matches("^[A-Z][A-Za-z0-9]*$")) {
|
||||
throw new IllegalArgumentException("UseCase name must be PascalCase.");
|
||||
}
|
||||
String moduleName = request.moduleName() == null || request.moduleName().isBlank()
|
||||
? "dap-was-oth" : request.moduleName().trim();
|
||||
String author = request.author() == null || request.author().isBlank()
|
||||
? System.getProperty("user.name") : request.author().trim();
|
||||
String date = request.date() == null || request.date().isBlank()
|
||||
? LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd")) : request.date().trim();
|
||||
return ToolScaffolder.scaffoldUseCase(request.useCaseName().trim(), moduleName, author, date,
|
||||
request.tools() == null ? List.of() : request.tools());
|
||||
} catch (Exception e) {
|
||||
return "Error: " + safeMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/field-draft")
|
||||
public ResponseEntity<?> generateFieldDraft(@RequestBody Map<String, String> req) {
|
||||
String description = req.getOrDefault("description", "").trim();
|
||||
@@ -133,8 +153,9 @@ public class ScaffoldingController {
|
||||
Generate Java DTO fields for an MCP tool.
|
||||
Return JSON only. Do not add Markdown, explanations, or code fences.
|
||||
The response must have this exact shape:
|
||||
{"fields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true}]}
|
||||
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal.
|
||||
{"fields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
|
||||
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal, Enum, List.
|
||||
Enum fields must include enumValues. List fields must include itemType; use Object plus itemFields for object lists.
|
||||
Generate fields only for the requested target: %s.
|
||||
For OUTPUT fields, include resultCode and resultMessage when appropriate.
|
||||
Keep field names valid Java camelCase identifiers. Generate at most 10 fields.
|
||||
@@ -162,12 +183,12 @@ 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","example":"example","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true}]}
|
||||
{"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","example":"example","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","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.
|
||||
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.
|
||||
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal, Enum, List. Enum must include enumValues; List must include itemType and object lists include itemFields.
|
||||
Keep all field names valid Java camelCase identifiers. Generate at most 10 fields per list.
|
||||
Do not generate interfaceId or clientSystemCode; those must come from a real integration contract.
|
||||
User request: %s
|
||||
@@ -248,7 +269,10 @@ public class ScaffoldingController {
|
||||
field.type() == null ? "String" : field.type().trim(),
|
||||
field.description() == null ? "" : field.description().trim(),
|
||||
field.example() == null ? "" : field.example().trim(),
|
||||
field.required()))
|
||||
field.required(),
|
||||
field.enumValues() == null ? List.of() : field.enumValues(),
|
||||
field.itemType(),
|
||||
field.itemFields() == null ? List.of() : field.itemFields()))
|
||||
.peek(field -> {
|
||||
if (!field.name().matches("^[A-Za-z_$][A-Za-z0-9_$]*$")) {
|
||||
throw new IllegalArgumentException("AI가 올바르지 않은 필드명을 생성했습니다: " + field.name());
|
||||
@@ -256,6 +280,7 @@ public class ScaffoldingController {
|
||||
if (!SUPPORTED_FIELD_TYPES.contains(field.type())) {
|
||||
throw new IllegalArgumentException("AI가 지원하지 않는 Type을 생성했습니다: " + field.type());
|
||||
}
|
||||
validateStructuredField(field);
|
||||
if (!names.add(field.name())) {
|
||||
throw new IllegalArgumentException("AI가 중복 필드명을 생성했습니다: " + field.name());
|
||||
}
|
||||
@@ -263,6 +288,18 @@ public class ScaffoldingController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void validateStructuredField(ToolScaffolder.FieldDefinition field) {
|
||||
if ("Enum".equals(field.type()) && field.enumValues().isEmpty()) {
|
||||
throw new IllegalArgumentException("Enum field needs enumValues: " + field.name());
|
||||
}
|
||||
if ("List".equals(field.type()) && (field.itemType() == null || field.itemType().isBlank())) {
|
||||
throw new IllegalArgumentException("List field needs itemType: " + field.name());
|
||||
}
|
||||
if ("List".equals(field.type()) && "Object".equals(field.itemType()) && field.itemFields().isEmpty()) {
|
||||
throw new IllegalArgumentException("Object List field needs itemFields: " + field.name());
|
||||
}
|
||||
}
|
||||
|
||||
private ToolDraft validateToolDraft(ToolDraft draft) {
|
||||
if (draft == null || draft.baseName() == null || !draft.baseName().trim().matches("^[A-Z][A-Za-z0-9]*$")) {
|
||||
throw new IllegalArgumentException("AI가 올바르지 않은 Base Name을 생성했습니다.");
|
||||
@@ -355,4 +392,8 @@ public class ScaffoldingController {
|
||||
List<ToolScaffolder.FieldDefinition> inputFields,
|
||||
List<ToolScaffolder.FieldDefinition> outputFields) {
|
||||
}
|
||||
|
||||
private record ToolGroupRequest(String useCaseName, String moduleName, String author, String date,
|
||||
List<ToolScaffolder.ToolMethodDefinition> tools) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,6 +824,19 @@
|
||||
<!-- Tool Creation Form -->
|
||||
<div class="tab-pane fade" id="tool" role="tabpanel">
|
||||
<form id="toolForm">
|
||||
<div class="mb-4 p-3 rounded" style="background:#18181b; border:1px solid #3f3f46;">
|
||||
<div class="d-flex justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<label class="form-label mb-1">Multi Tool UseCase (MCI)</label>
|
||||
<div class="input-hint mt-0">현재 Tool을 같은 UseCase에 추가하면, Tool별 Client를 호출하는 여러 MCP Tool 메서드가 생성됩니다.</div>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary-action" onclick="addCurrentToolToGroup()">현재 Tool 묶음에 추가</button>
|
||||
</div>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-5"><input id="toolGroupUseCaseName" type="text" class="form-control" placeholder="UseCase 이름 e.g. Customer"></div>
|
||||
<div class="col-md-7"><div id="toolGroupSummary" class="input-hint pt-2">묶음에 추가된 Tool 없음 — 일반 Generate Tool은 기존 단일 Tool 생성입니다.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 p-3 rounded" style="background:#18181b; border:1px solid #3f3f46;">
|
||||
<label class="form-label">AI Tool 초안 만들기</label>
|
||||
<div class="row g-2">
|
||||
@@ -1158,6 +1171,7 @@
|
||||
<tr>
|
||||
<th style="min-width: 150px;">Name</th>
|
||||
<th style="min-width: 125px;">Type</th>
|
||||
<th style="min-width: 170px;">Enum values / List item type</th>
|
||||
<th style="min-width: 210px;">Description</th>
|
||||
<th style="min-width: 180px;">Example</th>
|
||||
<th style="min-width: 125px;">Required</th>
|
||||
@@ -1469,6 +1483,95 @@
|
||||
});
|
||||
|
||||
handleFormSubmit('podForm', '/api/v1/scaffold/pod');
|
||||
|
||||
const groupedTools = [];
|
||||
|
||||
function toMethodName(baseName) {
|
||||
return baseName ? baseName.charAt(0).toLowerCase() + baseName.slice(1) : '';
|
||||
}
|
||||
|
||||
function parseToolFields(id) {
|
||||
const text = document.getElementById(id).value.trim();
|
||||
if (!text) return [];
|
||||
const fields = JSON.parse(text);
|
||||
if (!Array.isArray(fields)) throw new Error(`${id} must be a JSON array.`);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function currentToolDefinition() {
|
||||
const form = document.getElementById('toolForm');
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
if (!data.baseName || !data.interfaceId || !data.clientSystemCode) {
|
||||
throw new Error('Base Name, Legacy Interface ID, Client System Code를 입력해주세요.');
|
||||
}
|
||||
if (data.routingType !== 'MCI') {
|
||||
throw new Error('여러 Tool을 하나의 UseCase로 생성하는 기능은 현재 MCI 전용입니다.');
|
||||
}
|
||||
return {
|
||||
baseName: data.baseName,
|
||||
methodName: toMethodName(data.baseName),
|
||||
interfaceId: data.interfaceId,
|
||||
title: data.title || data.baseName,
|
||||
description: data.description || '',
|
||||
group: data.categoryKey,
|
||||
routingType: data.routingType,
|
||||
register: data.register === 'true',
|
||||
clientSystemCode: data.clientSystemCode,
|
||||
httpApiName: data.httpApiName || null,
|
||||
inputFields: parseToolFields('inputFields'),
|
||||
outputFields: parseToolFields('outputFields'),
|
||||
definitionOptions: {
|
||||
functionDescription: data.functionDescription || '', displayDescription: data.displayDescription || '',
|
||||
whenToUse: data.whenToUse || '', whenNotToUse: data.whenNotToUse || '', ioLimits: data.ioLimits || '',
|
||||
exampleQueries: (data.exampleQueries || '').split(/[\n,]+/).map(value => value.trim()).filter(Boolean),
|
||||
tags: (data.tags || '').split(',').map(value => value.trim()).filter(Boolean), ownerOrg: data.ownerOrg || 'MCP_TOOL'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function addCurrentToolToGroup() {
|
||||
try {
|
||||
const tool = currentToolDefinition();
|
||||
if (groupedTools.some(item => item.methodName === tool.methodName || item.baseName === tool.baseName)) {
|
||||
throw new Error('같은 Base Name 또는 메서드명이 이미 묶음에 있습니다.');
|
||||
}
|
||||
groupedTools.push(tool);
|
||||
renderToolGroupSummary();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolGroupSummary() {
|
||||
const summary = document.getElementById('toolGroupSummary');
|
||||
summary.textContent = groupedTools.length
|
||||
? `${groupedTools.length}개 Tool: ${groupedTools.map(tool => `${tool.baseName} → ${tool.methodName}()`).join(', ')}`
|
||||
: '묶음에 추가된 Tool 없음 — 일반 Generate Tool은 기존 단일 Tool 생성입니다.';
|
||||
}
|
||||
|
||||
document.getElementById('toolForm').addEventListener('submit', function(e) {
|
||||
if (!groupedTools.length) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
try {
|
||||
const useCaseName = document.getElementById('toolGroupUseCaseName').value.trim();
|
||||
if (!/^[A-Z][A-Za-z0-9]*$/.test(useCaseName)) throw new Error('UseCase 이름은 PascalCase로 입력해주세요. 예: Customer');
|
||||
const current = currentToolDefinition();
|
||||
const tools = groupedTools.some(item => item.methodName === current.methodName) ? groupedTools : [...groupedTools, current];
|
||||
const data = Object.fromEntries(new FormData(this).entries());
|
||||
fetch('/api/v1/scaffold/tool-group', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({useCaseName, moduleName: data.moduleName, author: data.author, date: data.date, tools})
|
||||
}).then(response => response.text()).then(result => {
|
||||
const resultBox = document.getElementById('resultBox');
|
||||
resultBox.style.display = 'block'; resultBox.className = result.startsWith('Error:') ? 'error' : 'success';
|
||||
resultBox.textContent = result;
|
||||
});
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
handleFormSubmit('toolForm', '/api/v1/scaffold/tool');
|
||||
|
||||
const fieldExamples = {
|
||||
@@ -1498,14 +1601,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal'];
|
||||
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal', 'Enum', 'List'];
|
||||
const typeExamples = {
|
||||
String: 'example',
|
||||
Integer: '1',
|
||||
Long: '1',
|
||||
Double: '1.0',
|
||||
Boolean: 'true',
|
||||
BigDecimal: '1000.00'
|
||||
BigDecimal: '1000.00',
|
||||
Enum: 'OPEN',
|
||||
List: 'C001'
|
||||
};
|
||||
const fieldTemplates = {
|
||||
customer: {
|
||||
@@ -1608,18 +1713,23 @@
|
||||
updateFieldEditorPreview();
|
||||
});
|
||||
|
||||
const detailsInput = makeInput(
|
||||
field.type === 'Enum' ? (field.enumValues || []).join(', ') : (field.type === 'List' ? (field.itemType || 'String') : ''),
|
||||
'Enum: OPEN, CLOSED / List: String');
|
||||
detailsInput.dataset.field = 'details';
|
||||
|
||||
const requiredSelect = document.createElement('select');
|
||||
requiredSelect.className = 'form-select form-select-sm';
|
||||
requiredSelect.dataset.field = 'required';
|
||||
requiredSelect.add(new Option('Required', 'true', false, field.required === true || field.required === 'true'));
|
||||
requiredSelect.add(new Option('Optional', 'false', false, !(field.required === true || field.required === 'true')));
|
||||
|
||||
[nameInput, descriptionInput, exampleInput, requiredSelect].forEach(control => {
|
||||
[nameInput, descriptionInput, exampleInput, requiredSelect, detailsInput].forEach(control => {
|
||||
control.addEventListener('input', updateFieldEditorPreview);
|
||||
control.addEventListener('change', updateFieldEditorPreview);
|
||||
});
|
||||
|
||||
[nameInput, typeSelect, descriptionInput, exampleInput, requiredSelect].forEach(control => {
|
||||
[nameInput, typeSelect, detailsInput, descriptionInput, exampleInput, requiredSelect].forEach(control => {
|
||||
const cell = document.createElement('td');
|
||||
cell.appendChild(control);
|
||||
row.appendChild(cell);
|
||||
@@ -1641,13 +1751,19 @@
|
||||
|
||||
function currentEditorFields() {
|
||||
return [...document.querySelectorAll('#fieldEditorBody tr')]
|
||||
.map(row => ({
|
||||
name: row.querySelector('[data-field="name"]').value.trim(),
|
||||
type: row.querySelector('[data-field="type"]').value,
|
||||
description: row.querySelector('[data-field="description"]').value.trim(),
|
||||
example: row.querySelector('[data-field="example"]').value.trim(),
|
||||
required: row.querySelector('[data-field="required"]').value === 'true'
|
||||
}))
|
||||
.map(row => {
|
||||
const type = row.querySelector('[data-field="type"]').value;
|
||||
const details = row.querySelector('[data-field="details"]').value.trim();
|
||||
return {
|
||||
name: row.querySelector('[data-field="name"]').value.trim(), type,
|
||||
description: row.querySelector('[data-field="description"]').value.trim(),
|
||||
example: row.querySelector('[data-field="example"]').value.trim(),
|
||||
required: row.querySelector('[data-field="required"]').value === 'true',
|
||||
enumValues: type === 'Enum' ? details.split(',').map(value => value.trim()).filter(Boolean) : [],
|
||||
itemType: type === 'List' ? (details || 'String') : null,
|
||||
itemFields: []
|
||||
};
|
||||
})
|
||||
.filter(field => field.name);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@@ -16,9 +19,33 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
|
||||
class ScaffoldingControllerToolDraftTest {
|
||||
|
||||
@TempDir
|
||||
Path root;
|
||||
|
||||
@Test
|
||||
void groupedToolRequestGeneratesOneUseCase() throws Exception {
|
||||
ChatClient.Builder builder = mock(ChatClient.Builder.class);
|
||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
|
||||
new ScaffoldingController(builder, new ObjectMapper()))
|
||||
.setMessageConverters(new MappingJackson2HttpMessageConverter())
|
||||
.build();
|
||||
String moduleName = root.resolve("dap-was-customer").toString().replace("\\", "\\\\");
|
||||
String request = """
|
||||
{"useCaseName":"Customer","moduleName":"%s","author":"tester","date":"2026.08.12","tools":[
|
||||
{"baseName":"CustomerGuidance","methodName":"searchGuidance","interfaceId":"CTMNILO00007","title":"Customer guidance","description":"Search guidance","group":"cmm","routingType":"MCI","register":false,"clientSystemCode":"NILD","inputFields":[{"name":"customerId","type":"String","description":"Customer ID","example":"C001","required":true}],"outputFields":[]}
|
||||
]}
|
||||
""".formatted(moduleName);
|
||||
|
||||
mockMvc.perform(post("/api/v1/scaffold/tool-group")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(request))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("CustomerUseCase.java")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolDraftEndpointIsAvailable() throws Exception {
|
||||
ChatClient.Builder builder = mock(ChatClient.Builder.class);
|
||||
|
||||
Reference in New Issue
Block a user