Enhance ToolScaffolder with Pattern and Examples validation, support AI prompt generation for Pattern
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m22s

This commit is contained in:
jade
2026-08-14 10:29:27 +09:00
parent b8d6fa28df
commit b53dfe3601
3 changed files with 105 additions and 55 deletions

View File

@@ -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", "Enum", "List");
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal", "List");
private static final Set<String> SUPPORTED_AI_MODELS = Set.of(
"inclusionai/ling-3.0-flash:free",
"openai/gpt-oss-20b:free",
@@ -96,7 +96,7 @@ public class ScaffoldingController {
List<ToolScaffolder.FieldDefinition> inputFields = parseFields(req.get("inputFields"));
List<ToolScaffolder.FieldDefinition> outputFields = parseFields(req.get("outputFields"));
if (inputFields.isEmpty()) {
inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", "example", false));
inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", List.of("example"), "", false));
}
ToolScaffolder.ToolDefinitionOptions definitionOptions = new ToolScaffolder.ToolDefinitionOptions(
req.get("functionDescription"),
@@ -187,9 +187,10 @@ 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,"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.
{"fields":[{"name":"camelCaseName","type":"String","description":"short description","examples":["example1","example2"],"pattern":"^regex$","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal, List.
Finite values should be enforced by populating enumValues on standard types. List fields must include itemType; use Object plus itemFields for object lists.
If applicable, provide a regex for pattern.
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.
@@ -217,12 +218,13 @@ 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,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
{"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":[]}]}
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, Enum, List. Enum must include enumValues; List must include itemType and object lists include itemFields.
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.
If applicable, provide a regex for pattern.
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
@@ -302,7 +304,8 @@ public class ScaffoldingController {
field.name().trim(),
field.type() == null ? "String" : field.type().trim(),
field.description() == null ? "" : field.description().trim(),
field.example() == null ? "" : field.example().trim(),
field.examples() == null ? List.of() : field.examples().stream().map(String::trim).toList(),
field.pattern() == null ? "" : field.pattern().trim(),
field.required(),
field.enumValues() == null ? List.of() : field.enumValues(),
field.itemType(),
@@ -323,9 +326,6 @@ public class ScaffoldingController {
}
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());
}

View File

@@ -1181,7 +1181,8 @@
<th style="min-width: 170px;">Enum values / List item type</th>
<th style="min-width: 260px;">Object list item fields (JSON)</th>
<th style="min-width: 210px;">Description</th>
<th style="min-width: 180px;">Example</th>
<th style="min-width: 150px;">Pattern (Regex)</th>
<th style="min-width: 180px;">Examples (Enter 단위 구분)</th>
<th style="min-width: 125px;">Required</th>
<th style="width: 1%;">삭제</th>
</tr>
@@ -1205,7 +1206,7 @@
<!-- Sub Field Editor Modal -->
<div class="modal fade" id="subFieldEditorModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">하위 필드(itemFields) 편집</h5>
@@ -1218,9 +1219,10 @@
<tr>
<th style="width:15%">Name</th>
<th style="width:15%">Type</th>
<th style="width:20%">Details</th>
<th style="width:20%">Description</th>
<th style="width:15%">Example</th>
<th style="width:15%">Details</th>
<th style="width:15%">Description</th>
<th style="width:10%">Pattern</th>
<th style="width:15%">Examples</th>
<th style="width:10%">Required</th>
<th style="width:5%"></th>
</tr>
@@ -1728,7 +1730,7 @@
}
}
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal', 'Enum', 'List'];
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal', 'List'];
const typeExamples = {
String: 'example',
Integer: '1',
@@ -1736,7 +1738,6 @@
Double: '1.0',
Boolean: 'true',
BigDecimal: '1000.00',
Enum: 'OPEN',
List: 'C001'
};
const fieldTemplates = {
@@ -1805,24 +1806,27 @@
};
const n = makeInput(field.name, 'name'); n.dataset.field = 'name';
const d = makeInput(field.description, 'desc'); d.dataset.field = 'description';
const e = makeInput(field.example, 'ex'); e.dataset.field = 'example';
const p = makeInput(field.pattern, 'pattern'); p.dataset.field = 'pattern';
const e = document.createElement('textarea');
e.className = 'form-control form-control-sm';
e.rows = 2; e.dataset.field = 'example';
e.value = (field.examples || []).join('\n') || '';
const t = document.createElement('select');
t.className = 'form-select form-select-sm'; t.dataset.field = 'type';
['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal', 'Enum'].forEach(type => {
['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal'].forEach(type => {
t.add(new Option(type, type, false, (field.type || 'String') === type));
});
const det = makeInput(field.type === 'Enum' ? (field.enumValues||[]).join(',') : '', 'Enum csv');
const det = makeInput(field.type !== 'List' ? (field.enumValues||[]).join(',') : '', 'Enum csv (콤마 구분)');
det.dataset.field = 'details';
t.addEventListener('change', () => {
if (t.value === 'Enum') det.placeholder = 'Enum csv';
else det.value = '';
det.placeholder = 'Enum csv (콤마 구분)';
});
const req = document.createElement('select');
req.className = 'form-select form-select-sm'; req.dataset.field = 'required';
req.add(new Option('Required', 'true', false, field.required === true || field.required === 'true'));
req.add(new Option('Optional', 'false', false, !(field.required === true || field.required === 'true')));
[n, t, det, d, e, req].forEach(ctrl => {
[n, t, det, d, p, e, req].forEach(ctrl => {
const td = document.createElement('td'); td.appendChild(ctrl); row.appendChild(td);
});
const del = document.createElement('button');
@@ -1841,9 +1845,10 @@
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(),
pattern: row.querySelector('[data-field="pattern"]').value.trim(),
examples: row.querySelector('[data-field="example"]').value.split('\n').map(s=>s.trim()).filter(Boolean),
required: row.querySelector('[data-field="required"]').value === 'true',
enumValues: type === 'Enum' ? details.split(',').map(s=>s.trim()).filter(Boolean) : [],
enumValues: type !== 'List' ? details.split(',').map(s=>s.trim()).filter(Boolean) : [],
itemType: null, itemFields: []
};
}).filter(f => f.name);
@@ -1898,8 +1903,16 @@
nameInput.dataset.field = 'name';
const descriptionInput = makeInput(field.description, 'e.g. Employee ID');
descriptionInput.dataset.field = 'description';
const exampleInput = makeInput(field.example, 'e.g. EMP10001');
const patternInput = makeInput(field.pattern, 'e.g. ^[0-9]{4}$');
patternInput.dataset.field = 'pattern';
const exampleInput = document.createElement('textarea');
exampleInput.className = 'form-control form-control-sm';
exampleInput.placeholder = 'e.g. EMP10001\nEMP10002';
exampleInput.rows = 2;
exampleInput.dataset.field = 'example';
exampleInput.value = (field.examples || []).join('\n') || '';
const typeSelect = document.createElement('select');
typeSelect.className = 'form-select form-select-sm';
@@ -1917,13 +1930,13 @@
input.add(new Option(itemType, itemType, false, (value || 'String') === itemType));
});
} else {
input = makeInput(type === 'Enum' ? value : '', 'Enum: OPEN, CLOSED');
input = makeInput(value, 'Enum csv (콤마 구분)');
}
input.dataset.field = 'details';
return input;
};
let detailsInput = createDetailsInput(field.type || 'String',
field.type === 'Enum' ? (field.enumValues || []).join(', ') : field.itemType);
field.type !== 'List' ? (field.enumValues || []).join(', ') : field.itemType);
const requiredSelect = document.createElement('select');
requiredSelect.className = 'form-select form-select-sm';
@@ -1978,12 +1991,12 @@
});
bindDetailsInput();
updateObjectListFieldsState();
[nameInput, descriptionInput, exampleInput, requiredSelect, itemFieldsInput].forEach(control => {
[nameInput, descriptionInput, patternInput, exampleInput, requiredSelect, itemFieldsInput].forEach(control => {
control.addEventListener('input', updateFieldEditorPreview);
control.addEventListener('change', updateFieldEditorPreview);
});
[nameInput, typeSelect, detailsInput, itemFieldsContainer, descriptionInput, exampleInput, requiredSelect].forEach(control => {
[nameInput, typeSelect, detailsInput, itemFieldsContainer, descriptionInput, patternInput, exampleInput, requiredSelect].forEach(control => {
const cell = document.createElement('td');
cell.appendChild(control);
row.appendChild(cell);
@@ -2021,9 +2034,10 @@
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(),
pattern: row.querySelector('[data-field="pattern"]').value.trim(),
examples: row.querySelector('[data-field="example"]').value.split('\n').map(e => e.trim()).filter(Boolean),
required: row.querySelector('[data-field="required"]').value === 'true',
enumValues: type === 'Enum' ? details.split(',').map(value => value.trim()).filter(Boolean) : [],
enumValues: type !== 'List' ? details.split(',').map(value => value.trim()).filter(Boolean) : [],
itemType: type === 'List' ? (details || 'String') : null,
itemFields
};

View File

@@ -45,10 +45,10 @@ public class ToolScaffolder {
private static final String BASE_PACKAGE = "io.shinhanlife.dap.mcc";
private static final String BASE_PACKAGE_PATH = "src/main/java/io/shinhanlife/dap/mcc";
public record FieldDefinition(String name, String type, String description, String example, boolean required,
public record FieldDefinition(String name, String type, String description, List<String> examples, String pattern, boolean required,
List<String> enumValues, String itemType, List<FieldDefinition> itemFields) {
public FieldDefinition(String name, String type, String description, String example, boolean required) {
this(name, type, description, example, required, List.of(), null, List.of());
public FieldDefinition(String name, String type, String description, List<String> examples, String pattern, boolean required) {
this(name, type, description, examples, pattern, required, List.of(), null, List.of());
}
}
@@ -457,7 +457,7 @@ public class ToolScaffolder {
String inputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-input-schema.json" : null;
String outputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-output-schema.json" : null;
String result = scaffold(baseName, interfaceId, title, description, group, routingType, moduleName, author, createDate, false, null, inputSchemaResource, outputSchemaResource, List.of(new FieldDefinition("query", "String", "Search query", "example", false)), List.of());
String result = scaffold(baseName, interfaceId, title, description, group, routingType, moduleName, author, createDate, false, null, inputSchemaResource, outputSchemaResource, List.of(new FieldDefinition("query", "String", "Search query", List.of("example"), "", false)), List.of());
System.out.println(result);
}
@@ -476,7 +476,7 @@ public class ToolScaffolder {
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource) throws IOException {
return scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate,
register, clientSystemCode, inputSchemaResource, outputSchemaResource,
List.of(new FieldDefinition("query", "String", "Search query", "example", false)), List.of());
List.of(new FieldDefinition("query", "String", "Search query", List.of("example"), "", false)), List.of());
}
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource, List<FieldDefinition> inputFields, List<FieldDefinition> outputFields) throws IOException {
@@ -1441,7 +1441,7 @@ public class ToolScaffolder {
private static void appendSchemaProperty(StringBuilder properties, FieldDefinition field) {
properties.append(" ").append(field.name().trim()).append(":\n")
.append(" type: ").append(jsonSchemaType(field.type())).append("\n")
.append(" description: ").append(yamlText(field.description())).append("\n");
.append(" description: ").append(yamlText(richDescription(field))).append("\n");
if ("Enum".equals(field.type()) && field.enumValues() != null && !field.enumValues().isEmpty()) {
properties.append(" enum: [").append(field.enumValues().stream()
.filter(value -> value != null && !value.isBlank()).map(String::trim)
@@ -1455,7 +1455,7 @@ public class ToolScaffolder {
for (FieldDefinition itemField : field.itemFields()) {
properties.append(" ").append(itemField.name()).append(":\n")
.append(" type: ").append(jsonSchemaType(itemField.type())).append("\n")
.append(" description: ").append(yamlText(itemField.description())).append("\n");
.append(" description: ").append(yamlText(richDescription(itemField))).append("\n");
}
}
}
@@ -1535,35 +1535,37 @@ public class ToolScaffolder {
body = " private String resultCode;\n\n private String resultMessage;\n" + body;
}
String listImport = hasListField(fields) ? "import java.util.List;\n" : "";
String patternImport = hasPatternField(fields) ? "import jakarta.validation.constraints.Pattern;\n" : "";
return """
package %s;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
%s
%s%s
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class %s {
%s%s}
""".formatted(packageName, listImport, className, body, innerObjectListClasses(fields));
""".formatted(packageName, listImport, patternImport, className, body, innerObjectListClasses(fields));
}
private static String mciIoContent(String packageSuffix, String className, List<FieldDefinition> fields,
String author, String createDate) {
String listImport = hasListField(fields) ? "import java.util.List;\n" : "";
String patternImport = hasPatternField(fields) ? "import jakarta.validation.constraints.Pattern;\n" : "";
return """
package %s.%s.io;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
%s
%s%s
@Data
public class %s {
%s%s}
""".formatted(BASE_PACKAGE, packageSuffix, listImport, className, fieldLines(fields, Set.of(), className),
""".formatted(BASE_PACKAGE, packageSuffix, listImport, patternImport, className, fieldLines(fields, Set.of(), className),
innerObjectListClasses(fields));
}
@@ -1571,6 +1573,18 @@ public class ToolScaffolder {
return fields != null && fields.stream().anyMatch(field -> field != null && "List".equals(field.type()));
}
private static boolean hasPatternField(List<FieldDefinition> fields) {
if (fields == null) return false;
return fields.stream().anyMatch(field -> {
if (field == null) return false;
if (field.pattern() != null && !field.pattern().isBlank()) return true;
if ("List".equals(field.type()) && "Object".equals(field.itemType())) {
return hasPatternField(field.itemFields());
}
return false;
});
}
private static void writeStructuredFieldTypes(Path directory, String packageName, String ownerClass,
List<FieldDefinition> fields) throws IOException {
for (FieldDefinition field : fields == null ? List.<FieldDefinition>of() : fields) {
@@ -1832,18 +1846,38 @@ public class ToolScaffolder {
continue;
}
String type = javaFieldType(field, ownerClass);
String description = field.description() == null ? "" : field.description().replace("\"", "\\\"");
String example = field.example() == null ? "" : field.example().replace("\"", "\\\"");
source.append(" @Schema(description = \"").append(description).append("\", example = \"")
.append(example).append("\"");
String description = richDescription(field).replace("\"", "\\\"");
String example = "";
if (field.examples() != null && !field.examples().isEmpty()) {
example = field.examples().get(0).replace("\"", "\\\"");
}
source.append(" @Schema(description = \"").append(description).append("\"");
if (!example.isEmpty()) {
source.append(", example = \"").append(example).append("\"");
}
if (field.required()) {
source.append(", requiredMode = Schema.RequiredMode.REQUIRED");
}
source.append(")\n private ").append(type).append(' ').append(fieldName).append(";\n\n");
source.append(")\n");
if (field.pattern() != null && !field.pattern().isBlank()) {
source.append(" @Pattern(regexp = \"").append(field.pattern().replace("\"", "\\\"")).append("\")\n");
}
source.append(" private ").append(type).append(' ').append(fieldName).append(";\n\n");
}
return source.toString();
}
private static String richDescription(FieldDefinition field) {
String desc = field.description() == null ? "" : field.description().trim();
if (field.pattern() != null && !field.pattern().isBlank()) {
desc += " (형식: " + field.pattern() + ")";
}
if (field.examples() != null && !field.examples().isEmpty()) {
desc += " (예시: " + String.join(", ", field.examples()) + ")";
}
return desc.trim();
}
private static String javaFieldType(FieldDefinition field, String ownerClass) {
return switch (field.type() == null ? "String" : field.type()) {
case "Enum" -> toPascalCase(field.name());
@@ -1914,19 +1948,21 @@ public class ToolScaffolder {
}
return "[" + object + "]";
}
FieldDefinition item = new FieldDefinition("item", field.itemType(), "", field.example(), false);
FieldDefinition item = new FieldDefinition("item", field.itemType(), "", field.examples(), field.pattern(), false);
return "[" + mockValue(item) + "]";
}
if (field.example() == null || field.example().isBlank()) {
String exampleStr = (field.examples() != null && !field.examples().isEmpty()) ? field.examples().get(0) : null;
if (exampleStr == null || exampleStr.isBlank()) {
return "null";
}
if ("Enum".equals(field.type())) {
return "\"" + jsonEscape(field.example()) + "\"";
return "\"" + jsonEscape(exampleStr) + "\"";
}
return switch (supportedType(field.type())) {
case "Integer", "Long", "Double", "BigDecimal" -> field.example();
case "Boolean" -> Boolean.parseBoolean(field.example()) ? "true" : "false";
default -> "\"" + jsonEscape(field.example()) + "\"";
case "Integer", "Long", "Double", "BigDecimal" -> exampleStr;
case "Boolean" -> Boolean.parseBoolean(exampleStr) ? "true" : "false";
default -> "\"" + jsonEscape(exampleStr) + "\"";
};
}