Compare commits
16 Commits
feat/train
...
02954aa439
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02954aa439 | ||
|
|
e3cf7b8909 | ||
| 59d39a79c3 | |||
|
|
501b4a6f51 | ||
|
|
be3e32a87a | ||
| 61d28c4ce3 | |||
| 593db69245 | |||
| 5651fd7819 | |||
| 71a8b45097 | |||
| 219e17e8ba | |||
| a9260c17f4 | |||
| a66b25dda5 | |||
| 5dc817ecad | |||
| 98e3c35d9a | |||
| 3da4b73c59 | |||
| c0532f230c |
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ public class FileManagerService {
|
||||
private static final String BASE_DATA_PATH = "/data";
|
||||
private static final long MAX_PATH_LENGTH = 500;
|
||||
|
||||
@Value("${train.docker.base_path}")
|
||||
private String basePath;
|
||||
|
||||
@Value("${train.docker.request_dir}")
|
||||
private String requestDir;
|
||||
|
||||
@@ -567,17 +570,19 @@ public class FileManagerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장공간 정보 조회 (남은 공간 포함) 고정 경로: /home/kcomu/data
|
||||
* 저장공간 정보 조회 (남은 공간 포함) 고정 경로: train.docker.base_path 설정값 사용
|
||||
*
|
||||
* @return 저장공간 정보 (사용량, 남은 공간, 디스크 용량)
|
||||
*/
|
||||
public FileManagerDto.StorageSpaceRes getStorageSpaceInfo() {
|
||||
// 고정 경로: /home/kcomu/data
|
||||
String directoryPath = "/home/kcomu/data";
|
||||
// 설정값에서 경로 가져오기 (train.docker.base_path)
|
||||
|
||||
log.info("basePath = {}", basePath);
|
||||
String directoryPath = basePath;
|
||||
|
||||
Path directory = Paths.get(directoryPath);
|
||||
if (!Files.exists(directory)) {
|
||||
log.debug("디렉토리가 존재하지 않습니다: {}", directoryPath);
|
||||
log.info("디렉토리가 존재하지 않습니다: {}", directoryPath);
|
||||
return FileManagerDto.StorageSpaceRes.builder()
|
||||
.directoryPath(directoryPath)
|
||||
.fileCount(0)
|
||||
@@ -595,7 +600,7 @@ public class FileManagerService {
|
||||
}
|
||||
|
||||
if (!Files.isDirectory(directory)) {
|
||||
log.debug("디렉토리 경로가 아닙니다: {}", directoryPath);
|
||||
log.info("디렉토리 경로가 아닙니다: {}", directoryPath);
|
||||
return FileManagerDto.StorageSpaceRes.builder()
|
||||
.directoryPath(directoryPath)
|
||||
.fileCount(0)
|
||||
@@ -617,6 +622,7 @@ public class FileManagerService {
|
||||
final int[] fileCount = {0};
|
||||
final int[] directoryCount = {0};
|
||||
|
||||
log.info("[StorageSpace] walkFileTree 시작 - path={}", directoryPath);
|
||||
try {
|
||||
Files.walkFileTree(
|
||||
directory,
|
||||
@@ -636,8 +642,14 @@ public class FileManagerService {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
log.info(
|
||||
"[StorageSpace] walkFileTree 완료 - fileCount={}, dirCount={}, usedSize={} bytes ({})",
|
||||
fileCount[0],
|
||||
directoryCount[0],
|
||||
usedSize[0],
|
||||
formatFileSize(usedSize[0]));
|
||||
} catch (IOException e) {
|
||||
log.debug("디렉토리 용량 계산 중 오류 발생: {}", directoryPath, e);
|
||||
log.error("디렉토리 용량 계산 중 오류 발생: {}", directoryPath, e);
|
||||
return FileManagerDto.StorageSpaceRes.builder()
|
||||
.directoryPath(directoryPath)
|
||||
.fileCount(0)
|
||||
@@ -654,7 +666,7 @@ public class FileManagerService {
|
||||
.build();
|
||||
}
|
||||
|
||||
// 디스크 공간 정보 조회 (FileStore 사용)
|
||||
// 디스크 공간 정보 조회 (FileStore 사용) — basePath가 속한 파티션/NFS 마운트 전체 기준
|
||||
long totalDiskSpace = 0;
|
||||
long freeSpace = 0;
|
||||
long usableSpace = 0;
|
||||
@@ -662,17 +674,28 @@ public class FileManagerService {
|
||||
|
||||
try {
|
||||
java.nio.file.FileStore fileStore = Files.getFileStore(directory);
|
||||
totalDiskSpace = fileStore.getTotalSpace(); // 전체 디스크 용량
|
||||
freeSpace = fileStore.getUnallocatedSpace(); // 남은 저장공간 (할당되지 않은 공간)
|
||||
usableSpace = fileStore.getUsableSpace(); // 사용 가능한 공간 (실제 사용 가능)
|
||||
totalDiskSpace = fileStore.getTotalSpace();
|
||||
freeSpace = fileStore.getUnallocatedSpace(); // OS 미할당 공간 (root 예약 블록 포함 가능)
|
||||
usableSpace = fileStore.getUsableSpace(); // 실제 프로세스가 사용 가능한 공간
|
||||
log.info(
|
||||
"[StorageSpace] FileStore - name={}, type={}, total={} bytes ({}), unallocated={} bytes ({}), usable={} bytes ({})",
|
||||
fileStore.name(),
|
||||
fileStore.type(),
|
||||
totalDiskSpace,
|
||||
formatFileSize(totalDiskSpace),
|
||||
freeSpace,
|
||||
formatFileSize(freeSpace),
|
||||
usableSpace,
|
||||
formatFileSize(usableSpace));
|
||||
|
||||
// 디스크 사용률 계산
|
||||
// 사용률은 usableSpace 기준으로 계산 (더 정확한 실사용 가능 공간 반영)
|
||||
if (totalDiskSpace > 0) {
|
||||
long usedDiskSpace = totalDiskSpace - freeSpace;
|
||||
long usedDiskSpace = totalDiskSpace - usableSpace;
|
||||
usagePercentage = (usedDiskSpace * 100.0) / totalDiskSpace;
|
||||
}
|
||||
log.info("[StorageSpace] usagePercentage={}%", Math.round(usagePercentage * 100.0) / 100.0);
|
||||
} catch (IOException e) {
|
||||
log.debug("디스크 공간 정보 조회 중 오류 발생: {}", directoryPath, e);
|
||||
log.warn("[StorageSpace] 디스크 공간 정보 조회 실패: {}", directoryPath, e);
|
||||
// 디스크 정보 조회 실패 시에도 디렉토리 용량은 반환
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ZipFileInfo> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ZipFileInfo> zipFiles = new ArrayList<>();
|
||||
long totalSize = 0L;
|
||||
|
||||
try (Stream<Path> stream = Files.list(baseDir)) {
|
||||
List<Path> 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<Path> 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<Path> findAllZipFiles(Path baseDir, UUID uuid) {
|
||||
try (Stream<Path> 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<Path> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,12 @@ public interface ModelMngRepositoryCustom {
|
||||
*/
|
||||
List<ModelMasterEntity> findByModelNoAndDelYnOrderByCreatedDttmDesc(
|
||||
String modelNo, Boolean delYn);
|
||||
|
||||
/**
|
||||
* 하이퍼파라미터 ID로 모델 조회
|
||||
*
|
||||
* @param hyperParamId 하이퍼파라미터 ID
|
||||
* @return 모델 목록
|
||||
*/
|
||||
List<ModelMasterEntity> findByHyperParamId(Long hyperParamId);
|
||||
}
|
||||
|
||||
@@ -221,4 +221,13 @@ public class ModelMngRepositoryImpl implements ModelMngRepositoryCustom {
|
||||
.orderBy(modelMasterEntity.createdDttm.desc())
|
||||
.fetch();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ModelMasterEntity> findByHyperParamId(Long hyperParamId) {
|
||||
return queryFactory
|
||||
.selectFrom(modelMasterEntity)
|
||||
.where(modelMasterEntity.hyperParamId.eq(hyperParamId), modelMasterEntity.delYn.eq(false))
|
||||
.orderBy(modelMasterEntity.createdDttm.desc())
|
||||
.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TrainingMetricsDto.Response> 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<TrainingMetricsDto.Response> getMetricsByModel(
|
||||
@Parameter(description = "모델 UUID", example = "b34a2d18-11e6-4b1b-a156-cd314bec45bb")
|
||||
@PathVariable
|
||||
UUID modelUuid) {
|
||||
TrainingMetricsDto.Response response =
|
||||
trainingMetricsService.getTrainingMetricsByModelUuid(modelUuid);
|
||||
return ApiResponseDto.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EpochMetrics> 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<ClassMetrics> 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<ClassMetrics> classes;
|
||||
}
|
||||
}
|
||||
@@ -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<ModelMasterEntity> 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<TrainingMetricsDto.EpochMetrics> 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<TrainingMetricsDto.EpochMetrics> 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<TrainingMetricsDto.EpochMetrics> parseCsvFiles(Path trainCsvPath, Path valCsvPath)
|
||||
throws IOException {
|
||||
|
||||
log.debug("CSV 파일 파싱 시작: train={}, val={}", trainCsvPath, valCsvPath);
|
||||
|
||||
// train.csv 파싱
|
||||
Map<Integer, TrainingMetricsDto.TrainMetrics> trainMetricsMap = parseTrainCsv(trainCsvPath);
|
||||
|
||||
// val.csv 파싱
|
||||
Map<Integer, ValMetricsData> valMetricsMap = parseValCsv(valCsvPath);
|
||||
|
||||
// 합치기
|
||||
List<TrainingMetricsDto.EpochMetrics> 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<Integer, TrainingMetricsDto.TrainMetrics> parseTrainCsv(Path trainCsvPath)
|
||||
throws IOException {
|
||||
|
||||
Map<Integer, TrainingMetricsDto.TrainMetrics> 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<Integer, ValMetricsData> parseValCsv(Path valCsvPath) throws IOException {
|
||||
|
||||
Map<Integer, ValMetricsData> 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<TrainingMetricsDto.ClassMetrics> 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<TrainingMetricsDto.EpochMetrics> parseProcessingLog(Path logPath)
|
||||
throws IOException {
|
||||
|
||||
log.debug("processing.log 파싱 시작: {}", logPath);
|
||||
|
||||
List<TrainingMetricsDto.EpochMetrics> 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<TrainingMetricsDto.ClassMetrics> 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<TrainingMetricsDto.ClassMetrics> classes;
|
||||
|
||||
ValMetricsData(
|
||||
TrainingMetricsDto.SummaryMetrics summary, List<TrainingMetricsDto.ClassMetrics> classes) {
|
||||
this.summary = summary;
|
||||
this.classes = classes;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user