fix: dap-was-dapmt 바라보도록 수정
This commit is contained in:
@@ -3,6 +3,7 @@ package io.shinhanlife.dat.report.application;
|
||||
import io.shinhanlife.dat.report.excel.ToolReportExcelWriter;
|
||||
import io.shinhanlife.dat.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dat.report.model.ToolSummary;
|
||||
import io.shinhanlife.dat.report.model.ToolSourceType;
|
||||
import io.shinhanlife.dat.report.source.ToolDetailAnalyzer;
|
||||
import io.shinhanlife.dat.report.source.ToolSourceDiscovery;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -26,13 +27,18 @@ public class ToolReportService {
|
||||
this.excelWriter = excelWriter;
|
||||
}
|
||||
|
||||
public byte[] createExcel(List<String> selectedNames) {
|
||||
public List<ToolSummary> findTools(String sourceType) {
|
||||
return discovery.discover(ToolSourceType.from(sourceType));
|
||||
}
|
||||
|
||||
public byte[] createExcel(List<String> selectedNames, String sourceTypeValue) {
|
||||
if (selectedNames == null || selectedNames.isEmpty()) {
|
||||
throw new IllegalArgumentException("보고서에 포함할 툴을 하나 이상 선택해야 합니다.");
|
||||
}
|
||||
|
||||
ToolSourceType sourceType = ToolSourceType.from(sourceTypeValue);
|
||||
Map<String, ToolSummary> sourceTools = new LinkedHashMap<>();
|
||||
discovery.discover().forEach(tool -> sourceTools.put(tool.name(), tool));
|
||||
discovery.discover(sourceType).forEach(tool -> sourceTools.put(tool.name(), tool));
|
||||
List<String> missingSources = selectedNames.stream()
|
||||
.filter(name -> !sourceTools.containsKey(name)).distinct().toList();
|
||||
if (!missingSources.isEmpty()) {
|
||||
@@ -41,7 +47,7 @@ public class ToolReportService {
|
||||
|
||||
List<ToolReportModel> reports = selectedNames.stream().distinct()
|
||||
.map(sourceTools::get)
|
||||
.map(analyzer::analyze)
|
||||
.map(tool -> analyzer.analyze(tool, sourceType))
|
||||
.toList();
|
||||
return excelWriter.write(reports);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** 보고서 소스 위치와 출력 정책 설정. */
|
||||
@ConfigurationProperties(prefix = "report")
|
||||
public record ReportProperties(String sourceRoot, String outputFilenamePrefix) {
|
||||
public record ReportProperties(String dapWasDapmtSourceRoot,
|
||||
String dapAdminSourceRoot,
|
||||
String outputFilenamePrefix) {
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ package io.shinhanlife.dat.report.model;
|
||||
import java.util.List;
|
||||
|
||||
/** 사용자가 선택한 툴 보고서 생성 요청. */
|
||||
public record ToolReportRequest(List<String> toolNames) {
|
||||
public record ToolReportRequest(List<String> toolNames, String sourceType) {
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package io.shinhanlife.dat.report.presentation;
|
||||
import io.shinhanlife.dat.report.application.ToolReportService;
|
||||
import io.shinhanlife.dat.report.config.ReportProperties;
|
||||
import io.shinhanlife.dat.report.model.ToolReportRequest;
|
||||
import io.shinhanlife.dat.report.model.ToolSummary;
|
||||
import java.util.List;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -12,10 +14,12 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
/** 툴 선택 목록과 Excel 다운로드 API. */
|
||||
@RestController
|
||||
@@ -33,7 +37,7 @@ public class ToolReportController {
|
||||
@PostMapping(value = "/tool-reports/excel",
|
||||
produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
public ResponseEntity<byte[]> excel(@RequestBody ToolReportRequest request) {
|
||||
byte[] content = service.createExcel(request.toolNames());
|
||||
byte[] content = service.createExcel(request.toolNames(), request.sourceType());
|
||||
String prefix = properties.outputFilenamePrefix() == null || properties.outputFilenamePrefix().isBlank()
|
||||
? "tool-report" : properties.outputFilenamePrefix();
|
||||
String filename = prefix + "-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
|
||||
@@ -47,6 +51,11 @@ public class ToolReportController {
|
||||
.body(content);
|
||||
}
|
||||
|
||||
@GetMapping("/report-tools")
|
||||
public List<ToolSummary> tools(@RequestParam(required = false) String sourceType) {
|
||||
return service.findTools(sourceType);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, String>> badRequest(IllegalArgumentException exception) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.shinhanlife.dat.report.source;
|
||||
|
||||
import com.github.javaparser.ast.NodeList;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.ArrayInitializerExpr;
|
||||
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
|
||||
import com.github.javaparser.ast.expr.Expression;
|
||||
import com.github.javaparser.ast.expr.MemberValuePair;
|
||||
@@ -9,6 +10,7 @@ import com.github.javaparser.ast.expr.NormalAnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.StringLiteralExpr;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** JavaParser AST에서 어노테이션 값을 안전하게 읽는 도우미. */
|
||||
final class JavaAnnotationReader {
|
||||
@@ -49,6 +51,18 @@ final class JavaAnnotationReader {
|
||||
.orElse(fallback);
|
||||
}
|
||||
|
||||
static String strings(AnnotationExpr annotation, String key) {
|
||||
return value(annotation, key)
|
||||
.filter(ArrayInitializerExpr.class::isInstance)
|
||||
.map(ArrayInitializerExpr.class::cast)
|
||||
.map(array -> array.getValues().stream()
|
||||
.filter(StringLiteralExpr.class::isInstance)
|
||||
.map(StringLiteralExpr.class::cast)
|
||||
.map(StringLiteralExpr::asString)
|
||||
.collect(Collectors.joining(" | ")))
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
static Optional<AnnotationExpr> nested(AnnotationExpr annotation, String key) {
|
||||
return value(annotation, key).filter(AnnotationExpr.class::isInstance).map(AnnotationExpr.class::cast);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package io.shinhanlife.dat.report.source;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||
@@ -10,135 +7,56 @@ import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import io.shinhanlife.dat.report.model.FieldDefinition;
|
||||
import io.shinhanlife.dat.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dat.report.model.ToolSourceType;
|
||||
import io.shinhanlife.dat.report.model.ToolSummary;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 선택된 툴의 DTO, JSON Schema와 원천 전문 필드를 수집한다. */
|
||||
/** 선택한 프로젝트의 DTO와 전문 Java 어노테이션에서 Report 파라미터를 수집한다. */
|
||||
@Component
|
||||
public class ToolDetailAnalyzer {
|
||||
|
||||
private final ToolSourceDiscovery discovery;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ObjectMapper yamlMapper;
|
||||
|
||||
public ToolDetailAnalyzer(ToolSourceDiscovery discovery) {
|
||||
this.discovery = discovery;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.yamlMapper = new ObjectMapper(new YAMLFactory());
|
||||
}
|
||||
|
||||
public ToolReportModel analyze(ToolSummary tool) {
|
||||
public ToolReportModel analyze(ToolSummary tool, ToolSourceType sourceType) {
|
||||
List<FieldDefinition> fields = new ArrayList<>();
|
||||
List<String> diagnostics = new ArrayList<>();
|
||||
Map<String, Path> javaFiles = indexJavaFiles();
|
||||
Path sourceRoot = discovery.sourceRoot(sourceType);
|
||||
Map<String, Path> javaFiles = indexJavaFiles(sourceRoot);
|
||||
|
||||
collectSchema(tool, tool.inputSchemaResource(), "INPUT", fields, diagnostics);
|
||||
collectSchema(tool, tool.outputSchemaResource(), "OUTPUT", fields, diagnostics);
|
||||
boolean definitionInput = collectDefinitionSchema(tool, "parameters_schema", "INPUT", fields, diagnostics);
|
||||
boolean definitionOutput = collectDefinitionSchema(tool, "output_schema", "OUTPUT", fields, diagnostics);
|
||||
if (tool.inputSchemaResource().isBlank() && !definitionInput) {
|
||||
collectJavaType(tool, javaFiles.get(tool.requestType()), "DTO", "INPUT", fields, diagnostics);
|
||||
}
|
||||
if (tool.outputSchemaResource().isBlank() && !definitionOutput) {
|
||||
collectJavaType(tool, javaFiles.get(tool.responseType()), "DTO", "OUTPUT", fields, diagnostics);
|
||||
}
|
||||
collectJavaType(sourceRoot, tool, javaFiles.get(tool.requestType()), "DTO", "INPUT", fields, diagnostics);
|
||||
collectJavaType(sourceRoot, tool, javaFiles.get(tool.responseType()), "DTO", "OUTPUT", fields, diagnostics);
|
||||
if (!tool.mappingId().isBlank()) {
|
||||
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_I"), "TELEGRAM", "INPUT", fields, diagnostics);
|
||||
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_O"), "TELEGRAM", "OUTPUT", fields, diagnostics);
|
||||
collectJavaType(sourceRoot, tool, javaFiles.get(tool.mappingId() + "_I"),
|
||||
"TELEGRAM", "INPUT", fields, diagnostics);
|
||||
collectJavaType(sourceRoot, tool, javaFiles.get(tool.mappingId() + "_O"),
|
||||
"TELEGRAM", "OUTPUT", fields, diagnostics);
|
||||
}
|
||||
return new ToolReportModel(tool, List.copyOf(fields), List.copyOf(diagnostics));
|
||||
}
|
||||
|
||||
private boolean collectDefinitionSchema(ToolSummary tool, String schemaKey, String direction,
|
||||
List<FieldDefinition> fields, List<String> diagnostics) {
|
||||
if (tool.definitionFile() == null || tool.definitionFile().isBlank()) return false;
|
||||
Path path = discovery.sourceRoot().resolve(tool.definitionFile()).normalize();
|
||||
try {
|
||||
JsonNode schema = yamlMapper.readTree(path.toFile()).path(schemaKey);
|
||||
if (schema.isMissingNode() || schema.isNull() || schema.isEmpty()) return false;
|
||||
collectSchemaFields(tool, schema, "TOOL_DEFINITION", direction, "", "", relative(path), fields);
|
||||
return true;
|
||||
} catch (Exception exception) {
|
||||
diagnostics.add("Tool definition schema 분석 실패: " + relative(path) + " (" + schemaKey + ")");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Path> indexJavaFiles() {
|
||||
private Map<String, Path> indexJavaFiles(Path sourceRoot) {
|
||||
Map<String, Path> result = new LinkedHashMap<>();
|
||||
try (var paths = Files.walk(discovery.sourceRoot())) {
|
||||
try (var paths = Files.walk(sourceRoot)) {
|
||||
paths.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".java"))
|
||||
.forEach(path -> result.putIfAbsent(path.getFileName().toString().replace(".java", ""), path));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to index Java sources", exception);
|
||||
throw new IllegalStateException("Failed to index Java sources: " + sourceRoot, exception);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void collectSchema(ToolSummary tool, String resource, String direction,
|
||||
List<FieldDefinition> fields, List<String> diagnostics) {
|
||||
if (resource == null || resource.isBlank()) return;
|
||||
String relative = resource.replaceFirst("^classpath:", "").replaceFirst("^/", "");
|
||||
List<Path> matches = new ArrayList<>();
|
||||
try (var paths = Files.walk(discovery.sourceRoot())) {
|
||||
paths.filter(path -> Files.isRegularFile(path))
|
||||
.filter(path -> path.toString().replace('\\', '/').endsWith("/src/main/resources/" + relative))
|
||||
.forEach(matches::add);
|
||||
} catch (IOException exception) {
|
||||
diagnostics.add("Schema 검색 실패: " + resource);
|
||||
return;
|
||||
}
|
||||
if (matches.isEmpty()) {
|
||||
diagnostics.add("Schema를 찾을 수 없음: " + resource);
|
||||
return;
|
||||
}
|
||||
Path path = matches.get(0);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(path.toFile());
|
||||
collectSchemaFields(tool, root, "JSON_SCHEMA", direction, "", "", relative(path), fields);
|
||||
} catch (Exception exception) {
|
||||
diagnostics.add("Schema 분석 실패: " + relative(path));
|
||||
}
|
||||
}
|
||||
|
||||
private void collectSchemaFields(ToolSummary tool, JsonNode schema, String sourceKind, String direction,
|
||||
String ownerPath, String fieldPrefix, String sourceFile,
|
||||
List<FieldDefinition> fields) {
|
||||
List<String> required = new ArrayList<>();
|
||||
schema.path("required").forEach(node -> required.add(node.asText()));
|
||||
Iterator<Map.Entry<String, JsonNode>> iterator = schema.path("properties").fields();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = iterator.next();
|
||||
JsonNode definition = entry.getValue();
|
||||
String fieldName = fieldPrefix.isBlank() ? entry.getKey() : fieldPrefix + "." + entry.getKey();
|
||||
String type = schemaType(definition);
|
||||
fields.add(new FieldDefinition(tool.name(), sourceKind, direction, ownerPath, fieldName, type,
|
||||
required.contains(entry.getKey()), definition.path("description").asText(""),
|
||||
constraints(definition), sourceFile));
|
||||
JsonNode nested = definition.path("properties").isObject() ? definition : definition.path("items");
|
||||
if (nested.path("properties").isObject()) {
|
||||
collectSchemaFields(tool, nested, sourceKind, direction, fieldName, fieldName, sourceFile, fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String schemaType(JsonNode definition) {
|
||||
String type = definition.path("type").asText("object");
|
||||
if ("array".equals(type)) {
|
||||
return "array<" + definition.path("items").path("type").asText("object") + ">";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
private void collectJavaType(ToolSummary tool, Path path, String sourceKind, String direction,
|
||||
private void collectJavaType(Path sourceRoot, ToolSummary tool, Path path, String sourceKind, String direction,
|
||||
List<FieldDefinition> fields, List<String> diagnostics) {
|
||||
if (path == null) {
|
||||
diagnostics.add(sourceKind + " " + direction + " 타입을 찾을 수 없음");
|
||||
@@ -147,18 +65,18 @@ public class ToolDetailAnalyzer {
|
||||
try {
|
||||
CompilationUnit unit = StaticJavaParser.parse(path);
|
||||
for (TypeDeclaration<?> type : unit.getTypes()) {
|
||||
collectFields(tool, path, type, sourceKind, direction, fields);
|
||||
collectFields(sourceRoot, tool, path, type, sourceKind, direction, fields);
|
||||
type.getMembers().stream().filter(TypeDeclaration.class::isInstance)
|
||||
.map(TypeDeclaration.class::cast)
|
||||
.forEach(nested -> collectFields(tool, path, nested, sourceKind, direction, fields));
|
||||
.forEach(nested -> collectFields(sourceRoot, tool, path, nested, sourceKind, direction, fields));
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
diagnostics.add(sourceKind + " 분석 실패: " + relative(path));
|
||||
diagnostics.add(sourceKind + " 분석 실패: " + relative(sourceRoot, path));
|
||||
}
|
||||
}
|
||||
|
||||
private void collectFields(ToolSummary tool, Path path, TypeDeclaration<?> owner, String sourceKind,
|
||||
String direction, List<FieldDefinition> fields) {
|
||||
private void collectFields(Path sourceRoot, ToolSummary tool, Path path, TypeDeclaration<?> owner,
|
||||
String sourceKind, String direction, List<FieldDefinition> fields) {
|
||||
for (FieldDeclaration declaration : owner.getFields()) {
|
||||
AnnotationExpr param = JavaAnnotationReader.find(declaration.getAnnotations(), "McpToolParam").orElse(null);
|
||||
AnnotationExpr schema = JavaAnnotationReader.find(declaration.getAnnotations(), "Schema").orElse(null);
|
||||
@@ -168,7 +86,7 @@ public class ToolDetailAnalyzer {
|
||||
variable.getTypeAsString(),
|
||||
param == null ? null : JavaAnnotationReader.bool(param, "required", false),
|
||||
param == null ? "" : JavaAnnotationReader.string(param, "description", ""),
|
||||
annotationConstraints(schema, telegram), relative(path))));
|
||||
annotationConstraints(schema, telegram), relative(sourceRoot, path))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,16 +97,7 @@ public class ToolDetailAnalyzer {
|
||||
return String.join("; ", values);
|
||||
}
|
||||
|
||||
private String constraints(JsonNode node) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (String key : List.of("format", "pattern", "minimum", "maximum", "minLength", "maxLength", "example")) {
|
||||
if (node.has(key)) values.add(key + "=" + node.get(key).asText());
|
||||
}
|
||||
if (node.has("enum")) values.add("enum=" + node.get("enum"));
|
||||
return String.join("; ", values);
|
||||
}
|
||||
|
||||
private String relative(Path path) {
|
||||
return discovery.sourceRoot().relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/');
|
||||
private String relative(Path sourceRoot, Path path) {
|
||||
return sourceRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import io.shinhanlife.dat.report.config.ReportProperties;
|
||||
import io.shinhanlife.dat.report.model.ToolSummary;
|
||||
import io.shinhanlife.dat.report.model.ToolSourceType;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -17,120 +15,50 @@ import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.EnumMap;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 프로젝트의 @McpTool 선언을 읽기 전용으로 탐색한다. */
|
||||
@Component
|
||||
public class ToolSourceDiscovery {
|
||||
|
||||
private final Path sourceRoot;
|
||||
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
|
||||
private final Map<ToolSourceType, Path> sourceRoots = new EnumMap<>(ToolSourceType.class);
|
||||
|
||||
public ToolSourceDiscovery(ReportProperties properties) {
|
||||
String configuredRoot = properties.sourceRoot();
|
||||
if (configuredRoot == null || configuredRoot.isBlank()) {
|
||||
throw new IllegalArgumentException("report.source-root must not be blank");
|
||||
}
|
||||
this.sourceRoot = resolveProjectRoot(Path.of(configuredRoot).toAbsolutePath().normalize());
|
||||
sourceRoots.put(ToolSourceType.DAP_WAS_DAPMT,
|
||||
resolveConfiguredRoot(properties.dapWasDapmtSourceRoot(), "report.dap-was-dapmt-source-root"));
|
||||
sourceRoots.put(ToolSourceType.DAP_ADMIN,
|
||||
resolveConfiguredRoot(properties.dapAdminSourceRoot(), "report.dap-admin-source-root"));
|
||||
}
|
||||
|
||||
public Path sourceRoot() {
|
||||
return sourceRoot;
|
||||
public Path sourceRoot(ToolSourceType sourceType) {
|
||||
return sourceRoots.get(sourceType);
|
||||
}
|
||||
|
||||
public List<ToolSummary> discover() {
|
||||
public List<ToolSummary> discover(ToolSourceType sourceType) {
|
||||
Path sourceRoot = sourceRoot(sourceType);
|
||||
if (!Files.isDirectory(sourceRoot)) {
|
||||
throw new IllegalStateException("Report source root does not exist: " + sourceRoot);
|
||||
}
|
||||
List<ToolSummary> tools = new ArrayList<>();
|
||||
try (var paths = Files.walk(sourceRoot)) {
|
||||
paths.filter(this::isUseCaseSource).forEach(path -> parse(path, tools));
|
||||
paths.filter(this::isUseCaseSource).forEach(path -> parse(sourceRoot, path, tools));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to scan tool sources: " + sourceRoot, exception);
|
||||
}
|
||||
Map<String, ToolSummary> discovered = tools.stream()
|
||||
return tools.stream()
|
||||
.sorted(Comparator.comparing(ToolSummary::sourceFile))
|
||||
.collect(Collectors.toMap(ToolSummary::name, Function.identity(),
|
||||
(first, duplicate) -> first, LinkedHashMap::new));
|
||||
discoverDefinitions().forEach((name, definition) ->
|
||||
discovered.merge(name, definition, this::mergeDefinition));
|
||||
return discovered.values().stream()
|
||||
.collect(java.util.stream.Collectors.toMap(ToolSummary::name, java.util.function.Function.identity(),
|
||||
(first, duplicate) -> first, LinkedHashMap::new)).values().stream()
|
||||
.sorted(Comparator.comparing(ToolSummary::name))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<String, ToolSummary> discoverDefinitions() {
|
||||
Map<String, ToolSummary> definitions = new LinkedHashMap<>();
|
||||
try (var paths = Files.walk(sourceRoot)) {
|
||||
paths.filter(this::isToolDefinition).sorted().forEach(path -> {
|
||||
try {
|
||||
JsonNode root = yamlMapper.readTree(path.toFile());
|
||||
String name = text(root, "name");
|
||||
if (name.isBlank()) return;
|
||||
JsonNode description = root.path("description");
|
||||
String function = text(description, "function");
|
||||
definitions.putIfAbsent(name, new ToolSummary(
|
||||
name, text(root, "display_name"), function, text(root, "category_key"),
|
||||
text(root, "legacy_interface_id"), true, false,
|
||||
bool(root, "read_only"), bool(root, "destructive"), bool(root, "idempotent"), false,
|
||||
"", "", "", "", "", "", text(root, "version"), function,
|
||||
text(description, "when_to_use"), text(description, "when_not_to_use"),
|
||||
text(description, "io_limits"), text(root, "display_description"),
|
||||
joined(root.path("example_queries")), joined(root.path("tags")),
|
||||
joined(root.path("required_env_keys")), text(root, "owner_org"), relative(path)));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to parse tool definition: " + path, exception);
|
||||
}
|
||||
});
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to scan tool definitions: " + sourceRoot, exception);
|
||||
private Path resolveConfiguredRoot(String configuredRoot, String propertyName) {
|
||||
if (configuredRoot == null || configuredRoot.isBlank()) {
|
||||
throw new IllegalArgumentException(propertyName + " must not be blank");
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private ToolSummary mergeDefinition(ToolSummary source, ToolSummary definition) {
|
||||
return new ToolSummary(source.name(), prefer(definition.title(), source.title()),
|
||||
prefer(definition.description(), source.description()),
|
||||
prefer(definition.categoryKey(), source.categoryKey()),
|
||||
prefer(definition.mappingId(), source.mappingId()), source.register(), source.requiresApproval(),
|
||||
definition.readOnlyHint(), definition.destructiveHint(), definition.idempotentHint(),
|
||||
source.openWorldHint(), source.requestType(), source.responseType(), source.useCaseClass(),
|
||||
source.sourceFile(), source.inputSchemaResource(), source.outputSchemaResource(), definition.version(),
|
||||
definition.functionDescription(), definition.whenToUse(), definition.whenNotToUse(),
|
||||
definition.ioLimits(), definition.displayDescription(), definition.exampleQueries(), definition.tags(),
|
||||
definition.requiredEnvKeys(), definition.ownerOrg(), definition.definitionFile());
|
||||
}
|
||||
|
||||
private boolean isToolDefinition(Path path) {
|
||||
String normalized = path.toString().replace('\\', '/');
|
||||
String fileName = path.getFileName().toString().toLowerCase();
|
||||
return Files.isRegularFile(path) && (fileName.endsWith(".yml") || fileName.endsWith(".yaml"))
|
||||
&& normalized.contains("/src/main/resources/tool-definitions/");
|
||||
}
|
||||
|
||||
private String text(JsonNode node, String field) {
|
||||
return node.path(field).asText("");
|
||||
}
|
||||
|
||||
private boolean bool(JsonNode node, String field) {
|
||||
return node.path(field).asBoolean(false);
|
||||
}
|
||||
|
||||
private String joined(JsonNode node) {
|
||||
if (!node.isArray()) return node.asText("");
|
||||
List<String> values = new ArrayList<>();
|
||||
node.forEach(value -> values.add(value.asText()));
|
||||
return String.join(" | ", values);
|
||||
}
|
||||
|
||||
private String prefer(String primary, String fallback) {
|
||||
return primary == null || primary.isBlank() ? fallback : primary;
|
||||
}
|
||||
|
||||
private String relative(Path path) {
|
||||
return sourceRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/');
|
||||
return resolveProjectRoot(Path.of(configuredRoot).toAbsolutePath().normalize());
|
||||
}
|
||||
|
||||
private Path resolveProjectRoot(Path configuredPath) {
|
||||
@@ -153,20 +81,20 @@ public class ToolSourceDiscovery {
|
||||
&& !normalized.contains("/dap-tool-report/");
|
||||
}
|
||||
|
||||
private void parse(Path path, List<ToolSummary> tools) {
|
||||
private void parse(Path sourceRoot, Path path, List<ToolSummary> tools) {
|
||||
try {
|
||||
CompilationUnit unit = StaticJavaParser.parse(path);
|
||||
String owner = unit.getPrimaryTypeName().orElse(path.getFileName().toString().replace(".java", ""));
|
||||
for (MethodDeclaration method : unit.findAll(MethodDeclaration.class)) {
|
||||
JavaAnnotationReader.find(method.getAnnotations(), "McpTool")
|
||||
.ifPresent(annotation -> tools.add(toSummary(path, owner, method, annotation)));
|
||||
.ifPresent(annotation -> tools.add(toSummary(sourceRoot, path, owner, method, annotation)));
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Failed to parse tool source: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private ToolSummary toSummary(Path path, String owner, MethodDeclaration method, AnnotationExpr tool) {
|
||||
private ToolSummary toSummary(Path sourceRoot, Path path, String owner, MethodDeclaration method, AnnotationExpr tool) {
|
||||
AnnotationExpr hint = JavaAnnotationReader.find(method.getAnnotations(), "GrowToolHint")
|
||||
.or(() -> JavaAnnotationReader.find(method.getAnnotations(), "ToolHint")).orElse(null);
|
||||
AnnotationExpr annotations = JavaAnnotationReader.nested(tool, "annotations").orElse(null);
|
||||
@@ -183,14 +111,30 @@ public class ToolSourceDiscovery {
|
||||
hint != null && JavaAnnotationReader.bool(hint, "register", false),
|
||||
hint != null && JavaAnnotationReader.bool(hint, "requiresApproval", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "readOnlyHint", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "destructiveHint", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "idempotentHint", false),
|
||||
annotations != null
|
||||
? JavaAnnotationReader.bool(annotations, "destructiveHint",
|
||||
hint != null && JavaAnnotationReader.bool(hint, "destructive", false))
|
||||
: hint != null && JavaAnnotationReader.bool(hint, "destructive", false),
|
||||
annotations != null
|
||||
? JavaAnnotationReader.bool(annotations, "idempotentHint",
|
||||
hint != null && JavaAnnotationReader.bool(hint, "idempotent", false))
|
||||
: hint != null && JavaAnnotationReader.bool(hint, "idempotent", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "openWorldHint", false),
|
||||
requestType,
|
||||
method.getTypeAsString(),
|
||||
owner,
|
||||
sourceRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "inputSchemaResource", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "outputSchemaResource", ""));
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "outputSchemaResource", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "version", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "functionDescription", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "whenToUse", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "whenNotToUse", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "ioLimits", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "displayDescription", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.strings(hint, "exampleQueries"),
|
||||
hint == null ? "" : JavaAnnotationReader.strings(hint, "tags"),
|
||||
hint == null ? "" : JavaAnnotationReader.strings(hint, "requiredEnvKeys"),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "ownerOrg", ""), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ spring:
|
||||
name: dap-tool-report
|
||||
|
||||
report:
|
||||
source-root: ${REPORT_SOURCE_ROOT:.}
|
||||
dap-was-dapmt-source-root: ${REPORT_DAP_WAS_DAPMT_SOURCE_ROOT:../dap-was-dapmt}
|
||||
dap-admin-source-root: ${REPORT_DAP_ADMIN_SOURCE_ROOT:.}
|
||||
output-filename-prefix: tool-report
|
||||
|
||||
@@ -3,49 +3,53 @@ package io.shinhanlife.dat.report.source;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.shinhanlife.dat.report.config.ReportProperties;
|
||||
import io.shinhanlife.dat.report.model.ToolSourceType;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolSourceDiscoveryTest {
|
||||
|
||||
private final Path adminRoot = projectRoot();
|
||||
private final Path dapmtRoot = adminRoot.resolve("../dap-was-dapmt").normalize();
|
||||
|
||||
@Test
|
||||
void discoversMcpToolsFromProjectSources() {
|
||||
Path projectRoot = Path.of("..").toAbsolutePath().normalize();
|
||||
ToolSourceDiscovery discovery = new ToolSourceDiscovery(
|
||||
new ReportProperties(projectRoot.toString(), "tool-report"));
|
||||
void discoversToolsFromBothSelectableProjects() {
|
||||
ToolSourceDiscovery discovery = discovery();
|
||||
|
||||
assertThat(discovery.discover())
|
||||
assertThat(discovery.discover(ToolSourceType.DAP_ADMIN))
|
||||
.extracting(tool -> tool.name())
|
||||
.contains("cmm_customer_tool", "cmm_claim_search", "iam_system_status");
|
||||
|
||||
assertThat(discovery.discover().stream()
|
||||
.filter(tool -> tool.name().equals("cmm_customer_tool"))
|
||||
.findFirst().orElseThrow())
|
||||
.satisfies(tool -> {
|
||||
assertThat(tool.version()).isEqualTo("1.0.0");
|
||||
assertThat(tool.mappingId()).isEqualTo("ONILD0320");
|
||||
assertThat(tool.definitionFile()).endsWith("tool-definitions/cmm/cmm_customer_tool.yml");
|
||||
assertThat(tool.whenToUse()).contains("고객 ID");
|
||||
});
|
||||
.contains("cmm_claim_search");
|
||||
assertThat(discovery.discover(ToolSourceType.DAP_WAS_DAPMT))
|
||||
.extracting(tool -> tool.name())
|
||||
.contains("spr_field_inquiry_list");
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectsJsonSchemaFieldsEmbeddedInToolDefinitionYaml() {
|
||||
Path projectRoot = Path.of("..").toAbsolutePath().normalize();
|
||||
ToolSourceDiscovery discovery = new ToolSourceDiscovery(
|
||||
new ReportProperties(projectRoot.toString(), "tool-report"));
|
||||
var tool = discovery.discover().stream()
|
||||
.filter(candidate -> candidate.name().equals("cmm_customer_tool"))
|
||||
void collectsParametersFromDtoAnnotationsInsteadOfYamlDefinitions() {
|
||||
ToolSourceDiscovery discovery = discovery();
|
||||
var tool = discovery.discover(ToolSourceType.DAP_WAS_DAPMT).stream()
|
||||
.filter(candidate -> candidate.name().equals("spr_field_inquiry_list"))
|
||||
.findFirst().orElseThrow();
|
||||
|
||||
var report = new ToolDetailAnalyzer(discovery).analyze(tool);
|
||||
var report = new ToolDetailAnalyzer(discovery).analyze(tool, ToolSourceType.DAP_WAS_DAPMT);
|
||||
|
||||
assertThat(report.fields())
|
||||
.anySatisfy(field -> {
|
||||
assertThat(field.sourceKind()).isEqualTo("TOOL_DEFINITION");
|
||||
assertThat(field.direction()).isEqualTo("INPUT");
|
||||
assertThat(field.fieldName()).isEqualTo("csNo");
|
||||
assertThat(field.required()).isTrue();
|
||||
});
|
||||
assertThat(report.fields()).anySatisfy(field -> {
|
||||
assertThat(field.sourceKind()).isEqualTo("DTO");
|
||||
assertThat(field.direction()).isEqualTo("INPUT");
|
||||
assertThat(field.fieldName()).isEqualTo("inquiryId");
|
||||
assertThat(field.constraints()).contains("Schema=");
|
||||
});
|
||||
assertThat(report.fields()).noneMatch(field -> field.sourceKind().equals("TOOL_DEFINITION"));
|
||||
}
|
||||
|
||||
private ToolSourceDiscovery discovery() {
|
||||
return new ToolSourceDiscovery(new ReportProperties(
|
||||
dapmtRoot.toString(), adminRoot.toString(), "tool-report"));
|
||||
}
|
||||
|
||||
private Path projectRoot() {
|
||||
Path current = Path.of("").toAbsolutePath().normalize();
|
||||
return java.nio.file.Files.isRegularFile(current.resolve("settings.gradle"))
|
||||
? current : current.getParent();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user