This commit is contained in:
@@ -24,15 +24,37 @@ import java.io.File;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/scaffold")
|
@RequestMapping("/api/v1/scaffold")
|
||||||
public class ScaffoldingController {
|
public class ScaffoldingController {
|
||||||
|
|
||||||
|
private static final Set<String> SUPPORTED_FIELD_TYPES = Set.of(
|
||||||
|
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal");
|
||||||
|
private static final Set<String> SUPPORTED_AI_MODELS = Set.of(
|
||||||
|
"inclusionai/ling-3.0-flash:free",
|
||||||
|
"openai/gpt-oss-20b:free",
|
||||||
|
"google/gemma-4-31b-it:free",
|
||||||
|
"nvidia/nemotron-3-nano-30b-a3b:free",
|
||||||
|
"cohere/north-mini-code:free");
|
||||||
|
|
||||||
|
private final ChatClient.Builder chatClientBuilder;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public ScaffoldingController(ChatClient.Builder chatClientBuilder, ObjectMapper objectMapper) {
|
||||||
|
this.chatClientBuilder = chatClientBuilder;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/pod")
|
@PostMapping("/pod")
|
||||||
public String scaffoldPod(@RequestBody Map<String, String> req) {
|
public String scaffoldPod(@RequestBody Map<String, String> req) {
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +105,78 @@ public class ScaffoldingController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/field-draft")
|
||||||
|
public ResponseEntity<?> generateFieldDraft(@RequestBody Map<String, String> req) {
|
||||||
|
String description = req.getOrDefault("description", "").trim();
|
||||||
|
String target = req.getOrDefault("target", "inputFields");
|
||||||
|
if (description.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "설명을 입력해주세요."));
|
||||||
|
}
|
||||||
|
if (!"inputFields".equals(target) && !"outputFields".equals(target)) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "지원하지 않는 필드 대상입니다."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String targetLabel = "inputFields".equals(target) ? "INPUT request DTO" : "OUTPUT response DTO";
|
||||||
|
String prompt = """
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
User description: %s
|
||||||
|
""".formatted(targetLabel, description);
|
||||||
|
|
||||||
|
String response = generateAiContent(prompt, req.get("model"));
|
||||||
|
FieldDraft draft = objectMapper.readValue(stripCodeFence(response), FieldDraft.class);
|
||||||
|
List<ToolScaffolder.FieldDefinition> fields = validateFields(draft.fields());
|
||||||
|
return ResponseEntity.ok(Map.of("fields", fields));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of("error", "AI 초안 생성 실패: " + safeMessage(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tool-draft")
|
||||||
|
public ResponseEntity<?> generateToolDraft(@RequestBody Map<String, String> req) {
|
||||||
|
String description = req.getOrDefault("description", "").trim();
|
||||||
|
if (description.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Tool 설명을 입력해주세요."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String prompt = """
|
||||||
|
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","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true}]}
|
||||||
|
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.
|
||||||
|
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal.
|
||||||
|
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
|
||||||
|
""".formatted(description);
|
||||||
|
|
||||||
|
String response = generateAiContent(prompt, req.get("model"));
|
||||||
|
ToolDraft draft = objectMapper.readValue(stripCodeFence(response), ToolDraft.class);
|
||||||
|
ToolDraft validatedDraft = validateToolDraft(draft);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"baseName", validatedDraft.baseName(),
|
||||||
|
"title", validatedDraft.title(),
|
||||||
|
"description", validatedDraft.description(),
|
||||||
|
"categoryKey", validatedDraft.categoryKey(),
|
||||||
|
"routingType", validatedDraft.routingType(),
|
||||||
|
"httpApiName", validatedDraft.httpApiName(),
|
||||||
|
"inputFields", validatedDraft.inputFields(),
|
||||||
|
"outputFields", validatedDraft.outputFields()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of("error", "AI Tool 초안 생성 실패: " + safeMessage(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/tool/update")
|
@PostMapping("/tool/update")
|
||||||
public String updateTool(@RequestBody Map<String, String> req) {
|
public String updateTool(@RequestBody Map<String, String> req) {
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +216,97 @@ public class ScaffoldingController {
|
|||||||
if (source == null || source.isBlank()) {
|
if (source == null || source.isBlank()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
return new ObjectMapper().readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
|
return objectMapper.readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ToolScaffolder.FieldDefinition> validateFields(List<ToolScaffolder.FieldDefinition> source) {
|
||||||
|
if (source == null || source.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("AI가 필드를 생성하지 않았습니다.");
|
||||||
|
}
|
||||||
|
Set<String> names = new LinkedHashSet<>();
|
||||||
|
return source.stream()
|
||||||
|
.limit(10)
|
||||||
|
.filter(field -> field != null && field.name() != null && !field.name().isBlank())
|
||||||
|
.map(field -> new ToolScaffolder.FieldDefinition(
|
||||||
|
field.name().trim(),
|
||||||
|
field.type() == null ? "String" : field.type().trim(),
|
||||||
|
field.description() == null ? "" : field.description().trim(),
|
||||||
|
field.example() == null ? "" : field.example().trim(),
|
||||||
|
field.required()))
|
||||||
|
.peek(field -> {
|
||||||
|
if (!field.name().matches("^[A-Za-z_$][A-Za-z0-9_$]*$")) {
|
||||||
|
throw new IllegalArgumentException("AI가 올바르지 않은 필드명을 생성했습니다: " + field.name());
|
||||||
|
}
|
||||||
|
if (!SUPPORTED_FIELD_TYPES.contains(field.type())) {
|
||||||
|
throw new IllegalArgumentException("AI가 지원하지 않는 Type을 생성했습니다: " + field.type());
|
||||||
|
}
|
||||||
|
if (!names.add(field.name())) {
|
||||||
|
throw new IllegalArgumentException("AI가 중복 필드명을 생성했습니다: " + field.name());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
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을 생성했습니다.");
|
||||||
|
}
|
||||||
|
String categoryKey = draft.categoryKey() == null ? "" : draft.categoryKey().trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!categoryKey.matches("^[a-z0-9]{3}$")) {
|
||||||
|
throw new IllegalArgumentException("AI가 올바르지 않은 Category Key를 생성했습니다.");
|
||||||
|
}
|
||||||
|
String routingType = draft.routingType() == null ? "" : draft.routingType().trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (!Set.of("HTTP", "MCI").contains(routingType)) {
|
||||||
|
throw new IllegalArgumentException("AI가 지원하지 않는 Protocol을 생성했습니다.");
|
||||||
|
}
|
||||||
|
String httpApiName = draft.httpApiName() == null ? "sample" : draft.httpApiName().trim();
|
||||||
|
if (!httpApiName.matches("^[A-Za-z0-9_-]+$")) {
|
||||||
|
throw new IllegalArgumentException("AI가 올바르지 않은 HTTP API Name을 생성했습니다.");
|
||||||
|
}
|
||||||
|
String title = draft.title() == null ? "" : draft.title().trim();
|
||||||
|
String description = draft.description() == null ? "" : draft.description().trim();
|
||||||
|
if (title.isBlank() || description.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("AI가 Tool 제목 또는 설명을 생성하지 않았습니다.");
|
||||||
|
}
|
||||||
|
return new ToolDraft(draft.baseName().trim(), title, description, categoryKey, routingType, httpApiName,
|
||||||
|
validateFields(draft.inputFields()), validateFields(draft.outputFields()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateAiContent(String prompt, String requestedModel) {
|
||||||
|
return chatClientBuilder.build().prompt()
|
||||||
|
.user(prompt)
|
||||||
|
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
|
||||||
|
.model(resolveModel(requestedModel))
|
||||||
|
.build())
|
||||||
|
.call()
|
||||||
|
.content();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveModel(String requestedModel) {
|
||||||
|
String model = requestedModel == null || requestedModel.isBlank()
|
||||||
|
? "cohere/north-mini-code:free" : requestedModel.trim();
|
||||||
|
if (!SUPPORTED_AI_MODELS.contains(model)) {
|
||||||
|
throw new IllegalArgumentException("지원하지 않는 AI 모델입니다.");
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stripCodeFence(String response) {
|
||||||
|
if (response == null) {
|
||||||
|
throw new IllegalArgumentException("AI 응답이 비어 있습니다.");
|
||||||
|
}
|
||||||
|
return response.trim().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("\\s*```$", "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeMessage(Exception e) {
|
||||||
|
return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record FieldDraft(List<ToolScaffolder.FieldDefinition> fields) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType,
|
||||||
|
String httpApiName, List<ToolScaffolder.FieldDefinition> inputFields,
|
||||||
|
List<ToolScaffolder.FieldDefinition> outputFields) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -444,6 +444,27 @@
|
|||||||
<!-- Tool Creation Form -->
|
<!-- Tool Creation Form -->
|
||||||
<div class="tab-pane fade" id="tool" role="tabpanel">
|
<div class="tab-pane fade" id="tool" role="tabpanel">
|
||||||
<form id="toolForm">
|
<form id="toolForm">
|
||||||
|
<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">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input id="toolNaturalLanguage" type="text" class="form-control" placeholder="예: 고객번호로 계약 상태를 조회하는 Tool">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select id="aiModelSelect" class="form-select" aria-label="AI 모델 선택">
|
||||||
|
<option value="cohere/north-mini-code:free" selected>Cohere North Mini Code</option>
|
||||||
|
<option value="inclusionai/ling-3.0-flash:free">InclusionAI Ling 3 Flash</option>
|
||||||
|
<option value="openai/gpt-oss-20b:free">OpenAI GPT-OSS 20B</option>
|
||||||
|
<option value="google/gemma-4-31b-it:free">Google Gemma 4 31B</option>
|
||||||
|
<option value="nvidia/nemotron-3-nano-30b-a3b:free">NVIDIA Nemotron 3 Nano</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-grid">
|
||||||
|
<button id="toolDraftButton" type="button" class="btn-action" onclick="createAiToolDraft()">AI로 Tool 채우기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-hint">선택한 AI가 Tool 기본 정보와 Input/Output Fields를 채웁니다. Legacy Interface ID와 Client System Code는 실제 연계 명세를 확인해 직접 입력하세요.</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Base Name (PascalCase)</label>
|
<label class="form-label">Base Name (PascalCase)</label>
|
||||||
@@ -482,11 +503,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mt-3 mt-md-0">
|
<div class="col-md-6 mt-3 mt-md-0">
|
||||||
<label class="form-label">Protocol</label>
|
<label class="form-label">Protocol</label>
|
||||||
<select class="form-select" name="routingType">
|
<select class="form-select" name="routingType">
|
||||||
<option value="HTTP">HTTP (REST)</option>
|
<option value="HTTP">HTTP (REST)</option>
|
||||||
<option value="TCP">TCP (Socket)</option>
|
<option value="MCI">MCI (Legacy)</option>
|
||||||
<option value="MCI">MCI (Legacy)</option>
|
</select>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -627,6 +647,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="input-hint mt-0 mb-3">행을 추가해 필드를 입력하면 저장할 때 JSON으로 자동 변환됩니다.</p>
|
<p class="input-hint mt-0 mb-3">행을 추가해 필드를 입력하면 저장할 때 JSON으로 자동 변환됩니다.</p>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
|
||||||
|
<span class="input-hint mt-0 mb-0">빠른 입력:</span>
|
||||||
|
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('customer')">고객조회</button>
|
||||||
|
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('list')">목록조회</button>
|
||||||
|
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('detail')">단건조회</button>
|
||||||
|
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('write')">등록/변경</button>
|
||||||
|
<button type="button" class="btn-secondary-action" onclick="applyFieldTemplate('clear')">초기화</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">자연어 초안 만들기</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input id="fieldNaturalLanguage" type="text" class="form-control" placeholder="예: 고객번호로 계약 상태를 조회">
|
||||||
|
<button id="fieldDraftButton" type="button" class="btn-action" onclick="createNaturalFieldDraft()">AI 초안 만들기</button>
|
||||||
|
</div>
|
||||||
|
<div class="input-hint">Chat 화면과 같은 AI 모델이 문장을 분석해 현재 Input 또는 Output 필드 초안을 만듭니다.</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table align-middle" id="fieldEditorTable">
|
<table class="table align-middle" id="fieldEditorTable">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -643,6 +679,10 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-secondary-action" onclick="addFieldEditorRow()">+ 필드 추가</button>
|
<button type="button" class="btn-secondary-action" onclick="addFieldEditorRow()">+ 필드 추가</button>
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="form-label">JSON 미리보기</label>
|
||||||
|
<pre id="fieldEditorPreview" class="mb-0 p-3 rounded" style="background:#09090b; border:1px solid #27272a; color:#e4e4e7; font-size:0.78rem; max-height:190px; overflow:auto;"></pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn-secondary-action" data-bs-dismiss="modal" style="padding: 0.5rem 1rem; font-size: 0.875rem;">취소</button>
|
<button type="button" class="btn-secondary-action" data-bs-dismiss="modal" style="padding: 0.5rem 1rem; font-size: 0.875rem;">취소</button>
|
||||||
@@ -784,6 +824,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal'];
|
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal'];
|
||||||
|
const typeExamples = {
|
||||||
|
String: 'example',
|
||||||
|
Integer: '1',
|
||||||
|
Long: '1',
|
||||||
|
Double: '1.0',
|
||||||
|
Boolean: 'true',
|
||||||
|
BigDecimal: '1000.00'
|
||||||
|
};
|
||||||
|
const fieldTemplates = {
|
||||||
|
customer: {
|
||||||
|
inputFields: [
|
||||||
|
{ name: 'customerId', type: 'String', description: 'Customer identifier', example: 'CUST00001', required: true }
|
||||||
|
],
|
||||||
|
outputFields: [
|
||||||
|
{ name: 'customerId', type: 'String', description: 'Customer identifier', example: 'CUST00001', required: true },
|
||||||
|
{ name: 'customerName', type: 'String', description: 'Customer name', example: 'Hong Gildong', required: false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
inputFields: [
|
||||||
|
{ name: 'page', type: 'Integer', description: 'Page number', example: '1', required: false },
|
||||||
|
{ name: 'pageSize', type: 'Integer', description: 'Items per page', example: '20', required: false }
|
||||||
|
],
|
||||||
|
outputFields: [
|
||||||
|
{ name: 'totalCount', type: 'Long', description: 'Total result count', example: '100', required: false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
inputFields: [
|
||||||
|
{ name: 'id', type: 'String', description: 'Item identifier', example: 'ID00001', required: true }
|
||||||
|
],
|
||||||
|
outputFields: [
|
||||||
|
{ name: 'resultCode', type: 'String', description: 'Result code', example: 'SUCCESS', required: true },
|
||||||
|
{ name: 'resultMessage', type: 'String', description: 'Result message', example: 'Completed', required: false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
write: {
|
||||||
|
inputFields: [
|
||||||
|
{ name: 'requestId', type: 'String', description: 'Request identifier', example: 'REQ00001', required: true },
|
||||||
|
{ name: 'requestType', type: 'String', description: 'Registration or update type', example: 'CREATE', required: true }
|
||||||
|
],
|
||||||
|
outputFields: [
|
||||||
|
{ name: 'resultCode', type: 'String', description: 'Result code', example: 'SUCCESS', required: true },
|
||||||
|
{ name: 'resultMessage', type: 'String', description: 'Result message', example: 'Completed', required: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
let fieldEditorTargetId = null;
|
let fieldEditorTargetId = null;
|
||||||
let fieldEditorModalInstance = null;
|
let fieldEditorModalInstance = null;
|
||||||
|
|
||||||
@@ -807,6 +894,7 @@
|
|||||||
const body = document.getElementById('fieldEditorBody');
|
const body = document.getElementById('fieldEditorBody');
|
||||||
body.replaceChildren();
|
body.replaceChildren();
|
||||||
(fields.length ? fields : [{}]).forEach(addFieldEditorRow);
|
(fields.length ? fields : [{}]).forEach(addFieldEditorRow);
|
||||||
|
updateFieldEditorPreview();
|
||||||
|
|
||||||
if (!fieldEditorModalInstance) {
|
if (!fieldEditorModalInstance) {
|
||||||
fieldEditorModalInstance = new bootstrap.Modal(document.getElementById('fieldEditorModal'));
|
fieldEditorModalInstance = new bootstrap.Modal(document.getElementById('fieldEditorModal'));
|
||||||
@@ -838,6 +926,12 @@
|
|||||||
const option = new Option(type, type, false, (field.type || 'String') === type);
|
const option = new Option(type, type, false, (field.type || 'String') === type);
|
||||||
typeSelect.add(option);
|
typeSelect.add(option);
|
||||||
});
|
});
|
||||||
|
typeSelect.addEventListener('change', () => {
|
||||||
|
if (!exampleInput.value.trim()) {
|
||||||
|
exampleInput.value = typeExamples[typeSelect.value];
|
||||||
|
}
|
||||||
|
updateFieldEditorPreview();
|
||||||
|
});
|
||||||
|
|
||||||
const requiredSelect = document.createElement('select');
|
const requiredSelect = document.createElement('select');
|
||||||
requiredSelect.className = 'form-select form-select-sm';
|
requiredSelect.className = 'form-select form-select-sm';
|
||||||
@@ -845,6 +939,11 @@
|
|||||||
requiredSelect.add(new Option('Required', 'true', false, field.required === true || field.required === 'true'));
|
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')));
|
requiredSelect.add(new Option('Optional', 'false', false, !(field.required === true || field.required === 'true')));
|
||||||
|
|
||||||
|
[nameInput, descriptionInput, exampleInput, requiredSelect].forEach(control => {
|
||||||
|
control.addEventListener('input', updateFieldEditorPreview);
|
||||||
|
control.addEventListener('change', updateFieldEditorPreview);
|
||||||
|
});
|
||||||
|
|
||||||
[nameInput, typeSelect, descriptionInput, exampleInput, requiredSelect].forEach(control => {
|
[nameInput, typeSelect, descriptionInput, exampleInput, requiredSelect].forEach(control => {
|
||||||
const cell = document.createElement('td');
|
const cell = document.createElement('td');
|
||||||
cell.appendChild(control);
|
cell.appendChild(control);
|
||||||
@@ -855,19 +954,122 @@
|
|||||||
deleteButton.type = 'button';
|
deleteButton.type = 'button';
|
||||||
deleteButton.className = 'btn-secondary-action';
|
deleteButton.className = 'btn-secondary-action';
|
||||||
deleteButton.textContent = '삭제';
|
deleteButton.textContent = '삭제';
|
||||||
deleteButton.addEventListener('click', () => row.remove());
|
deleteButton.addEventListener('click', () => {
|
||||||
|
row.remove();
|
||||||
|
updateFieldEditorPreview();
|
||||||
|
});
|
||||||
deleteCell.appendChild(deleteButton);
|
deleteCell.appendChild(deleteButton);
|
||||||
row.appendChild(deleteCell);
|
row.appendChild(deleteCell);
|
||||||
document.getElementById('fieldEditorBody').appendChild(row);
|
document.getElementById('fieldEditorBody').appendChild(row);
|
||||||
|
updateFieldEditorPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}))
|
||||||
|
.filter(field => field.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFieldEditorPreview() {
|
||||||
|
const preview = document.getElementById('fieldEditorPreview');
|
||||||
|
if (preview) preview.textContent = JSON.stringify(currentEditorFields(), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFieldTemplate(templateName) {
|
||||||
|
const body = document.getElementById('fieldEditorBody');
|
||||||
|
body.replaceChildren();
|
||||||
|
const fields = templateName === 'clear'
|
||||||
|
? [{}]
|
||||||
|
: (fieldTemplates[templateName]?.[fieldEditorTargetId] || []);
|
||||||
|
(fields.length ? fields : [{}]).forEach(addFieldEditorRow);
|
||||||
|
updateFieldEditorPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNaturalFieldDraft() {
|
||||||
|
const source = document.getElementById('fieldNaturalLanguage').value.trim();
|
||||||
|
if (!source) {
|
||||||
|
alert('필드 초안을 만들 문장을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = document.getElementById('fieldDraftButton');
|
||||||
|
const originalLabel = button.textContent;
|
||||||
|
button.textContent = 'AI 생성 중...';
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/scaffold/field-draft', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ description: source, target: fieldEditorTargetId, model: selectedAiModel() })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.error || 'AI 초안 생성에 실패했습니다.');
|
||||||
|
if (!Array.isArray(result.fields) || result.fields.length === 0) {
|
||||||
|
throw new Error('AI가 사용할 수 있는 필드를 만들지 못했습니다.');
|
||||||
|
}
|
||||||
|
const body = document.getElementById('fieldEditorBody');
|
||||||
|
body.replaceChildren();
|
||||||
|
result.fields.forEach(addFieldEditorRow);
|
||||||
|
updateFieldEditorPreview();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`AI 초안 생성 실패: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
button.textContent = originalLabel;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedAiModel() {
|
||||||
|
return document.getElementById('aiModelSelect').value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAiToolDraft() {
|
||||||
|
const source = document.getElementById('toolNaturalLanguage').value.trim();
|
||||||
|
if (!source) {
|
||||||
|
alert('AI가 만들 Tool 설명을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = document.getElementById('toolDraftButton');
|
||||||
|
const originalLabel = button.textContent;
|
||||||
|
button.textContent = 'AI 생성 중...';
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/scaffold/tool-draft', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ description: source, model: selectedAiModel() })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.error || 'AI Tool 초안 생성에 실패했습니다.');
|
||||||
|
|
||||||
|
const form = document.getElementById('toolForm');
|
||||||
|
form.elements.baseName.value = result.baseName || '';
|
||||||
|
form.elements.title.value = result.title || '';
|
||||||
|
form.elements.description.value = result.description || '';
|
||||||
|
form.elements.categoryKey.value = result.categoryKey || '';
|
||||||
|
form.elements.routingType.value = result.routingType || 'HTTP';
|
||||||
|
form.elements.httpApiName.value = result.httpApiName || 'sample';
|
||||||
|
document.getElementById('inputFields').value = JSON.stringify(result.inputFields || [], null, 2);
|
||||||
|
document.getElementById('outputFields').value = JSON.stringify(result.outputFields || [], null, 2);
|
||||||
|
alert('Tool 초안을 채웠습니다. Legacy Interface ID와 Client System Code는 실제 연계 명세를 확인해 입력해주세요.');
|
||||||
|
} catch (error) {
|
||||||
|
alert(`AI Tool 초안 생성 실패: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
button.textContent = originalLabel;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFieldEditor() {
|
function applyFieldEditor() {
|
||||||
const rows = [...document.querySelectorAll('#fieldEditorBody tr')];
|
|
||||||
const names = new Set();
|
const names = new Set();
|
||||||
const fields = [];
|
const fields = currentEditorFields();
|
||||||
for (const row of rows) {
|
for (const field of fields) {
|
||||||
const name = row.querySelector('[data-field="name"]').value.trim();
|
const name = field.name;
|
||||||
if (!name) continue;
|
|
||||||
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
|
||||||
alert(`필드명 "${name}"은(는) Java 필드명 형식으로 입력해주세요.`);
|
alert(`필드명 "${name}"은(는) Java 필드명 형식으로 입력해주세요.`);
|
||||||
return;
|
return;
|
||||||
@@ -877,13 +1079,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
names.add(name);
|
names.add(name);
|
||||||
fields.push({
|
|
||||||
name,
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
document.getElementById(fieldEditorTargetId).value = JSON.stringify(fields, null, 2);
|
document.getElementById(fieldEditorTargetId).value = JSON.stringify(fields, null, 2);
|
||||||
fieldEditorModalInstance.hide();
|
fieldEditorModalInstance.hide();
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package io.shinhanlife.dap.mcg.presentation;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||||
|
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 static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class ScaffoldingControllerToolDraftTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolDraftEndpointIsAvailable() throws Exception {
|
||||||
|
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("""
|
||||||
|
{"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"HTTP","httpApiName":"contract-status","inputFields":[{"name":"customerId","type":"String","description":"고객번호","example":"C123","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","example":"SUCCESS","required":true}]}
|
||||||
|
""");
|
||||||
|
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
|
||||||
|
new ScaffoldingController(builder, new ObjectMapper()))
|
||||||
|
.setMessageConverters(new MappingJackson2HttpMessageConverter())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/scaffold/tool-draft")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"description\":\"고객번호로 계약 상태를 조회\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.baseName").value("CustomerContractStatus"))
|
||||||
|
.andExpect(jsonPath("$.inputFields[0].name").value("customerId"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user