From 59d39a79c34b3c1252adbf77b1d94aea556fbba5 Mon Sep 17 00:00:00 2001 From: "gayoun.park" Date: Thu, 9 Apr 2026 09:16:42 +0900 Subject: [PATCH] =?UTF-8?q?daniel=20=EC=9E=91=EC=97=85=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../training/dataset/dto/DatasetObjDto.java | 4 +- .../service/FileManagerService.java | 2 +- .../model/ModelTrainDetailApiController.java | 39 ++ .../model/dto/ModelTrainDetailDto.java | 69 +++ .../service/ModelTrainDetailService.java | 368 +++++++++++++ .../model/ModelMngRepositoryCustom.java | 8 + .../model/ModelMngRepositoryImpl.java | 9 + .../cd/training/train/TrainApiController.java | 62 +++ .../train/dto/TrainingMetricsDto.java | 148 ++++++ .../train/service/TrainingMetricsService.java | 482 ++++++++++++++++++ 10 files changed, 1189 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/kamco/cd/training/train/dto/TrainingMetricsDto.java create mode 100644 src/main/java/com/kamco/cd/training/train/service/TrainingMetricsService.java diff --git a/src/main/java/com/kamco/cd/training/dataset/dto/DatasetObjDto.java b/src/main/java/com/kamco/cd/training/dataset/dto/DatasetObjDto.java index 6e0cce0..d9335fb 100644 --- a/src/main/java/com/kamco/cd/training/dataset/dto/DatasetObjDto.java +++ b/src/main/java/com/kamco/cd/training/dataset/dto/DatasetObjDto.java @@ -131,7 +131,9 @@ public class DatasetObjDto { private String classCd; public String getClassName() { - return DetectionClassification.valueOf(classCd.toUpperCase()).getDesc(); + return DetectionClassification.fromString(classCd).getDesc(); + // fromString 메서드를 사용하여 안전하게 변환 (미정의 값은 ETC로 처리) + // return DetectionClassification.valueOf(classCd.toUpperCase()).getDesc(); } } diff --git a/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java b/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java index 1e52168..2bdb020 100644 --- a/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java +++ b/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java @@ -577,7 +577,7 @@ public class FileManagerService { public FileManagerDto.StorageSpaceRes getStorageSpaceInfo() { // 설정값에서 경로 가져오기 (train.docker.base_path) - log.info("basePath = {}",basePath); + log.info("basePath = {}", basePath); String directoryPath = basePath; Path directory = Paths.get(directoryPath); diff --git a/src/main/java/com/kamco/cd/training/model/ModelTrainDetailApiController.java b/src/main/java/com/kamco/cd/training/model/ModelTrainDetailApiController.java index 5fc9c8c..1c49afb 100644 --- a/src/main/java/com/kamco/cd/training/model/ModelTrainDetailApiController.java +++ b/src/main/java/com/kamco/cd/training/model/ModelTrainDetailApiController.java @@ -37,6 +37,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -366,4 +367,42 @@ public class ModelTrainDetailApiController { @Parameter(description = "모델 uuid") @PathVariable UUID uuid) { return ApiResponseDto.ok(modelTrainDetailService.cleanup(uuid)); } + + @Operation( + summary = "학습 결과 ZIP 파일 전체 다운로드", + description = + "모델 UUID에 해당하는 모든 ZIP 파일 목록을 조회하고" + "생성된 모든 학습데이터의 ZIP 파일을 하나의 ZIP 파일로 압축하여 다운로드", + parameters = { + @Parameter( + name = "kamco-download-uuid", + in = ParameterIn.HEADER, + required = true, + description = "다운로드 요청 UUID", + schema = + @Schema( + type = "string", + format = "uuid", + example = "6d8d49dc-0c9d-4124-adc7-b9ca610cc394")) + }) + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "학습데이터 zip파일 다운로드", + content = + @Content( + mediaType = "application/octet-stream", + schema = @Schema(type = "string", format = "binary"))), + @ApiResponse(responseCode = "404", description = "모델 또는 파일 없음", content = @Content), + @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) + }) + @GetMapping("/downloadzip/{uuid}") + public ResponseEntity downloadZip( + @Parameter(description = "모델 UUID") @PathVariable UUID uuid, + @Parameter(hidden = true) @RequestHeader("kamco-download-uuid") String downloadUuid, + HttpServletRequest request) + throws IOException { + + return modelTrainDetailService.downloadZipFile(uuid, downloadUuid, request); + } } diff --git a/src/main/java/com/kamco/cd/training/model/dto/ModelTrainDetailDto.java b/src/main/java/com/kamco/cd/training/model/dto/ModelTrainDetailDto.java index cba368d..c39a0af 100644 --- a/src/main/java/com/kamco/cd/training/model/dto/ModelTrainDetailDto.java +++ b/src/main/java/com/kamco/cd/training/model/dto/ModelTrainDetailDto.java @@ -266,4 +266,73 @@ public class ModelTrainDetailDto { private Boolean fileExistsYn; private String fileName; } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(name = "ZipFileListResponse", description = "ZIP 파일 목록 조회 응답") + public static class ZipFileListResponse { + + @Schema(description = "모델 UUID", example = "2c5cdd07-5b9b-44ad-a063-8dead628b45f") + private String modelUuid; + + @Schema(description = "모델 번호", example = "G2") + private String modelNo; + + @Schema(description = "현재 모델 버전", example = "G2_000001") + private String modelVer; + + @Schema( + description = "기본 경로", + example = "/home/kcomu/data/response/2c5cdd07-5b9b-44ad-a063-8dead628b45f") + private String basePath; + + @Schema(description = "ZIP 파일 목록") + private List zipFiles; + + @Schema(description = "총 파일 개수", example = "3") + private Integer totalFiles; + + @Schema(description = "전체 파일 크기 (bytes)", example = "4048640000") + private Long totalSize; + + @Schema(description = "전체 파일 크기 (포맷)", example = "3.77 GB") + private String totalSizeFormatted; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(name = "ZipFileInfo", description = "ZIP 파일 상세 정보") + public static class ZipFileInfo { + + @Schema(description = "파일명", example = "G2.G2_000001.{uuid}.zip") + private String fileName; + + @Schema(description = "파일 전체 경로") + private String filePath; + + @Schema(description = "모델 버전", example = "G2_000001") + private String version; + + @Schema(description = "파일 크기 (bytes)", example = "1349516895") + private Long fileSize; + + @Schema(description = "파일 크기 (포맷)", example = "1.26 GB") + private String fileSizeFormatted; + + @Schema(description = "최종 수정 시간", example = "2026-03-10T23:19:24.347+09:00") + @JsonFormatDttm + private ZonedDateTime lastModified; + + @Schema(description = "현재 모델 버전 여부", example = "true") + private Boolean isCurrent; + + @Schema(description = "다운로드 URL") + private String downloadUrl; + } } diff --git a/src/main/java/com/kamco/cd/training/model/service/ModelTrainDetailService.java b/src/main/java/com/kamco/cd/training/model/service/ModelTrainDetailService.java index 882e5e6..be8945b 100644 --- a/src/main/java/com/kamco/cd/training/model/service/ModelTrainDetailService.java +++ b/src/main/java/com/kamco/cd/training/model/service/ModelTrainDetailService.java @@ -1,5 +1,6 @@ package com.kamco.cd.training.model.service; +import com.kamco.cd.training.common.download.RangeDownloadResponder; import com.kamco.cd.training.common.enums.ModelType; import com.kamco.cd.training.common.enums.TrainStatusType; import com.kamco.cd.training.common.exception.CustomApiException; @@ -16,12 +17,15 @@ import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelTrainMetrics; import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelValidationMetrics; import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferDetailDto; import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferHyperSummary; +import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ZipFileInfo; +import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ZipFileListResponse; import com.kamco.cd.training.model.dto.ModelTrainMngDto; import com.kamco.cd.training.model.dto.ModelTrainMngDto.Basic; import com.kamco.cd.training.model.dto.ModelTrainMngDto.CleanupResult; import com.kamco.cd.training.model.dto.ModelTrainMngDto.ModelProgressStepDto; import com.kamco.cd.training.postgres.core.ModelTrainDetailCoreService; import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService; +import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.nio.file.AccessDeniedException; import java.nio.file.FileVisitOption; @@ -32,7 +36,10 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.time.ZoneId; import java.util.ArrayList; +import java.util.Comparator; import java.util.EnumSet; import java.util.List; import java.util.UUID; @@ -41,6 +48,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -52,6 +60,7 @@ public class ModelTrainDetailService { private final ModelTrainDetailCoreService modelTrainDetailCoreService; private final ModelTrainMngCoreService mngCoreService; + private final RangeDownloadResponder rangeDownloadResponder; @Value("${train.docker.response_dir}") private String responseDir; @@ -434,4 +443,363 @@ public class ModelTrainDetailService { } }); } + + /** + * 모델 UUID로 ZIP 파일 목록 조회 및 다운로드 링크 생성 + * + * @param uuid 모델 UUID + * @param downloadUuid 다운로드 추적 UUID + * @return ZIP 파일 목록 및 다운로드 링크 + */ + public ZipFileListResponse getZipFileList(UUID uuid, String downloadUuid) { + log.info("ZIP 파일 목록 조회 시작: modelUuid={}, downloadUuid={}", uuid, downloadUuid); + + // 1. 모델 정보 조회 + Basic modelInfo; + try { + modelInfo = findByModelByUUID(uuid); + if (modelInfo == null) { + throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다: " + uuid); + } + } catch (NullPointerException e) { + log.error("모델 조회 실패: {}", uuid, e); + throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다: " + uuid); + } + + // 2. 실제 디렉토리 경로 찾기 (uuid 또는 uuid-out) + Path baseDir = findActualBasePath(uuid); + + if (baseDir == null || !Files.exists(baseDir)) { + log.warn( + "디렉토리를 찾을 수 없음: modelUuid={}, 시도한 경로: {} 또는 {}-out", + uuid, + responseDir + "/" + uuid, + responseDir + "/" + uuid); + throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 결과 디렉토리가 존재하지 않습니다."); + } + + log.info("디렉토리 발견: basePath={}", baseDir.toString()); + + // 3. ZIP 파일 목록 검색 + List zipFiles = new ArrayList<>(); + long totalSize = 0L; + + try (Stream stream = Files.list(baseDir)) { + List files = + stream + .filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(".zip")) + .filter(p -> p.getFileName().toString().contains(uuid.toString())) + .sorted( + Comparator.comparing(p -> p.getFileName().toString(), Comparator.reverseOrder())) + .toList(); + + for (Path file : files) { + String fileName = file.getFileName().toString(); + long fileSize = Files.size(file); + totalSize += fileSize; + + // 파일명에서 버전 추출 + String version = extractVersionFromZipFileName(fileName); + boolean isCurrent = version.equals(modelInfo.getModelVer()); + + // 다운로드 URL 생성 + String downloadUrl = String.format("/api/models/download/%s?file=%s", uuid, fileName); + + zipFiles.add( + ZipFileInfo.builder() + .fileName(fileName) + .filePath(file.toString()) + .version(version) + .fileSize(fileSize) + .fileSizeFormatted(formatFileSize(fileSize)) + .lastModified( + Files.getLastModifiedTime(file).toInstant().atZone(ZoneId.systemDefault())) + .isCurrent(isCurrent) + .downloadUrl(downloadUrl) + .build()); + } + + log.info("ZIP 파일 {}개 발견", zipFiles.size()); + + } catch (IOException e) { + log.error("ZIP 파일 목록 조회 실패: {}", baseDir, e); + throw new CustomApiException( + "INTERNAL_SERVER_ERROR", HttpStatus.INTERNAL_SERVER_ERROR, "ZIP 파일 목록 조회 실패"); + } + + return ZipFileListResponse.builder() + .modelUuid(uuid.toString()) + .modelNo(modelInfo.getModelNo()) + .modelVer(modelInfo.getModelVer()) + .basePath(baseDir.toString()) + .zipFiles(zipFiles) + .totalFiles(zipFiles.size()) + .totalSize(totalSize) + .totalSizeFormatted(formatFileSize(totalSize)) + .build(); + } + + /** + * 실제 디렉토리 경로 찾기 (uuid 또는 uuid-out) + * + * @param uuid 모델 UUID + * @return 실제 경로 또는 null + */ + private Path findActualBasePath(UUID uuid) { + // 1순위: {uuid}-out + Path pathWithSuffix = Paths.get(responseDir, uuid + "-out"); + if (Files.exists(pathWithSuffix) && Files.isDirectory(pathWithSuffix)) { + log.debug("경로 발견: {} (suffix -out 포함)", pathWithSuffix); + return pathWithSuffix; + } + + // 2순위: {uuid} + Path pathWithoutSuffix = Paths.get(responseDir, uuid.toString()); + if (Files.exists(pathWithoutSuffix) && Files.isDirectory(pathWithoutSuffix)) { + log.debug("경로 발견: {} (suffix 없음)", pathWithoutSuffix); + return pathWithoutSuffix; + } + + return null; + } + + /** + * ZIP 파일명에서 버전 추출 예: G2.G2_000001.{uuid}.zip → G2_000001 + * + * @param fileName ZIP 파일명 + * @return 모델 버전 + */ + private String extractVersionFromZipFileName(String fileName) { + // 패턴: {modelNo}.{modelVer}.{uuid}.zip + // 예: G2.G2_000001.d7dd54e9-1f20-46a7-9c26-89287a7d61b0.zip + + String[] parts = fileName.split("\\."); + + // parts[0] = G2 (modelNo) + // parts[1] = G2_000001 (modelVer) + // parts[2-7] = uuid parts + // parts[8] = zip + + if (parts.length >= 2) { + return parts[1]; // G2_000001 + } + + // fallback: 전체 파일명에서 .zip 제거 + return fileName.replace(".zip", ""); + } + + /** + * 파일 크기를 읽기 쉬운 형식으로 변환 + * + * @param size 파일 크기 (bytes) + * @return 포맷된 문자열 (예: "1.26 GB") + */ + private String formatFileSize(long size) { + if (size < 1024) { + return size + " B"; + } else if (size < 1024 * 1024) { + return String.format("%.2f KB", size / 1024.0); + } else if (size < 1024 * 1024 * 1024) { + return String.format("%.2f MB", size / (1024.0 * 1024.0)); + } else { + return String.format("%.2f GB", size / (1024.0 * 1024.0 * 1024.0)); + } + } + + /** + * 모델 UUID로 모든 ZIP 파일 다운로드 (여러 파일이 있으면 하나의 zip으로 묶어서) + * + * @param uuid 모델 UUID + * @param downloadUuid 다운로드 추적 UUID + * @param request HTTP 요청 + * @return ZIP 파일 다운로드 응답 + */ + public ResponseEntity downloadZipFile( + UUID uuid, String downloadUuid, HttpServletRequest request) throws IOException { + log.info("ZIP 파일 다운로드 시작: modelUuid={}, downloadUuid={}", uuid, downloadUuid); + + // 1. 모델 정보 조회 + Basic modelInfo; + try { + modelInfo = findByModelByUUID(uuid); + if (modelInfo == null) { + throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다: " + uuid); + } + } catch (NullPointerException e) { + log.warn("모델 조회 실패: {}", uuid); + throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다: " + uuid); + } + + // 2. 실제 디렉토리 경로 찾기 (uuid 또는 uuid-out) + Path baseDir = findActualBasePath(uuid); + + if (baseDir == null || !Files.exists(baseDir)) { + log.warn( + "디렉토리를 찾을 수 없음: modelUuid={}, 시도한 경로: {} 또는 {}-out", + uuid, + responseDir + "/" + uuid, + responseDir + "/" + uuid); + throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 결과 디렉토리가 존재하지 않습니다."); + } + + log.info("디렉토리 발견: basePath={}", baseDir); + + // 3. 모든 ZIP 파일 찾기 + List zipFiles = findAllZipFiles(baseDir, uuid); + + if (zipFiles.isEmpty()) { + log.warn("ZIP 파일을 찾을 수 없음: modelUuid={}, basePath={}", uuid, baseDir); + throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "ZIP 파일을 찾을 수 없습니다."); + } + + log.info( + "ZIP 파일 {}개 발견: basePath={}, files={}", + zipFiles.size(), + baseDir, + zipFiles.stream().map(p -> p.getFileName().toString()).toList()); + + // 4. 파일이 1개면 바로 다운로드 + if (zipFiles.size() == 1) { + Path zipPath = zipFiles.get(0); + log.info( + "ZIP 파일 1개 다운로드: fileName={}, fileSize={} bytes, basePath={}", + zipPath.getFileName(), + Files.size(zipPath), + baseDir); + String downloadFileName = zipPath.getFileName().toString(); + return rangeDownloadResponder.buildZipResponse(zipPath, downloadFileName, request); + } + + // 5. 파일이 여러 개면 하나의 zip으로 묶어서 다운로드 + log.info("여러 ZIP 파일을 하나로 묶어서 다운로드: 총 {}개 파일", zipFiles.size()); + + String combinedZipName = modelInfo.getModelNo() + "." + modelInfo.getModelVer() + ".all.zip"; + Path tempZipPath = createCombinedZipFile(zipFiles, combinedZipName, uuid, baseDir); + + try { + long totalSize = Files.size(tempZipPath); + log.info( + "통합 ZIP 파일 생성 완료: fileName={}, fileSize={} bytes, basePath={}", + tempZipPath.getFileName(), + totalSize, + baseDir); + + ResponseEntity response = + rangeDownloadResponder.buildZipResponse(tempZipPath, combinedZipName, request); + + // 다운로드 완료 후 임시 파일 삭제 (비동기) + deleteTempFileAsync(tempZipPath); + + return response; + + } catch (IOException e) { + log.warn("통합 ZIP 파일 처리 실패: {}", tempZipPath, e); + // 에러 발생 시 임시 파일 삭제 + try { + Files.deleteIfExists(tempZipPath); + Path tempDir = tempZipPath.getParent(); + if (tempDir != null && Files.exists(tempDir)) { + Files.deleteIfExists(tempDir); + } + } catch (IOException ex) { + log.warn("임시 파일 삭제 실패: {}", tempZipPath, ex); + } + throw new CustomApiException( + "INTERNAL_SERVER_ERROR", HttpStatus.INTERNAL_SERVER_ERROR, "ZIP 파일 생성 실패"); + } + } + + /** + * 모든 ZIP 파일 찾기 + * + * @param baseDir 기본 디렉토리 + * @param uuid 모델 UUID + * @return ZIP 파일 목록 (최신순 정렬) + */ + private List findAllZipFiles(Path baseDir, UUID uuid) { + try (Stream stream = Files.list(baseDir)) { + return stream + .filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(".zip")) + .filter(p -> p.getFileName().toString().contains(uuid.toString())) + .sorted( + Comparator.comparing( + p -> { + try { + return Files.getLastModifiedTime(p); + } catch (IOException e) { + return FileTime.fromMillis(0); + } + }, + Comparator.reverseOrder())) + .toList(); + } catch (IOException e) { + log.warn("ZIP 파일 검색 실패: basePath={}", baseDir, e); + return new ArrayList<>(); + } + } + + /** + * 여러 ZIP 파일을 하나의 ZIP으로 묶기 + * + * @param zipFiles 원본 ZIP 파일 목록 + * @param combinedZipName 통합 ZIP 파일명 + * @param uuid 모델 UUID + * @param baseDir 기본 디렉토리 (로그용) + * @return 통합 ZIP 파일 경로 + */ + private Path createCombinedZipFile( + List zipFiles, String combinedZipName, UUID uuid, Path baseDir) throws IOException { + // 임시 디렉토리에 통합 ZIP 생성 + Path tempDir = Files.createTempDirectory("kamco-download-" + uuid); + Path combinedZipPath = tempDir.resolve(combinedZipName); + + log.debug("통합 ZIP 생성 시작: tempPath={}, 원본 파일 {}개", combinedZipPath, zipFiles.size()); + + try (java.util.zip.ZipOutputStream zos = + new java.util.zip.ZipOutputStream(Files.newOutputStream(combinedZipPath))) { + + for (Path zipFile : zipFiles) { + String entryName = zipFile.getFileName().toString(); + log.debug("ZIP에 파일 추가: {}", entryName); + + java.util.zip.ZipEntry entry = new java.util.zip.ZipEntry(entryName); + zos.putNextEntry(entry); + + Files.copy(zipFile, zos); + zos.closeEntry(); + } + + zos.finish(); + } + + log.debug("통합 ZIP 생성 완료: {}", combinedZipPath); + return combinedZipPath; + } + + /** + * 임시 파일 비동기 삭제 + * + * @param tempFile 삭제할 임시 파일 + */ + private void deleteTempFileAsync(Path tempFile) { + new Thread( + () -> { + try { + // 다운로드 완료 후 대기 + Thread.sleep(10000); // 10초 대기 + Files.deleteIfExists(tempFile); + // 임시 디렉토리도 삭제 + Path tempDir = tempFile.getParent(); + if (tempDir != null && Files.exists(tempDir)) { + Files.deleteIfExists(tempDir); + } + log.debug("임시 파일 삭제 완료: {}", tempFile); + } catch (Exception e) { + log.warn("임시 파일 삭제 실패: {}", tempFile, e); + } + }) + .start(); + } } diff --git a/src/main/java/com/kamco/cd/training/postgres/repository/model/ModelMngRepositoryCustom.java b/src/main/java/com/kamco/cd/training/postgres/repository/model/ModelMngRepositoryCustom.java index 2cd976b..ae63b77 100644 --- a/src/main/java/com/kamco/cd/training/postgres/repository/model/ModelMngRepositoryCustom.java +++ b/src/main/java/com/kamco/cd/training/postgres/repository/model/ModelMngRepositoryCustom.java @@ -39,4 +39,12 @@ public interface ModelMngRepositoryCustom { */ List findByModelNoAndDelYnOrderByCreatedDttmDesc( String modelNo, Boolean delYn); + + /** + * 하이퍼파라미터 ID로 모델 조회 + * + * @param hyperParamId 하이퍼파라미터 ID + * @return 모델 목록 + */ + List findByHyperParamId(Long hyperParamId); } diff --git a/src/main/java/com/kamco/cd/training/postgres/repository/model/ModelMngRepositoryImpl.java b/src/main/java/com/kamco/cd/training/postgres/repository/model/ModelMngRepositoryImpl.java index 1f31b74..54eff48 100644 --- a/src/main/java/com/kamco/cd/training/postgres/repository/model/ModelMngRepositoryImpl.java +++ b/src/main/java/com/kamco/cd/training/postgres/repository/model/ModelMngRepositoryImpl.java @@ -221,4 +221,13 @@ public class ModelMngRepositoryImpl implements ModelMngRepositoryCustom { .orderBy(modelMasterEntity.createdDttm.desc()) .fetch(); } + + @Override + public List findByHyperParamId(Long hyperParamId) { + return queryFactory + .selectFrom(modelMasterEntity) + .where(modelMasterEntity.hyperParamId.eq(hyperParamId), modelMasterEntity.delYn.eq(false)) + .orderBy(modelMasterEntity.createdDttm.desc()) + .fetch(); + } } diff --git a/src/main/java/com/kamco/cd/training/train/TrainApiController.java b/src/main/java/com/kamco/cd/training/train/TrainApiController.java index e500a90..8dd3ed9 100644 --- a/src/main/java/com/kamco/cd/training/train/TrainApiController.java +++ b/src/main/java/com/kamco/cd/training/train/TrainApiController.java @@ -1,9 +1,11 @@ package com.kamco.cd.training.train; import com.kamco.cd.training.config.api.ApiResponseDto; +import com.kamco.cd.training.train.dto.TrainingMetricsDto; import com.kamco.cd.training.train.service.DataSetCountersService; import com.kamco.cd.training.train.service.TestJobService; import com.kamco.cd.training.train.service.TrainJobService; +import com.kamco.cd.training.train.service.TrainingMetricsService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -29,6 +31,7 @@ public class TrainApiController { private final TrainJobService trainJobService; private final TestJobService testJobService; private final DataSetCountersService dataSetCountersService; + private final TrainingMetricsService trainingMetricsService; @Operation(summary = "학습 실행", description = "학습 실행 API") @ApiResponses( @@ -236,4 +239,63 @@ public class TrainApiController { trainJobService.status(uuid, modelId); return ApiResponseDto.ok("ok"); } + + @Operation( + summary = "하이퍼파라미터 기반 학습 메트릭 조회", + description = + "하이퍼파라미터 UUID로 해당 파라미터를 사용하는 모델의 학습 메트릭을 조회합니다. " + + "val.csv와 train.csv를 우선 사용하며, 없을 경우 processing.log를 파싱합니다.") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = TrainingMetricsDto.Response.class))), + @ApiResponse( + responseCode = "404", + description = "하이퍼파라미터 또는 모델을 찾을 수 없음", + content = @Content), + @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) + }) + @GetMapping( + path = "/metrics/hyper-param/{hyperParamUuid}", + produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponseDto getMetricsByHyperParam( + @Parameter(description = "하이퍼파라미터 UUID", example = "57fc9170-64c1-4128-aa7b-0657f08d6d10") + @PathVariable + UUID hyperParamUuid) { + TrainingMetricsDto.Response response = + trainingMetricsService.getTrainingMetricsByHyperParam(hyperParamUuid); + return ApiResponseDto.ok(response); + } + + @Operation( + summary = "모델 기반 학습 메트릭 조회", + description = + "모델 UUID로 해당 모델의 학습 메트릭을 조회합니다. " + + "val.csv와 train.csv를 우선 사용하며, 없을 경우 processing.log를 파싱합니다.") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = TrainingMetricsDto.Response.class))), + @ApiResponse(responseCode = "404", description = "모델을 찾을 수 없음", content = @Content), + @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) + }) + @GetMapping(path = "/metrics/model/{modelUuid}", produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponseDto getMetricsByModel( + @Parameter(description = "모델 UUID", example = "b34a2d18-11e6-4b1b-a156-cd314bec45bb") + @PathVariable + UUID modelUuid) { + TrainingMetricsDto.Response response = + trainingMetricsService.getTrainingMetricsByModelUuid(modelUuid); + return ApiResponseDto.ok(response); + } } diff --git a/src/main/java/com/kamco/cd/training/train/dto/TrainingMetricsDto.java b/src/main/java/com/kamco/cd/training/train/dto/TrainingMetricsDto.java new file mode 100644 index 0000000..f5d33d7 --- /dev/null +++ b/src/main/java/com/kamco/cd/training/train/dto/TrainingMetricsDto.java @@ -0,0 +1,148 @@ +package com.kamco.cd.training.train.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +public class TrainingMetricsDto { + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "TrainingMetricsResponse", description = "학습 메트릭 조회 응답") + public static class Response { + + @Schema(description = "학습 작업 ID (모델 UUID)", example = "b34a2d18-11e6-4b1b-a156-cd314bec45bb") + private String jobId; + + @Schema( + description = "기본 경로", + example = "/data/training/response/b34a2d18-11e6-4b1b-a156-cd314bec45bb-out") + private String basePath; + + @Schema(description = "데이터 소스 (csv 또는 log)", example = "csv") + private String source; + + @Schema(description = "응답 상태 (SUCCESS, EMPTY, ERROR)", example = "SUCCESS") + private String status; + + @Schema(description = "에러 메시지 (오류 발생 시만 포함)") + private String errorMessage; + + @Schema(description = "Epoch별 학습 메트릭 데이터") + private List epochs; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "EpochMetrics", description = "Epoch 단위 메트릭 데이터") + public static class EpochMetrics { + + @Schema(description = "Epoch 번호", example = "1") + private Integer epoch; + + @Schema(description = "학습 데이터 (train.csv에서 추출)") + private TrainMetrics train; + + @Schema(description = "검증 데이터 요약 (val.csv에서 추출)") + private SummaryMetrics summary; + + @Schema(description = "클래스별 메트릭 (changed, unchanged)") + private List classes; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "TrainMetrics", description = "학습 메트릭 (train.csv)") + public static class TrainMetrics { + + @Schema(description = "Iteration", example = "37") + @JsonProperty("iteration") + private Integer iteration; + + @Schema(description = "Loss", example = "0.61584342") + @JsonProperty("loss") + private Double loss; + + @Schema(description = "Learning Rate", example = "8.4e-07") + @JsonProperty("lr") + private String lr; + + @Schema(description = "처리 시간 (초)", example = "1.5034") + @JsonProperty("time") + private Double time; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "SummaryMetrics", description = "검증 메트릭 요약 (val.csv)") + public static class SummaryMetrics { + + @Schema(description = "Accuracy (All Accuracy)", example = "29.92") + @JsonProperty("aAcc") + private Double aAcc; + + @Schema(description = "Mean F-Score", example = "24.49") + @JsonProperty("mFscore") + private Double mFscore; + + @Schema(description = "Mean Precision", example = "51.07") + @JsonProperty("mPrecision") + private Double mPrecision; + + @Schema(description = "Mean Recall", example = "64.22") + @JsonProperty("mRecall") + private Double mRecall; + + @Schema(description = "Mean IoU (Intersection over Union)", example = "15.49") + @JsonProperty("mIoU") + private Double mIoU; + + @Schema(description = "Mean Accuracy", example = "64.22") + @JsonProperty("mAcc") + private Double mAcc; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "ClassMetrics", description = "클래스별 메트릭 (changed/unchanged)") + public static class ClassMetrics { + + @Schema(description = "클래스명 (changed 또는 unchanged)", example = "unchanged") + private String className; + + @Schema(description = "F-Score", example = "44.735149") + private Double fscore; + + @Schema(description = "Precision", example = "99.779264") + private Double precision; + + @Schema(description = "Recall", example = "28.813878") + private Double recall; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "ProcessingLogMetrics", description = "processing.log 파싱 결과 (내부용)") + public static class ProcessingLogMetrics { + + private Integer epoch; + private SummaryMetrics summary; + private List classes; + } +} diff --git a/src/main/java/com/kamco/cd/training/train/service/TrainingMetricsService.java b/src/main/java/com/kamco/cd/training/train/service/TrainingMetricsService.java new file mode 100644 index 0000000..193adf5 --- /dev/null +++ b/src/main/java/com/kamco/cd/training/train/service/TrainingMetricsService.java @@ -0,0 +1,482 @@ +package com.kamco.cd.training.train.service; + +import com.kamco.cd.training.postgres.entity.ModelHyperParamEntity; +import com.kamco.cd.training.postgres.entity.ModelMasterEntity; +import com.kamco.cd.training.postgres.repository.hyperparam.HyperParamRepository; +import com.kamco.cd.training.postgres.repository.model.ModelMngRepository; +import com.kamco.cd.training.train.dto.TrainingMetricsDto; +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TrainingMetricsService { + + @Value("${train.docker.response_dir}") + private String responseDir; + + private final HyperParamRepository hyperParamRepository; + private final ModelMngRepository modelMngRepository; + + /** + * 하이퍼파라미터 UUID로 학습 메트릭 조회 + * + * @param hyperParamUuid 하이퍼파라미터 UUID + * @return 학습 메트릭 응답 + */ + public TrainingMetricsDto.Response getTrainingMetricsByHyperParam(UUID hyperParamUuid) { + log.info("하이퍼파라미터 UUID로 학습 메트릭 조회 시작: {}", hyperParamUuid); + + // 1. 하이퍼파라미터로 모델 찾기 + ModelHyperParamEntity hyperParam = + hyperParamRepository + .findHyperParamByUuid(hyperParamUuid) + .orElseThrow( + () -> new IllegalArgumentException("하이퍼파라미터를 찾을 수 없습니다: " + hyperParamUuid)); + + // 2. 해당 하이퍼파라미터를 사용하는 모델 찾기 + List models = modelMngRepository.findByHyperParamId(hyperParam.getId()); + + if (models.isEmpty()) { + log.warn("하이퍼파라미터 ID {}를 사용하는 모델이 없습니다.", hyperParam.getId()); + return TrainingMetricsDto.Response.builder() + .jobId(hyperParamUuid.toString()) + .basePath(null) + .source("none") + .status("EMPTY") + .errorMessage("해당 하이퍼파라미터를 사용하는 모델이 없습니다.") + .epochs(new ArrayList<>()) + .build(); + } + + // 3. 가장 최근 모델 선택 (step1이 완료된 것 우선) + ModelMasterEntity targetModel = + models.stream() + .filter(m -> "COMPLETED".equals(m.getStep1State())) + .findFirst() + .orElse(models.get(0)); + + log.info( + "선택된 모델 UUID: {}, ModelNo: {}, Step1State: {}", + targetModel.getUuid(), + targetModel.getModelNo(), + targetModel.getStep1State()); + + // 4. 모델 UUID로 메트릭 조회 + return getTrainingMetricsByModelUuid(targetModel.getUuid()); + } + + /** + * 모델 UUID로 학습 메트릭 조회 + * + * @param modelUuid 모델 UUID + * @return 학습 메트릭 응답 + */ + public TrainingMetricsDto.Response getTrainingMetricsByModelUuid(UUID modelUuid) { + log.info("모델 UUID로 학습 메트릭 조회 시작: {}", modelUuid); + + // 실제 존재하는 basePath 찾기 (uuid 또는 uuid-out) + PathInfo pathInfo = findActualBasePath(modelUuid); + + if (pathInfo == null || !Files.exists(pathInfo.basePath)) { + log.warn( + "모델 결과 디렉토리가 존재하지 않습니다. 시도한 경로: {} 또는 {}-out", + responseDir + "/" + modelUuid, + responseDir + "/" + modelUuid); + return TrainingMetricsDto.Response.builder() + .jobId(modelUuid.toString()) + .basePath(null) + .source("none") + .status("EMPTY") + .errorMessage("모델 결과 디렉토리가 존재하지 않습니다.") + .epochs(new ArrayList<>()) + .build(); + } + + Path basePathObj = pathInfo.basePath; + String basePath = pathInfo.basePathString; + + // 1. CSV 파일 우선 시도 + Path metricsDir = basePathObj.resolve("metrics"); + Path valCsvPath = metricsDir.resolve("val.csv"); + Path trainCsvPath = metricsDir.resolve("train.csv"); + + if (Files.exists(valCsvPath) && Files.exists(trainCsvPath)) { + log.info("CSV 파일을 사용하여 메트릭 파싱: {}", metricsDir); + try { + List epochs = parseCsvFiles(trainCsvPath, valCsvPath); + return TrainingMetricsDto.Response.builder() + .jobId(modelUuid.toString()) + .basePath(basePath) + .source("csv") + .status("SUCCESS") + .epochs(epochs) + .build(); + } catch (Exception e) { + log.error("CSV 파싱 중 오류 발생", e); + // CSV 실패 시 log fallback + } + } + + // 2. processing.log fallback + Path processingDir = basePathObj.resolve("processing"); + Path logPath = processingDir.resolve("processing.log"); + + if (Files.exists(logPath)) { + log.info("processing.log 파일을 사용하여 메트릭 파싱: {}", logPath); + try { + List epochs = parseProcessingLog(logPath); + return TrainingMetricsDto.Response.builder() + .jobId(modelUuid.toString()) + .basePath(basePath) + .source("log") + .status("SUCCESS") + .epochs(epochs) + .build(); + } catch (Exception e) { + log.error("processing.log 파싱 중 오류 발생", e); + return TrainingMetricsDto.Response.builder() + .jobId(modelUuid.toString()) + .basePath(basePath) + .source("log") + .status("ERROR") + .errorMessage("로그 파싱 중 오류 발생: " + e.getMessage()) + .epochs(new ArrayList<>()) + .build(); + } + } + + // 3. 둘 다 없으면 EMPTY + log.warn("메트릭 파일을 찾을 수 없습니다: {}", basePath); + return TrainingMetricsDto.Response.builder() + .jobId(modelUuid.toString()) + .basePath(basePath) + .source("none") + .status("EMPTY") + .errorMessage("메트릭 파일(val.csv 또는 processing.log)을 찾을 수 없습니다.") + .epochs(new ArrayList<>()) + .build(); + } + + /** + * train.csv + val.csv 파싱 + * + * @param trainCsvPath train.csv 경로 + * @param valCsvPath val.csv 경로 + * @return Epoch별 메트릭 리스트 + */ + private List parseCsvFiles(Path trainCsvPath, Path valCsvPath) + throws IOException { + + log.debug("CSV 파일 파싱 시작: train={}, val={}", trainCsvPath, valCsvPath); + + // train.csv 파싱 + Map trainMetricsMap = parseTrainCsv(trainCsvPath); + + // val.csv 파싱 + Map valMetricsMap = parseValCsv(valCsvPath); + + // 합치기 + List epochs = new ArrayList<>(); + + for (Integer epoch : trainMetricsMap.keySet()) { + TrainingMetricsDto.TrainMetrics trainMetrics = trainMetricsMap.get(epoch); + ValMetricsData valData = valMetricsMap.get(epoch); + + if (valData != null) { + epochs.add( + TrainingMetricsDto.EpochMetrics.builder() + .epoch(epoch) + .train(trainMetrics) + .summary(valData.summary) + .classes(valData.classes) + .build()); + } + } + + log.debug("CSV 파싱 완료: {} epoch(s)", epochs.size()); + return epochs; + } + + /** + * train.csv 파싱 + * + * @param trainCsvPath train.csv 경로 + * @return Epoch별 TrainMetrics Map + */ + private Map parseTrainCsv(Path trainCsvPath) + throws IOException { + + Map result = new HashMap<>(); + + try (BufferedReader reader = Files.newBufferedReader(trainCsvPath)) { + String headerLine = reader.readLine(); // 헤더 스킵 + if (headerLine == null) { + return result; + } + + String line; + while ((line = reader.readLine()) != null) { + String[] parts = line.split(","); + if (parts.length >= 5) { + try { + int epoch = Integer.parseInt(parts[0].trim()); + int iteration = Integer.parseInt(parts[1].trim()); + double loss = Double.parseDouble(parts[2].trim()); + String lr = parts[3].trim(); + double time = Double.parseDouble(parts[4].trim()); + + result.put( + epoch, + TrainingMetricsDto.TrainMetrics.builder() + .iteration(iteration) + .loss(loss) + .lr(lr) + .time(time) + .build()); + } catch (NumberFormatException e) { + log.warn("train.csv 파싱 오류 (라인 스킵): {}", line); + } + } + } + } + + return result; + } + + /** + * val.csv 파싱 + * + * @param valCsvPath val.csv 경로 + * @return Epoch별 ValMetricsData Map + */ + private Map parseValCsv(Path valCsvPath) throws IOException { + + Map result = new HashMap<>(); + + try (BufferedReader reader = Files.newBufferedReader(valCsvPath)) { + String headerLine = reader.readLine(); // 헤더 스킵 + if (headerLine == null) { + return result; + } + + String line; + while ((line = reader.readLine()) != null) { + String[] parts = line.split(","); + if (parts.length >= 13) { + try { + int epoch = Integer.parseInt(parts[0].trim()); + double aAcc = Double.parseDouble(parts[1].trim()); + double mFscore = Double.parseDouble(parts[2].trim()); + double mPrecision = Double.parseDouble(parts[3].trim()); + double mRecall = Double.parseDouble(parts[4].trim()); + double mIoU = Double.parseDouble(parts[5].trim()); + double mAcc = Double.parseDouble(parts[6].trim()); + + double changedFscore = Double.parseDouble(parts[7].trim()); + double changedPrecision = Double.parseDouble(parts[8].trim()); + double changedRecall = Double.parseDouble(parts[9].trim()); + + double unchangedFscore = Double.parseDouble(parts[10].trim()); + double unchangedPrecision = Double.parseDouble(parts[11].trim()); + double unchangedRecall = Double.parseDouble(parts[12].trim()); + + TrainingMetricsDto.SummaryMetrics summary = + TrainingMetricsDto.SummaryMetrics.builder() + .aAcc(aAcc) + .mFscore(mFscore) + .mPrecision(mPrecision) + .mRecall(mRecall) + .mIoU(mIoU) + .mAcc(mAcc) + .build(); + + List classes = new ArrayList<>(); + classes.add( + TrainingMetricsDto.ClassMetrics.builder() + .className("unchanged") + .fscore(unchangedFscore) + .precision(unchangedPrecision) + .recall(unchangedRecall) + .build()); + classes.add( + TrainingMetricsDto.ClassMetrics.builder() + .className("changed") + .fscore(changedFscore) + .precision(changedPrecision) + .recall(changedRecall) + .build()); + + result.put(epoch, new ValMetricsData(summary, classes)); + } catch (NumberFormatException e) { + log.warn("val.csv 파싱 오류 (라인 스킵): {}", line); + } + } + } + } + + return result; + } + + /** + * processing.log 파싱 (fallback) + * + * @param logPath processing.log 경로 + * @return Epoch별 메트릭 리스트 + */ + private List parseProcessingLog(Path logPath) + throws IOException { + + log.debug("processing.log 파싱 시작: {}", logPath); + + List epochs = new ArrayList<>(); + + // 정규식 패턴 + Pattern epochPattern = Pattern.compile("Epoch\\(val\\)\\s*\\[(\\d+)\\]"); + Pattern summaryPattern = + Pattern.compile( + "aAcc:\\s*([\\d.]+)\\s+mFscore:\\s*([\\d.]+)\\s+mPrecision:\\s*([\\d.]+)\\s+mRecall:\\s*([\\d.]+)\\s+mIoU:\\s*([\\d.]+)\\s+mAcc:\\s*([\\d.]+)"); + Pattern classPattern = + Pattern.compile( + "\\|\\s*(unchanged|changed)\\s*\\|\\s*([\\d.]+)\\s*\\|\\s*([\\d.]+)\\s*\\|\\s*([\\d.]+)\\s*\\|\\s*([\\d.]+)\\s*\\|\\s*([\\d.]+)"); + + try (BufferedReader reader = Files.newBufferedReader(logPath)) { + String line; + Integer currentEpoch = null; + TrainingMetricsDto.SummaryMetrics currentSummary = null; + List currentClasses = new ArrayList<>(); + + while ((line = reader.readLine()) != null) { + // Epoch 추출 + Matcher epochMatcher = epochPattern.matcher(line); + if (epochMatcher.find()) { + // 이전 epoch 데이터 저장 + if (currentEpoch != null && currentSummary != null) { + epochs.add( + TrainingMetricsDto.EpochMetrics.builder() + .epoch(currentEpoch) + .train(null) // log에는 train 정보 없음 + .summary(currentSummary) + .classes(new ArrayList<>(currentClasses)) + .build()); + } + + // 새 epoch 시작 + currentEpoch = Integer.parseInt(epochMatcher.group(1)); + currentSummary = null; + currentClasses.clear(); + continue; + } + + // Summary 추출 + Matcher summaryMatcher = summaryPattern.matcher(line); + if (summaryMatcher.find()) { + currentSummary = + TrainingMetricsDto.SummaryMetrics.builder() + .aAcc(Double.parseDouble(summaryMatcher.group(1))) + .mFscore(Double.parseDouble(summaryMatcher.group(2))) + .mPrecision(Double.parseDouble(summaryMatcher.group(3))) + .mRecall(Double.parseDouble(summaryMatcher.group(4))) + .mIoU(Double.parseDouble(summaryMatcher.group(5))) + .mAcc(Double.parseDouble(summaryMatcher.group(6))) + .build(); + continue; + } + + // Class 추출 + Matcher classMatcher = classPattern.matcher(line); + if (classMatcher.find()) { + currentClasses.add( + TrainingMetricsDto.ClassMetrics.builder() + .className(classMatcher.group(1)) + .fscore(Double.parseDouble(classMatcher.group(2))) + .precision(Double.parseDouble(classMatcher.group(3))) + .recall(Double.parseDouble(classMatcher.group(4))) + .build()); + } + } + + // 마지막 epoch 데이터 저장 + if (currentEpoch != null && currentSummary != null) { + epochs.add( + TrainingMetricsDto.EpochMetrics.builder() + .epoch(currentEpoch) + .train(null) + .summary(currentSummary) + .classes(new ArrayList<>(currentClasses)) + .build()); + } + } + + log.debug("processing.log 파싱 완료: {} epoch(s)", epochs.size()); + return epochs; + } + + /** + * 실제 존재하는 basePath 찾기 (uuid 또는 uuid-out 둘 다 확인) + * + * @param modelUuid 모델 UUID + * @return PathInfo 또는 null + */ + private PathInfo findActualBasePath(UUID modelUuid) { + // 1순위: {uuid}-out 경로 확인 + String basePathWithSuffix = responseDir + "/" + modelUuid + "-out"; + Path pathWithSuffix = Paths.get(basePathWithSuffix); + + if (Files.exists(pathWithSuffix) && Files.isDirectory(pathWithSuffix)) { + log.debug("경로 발견: {} (suffix -out 포함)", basePathWithSuffix); + return new PathInfo(pathWithSuffix, basePathWithSuffix); + } + + // 2순위: {uuid} 경로 확인 (suffix 없음) + String basePathWithoutSuffix = responseDir + "/" + modelUuid; + Path pathWithoutSuffix = Paths.get(basePathWithoutSuffix); + + if (Files.exists(pathWithoutSuffix) && Files.isDirectory(pathWithoutSuffix)) { + log.debug("경로 발견: {} (suffix 없음)", basePathWithoutSuffix); + return new PathInfo(pathWithoutSuffix, basePathWithoutSuffix); + } + + // 둘 다 없으면 null 반환 + log.warn("경로를 찾을 수 없음: {} 또는 {}", basePathWithSuffix, basePathWithoutSuffix); + return null; + } + + /** 경로 정보를 담는 내부 클래스 */ + private static class PathInfo { + Path basePath; + String basePathString; + + PathInfo(Path basePath, String basePathString) { + this.basePath = basePath; + this.basePathString = basePathString; + } + } + + /** val.csv 파싱 결과를 담는 내부 클래스 */ + private static class ValMetricsData { + TrainingMetricsDto.SummaryMetrics summary; + List classes; + + ValMetricsData( + TrainingMetricsDto.SummaryMetrics summary, List classes) { + this.summary = summary; + this.classes = classes; + } + } +}