diff --git a/src/main/java/notai/aiTask/application/AITaskService.java b/src/main/java/notai/aiTask/application/AITaskService.java deleted file mode 100644 index e076f3e..0000000 --- a/src/main/java/notai/aiTask/application/AITaskService.java +++ /dev/null @@ -1,38 +0,0 @@ -package notai.aiTask.application; - -import java.time.LocalDateTime; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import notai.aiTask.application.command.AITaskCommand; -import notai.aiTask.domain.AITaskRepository; -import notai.aiTask.presentation.response.AITaskResponse; -import org.springframework.stereotype.Service; - -/** - * SummaryService 와 ExamService 는 엔티티와 관련된 로직만 처리하고 - * AI 요약 및 문제 생성 요청은 여기서 처리하는 식으로 생각했습니다. - * AI 서버와의 통신은 별도 클래스에서 처리합니다. - */ -@Service -@RequiredArgsConstructor -public class AITaskService { - - private final AITaskRepository aiTaskRepository; - - /** - * 흐름 파악용 임시 메서드 - */ - public AITaskResponse submitTask(AITaskCommand command) { - command.pages().forEach(page -> { - UUID taskId = sendRequestToAIServer(); - // TODO: command 데이터를 이용해 content 만 null 인 Summary, Problem 생성 - // TODO: Summary, Problem 과 매핑된 AITask 생성 -> 작업 상태는 모두 PENDING - }); - - return AITaskResponse.of(command.documentId(), LocalDateTime.now()); - } - - private UUID sendRequestToAIServer() { - return UUID.randomUUID(); // 임시 값, 실제 구현에선 AI 서버에서 UUID 가 반환됨. - } -} diff --git a/src/main/java/notai/aiTask/application/command/AITaskCommand.java b/src/main/java/notai/aiTask/application/command/AITaskCommand.java deleted file mode 100644 index 34860c4..0000000 --- a/src/main/java/notai/aiTask/application/command/AITaskCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package notai.aiTask.application.command; - -import java.util.List; - -public record AITaskCommand( - Long documentId, - List pages -) { - -} diff --git a/src/main/java/notai/aiTask/domain/AITaskRepository.java b/src/main/java/notai/aiTask/domain/AITaskRepository.java deleted file mode 100644 index 39d14ae..0000000 --- a/src/main/java/notai/aiTask/domain/AITaskRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package notai.aiTask.domain; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface AITaskRepository extends JpaRepository { - -} diff --git a/src/main/java/notai/aiTask/presentation/AITaskController.java b/src/main/java/notai/aiTask/presentation/AITaskController.java deleted file mode 100644 index b43cd3a..0000000 --- a/src/main/java/notai/aiTask/presentation/AITaskController.java +++ /dev/null @@ -1,28 +0,0 @@ -package notai.aiTask.presentation; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import notai.aiTask.application.AITaskService; -import notai.aiTask.application.command.AITaskCommand; -import notai.aiTask.presentation.request.AITaskRequest; -import notai.aiTask.presentation.response.AITaskResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/ai/tasks") -@RequiredArgsConstructor -public class AITaskController { - - private final AITaskService aiTaskService; - - @PostMapping - public ResponseEntity submitTask(@RequestBody @Valid AITaskRequest request) { - AITaskCommand command = request.toCommand(); - AITaskResponse response = aiTaskService.submitTask(command); - return ResponseEntity.accepted().body(response); - } -} diff --git a/src/main/java/notai/aiTask/presentation/request/AITaskRequest.java b/src/main/java/notai/aiTask/presentation/request/AITaskRequest.java deleted file mode 100644 index 50768b7..0000000 --- a/src/main/java/notai/aiTask/presentation/request/AITaskRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package notai.aiTask.presentation.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import java.util.List; -import notai.aiTask.application.command.AITaskCommand; - -public record AITaskRequest( - - @NotNull(message = "문서 ID는 필수 입력 값입니다.") - Long documentId, - - List<@Positive(message = "페이지 번호는 양수여야 합니다.") Integer> pages -) { - public AITaskCommand toCommand() { - return new AITaskCommand(documentId, pages); - } -} diff --git a/src/main/java/notai/aiTask/presentation/response/AITaskResponse.java b/src/main/java/notai/aiTask/presentation/response/AITaskResponse.java deleted file mode 100644 index c990a99..0000000 --- a/src/main/java/notai/aiTask/presentation/response/AITaskResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package notai.aiTask.presentation.response; - -import java.time.LocalDateTime; - -public record AITaskResponse( - Long documentId, - LocalDateTime createdAt -) { - public static AITaskResponse of(Long documentId, LocalDateTime createdAt) { - return new AITaskResponse(documentId, createdAt); - } -} diff --git a/src/main/java/notai/llm/application/LLMQueryService.java b/src/main/java/notai/llm/application/LLMQueryService.java new file mode 100644 index 0000000..2858c2a --- /dev/null +++ b/src/main/java/notai/llm/application/LLMQueryService.java @@ -0,0 +1,110 @@ +package notai.llm.application; + +import lombok.RequiredArgsConstructor; +import notai.common.exception.type.BadRequestException; +import notai.common.exception.type.InternalServerErrorException; +import notai.common.exception.type.NotFoundException; +import notai.document.domain.DocumentRepository; +import notai.llm.application.result.LLMResultsResult; +import notai.llm.application.result.LLMResultsResult.LLMContent; +import notai.llm.application.result.LLMResultsResult.LLMResult; +import notai.llm.application.result.LLMStatusResult; +import notai.llm.domain.TaskStatus; +import notai.llm.query.LLMQueryRepository; +import notai.problem.query.ProblemQueryRepository; +import notai.problem.query.result.ProblemPageContentResult; +import notai.summary.query.SummaryQueryRepository; +import notai.summary.query.result.SummaryPageContentResult; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +import static notai.llm.domain.TaskStatus.COMPLETED; +import static notai.llm.domain.TaskStatus.IN_PROGRESS; + +@Service +@RequiredArgsConstructor +public class LLMQueryService { + + private final LLMQueryRepository llmQueryRepository; + private final DocumentRepository documentRepository; + private final SummaryQueryRepository summaryQueryRepository; + private final ProblemQueryRepository problemQueryRepository; + + public LLMStatusResult fetchTaskStatus(Long documentId) { + checkDocumentExists(documentId); + List summaryIds = getSummaryIds(documentId); + List taskStatuses = getTaskStatuses(summaryIds); + + int totalPages = summaryIds.size(); + int completedPages = Collections.frequency(taskStatuses, COMPLETED); + + if (totalPages == completedPages) { + return LLMStatusResult.of(documentId, COMPLETED, totalPages, completedPages); + } + return LLMStatusResult.of(documentId, IN_PROGRESS, totalPages, completedPages); + } + + public LLMResultsResult findTaskResult(Long documentId) { + checkDocumentExists(documentId); + List summaryResults = getSummaryPageContentResults(documentId); + List problemResults = getProblemPageContentResults(documentId); + checkSummaryAndProblemCountsEqual(summaryResults, problemResults); + + List results = summaryResults.stream().map(summaryResult -> { + LLMContent content = LLMContent.of( + summaryResult.content(), + findProblemContentByPageNumber(problemResults, summaryResult.pageNumber()) + ); + return LLMResult.of(summaryResult.pageNumber(), content); + }).toList(); + + return LLMResultsResult.of(documentId, results); + } + + private void checkDocumentExists(Long documentId) { + if (!documentRepository.existsById(documentId)) { + throw new NotFoundException("해당 강의자료를 찾을 수 없습니다."); + } + } + + private static void checkSummaryAndProblemCountsEqual( + List summaryResults, List problemResults + ) { + if (summaryResults.size() != problemResults.size()) { + throw new InternalServerErrorException("AI 요약 및 문제 생성 중에 문제가 발생했습니다."); // 요약 개수와 문제 개수가 불일치 + } + } + + private List getSummaryIds(Long documentId) { + List summaryIds = summaryQueryRepository.getSummaryIdsByDocumentId(documentId); + if (summaryIds.isEmpty()) { + throw new BadRequestException("AI 기능을 요청한 기록이 없습니다."); + } + return summaryIds; + } + + private List getTaskStatuses(List summaryIds) { + return summaryIds.stream().map(llmQueryRepository::getTaskStatusBySummaryId).toList(); + } + + private List getSummaryPageContentResults(Long documentId) { + List summaryResults = summaryQueryRepository.getPageNumbersAndContentByDocumentId( + documentId); + if (summaryResults.isEmpty()) { + throw new NotFoundException("AI 기능을 요청한 기록이 없습니다."); + } + return summaryResults; + } + + private List getProblemPageContentResults(Long documentId) { + return problemQueryRepository.getPageNumbersAndContentByDocumentId(documentId); + } + + private String findProblemContentByPageNumber(List results, int pageNumber) { + return results.stream().filter(result -> result.pageNumber() == pageNumber).findFirst().map( + ProblemPageContentResult::content).orElseThrow(() -> new InternalServerErrorException( + "AI 요약 및 문제 생성 중에 문제가 발생했습니다.")); // 요약 페이지와 문제 페이지가 불일치 + } +} diff --git a/src/main/java/notai/llm/application/LLMService.java b/src/main/java/notai/llm/application/LLMService.java new file mode 100644 index 0000000..d6e529e --- /dev/null +++ b/src/main/java/notai/llm/application/LLMService.java @@ -0,0 +1,76 @@ +package notai.llm.application; + +import lombok.RequiredArgsConstructor; +import notai.common.exception.type.NotFoundException; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.llm.application.command.LLMSubmitCommand; +import notai.llm.application.command.SummaryAndProblemUpdateCommand; +import notai.llm.application.result.LLMSubmitResult; +import notai.llm.domain.LLM; +import notai.llm.domain.LLMRepository; +import notai.problem.domain.Problem; +import notai.problem.domain.ProblemRepository; +import notai.summary.domain.Summary; +import notai.summary.domain.SummaryRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * SummaryService 와 ExamService 는 엔티티와 관련된 로직만 처리하고 + * AI 요약 및 문제 생성 요청은 여기서 처리하는 식으로 생각했습니다. + * AI 서버와의 통신은 별도 클래스에서 처리합니다. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class LLMService { + + private final LLMRepository llmRepository; + private final DocumentRepository documentRepository; + private final SummaryRepository summaryRepository; + private final ProblemRepository problemRepository; + + public LLMSubmitResult submitTask(LLMSubmitCommand command) { + // TODO: document 개발 코드 올려주시면, getById 로 수정 + Document foundDocument = + documentRepository.findById(command.documentId()).orElseThrow(() -> new NotFoundException("")); + + command.pages().forEach(pageNumber -> { + UUID taskId = sendRequestToAIServer(); + Summary summary = new Summary(foundDocument, pageNumber); + Problem problem = new Problem(foundDocument, pageNumber); + + LLM taskRecord = new LLM(taskId, summary, problem); + llmRepository.save(taskRecord); + }); + + return LLMSubmitResult.of(command.documentId(), LocalDateTime.now()); + } + + public Integer updateSummaryAndProblem(SummaryAndProblemUpdateCommand command) { + LLM taskRecord = llmRepository.getById(command.taskId()); + Summary foundSummary = summaryRepository.getById(taskRecord.getSummary().getId()); + Problem foundProblem = problemRepository.getById(taskRecord.getProblem().getId()); + + taskRecord.completeTask(); + foundSummary.updateContent(command.summary()); + foundProblem.updateContent(command.problem()); + + llmRepository.save(taskRecord); + summaryRepository.save(foundSummary); + problemRepository.save(foundProblem); + + return command.pageNumber(); + } + + /** + * 임시 값 반환, 추후 AI 서버에서 작업 단위 UUID 가 반환됨. + */ + private UUID sendRequestToAIServer() { + return UUID.randomUUID(); + } +} diff --git a/src/main/java/notai/llm/application/command/LLMSubmitCommand.java b/src/main/java/notai/llm/application/command/LLMSubmitCommand.java new file mode 100644 index 0000000..3e7b329 --- /dev/null +++ b/src/main/java/notai/llm/application/command/LLMSubmitCommand.java @@ -0,0 +1,10 @@ +package notai.llm.application.command; + +import java.util.List; + +public record LLMSubmitCommand( + Long documentId, + List pages +) { + +} diff --git a/src/main/java/notai/llm/application/command/SummaryAndProblemUpdateCommand.java b/src/main/java/notai/llm/application/command/SummaryAndProblemUpdateCommand.java new file mode 100644 index 0000000..420e5d4 --- /dev/null +++ b/src/main/java/notai/llm/application/command/SummaryAndProblemUpdateCommand.java @@ -0,0 +1,11 @@ +package notai.llm.application.command; + +import java.util.UUID; + +public record SummaryAndProblemUpdateCommand( + UUID taskId, + Integer pageNumber, + String summary, + String problem +) { +} diff --git a/src/main/java/notai/llm/application/result/LLMResultsResult.java b/src/main/java/notai/llm/application/result/LLMResultsResult.java new file mode 100644 index 0000000..63dfcaa --- /dev/null +++ b/src/main/java/notai/llm/application/result/LLMResultsResult.java @@ -0,0 +1,31 @@ +package notai.llm.application.result; + +import java.util.List; + +public record LLMResultsResult( + Long documentId, + Integer totalPages, + List results +) { + public static LLMResultsResult of(Long documentId, List results) { + return new LLMResultsResult(documentId, results.size(), results); + } + + public record LLMResult( + Integer pageNumber, + LLMContent content + ) { + public static LLMResult of(Integer pageNumber, LLMContent content) { + return new LLMResult(pageNumber, content); + } + } + + public record LLMContent( + String summary, + String problem + ) { + public static LLMContent of(String summary, String problem) { + return new LLMContent(summary, problem); + } + } +} diff --git a/src/main/java/notai/llm/application/result/LLMStatusResult.java b/src/main/java/notai/llm/application/result/LLMStatusResult.java new file mode 100644 index 0000000..a9a3768 --- /dev/null +++ b/src/main/java/notai/llm/application/result/LLMStatusResult.java @@ -0,0 +1,14 @@ +package notai.llm.application.result; + +import notai.llm.domain.TaskStatus; + +public record LLMStatusResult( + Long documentId, + TaskStatus overallStatus, + Integer totalPages, + Integer completedPages +) { + public static LLMStatusResult of(Long documentId, TaskStatus overallStatus, Integer totalPages, Integer completedPages) { + return new LLMStatusResult(documentId, overallStatus, totalPages, completedPages); + } +} diff --git a/src/main/java/notai/llm/application/result/LLMSubmitResult.java b/src/main/java/notai/llm/application/result/LLMSubmitResult.java new file mode 100644 index 0000000..ab0c2ac --- /dev/null +++ b/src/main/java/notai/llm/application/result/LLMSubmitResult.java @@ -0,0 +1,12 @@ +package notai.llm.application.result; + +import java.time.LocalDateTime; + +public record LLMSubmitResult( + Long documentId, + LocalDateTime createdAt +) { + public static LLMSubmitResult of(Long documentId, LocalDateTime createdAt) { + return new LLMSubmitResult(documentId, createdAt); + } +} diff --git a/src/main/java/notai/aiTask/domain/AITask.java b/src/main/java/notai/llm/domain/LLM.java similarity index 57% rename from src/main/java/notai/aiTask/domain/AITask.java rename to src/main/java/notai/llm/domain/LLM.java index 5f8f995..f93c53f 100644 --- a/src/main/java/notai/aiTask/domain/AITask.java +++ b/src/main/java/notai/llm/domain/LLM.java @@ -1,40 +1,37 @@ -package notai.aiTask.domain; +package notai.llm.domain; -import static lombok.AccessLevel.PROTECTED; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; -import java.util.UUID; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; import notai.problem.domain.Problem; import notai.summary.domain.Summary; +import java.util.UUID; + +import static lombok.AccessLevel.PROTECTED; + + +/** + * 요약과 문제 생성을 하는 LLM 모델의 작업 기록을 저장하는 테이블입니다. + */ @Getter @NoArgsConstructor(access = PROTECTED) @Entity -@Table(name = "ai_task") -public class AITask extends RootEntity { +@Table(name = "llm") +public class LLM extends RootEntity { @Id private UUID id; @NotNull - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) @JoinColumn(name = "summary_id") private Summary summary; @NotNull - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) @JoinColumn(name = "problem_id") private Problem problem; @@ -43,10 +40,14 @@ public class AITask extends RootEntity { @Column(length = 20) private TaskStatus status; - public AITask(UUID id, Summary summary, Problem problem) { + public LLM(UUID id, Summary summary, Problem problem) { this.id = id; this.summary = summary; this.problem = problem; this.status = TaskStatus.PENDING; } + + public void completeTask() { + this.status = TaskStatus.COMPLETED; + } } diff --git a/src/main/java/notai/llm/domain/LLMRepository.java b/src/main/java/notai/llm/domain/LLMRepository.java new file mode 100644 index 0000000..c9bfa6c --- /dev/null +++ b/src/main/java/notai/llm/domain/LLMRepository.java @@ -0,0 +1,12 @@ +package notai.llm.domain; + +import notai.common.exception.type.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface LLMRepository extends JpaRepository { + default LLM getById(UUID id) { + return findById(id).orElseThrow(() -> new NotFoundException("해당 작업 기록을 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/notai/aiTask/domain/TaskStatus.java b/src/main/java/notai/llm/domain/TaskStatus.java similarity index 71% rename from src/main/java/notai/aiTask/domain/TaskStatus.java rename to src/main/java/notai/llm/domain/TaskStatus.java index 3209e9f..be44ed8 100644 --- a/src/main/java/notai/aiTask/domain/TaskStatus.java +++ b/src/main/java/notai/llm/domain/TaskStatus.java @@ -1,4 +1,4 @@ -package notai.aiTask.domain; +package notai.llm.domain; public enum TaskStatus { PENDING, diff --git a/src/main/java/notai/llm/presentation/LLMController.java b/src/main/java/notai/llm/presentation/LLMController.java new file mode 100644 index 0000000..69ad4ff --- /dev/null +++ b/src/main/java/notai/llm/presentation/LLMController.java @@ -0,0 +1,56 @@ +package notai.llm.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import notai.llm.application.LLMQueryService; +import notai.llm.application.LLMService; +import notai.llm.application.command.LLMSubmitCommand; +import notai.llm.application.command.SummaryAndProblemUpdateCommand; +import notai.llm.application.result.LLMResultsResult; +import notai.llm.application.result.LLMStatusResult; +import notai.llm.application.result.LLMSubmitResult; +import notai.llm.presentation.request.LLMSubmitRequest; +import notai.llm.presentation.request.SummaryAndProblemUpdateRequest; +import notai.llm.presentation.response.LLMResultsResponse; +import notai.llm.presentation.response.LLMStatusResponse; +import notai.llm.presentation.response.LLMSubmitResponse; +import notai.llm.presentation.response.SummaryAndProblemUpdateResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/ai/llm") +@RequiredArgsConstructor +public class LLMController { + + private final LLMService llmService; + private final LLMQueryService llmQueryService; + + @PostMapping + public ResponseEntity submitTask(@RequestBody @Valid LLMSubmitRequest request) { + LLMSubmitCommand command = request.toCommand(); + LLMSubmitResult result = llmService.submitTask(command); + return ResponseEntity.accepted().body(LLMSubmitResponse.from(result)); + } + + @GetMapping("/status/{documentId}") + public ResponseEntity fetchTaskStatus(@PathVariable("documentId") Long documentId) { + LLMStatusResult result = llmQueryService.fetchTaskStatus(documentId); + return ResponseEntity.ok(LLMStatusResponse.from(result)); + } + + @GetMapping("/results/{documentId}") + public ResponseEntity findTaskResult(@PathVariable("documentId") Long documentId) { + LLMResultsResult result = llmQueryService.findTaskResult(documentId); + return ResponseEntity.ok(LLMResultsResponse.of(result)); + } + + @PostMapping("/callback") + public ResponseEntity handleTaskCallback( + @RequestBody @Valid SummaryAndProblemUpdateRequest request + ) { + SummaryAndProblemUpdateCommand command = request.toCommand(); + Integer receivedPage = llmService.updateSummaryAndProblem(command); + return ResponseEntity.ok(SummaryAndProblemUpdateResponse.from(receivedPage)); + } +} diff --git a/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java b/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java new file mode 100644 index 0000000..8f78f1d --- /dev/null +++ b/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java @@ -0,0 +1,19 @@ +package notai.llm.presentation.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import notai.llm.application.command.LLMSubmitCommand; + +import java.util.List; + +public record LLMSubmitRequest( + + @NotNull(message = "문서 ID는 필수 입력 값입니다.") + Long documentId, + + List<@Positive(message = "페이지 번호는 양수여야 합니다.") Integer> pages +) { + public LLMSubmitCommand toCommand() { + return new LLMSubmitCommand(documentId, pages); + } +} diff --git a/src/main/java/notai/llm/presentation/request/SummaryAndProblemUpdateRequest.java b/src/main/java/notai/llm/presentation/request/SummaryAndProblemUpdateRequest.java new file mode 100644 index 0000000..1eba697 --- /dev/null +++ b/src/main/java/notai/llm/presentation/request/SummaryAndProblemUpdateRequest.java @@ -0,0 +1,26 @@ +package notai.llm.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import notai.llm.application.command.SummaryAndProblemUpdateCommand; + +import java.util.UUID; + +public record SummaryAndProblemUpdateRequest( + UUID taskId, + + @NotNull Long documentId, + + Integer totalPages, + + @NotNull @Positive Integer pageNumber, + + @NotBlank String summary, + + @NotBlank String problem +) { + public SummaryAndProblemUpdateCommand toCommand() { + return new SummaryAndProblemUpdateCommand(taskId, pageNumber, summary, problem); + } +} diff --git a/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java b/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java new file mode 100644 index 0000000..535a1f2 --- /dev/null +++ b/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java @@ -0,0 +1,39 @@ +package notai.llm.presentation.response; + +import notai.llm.application.result.LLMResultsResult; +import notai.llm.application.result.LLMResultsResult.LLMContent; +import notai.llm.application.result.LLMResultsResult.LLMResult; + +import java.util.List; + +public record LLMResultsResponse( + Long documentId, + Integer totalPages, + List results +) { + public static LLMResultsResponse of(LLMResultsResult result) { + return new LLMResultsResponse( + result.documentId(), + result.results().size(), + result.results().stream().map(Result::of).toList() + ); + } + + public record Result( + Integer pageNumber, + Content content + ) { + public static Result of(LLMResult result) { + return new Result(result.pageNumber(), Content.of(result.content())); + } + } + + public record Content( + String summary, + String problem + ) { + public static Content of(LLMContent result) { + return new Content(result.summary(), result.problem()); + } + } +} diff --git a/src/main/java/notai/llm/presentation/response/LLMStatusResponse.java b/src/main/java/notai/llm/presentation/response/LLMStatusResponse.java new file mode 100644 index 0000000..9981105 --- /dev/null +++ b/src/main/java/notai/llm/presentation/response/LLMStatusResponse.java @@ -0,0 +1,20 @@ +package notai.llm.presentation.response; + +import notai.llm.application.result.LLMStatusResult; +import notai.llm.domain.TaskStatus; + +public record LLMStatusResponse( + Long documentId, + TaskStatus overallStatus, + Integer totalPages, + Integer completedPages +) { + public static LLMStatusResponse from(LLMStatusResult result) { + return new LLMStatusResponse( + result.documentId(), + result.overallStatus(), + result.totalPages(), + result.completedPages() + ); + } +} diff --git a/src/main/java/notai/llm/presentation/response/LLMSubmitResponse.java b/src/main/java/notai/llm/presentation/response/LLMSubmitResponse.java new file mode 100644 index 0000000..f63240e --- /dev/null +++ b/src/main/java/notai/llm/presentation/response/LLMSubmitResponse.java @@ -0,0 +1,14 @@ +package notai.llm.presentation.response; + +import notai.llm.application.result.LLMSubmitResult; + +import java.time.LocalDateTime; + +public record LLMSubmitResponse( + Long documentId, + LocalDateTime createdAt +) { + public static LLMSubmitResponse from(LLMSubmitResult result) { + return new LLMSubmitResponse(result.documentId(), result.createdAt()); + } +} diff --git a/src/main/java/notai/llm/presentation/response/SummaryAndProblemUpdateResponse.java b/src/main/java/notai/llm/presentation/response/SummaryAndProblemUpdateResponse.java new file mode 100644 index 0000000..33d2edc --- /dev/null +++ b/src/main/java/notai/llm/presentation/response/SummaryAndProblemUpdateResponse.java @@ -0,0 +1,9 @@ +package notai.llm.presentation.response; + +public record SummaryAndProblemUpdateResponse( + Integer receivedPage +) { + public static SummaryAndProblemUpdateResponse from(Integer receivedPage) { + return new SummaryAndProblemUpdateResponse(receivedPage); + } +} diff --git a/src/main/java/notai/llm/query/LLMQueryRepository.java b/src/main/java/notai/llm/query/LLMQueryRepository.java new file mode 100644 index 0000000..d7c1bb0 --- /dev/null +++ b/src/main/java/notai/llm/query/LLMQueryRepository.java @@ -0,0 +1,24 @@ +package notai.llm.query; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import notai.llm.domain.QLLM; +import notai.llm.domain.TaskStatus; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class LLMQueryRepository { + + private final JPAQueryFactory queryFactory; + + public TaskStatus getTaskStatusBySummaryId(Long summaryId) { + QLLM lLM = QLLM.lLM; + + return queryFactory + .select(lLM.status) + .from(lLM) + .where(lLM.summary.id.eq(summaryId)) + .fetchOne(); + } +} diff --git a/src/main/java/notai/problem/domain/Problem.java b/src/main/java/notai/problem/domain/Problem.java index 521d90f..0b28a63 100644 --- a/src/main/java/notai/problem/domain/Problem.java +++ b/src/main/java/notai/problem/domain/Problem.java @@ -1,15 +1,16 @@ package notai.problem.domain; import jakarta.persistence.*; -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; import notai.document.domain.Document; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + @Getter @NoArgsConstructor(access = PROTECTED) @Entity @@ -29,4 +30,13 @@ public class Problem extends RootEntity { @Column(columnDefinition = "TEXT") private String content; + + public Problem(Document document, Integer pageNumber) { + this.document = document; + this.pageNumber = pageNumber; + } + + public void updateContent(String content) { + this.content = content; + } } diff --git a/src/main/java/notai/problem/domain/ProblemRepository.java b/src/main/java/notai/problem/domain/ProblemRepository.java index da3b9bc..d5f558d 100644 --- a/src/main/java/notai/problem/domain/ProblemRepository.java +++ b/src/main/java/notai/problem/domain/ProblemRepository.java @@ -1,7 +1,10 @@ package notai.problem.domain; +import notai.common.exception.type.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; public interface ProblemRepository extends JpaRepository { - + default Problem getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("해당 문제 정보를 찾을 수 없습니다.")); + } } diff --git a/src/main/java/notai/problem/query/ProblemQueryRepository.java b/src/main/java/notai/problem/query/ProblemQueryRepository.java new file mode 100644 index 0000000..615c321 --- /dev/null +++ b/src/main/java/notai/problem/query/ProblemQueryRepository.java @@ -0,0 +1,42 @@ +package notai.problem.query; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import notai.problem.domain.QProblem; +import notai.problem.query.result.ProblemPageContentResult; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ProblemQueryRepository { + + private final JPAQueryFactory queryFactory; + + public List getProblemIdsByDocumentId(Long documentId) { + QProblem problem = QProblem.problem; + + return queryFactory + .select(problem.id) + .from(problem) + .where(problem.document.id.eq(documentId)) + .fetch(); + } + + public List getPageNumbersAndContentByDocumentId(Long documentId) { + QProblem problem = QProblem.problem; + + return queryFactory + .select(Projections.constructor( + ProblemPageContentResult.class, + problem.pageNumber, + problem.content + )) + .from(problem) + .where(problem.document.id.eq(documentId) + .and(problem.content.isNotNull())) + .fetch(); + } +} diff --git a/src/main/java/notai/problem/query/result/ProblemPageContentResult.java b/src/main/java/notai/problem/query/result/ProblemPageContentResult.java new file mode 100644 index 0000000..675b22b --- /dev/null +++ b/src/main/java/notai/problem/query/result/ProblemPageContentResult.java @@ -0,0 +1,8 @@ +package notai.problem.query.result; + +public record ProblemPageContentResult( + Integer pageNumber, + String content +) { + +} diff --git a/src/main/java/notai/summary/domain/Summary.java b/src/main/java/notai/summary/domain/Summary.java index cdd0259..9882179 100644 --- a/src/main/java/notai/summary/domain/Summary.java +++ b/src/main/java/notai/summary/domain/Summary.java @@ -1,15 +1,16 @@ package notai.summary.domain; import jakarta.persistence.*; -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; import notai.document.domain.Document; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + @Getter @NoArgsConstructor(access = PROTECTED) @Entity @@ -29,4 +30,13 @@ public class Summary extends RootEntity { @Column(columnDefinition = "TEXT") private String content; + + public Summary(Document document, Integer pageNumber) { + this.document = document; + this.pageNumber = pageNumber; + } + + public void updateContent(String content) { + this.content = content; + } } diff --git a/src/main/java/notai/summary/domain/SummaryRepository.java b/src/main/java/notai/summary/domain/SummaryRepository.java index 63085d9..45d6b5d 100644 --- a/src/main/java/notai/summary/domain/SummaryRepository.java +++ b/src/main/java/notai/summary/domain/SummaryRepository.java @@ -1,7 +1,10 @@ package notai.summary.domain; +import notai.common.exception.type.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; public interface SummaryRepository extends JpaRepository { - + default Summary getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("해당 요약 정보를 찾을 수 없습니다.")); + } } diff --git a/src/main/java/notai/summary/query/SummaryQueryRepository.java b/src/main/java/notai/summary/query/SummaryQueryRepository.java new file mode 100644 index 0000000..b767161 --- /dev/null +++ b/src/main/java/notai/summary/query/SummaryQueryRepository.java @@ -0,0 +1,42 @@ +package notai.summary.query; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import notai.summary.query.result.SummaryPageContentResult; +import notai.summary.domain.QSummary; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class SummaryQueryRepository { + + private final JPAQueryFactory queryFactory; + + public List getSummaryIdsByDocumentId(Long documentId) { + QSummary summary = QSummary.summary; + + return queryFactory + .select(summary.id) + .from(summary) + .where(summary.document.id.eq(documentId)) + .fetch(); + } + + public List getPageNumbersAndContentByDocumentId(Long documentId) { + QSummary summary = QSummary.summary; + + return queryFactory + .select(Projections.constructor( + SummaryPageContentResult.class, + summary.pageNumber, + summary.content + )) + .from(summary) + .where(summary.document.id.eq(documentId) + .and(summary.content.isNotNull())) + .fetch(); + } +} diff --git a/src/main/java/notai/summary/query/result/SummaryPageContentResult.java b/src/main/java/notai/summary/query/result/SummaryPageContentResult.java new file mode 100644 index 0000000..aa1e5f1 --- /dev/null +++ b/src/main/java/notai/summary/query/result/SummaryPageContentResult.java @@ -0,0 +1,8 @@ +package notai.summary.query.result; + +public record SummaryPageContentResult( + Integer pageNumber, + String content +) { + +} diff --git a/src/test/java/notai/llm/application/LLMQueryServiceTest.java b/src/test/java/notai/llm/application/LLMQueryServiceTest.java new file mode 100644 index 0000000..c1d9372 --- /dev/null +++ b/src/test/java/notai/llm/application/LLMQueryServiceTest.java @@ -0,0 +1,165 @@ +package notai.llm.application; + +import notai.common.exception.type.InternalServerErrorException; +import notai.common.exception.type.NotFoundException; +import notai.document.domain.DocumentRepository; +import notai.llm.application.result.LLMResultsResult; +import notai.llm.application.result.LLMStatusResult; +import notai.llm.query.LLMQueryRepository; +import notai.problem.query.ProblemQueryRepository; +import notai.problem.query.result.ProblemPageContentResult; +import notai.summary.query.SummaryQueryRepository; +import notai.summary.query.result.SummaryPageContentResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static notai.llm.domain.TaskStatus.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class LLMQueryServiceTest { + + @InjectMocks + private LLMQueryService llmQueryService; + + @Mock + private LLMQueryRepository llmQueryRepository; + + @Mock + private DocumentRepository documentRepository; + + @Mock + private SummaryQueryRepository summaryQueryRepository; + + @Mock + private ProblemQueryRepository problemQueryRepository; + + @Test + void 작업_상태_확인시_존재하지_않는_문서ID로_요청한_경우_예외_발생() { + // given + given(documentRepository.existsById(anyLong())).willReturn(false); + + // when & then + assertAll(() -> assertThrows(NotFoundException.class, () -> llmQueryService.fetchTaskStatus(1L)), + () -> verify(documentRepository).existsById(anyLong()) + ); + } + + @Test + void 작업_상태_확인시_모든_페이지의_작업이_완료된_경우_COMPLETED() { + // given + Long documentId = 1L; + List summaryIds = List.of(1L, 2L, 3L); + + given(documentRepository.existsById(anyLong())).willReturn(true); + given(summaryQueryRepository.getSummaryIdsByDocumentId(documentId)).willReturn(summaryIds); + given(llmQueryRepository.getTaskStatusBySummaryId(1L)).willReturn(COMPLETED); + given(llmQueryRepository.getTaskStatusBySummaryId(2L)).willReturn(COMPLETED); + given(llmQueryRepository.getTaskStatusBySummaryId(3L)).willReturn(COMPLETED); + + // when + LLMStatusResult result = llmQueryService.fetchTaskStatus(documentId); + + // then + assertAll(() -> assertThat(result.overallStatus()).isEqualTo(COMPLETED), + () -> assertThat(result.totalPages()).isEqualTo(3), + () -> assertThat(result.completedPages()).isEqualTo(3), + () -> verify(documentRepository).existsById(documentId), + () -> verify(summaryQueryRepository).getSummaryIdsByDocumentId(documentId), + () -> verify(llmQueryRepository).getTaskStatusBySummaryId(documentId) + ); + } + + @Test + void 작업_상태_확인시_모든_페이지의_작업이_완료되지_않은_경우_IN_PROGRESS() { + // given + Long documentId = 1L; + List summaryIds = List.of(1L, 2L, 3L); + + given(documentRepository.existsById(anyLong())).willReturn(true); + given(summaryQueryRepository.getSummaryIdsByDocumentId(documentId)).willReturn(summaryIds); + given(llmQueryRepository.getTaskStatusBySummaryId(1L)).willReturn(COMPLETED); + given(llmQueryRepository.getTaskStatusBySummaryId(2L)).willReturn(IN_PROGRESS); + given(llmQueryRepository.getTaskStatusBySummaryId(3L)).willReturn(PENDING); + + // when + LLMStatusResult result = llmQueryService.fetchTaskStatus(documentId); + + // then + assertAll(() -> assertThat(result.overallStatus()).isEqualTo(IN_PROGRESS), + () -> assertThat(result.totalPages()).isEqualTo(3), + () -> assertThat(result.completedPages()).isEqualTo(1), + () -> verify(documentRepository).existsById(documentId), + () -> verify(summaryQueryRepository).getSummaryIdsByDocumentId(documentId), + () -> verify(llmQueryRepository).getTaskStatusBySummaryId(documentId) + ); + } + + @Test + void 작업_결과_확인시_존재하지_않는_문서ID로_요청한_경우_예외_발생() { + // given + given(documentRepository.existsById(anyLong())).willReturn(false); + + // when & then + assertAll(() -> assertThrows(NotFoundException.class, () -> llmQueryService.findTaskResult(1L)), + () -> verify(documentRepository).existsById(anyLong()) + ); + } + + @Test + void 작업_결과_확인시_생성된_요약과_문제의_수가_일치하지_않는_경우_예외_발생() { + // given + Long documentId = 1L; + List summaryResults = List.of(new SummaryPageContentResult(1, "요약 내용")); + List problemResults = List.of(new ProblemPageContentResult(1, "요약 내용"), + new ProblemPageContentResult(2, "요약 내용") + ); + + given(documentRepository.existsById(anyLong())).willReturn(true); + given(summaryQueryRepository.getPageNumbersAndContentByDocumentId(documentId)).willReturn(summaryResults); + given(problemQueryRepository.getPageNumbersAndContentByDocumentId(documentId)).willReturn(problemResults); + + // when & then + assertAll(() -> assertThrows(InternalServerErrorException.class, () -> llmQueryService.findTaskResult(1L)), + () -> verify(documentRepository).existsById(documentId), + () -> verify(summaryQueryRepository).getPageNumbersAndContentByDocumentId(documentId), + () -> verify(problemQueryRepository).getPageNumbersAndContentByDocumentId(documentId) + ); + } + + @Test + void 작업_결과_확인() { + // given + Long documentId = 1L; + List summaryResults = List.of(new SummaryPageContentResult(1, "요약 내용"), + new SummaryPageContentResult(2, "요약 내용") + ); + List problemResults = List.of(new ProblemPageContentResult(1, "요약 내용"), + new ProblemPageContentResult(2, "요약 내용") + ); + + given(documentRepository.existsById(anyLong())).willReturn(true); + given(summaryQueryRepository.getPageNumbersAndContentByDocumentId(documentId)).willReturn(summaryResults); + given(problemQueryRepository.getPageNumbersAndContentByDocumentId(documentId)).willReturn(problemResults); + + // when + LLMResultsResult response = llmQueryService.findTaskResult(documentId); + + // then + assertAll(() -> assertEquals(documentId, response.documentId()), + () -> assertEquals(2, response.results().size()), + () -> verify(documentRepository).existsById(documentId), + () -> verify(summaryQueryRepository).getPageNumbersAndContentByDocumentId(documentId), + () -> verify(problemQueryRepository).getPageNumbersAndContentByDocumentId(documentId) + ); + } +} \ No newline at end of file diff --git a/src/test/java/notai/llm/application/LLMServiceTest.java b/src/test/java/notai/llm/application/LLMServiceTest.java new file mode 100644 index 0000000..8d85bee --- /dev/null +++ b/src/test/java/notai/llm/application/LLMServiceTest.java @@ -0,0 +1,126 @@ +package notai.llm.application; + +import notai.common.exception.type.NotFoundException; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.llm.application.command.LLMSubmitCommand; +import notai.llm.application.command.SummaryAndProblemUpdateCommand; +import notai.llm.application.result.LLMSubmitResult; +import notai.llm.domain.LLM; +import notai.llm.domain.LLMRepository; +import notai.problem.domain.Problem; +import notai.problem.domain.ProblemRepository; +import notai.summary.domain.Summary; +import notai.summary.domain.SummaryRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LLMServiceTest { + + @InjectMocks + private LLMService llmService; + + @Mock + private LLMRepository llmRepository; + + @Mock + private DocumentRepository documentRepository; + + @Mock + private SummaryRepository summaryRepository; + + @Mock + private ProblemRepository problemRepository; + + @Test + void AI_기능_요청시_존재하지_않는_문서ID로_요청한_경우_예외_발생() { + // given + Long documentId = 1L; + List pages = List.of(1, 2, 3); + LLMSubmitCommand command = new LLMSubmitCommand(documentId, pages); + + given(documentRepository.findById(anyLong())).willReturn(Optional.empty()); + + // when & then + assertAll(() -> assertThrows(NotFoundException.class, () -> llmService.submitTask(command)), + () -> verify(documentRepository, times(1)).findById(documentId), + () -> verify(llmRepository, never()).save(any(LLM.class)) + ); + } + + @Test + void AI_기능_요청() { + // given + Long documentId = 1L; + List pages = List.of(1, 2, 3); + LLMSubmitCommand command = new LLMSubmitCommand(documentId, pages); + Document document = mock(Document.class); + + given(documentRepository.findById(anyLong())).willReturn(Optional.of(document)); + given(llmRepository.save(any(LLM.class))).willAnswer(invocation -> invocation.getArgument(0)); + // when + LLMSubmitResult result = llmService.submitTask(command); + + // then + assertAll(() -> verify(documentRepository, times(1)).findById(anyLong()), + () -> verify(llmRepository, times(3)).save(any(LLM.class)) + ); + } + + @Test + void AI_서버에서_페이지별_작업이_완료되면_Summary와_Problem_업데이트() { + // given + UUID taskId = UUID.randomUUID(); + Long summaryId = 1L; + Long problemId = 1L; + String summaryContent = "요약 내용"; + String problemContent = "문제 내용"; + Integer pageNumber = 5; + + LLM taskRecord = mock(LLM.class); + Summary summary = mock(Summary.class); + Problem problem = mock(Problem.class); + + SummaryAndProblemUpdateCommand command = new SummaryAndProblemUpdateCommand(taskId, + pageNumber, + summaryContent, + problemContent + ); + + given(llmRepository.getById(any(UUID.class))).willReturn(taskRecord); + given(summaryRepository.getById(anyLong())).willReturn(summary); + given(problemRepository.getById(anyLong())).willReturn(problem); + + given(taskRecord.getSummary()).willReturn(summary); + given(taskRecord.getProblem()).willReturn(problem); + given(summary.getId()).willReturn(summaryId); + given(problem.getId()).willReturn(problemId); + + // when + Integer resultPageNumber = llmService.updateSummaryAndProblem(command); + + // then + assertAll(() -> verify(taskRecord).completeTask(), + () -> verify(summary).updateContent(summaryContent), + () -> verify(problem).updateContent(problemContent), + () -> verify(llmRepository, times(1)).save(taskRecord), + () -> verify(summaryRepository, times(1)).save(summary), + () -> verify(problemRepository, times(1)).save(problem), + () -> assertEquals(pageNumber, resultPageNumber) + ); + } +} \ No newline at end of file