diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java index 97816ea8..b53876b3 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java @@ -24,15 +24,37 @@ import java.io.File; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/scaffold") public class ScaffoldingController { + private static final Set SUPPORTED_FIELD_TYPES = Set.of( + "String", "Integer", "Long", "Double", "Boolean", "BigDecimal"); + private static final Set 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") public String scaffoldPod(@RequestBody Map req) { try { @@ -83,6 +105,78 @@ public class ScaffoldingController { } } + @PostMapping("/field-draft") + public ResponseEntity generateFieldDraft(@RequestBody Map 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 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 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") public String updateTool(@RequestBody Map req) { try { @@ -122,6 +216,97 @@ public class ScaffoldingController { if (source == null || source.isBlank()) { return List.of(); } - return new ObjectMapper().readValue(source, new TypeReference>() { }); + return objectMapper.readValue(source, new TypeReference>() { }); + } + + private List validateFields(List source) { + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException("AI가 필드를 생성하지 않았습니다."); + } + Set 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 fields) { + } + + private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType, + String httpApiName, List inputFields, + List outputFields) { } } diff --git a/dap-gateway/src/main/resources/static/admin/scaffold.html b/dap-gateway/src/main/resources/static/admin/scaffold.html index 9e2eb79d..9cf624dd 100644 --- a/dap-gateway/src/main/resources/static/admin/scaffold.html +++ b/dap-gateway/src/main/resources/static/admin/scaffold.html @@ -444,6 +444,27 @@
+
+ +
+
+ +
+
+ +
+
+ +
+
+
선택한 AI가 Tool 기본 정보와 Input/Output Fields를 채웁니다. Legacy Interface ID와 Client System Code는 실제 연계 명세를 확인해 직접 입력하세요.
+
@@ -482,11 +503,10 @@
- +
@@ -627,6 +647,22 @@