diff --git a/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java b/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java deleted file mode 100644 index 510d5181..00000000 --- a/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java +++ /dev/null @@ -1,29 +0,0 @@ -package starlight.adapter.ai; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import starlight.adapter.ai.infra.OpenAiGenerator; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.required.AiReportGrader; - -/** - * AI 리포트 채점을 오케스트레이션하는 컴포넌트 - * 각 단계별 책임을 다른 컴포넌트에 위임하여 단일 책임 원칙을 준수 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class OpenAiReportGrader implements AiReportGrader { - - private final OpenAiGenerator chatClientGenerator; - private final AiReportResponseParser responseParser; - - @Override - public AiReportResponse gradeContent(String content){ - String llmResponse = chatClientGenerator.generateReport(content); - - return responseParser.parse(llmResponse); - } -} diff --git a/src/main/java/starlight/adapter/ai/infra/PromptProvider.java b/src/main/java/starlight/adapter/ai/infra/PromptProvider.java deleted file mode 100644 index 9b283363..00000000 --- a/src/main/java/starlight/adapter/ai/infra/PromptProvider.java +++ /dev/null @@ -1,96 +0,0 @@ -package starlight.adapter.ai.infra; - -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.prompt.PromptTemplate; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@Component -public class PromptProvider { - - @Value("${prompt.report.grading.system}") - private String reportGradingSystemPrompt; - - @Value("${prompt.report.grading.user}") - private String reportGradingUserPromptTemplate; - - @Value("${prompt.checklist.grading.system}") - private String checklistGradingSystemPrompt; - - @Value("${prompt.checklist.grading.user.template}") - private String checklistGradingUserPromptTemplate; - - /** - * 리포트 채점용 Prompt 객체 생성 - */ - public Prompt createReportGradingPrompt(String businessPlanContent) { - Message systemMessage = new SystemMessage(getReportGradingSystemPrompt()); - Message userMessage = new UserMessage(buildReportGradingUserPrompt(businessPlanContent)); - return new Prompt(List.of(systemMessage, userMessage)); - } - - /** - * 체크리스트 채점용 Prompt 객체 생성 - */ - public Prompt createChecklistGradingPrompt( - SubSectionType subSectionType, - String content, - List criteria, - List detailedCriteria - ) { - String userPrompt = buildChecklistGradingUserPrompt(subSectionType, content, criteria, detailedCriteria); - Message systemMessage = new SystemMessage(checklistGradingSystemPrompt); - Message userMessage = new UserMessage(userPrompt); - return new Prompt(List.of(systemMessage, userMessage)); - } - - /** - * 리포트 채점용 시스템 프롬프트 - */ - private String getReportGradingSystemPrompt() { - return reportGradingSystemPrompt; - } - - /** - * 리포트 채점용 사용자 프롬프트 생성 - */ - private String buildReportGradingUserPrompt(String businessPlanContent) { - PromptTemplate promptTemplate = new PromptTemplate(reportGradingUserPromptTemplate); - Map variables = Map.of("businessPlanContent", businessPlanContent); - return promptTemplate.render(variables); - } - - /** - * 체크리스트 채점용 사용자 프롬프트 생성 - */ - private String buildChecklistGradingUserPrompt( - SubSectionType subSectionType, - String content, - List criteria, - List detailedCriteria) { - // 체크리스트 상세 기준 포맷팅 - StringBuilder criteriaBuilder = new StringBuilder(); - for (int i = 0; i < criteria.size() && i < detailedCriteria.size(); i++) { - criteriaBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); - criteriaBuilder.append(detailedCriteria.get(i)).append("\n\n"); - } - String formattedCriteria = criteriaBuilder.toString().trim(); - - Map variables = new HashMap<>(); - variables.put("subsectionType", subSectionType.getDescription()); - variables.put("checklistCriteria", formattedCriteria); - variables.put("input", content); - variables.put("requestLength", criteria.size()); - - PromptTemplate promptTemplate = new PromptTemplate(checklistGradingUserPromptTemplate); - return promptTemplate.render(variables); - } -} diff --git a/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java b/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java deleted file mode 100644 index 86a5236e..00000000 --- a/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java +++ /dev/null @@ -1,65 +0,0 @@ -package starlight.adapter.ai.util; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Component -@ConfigurationProperties(prefix = "prompt.checklist") -@Getter -@Setter -public class ChecklistCatalog { - - private Map catalog; - - @Getter - @Setter - public static class CatalogSection { - private List items; - } - - @Getter - @Setter - public static class ChecklistItem { - private String criteria; - private String detailed; - } - - // 서브섹션 타입에 해당하는 criteria 리스트를 반환합니다 - public List getCriteriaBySubSectionType(SubSectionType subSectionType) { - String tag = subSectionType.getTag(); - if (catalog == null || !catalog.containsKey(tag)) { - return List.of(); - } - CatalogSection section = catalog.get(tag); - if (section == null || section.getItems() == null) { - return List.of(); - } - return section.getItems().stream() - .map(ChecklistItem::getCriteria) - .filter(c -> c != null && !c.isEmpty()) - .collect(Collectors.toList()); - } - - // 서브섹션 타입에 해당하는 detailed-criteria 리스트를 반환합니다. - public List getDetailedCriteriaBySubSectionType(SubSectionType subSectionType) { - String tag = subSectionType.getTag(); - if (catalog == null || !catalog.containsKey(tag)) { - return List.of(); - } - CatalogSection section = catalog.get(tag); - if (section == null || section.getItems() == null) { - return List.of(); - } - return section.getItems().stream() - .map(ChecklistItem::getDetailed) - .filter(d -> d != null && !d.isEmpty()) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java index d36d7709..5b318696 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java @@ -9,7 +9,7 @@ import starlight.adapter.aireport.infrastructure.ocr.util.OcrResponseMerger; import starlight.adapter.aireport.infrastructure.ocr.util.OcrTextExtractor; import starlight.adapter.aireport.infrastructure.ocr.util.PdfUtils; -import starlight.application.aireport.required.OcrProvider; +import starlight.application.aireport.required.OcrProviderPort; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.ArrayList; @@ -18,7 +18,7 @@ @Slf4j @Service @RequiredArgsConstructor -public class ClovaOcrProvider implements OcrProvider { +public class ClovaOcrProvider implements OcrProviderPort { private static final int MAX_PAGES_PER_REQUEST = 10; diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java index 9c04da39..59ffb381 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java @@ -12,7 +12,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; -import starlight.application.aireport.required.PresignedUrlProvider; +import starlight.application.aireport.required.PresignedUrlProviderPort; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URLEncoder; @@ -22,7 +22,7 @@ @Slf4j @Service @RequiredArgsConstructor -public class NcpPresignedUrlProvider implements PresignedUrlProvider { +public class NcpPresignedUrlProvider implements PresignedUrlProviderPort { private final S3Client ncpS3Client; private final S3Presigner ncpS3Presigner; diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java index a5513466..1627a735 100644 --- a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java @@ -2,8 +2,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.required.AiReportQuery; +import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.aireport.required.AiReportCommandPort; +import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.expert.required.AiReportSummaryLookupPort; import starlight.domain.aireport.entity.AiReport; @@ -15,7 +16,7 @@ @Component @RequiredArgsConstructor -public class AiReportJpa implements AiReportQuery, AiReportSummaryLookupPort { +public class AiReportJpa implements AiReportCommandPort, AiReportQueryPort, AiReportSummaryLookupPort { private final AiReportRepository aiReportRepository; private final AiReportResponseParser responseParser; diff --git a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java new file mode 100644 index 00000000..fa1fe7b2 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java @@ -0,0 +1,242 @@ +package starlight.adapter.aireport.report; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import starlight.adapter.aireport.report.agent.FullReportGradeAgent; +import starlight.adapter.aireport.report.agent.SectionGradeAgent; +import starlight.adapter.aireport.report.dto.SectionGradingResult; +import starlight.adapter.aireport.report.supervisor.SpringAiReportSupervisor; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; +import starlight.shared.enumerate.SectionType; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * AI 리포트 채점을 오케스트레이션하는 컴포넌트 + * 4개의 섹션별 Advisor를 병렬로 실행하고, 슈퍼바이저가 장단점을 생성 + */ +@Slf4j +@Component +public class SpringAiReportGrader implements ReportGraderPort { + + private final Map sectionGradeAgentMap; + private final FullReportGradeAgent fullReportGradeAgent; + private final SpringAiReportSupervisor supervisor; + private final BusinessPlanContentExtractor contentExtractor; + private final Executor sectionGradingExecutor; + + public SpringAiReportGrader( + List sectionGradeAgentList, + FullReportGradeAgent fullReportGradeAgent, + SpringAiReportSupervisor supervisor, + BusinessPlanContentExtractor contentExtractor, + @Qualifier("sectionGradingExecutor") Executor sectionGradingExecutor) { + try { + this.sectionGradeAgentMap = sectionGradeAgentList.stream() + .collect(Collectors.toMap( + SectionGradeAgent::getSectionType, + advisor -> advisor)); + } catch (IllegalStateException e) { + log.error("중복된 SectionType 에이전트로 인한 에러", e); + throw new AiReportException(AiReportErrorType.AI_AGENT_DUPLICATED); + } + this.fullReportGradeAgent = fullReportGradeAgent; + this.supervisor = supervisor; + this.contentExtractor = contentExtractor; + this.sectionGradingExecutor = sectionGradingExecutor; + } + + /** + * PDF에서 추출한 텍스트를 한 번에 채점하는 메소드 + * 전체 프롬프트를 사용하여 LLM에 한 번에 요청하고 결과를 파싱하여 반환 + */ + @Override + public AiReportResult gradeWithFullPrompt(String content) { + log.info("전체 프롬프트를 사용한 채점 시작"); + try { + AiReportResult result = fullReportGradeAgent.gradeFullReport(content); + log.info("전체 프롬프트를 사용한 채점 완료"); + return result; + } catch (starlight.domain.aireport.exception.AiReportException e) { + log.error("전체 프롬프트 채점 중 예외 발생", e); + throw e; + } + } + + /** + * 섹션별 에이전트를 통해 채점하는 메소드 + * 에이전트 결과를 슈퍼바이저 LLM에 요청하여 결과를 파싱 + */ + @Override + public AiReportResult gradeWithSectionAgents(Map sectionContents, String fullContent) { + log.info("섹션별 에이전트를 통한 채점 시작"); + + if (sectionContents == null || sectionContents.isEmpty()) { + log.error("섹션별 내용이 비어있습니다"); + throw new starlight.domain.aireport.exception.AiReportException( + starlight.domain.aireport.exception.AiReportErrorType.AI_GRADING_FAILED); + } + + if (fullContent == null || fullContent.trim().isEmpty()) { + log.error("전체 내용이 비어있습니다"); + throw new starlight.domain.aireport.exception.AiReportException( + starlight.domain.aireport.exception.AiReportErrorType.AI_GRADING_FAILED); + } + + log.debug("섹션별 내용 추출 완료. 섹션 수: {}", sectionContents.size()); + + // 4개 섹션을 병렬로 채점 + Map> futureMap = Arrays.asList( + SectionType.PROBLEM_RECOGNITION, + SectionType.FEASIBILITY, + SectionType.GROWTH_STRATEGY, + SectionType.TEAM_COMPETENCE).stream() + .collect(Collectors.toMap( + sectionType -> sectionType, + sectionType -> { + SectionGradeAgent agent = sectionGradeAgentMap.get(sectionType); + String sectionContent = sectionContents.get(sectionType); + + if (agent != null && sectionContent != null && !sectionContent.isBlank()) { + return CompletableFuture + .supplyAsync( + () -> { + try { + return agent.gradeSection(sectionContent); + } catch (Exception e) { + log.error("[{}] 섹션 채점 중 예외 발생", sectionType, e); + return SectionGradingResult.failure(sectionType, + e.getMessage()); + } + }, + sectionGradingExecutor) + .exceptionally(ex -> { + log.error("[{}] 섹션 채점 Future 예외 처리", sectionType, ex); + return SectionGradingResult.failure(sectionType, ex.getMessage()); + }); + } else { + log.warn("[{}] 섹션 내용이 없거나 Agent가 없습니다. agent={}, content={}", + sectionType, agent != null, + sectionContent != null && !sectionContent.isBlank()); + return CompletableFuture.completedFuture( + SectionGradingResult.failure(sectionType, "섹션 내용 없음")); + } + })); + + // 모든 채점 완료 대기 (최대 2분) + CompletableFuture[] futures = futureMap.values().toArray(new CompletableFuture[0]); + CompletableFuture allFutures = CompletableFuture.allOf(futures); + + try { + allFutures.get(2, TimeUnit.MINUTES); + } catch (java.util.concurrent.TimeoutException e) { + log.warn("섹션별 채점 타임아웃 발생. 모든 Future 취소하여 스레드 자원 해제 중..."); + for (CompletableFuture future : futureMap.values()) { + if (!future.isDone()) { + future.cancel(true); + } + } + } catch (Exception e) { + log.error("섹션별 채점 중 예외 발생", e); + for (CompletableFuture future : futureMap.values()) { + if (!future.isDone()) { + future.cancel(true); + } + } + } + + // 결과 수집 + List results = futureMap.entrySet().stream() + .map(entry -> { + SectionType sectionType = entry.getKey(); + CompletableFuture future = entry.getValue(); + try { + if (future.isCancelled()) { + return SectionGradingResult.failure(sectionType, "타임아웃"); + } + return future.get(0, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + return SectionGradingResult.failure(sectionType, "타임아웃"); + } catch (Exception e) { + return SectionGradingResult.failure(sectionType, "예외: " + e.getMessage()); + } + }) + .collect(Collectors.toList()); + + long successCount = results.stream().filter(SectionGradingResult::success).count(); + long failureCount = results.stream().filter(r -> !r.success()).count(); + log.info("모든 섹션 채점 완료. 성공: {}, 실패: {}", successCount, failureCount); + + // 모든 섹션이 실패한 경우 예외 발생 + if (successCount == 0) { + log.error("모든 섹션 채점이 실패했습니다. 실패 상세: {}", + results.stream() + .map(r -> String.format("[%s: %s]", r.sectionType(), r.errorMessage())) + .collect(Collectors.joining(", "))); + throw new starlight.domain.aireport.exception.AiReportException( + starlight.domain.aireport.exception.AiReportErrorType.AI_GRADING_FAILED); + } + + // 슈퍼바이저가 장단점 생성 + log.debug("슈퍼바이저 장단점 생성 시작"); + List strengths = supervisor.generateStrengths(fullContent, results); + List weaknesses = supervisor.generateWeaknesses(fullContent, results); + log.debug("슈퍼바이저 장단점 생성 완료. 강점: {}, 약점: {}", strengths.size(), weaknesses.size()); + + // 결과 통합 + AiReportResult finalResult = assembleReportResponse(results, strengths, weaknesses); + log.info("섹션별 채점 최종 완료. 총점: {}, 문제인식={}, 실현가능성={}, 성장전략={}, 팀역량={}", + finalResult.totalScore(), + finalResult.problemRecognitionScore(), + finalResult.feasibilityScore(), + finalResult.growthStrategyScore(), + finalResult.teamCompetenceScore()); + + return finalResult; + } + + private AiReportResult assembleReportResponse( + List results, + List strengths, + List weaknesses) { + Integer problemRecognitionScore = extractScore( + results, + SectionType.PROBLEM_RECOGNITION); + Integer feasibilityScore = extractScore(results, SectionType.FEASIBILITY); + Integer growthStrategyScore = extractScore(results, SectionType.GROWTH_STRATEGY); + Integer teamCompetenceScore = extractScore(results, SectionType.TEAM_COMPETENCE); + + List sectionScores = results.stream() + .filter(SectionGradingResult::success) + .map(SectionGradingResult::sectionScore) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // 기존 AiReportResponse.fromGradingResult 사용 (구조 동일) + return AiReportResult.fromGradingResult( + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + strengths, + weaknesses); + } + + private Integer extractScore(List results, SectionType type) { + return results.stream() + .filter(r -> r.sectionType() == type) + .findFirst() + .map(SectionGradingResult::score) + .orElse(0); + } +} diff --git a/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java new file mode 100644 index 00000000..134ce934 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java @@ -0,0 +1,8 @@ +package starlight.adapter.aireport.report.agent; + +import starlight.application.aireport.provided.dto.AiReportResult; + +public interface FullReportGradeAgent { + + AiReportResult gradeFullReport(String content); +} diff --git a/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java new file mode 100644 index 00000000..2119e18f --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java @@ -0,0 +1,14 @@ +package starlight.adapter.aireport.report.agent; + +import starlight.adapter.aireport.report.dto.SectionGradingResult; +import starlight.shared.enumerate.SectionType; + +public interface SectionGradeAgent { + + SectionType getSectionType(); + + SectionGradingResult gradeSection(String sectionContent); +} + + + diff --git a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java new file mode 100644 index 00000000..01ca5479 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java @@ -0,0 +1,65 @@ +package starlight.adapter.aireport.report.agent.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.stereotype.Component; +import starlight.adapter.aireport.report.agent.FullReportGradeAgent; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.report.provider.ReportPromptProvider; +import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpringAiFullReportGradeAgent implements FullReportGradeAgent { + + private final ChatClient.Builder chatClientBuilder; + private final ReportPromptProvider reportPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + private final AiReportResponseParser responseParser; + + @Override + public AiReportResult gradeFullReport(String content) { + if (content == null || content.trim().isEmpty()) { + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + try { + Prompt prompt = reportPromptProvider.createReportGradingPrompt(content); + + ChatClient chatClient = chatClientBuilder.build(); + QuestionAnswerAdvisor qaAdvisor = advisorProvider + .getQuestionAnswerAdvisor(0.6, 3, null); + SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); + + String llmResponse = chatClient + .prompt(prompt) + .options(ChatOptions.builder() + .temperature(0.0) + .topP(0.1) + .build()) + .advisors(qaAdvisor, slAdvisor) + .call() + .content(); + + if (llmResponse == null || llmResponse.trim().isEmpty()) { + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + return responseParser.parse(llmResponse); + + } catch (AiReportException e) { + throw e; + } catch (Exception e) { + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + } +} diff --git a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java new file mode 100644 index 00000000..f98abad2 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java @@ -0,0 +1,120 @@ +package starlight.adapter.aireport.report.agent.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import starlight.adapter.aireport.report.agent.SectionGradeAgent; +import starlight.adapter.aireport.report.circuitbreaker.SectionGradingCircuitBreaker; +import starlight.adapter.aireport.report.dto.SectionGradingResult; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.report.provider.ReportPromptProvider; +import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.aireport.util.SectionScoreExtractor; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.shared.enumerate.SectionType; + +@Slf4j +@RequiredArgsConstructor +public class SpringAiSectionGradeAgent implements SectionGradeAgent { + + private final SectionType sectionType; + private final ChatClient.Builder chatClientBuilder; + private final ReportPromptProvider reportPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + private final AiReportResponseParser responseParser; + private final SectionGradingCircuitBreaker circuitBreaker; + + @Override + public SectionType getSectionType() { + return sectionType; + } + + @Override + public SectionGradingResult gradeSection(String sectionContent) { + // 서킷브레이커 체크 + if (!circuitBreaker.allowRequest(getSectionType())) { + log.warn("[{}] Circuit breaker is OPEN", getSectionType()); + return SectionGradingResult.failure(getSectionType(), "Circuit breaker is OPEN"); + } + + try { + Prompt prompt = reportPromptProvider.createSectionGradingPrompt( + getSectionType(), + sectionContent); + + ChatClient chatClient = chatClientBuilder.build(); + + // SectionType의 tag만 사용 + String filter = buildFilterExpression(); + QuestionAnswerAdvisor qaAdvisor = advisorProvider + .getQuestionAnswerAdvisor(0.6, 3, filter); + SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); + + String llmResponse = chatClient + .prompt(prompt) + .options(ChatOptions.builder() + .temperature(0.0) + .topP(0.1) + .build()) + .advisors(qaAdvisor, slAdvisor) + .call() + .content(); + + // 섹션별 응답 파싱 + SectionGradingResult result = parseSectionResult(llmResponse); + + if (result.success()) { + circuitBreaker.recordSuccess(getSectionType()); + } else { + circuitBreaker.recordFailure(getSectionType()); + } + + log.info("[{}] 채점 완료: score={}, filter={}", + getSectionType(), result.score(), filter); + return result; + + } catch (Exception e) { + circuitBreaker.recordFailure(getSectionType()); + log.error("[{}] 채점 실패", getSectionType(), e); + return SectionGradingResult.failure(getSectionType(), e.getMessage()); + } + } + + private String buildFilterExpression() { + SectionType sectionType = getSectionType(); + String tag = sectionType.getTag(); + + if (tag == null || tag.isBlank()) { + return null; + } + + return "tag == '" + tag + "'"; + } + + private SectionGradingResult parseSectionResult(String llmResponse) { + try { + // 섹션별 응답 파싱 메소드 사용 + AiReportResult sectionResponse = responseParser.parseSectionResponse(llmResponse); + + // SectionScoreExtractor를 사용하여 점수 추출 + Integer score = SectionScoreExtractor.extractScore(getSectionType(), sectionResponse); + + // sectionScores에서 해당 섹션 찾기 + String sectionTypeString = getSectionType().name(); + AiReportResult.SectionScoreDetailResponse sectionScore = sectionResponse.sectionScores().stream() + .filter(ss -> sectionTypeString.equals(ss.sectionType())) + .findFirst() + .orElse(null); + + return SectionGradingResult.success(getSectionType(), score, sectionScore); + + } catch (Exception e) { + log.error("[{}] 응답 파싱 실패", getSectionType(), e); + return SectionGradingResult.failure(getSectionType(), "파싱 실패: " + e.getMessage()); + } + } +} diff --git a/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java new file mode 100644 index 00000000..a970d3a9 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java @@ -0,0 +1,124 @@ +package starlight.adapter.aireport.report.circuitbreaker; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import starlight.shared.enumerate.SectionType; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +@Slf4j +@Component +public class SectionGradingCircuitBreaker { + + private static final int FAILURE_THRESHOLD = 5; // 5번 연속 실패 시 오픈 + private static final int SUCCESS_THRESHOLD = 2; // 2번 연속 성공 시 클로즈 + private static final long HALF_OPEN_TIMEOUT_SECONDS = 60; // 60초 후 하프오픈 시도 + + private final Map circuitStates = new ConcurrentHashMap<>(); + + public enum State { + CLOSED, // 정상 동작 + OPEN, // 차단됨 + HALF_OPEN // 테스트 중 + } + + private static class CircuitState { + private final AtomicReference state = new AtomicReference<>(State.CLOSED); + private final AtomicInteger failureCount = new AtomicInteger(0); + private final AtomicInteger successCount = new AtomicInteger(0); + private final AtomicReference lastFailureTime = new AtomicReference<>(); + + public boolean allowRequest() { + State current = state.get(); + + if (current == State.CLOSED) { + return true; + } + + if (current == State.OPEN) { + // 타임아웃 체크하여 HALF_OPEN으로 전환 + LocalDateTime lastFailure = lastFailureTime.get(); + if (lastFailure != null && + java.time.Duration.between(lastFailure, LocalDateTime.now()) + .getSeconds() >= HALF_OPEN_TIMEOUT_SECONDS) { + if (state.compareAndSet(State.OPEN, State.HALF_OPEN)) { + successCount.set(0); + failureCount.set(0); // HALF_OPEN 전환 시 failureCount 리셋 + lastFailureTime.set(LocalDateTime.now()); // lastFailureTime도 갱신 + log.info("Circuit breaker transitioning to HALF_OPEN"); + return true; + } + } + return false; + } + + // HALF_OPEN 상태 + return true; + } + + public void recordSuccess() { + State current = state.get(); + if (current == State.HALF_OPEN) { + int success = successCount.incrementAndGet(); + if (success >= SUCCESS_THRESHOLD) { + if (state.compareAndSet(State.HALF_OPEN, State.CLOSED)) { + failureCount.set(0); + log.info("Circuit breaker CLOSED after successful recovery"); + } + } + } else if (current == State.CLOSED) { + failureCount.set(0); // 성공 시 실패 카운트 리셋 + } + } + + public void recordFailure() { + State current = state.get(); + if (current == State.CLOSED) { + int failures = failureCount.incrementAndGet(); + lastFailureTime.set(LocalDateTime.now()); + + if (failures >= FAILURE_THRESHOLD) { + if (state.compareAndSet(State.CLOSED, State.OPEN)) { + log.warn("Circuit breaker OPENED after {} failures", failures); + } + } + } else if (current == State.HALF_OPEN) { + // HALF_OPEN에서는 첫 실패 시 즉시 OPEN으로 전환 + lastFailureTime.set(LocalDateTime.now()); + if (state.compareAndSet(State.HALF_OPEN, State.OPEN)) { + log.warn("Circuit breaker OPENED after failure in HALF_OPEN state"); + } + } + } + } + + public boolean allowRequest(SectionType sectionType) { + CircuitState circuit = circuitStates.computeIfAbsent( + sectionType, + k -> new CircuitState() + ); + return circuit.allowRequest(); + } + + public void recordSuccess(SectionType sectionType) { + CircuitState circuit = circuitStates.computeIfAbsent( + sectionType, + k -> new CircuitState()); + circuit.recordSuccess(); + } + + public void recordFailure(SectionType sectionType, String errorMessage) { + CircuitState circuit = circuitStates.computeIfAbsent( + sectionType, + k -> new CircuitState()); + circuit.recordFailure(); + } + + public void recordFailure(SectionType sectionType) { + recordFailure(sectionType, null); + } +} diff --git a/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java b/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java new file mode 100644 index 00000000..f1bf0693 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java @@ -0,0 +1,45 @@ +package starlight.adapter.aireport.report.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import starlight.adapter.aireport.report.agent.SectionGradeAgent; +import starlight.adapter.aireport.report.agent.impl.SpringAiSectionGradeAgent; +import starlight.adapter.aireport.report.circuitbreaker.SectionGradingCircuitBreaker; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.report.provider.ReportPromptProvider; +import starlight.application.aireport.util.AiReportResponseParser; +import starlight.shared.enumerate.SectionType; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Configuration +@RequiredArgsConstructor +public class SectionAdvisorConfig { + + private final ChatClient.Builder chatClientBuilder; + private final ReportPromptProvider reportPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + private final AiReportResponseParser responseParser; + private final SectionGradingCircuitBreaker circuitBreaker; + + @Bean + public List sectionAdvisors() { + // 채점 대상이 아닌 OVERVIEW를 제외한 모든 SectionType에 대해 Advisor 생성 + return Arrays.stream(SectionType.values()) + .filter(sectionType -> sectionType.getTag() != null) // OVERVIEW 제외 + .map(sectionType -> new SpringAiSectionGradeAgent( + sectionType, + chatClientBuilder, + reportPromptProvider, + advisorProvider, + responseParser, + circuitBreaker + )) + .collect(Collectors.toList()); + } +} + diff --git a/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java b/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java new file mode 100644 index 00000000..117266e2 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java @@ -0,0 +1,27 @@ +package starlight.adapter.aireport.report.dto; + +import starlight.application.aireport.provided.dto.AiReportResult.SectionScoreDetailResponse; +import starlight.shared.enumerate.SectionType; + +public record SectionGradingResult( + SectionType sectionType, + Integer score, + SectionScoreDetailResponse sectionScore, + boolean success, + String errorMessage +) { + public static SectionGradingResult success( + SectionType sectionType, + Integer score, + SectionScoreDetailResponse sectionScore + ) { + return new SectionGradingResult(sectionType, score, sectionScore, true, null); + } + + public static SectionGradingResult failure(SectionType sectionType, String errorMessage) { + return new SectionGradingResult(sectionType, 0, null, false, errorMessage); + } +} + + + diff --git a/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java b/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java new file mode 100644 index 00000000..3c590b70 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java @@ -0,0 +1,73 @@ +package starlight.adapter.aireport.report.provider; + +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import starlight.shared.enumerate.SectionType; + +import java.util.List; + +@Component +public class ReportPromptProvider { + + // 사업계획서 전체 채점 프롬프트 + @Value("${prompt.report.grading.system}") + private String reportGradingSystemPrompt; + + // 사업계획서 섹션별 채점 공통 프롬프트 + @Value("${prompt.report.section.default.system}") + private String sectionDefaultSystemPrompt; + + // 사업계획서 섹션별 채점 프롬프트 + @Value("${prompt.report.section.problem_recognition.system}") + private String problemRecognitionSystemPrompt; + + @Value("${prompt.report.section.feasibility.system}") + private String feasibilitySystemPrompt; + + @Value("${prompt.report.section.growth_strategy.system}") + private String growthStrategySystemPrompt; + + @Value("${prompt.report.section.team_competence.system}") + private String teamCompetenceSystemPrompt; + + /** + * 리포트 채점용 Prompt 객체 생성 + */ + public Prompt createReportGradingPrompt(String businessPlanContent) { + Message systemMessage = new SystemMessage(reportGradingSystemPrompt); + Message userMessage = new UserMessage(businessPlanContent); // 사업계획서 내용만 직접 전달 + return new Prompt(List.of(systemMessage, userMessage)); + } + + /** + * 섹션별 채점용 Prompt 객체 생성 + */ + public Prompt createSectionGradingPrompt(SectionType sectionType, String sectionContent) { + String systemPrompt = getSectionGradingSystemPrompt(sectionType); + + Message systemMessage = new SystemMessage(systemPrompt); + Message userMessage = new UserMessage(sectionContent); // 섹션 내용만 직접 전달 + return new Prompt(List.of(systemMessage, userMessage)); + } + + /** + * 섹션별 채점용 시스템 프롬프트 + * 공통 프롬프트와 섹션별 프롬프트를 합쳐서 반환 + */ + private String getSectionGradingSystemPrompt(SectionType sectionType) { + String sectionSpecificPrompt = switch (sectionType) { + case PROBLEM_RECOGNITION -> problemRecognitionSystemPrompt; + case FEASIBILITY -> feasibilitySystemPrompt; + case GROWTH_STRATEGY -> growthStrategySystemPrompt; + case TEAM_COMPETENCE -> teamCompetenceSystemPrompt; + default -> ""; // 기본값 + }; + + // 공통 프롬프트와 섹션별 프롬프트를 합침 + return sectionDefaultSystemPrompt + "\n\n" + sectionSpecificPrompt; + } +} diff --git a/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java similarity index 93% rename from src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java rename to src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java index 61e73481..16c742a8 100644 --- a/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java +++ b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java @@ -1,4 +1,4 @@ -package starlight.adapter.ai.infra; +package starlight.adapter.aireport.report.provider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,7 +12,7 @@ @Service @Slf4j @RequiredArgsConstructor -public class AdvisorProvider { +public class SpringAiAdvisorProvider { private final VectorStore vectorStore; diff --git a/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java new file mode 100644 index 00000000..ca0178cd --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java @@ -0,0 +1,106 @@ +package starlight.adapter.aireport.report.supervisor; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import starlight.adapter.aireport.report.dto.SectionGradingResult; +import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpringAiReportSupervisor { + + private final ChatClient.Builder chatClientBuilder; + private final AiReportResponseParser responseParser; + + @Value("${prompt.report.supervisor.system}") + private String supervisorSystemPrompt; + + @Value("${prompt.report.supervisor.user.template}") + private String supervisorUserPromptTemplate; + + public List generateStrengths( + String businessPlanContent, + List sectionResults + ) { + return generateStrengthWeakness(businessPlanContent, sectionResults, "strengths"); + } + + public List generateWeaknesses( + String businessPlanContent, + List sectionResults + ) { + return generateStrengthWeakness(businessPlanContent, sectionResults, "weaknesses"); + } + + private List generateStrengthWeakness( + String businessPlanContent, + List sectionResults, + String type + ) { + try { + String prompt = buildSupervisorPrompt(businessPlanContent, sectionResults, type); + + ChatClient chatClient = chatClientBuilder.build(); + String llmResponse = chatClient + .prompt(new Prompt(List.of( + new SystemMessage(supervisorSystemPrompt), + new UserMessage(prompt) + ))) + .options(ChatOptions.builder() + .temperature(0.0) + .topP(0.1) + .build()) + .call() + .content(); + + return responseParser.parseStrengthWeakness(llmResponse, type); + + } catch (Exception e) { + log.error("Supervisor failed to generate {}", type, e); + return List.of(); // 빈 리스트 반환 + } + } + + private String buildSupervisorPrompt( + String businessPlanContent, + List sectionResults, + String type + ) { + // 섹션별 채점 결과 포맷팅 + StringBuilder sectionResultsBuilder = new StringBuilder(); + for (SectionGradingResult result : sectionResults) { + if (result.success()) { + sectionResultsBuilder.append(String.format("- %s: %d점\n", + result.sectionType().getDescription(), + result.score())); + } else { + sectionResultsBuilder.append(String.format("- %s: 채점 실패 (%s)\n", + result.sectionType().getDescription(), + result.errorMessage())); + } + } + + PromptTemplate promptTemplate = new PromptTemplate(supervisorUserPromptTemplate); + Map variables = new HashMap<>(); + variables.put("businessPlanContent", businessPlanContent); + variables.put("sectionResults", sectionResultsBuilder.toString().trim()); + variables.put("type", type); + + return promptTemplate.render(variables); + } +} + diff --git a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java index e39411be..6d917d07 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java @@ -10,8 +10,8 @@ import org.springframework.web.bind.annotation.*; import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; import starlight.adapter.member.auth.security.auth.AuthDetails; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.provided.AiReportService; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.provided.AiReportUseCase; import starlight.shared.apiPayload.response.ApiResponse; @Validated @@ -22,24 +22,24 @@ @SecurityRequirement(name = "bearerAuth") public class AiReportController { - private final AiReportService aiReportService; + private final AiReportUseCase aiReportUseCase; @Operation(summary = "사업계획서를 AI로 채점 및 생성합니다.") @PostMapping("/evaluation/{planId}") - public ApiResponse gradeBusinessPlan( + public ApiResponse gradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { - return ApiResponse.success(aiReportService.gradeBusinessPlan(planId, authDetails.getMemberId())); + return ApiResponse.success(aiReportUseCase.gradeBusinessPlan(planId, authDetails.getMemberId())); } @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성하고, AI로 채점 및 생성합니다.") @PostMapping("/evaluation/pdf") - public ApiResponse createAndGradeBusinessPlan( + public ApiResponse createAndGradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @Valid @RequestBody BusinessPlanCreateWithPdfRequest request ) { - return ApiResponse.success(aiReportService.createAndGradePdfBusinessPlan( + return ApiResponse.success(aiReportUseCase.createAndGradePdfBusinessPlan( request.title(), request.pdfUrl(), authDetails.getMemberId() @@ -48,10 +48,10 @@ public ApiResponse createAndGradeBusinessPlan( @Operation(summary = "AI 리포트를 조회합니다.") @GetMapping("/{planId}") - public ApiResponse getAiReport( + public ApiResponse getAiReport( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { - return ApiResponse.success(aiReportService.getAiReport(planId, authDetails.getMemberId())); + return ApiResponse.success(aiReportUseCase.getAiReport(planId, authDetails.getMemberId())); } } diff --git a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java b/src/main/java/starlight/adapter/aireport/webapi/ImageController.java index 442d4993..19302ff0 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/ImageController.java @@ -5,7 +5,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; -import starlight.application.aireport.required.PresignedUrlProvider; +import starlight.application.aireport.required.PresignedUrlProviderPort; import starlight.adapter.aireport.webapi.swagger.ImageApiDoc; import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; @@ -15,7 +15,7 @@ @RequiredArgsConstructor public class ImageController implements ImageApiDoc { - private final PresignedUrlProvider presignedUrlReader; + private final PresignedUrlProviderPort presignedUrlReader; @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( diff --git a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java b/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java similarity index 75% rename from src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java rename to src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java index 6ea468e4..33b8b1f6 100644 --- a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java +++ b/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java @@ -1,23 +1,23 @@ -package starlight.adapter.ai; +package starlight.adapter.businessplan.checklist; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import starlight.adapter.ai.infra.OpenAiGenerator; -import starlight.application.businessplan.required.ChecklistGrader; +import starlight.adapter.businessplan.checklist.agent.SpringAiChecklistAgent; +import starlight.application.businessplan.required.ChecklistGraderPort; import starlight.domain.businessplan.enumerate.SubSectionType; import java.util.List; import java.util.ArrayList; -import starlight.adapter.ai.util.ChecklistCatalog; +import starlight.adapter.businessplan.checklist.provider.ChecklistPromptProvider; @Slf4j @Service @RequiredArgsConstructor -public class OpenAiChecklistGrader implements ChecklistGrader { +public class SpringAiChecklistGrader implements ChecklistGraderPort { - private final OpenAiGenerator generator; - private final ChecklistCatalog checklistCatalog; + private final SpringAiChecklistAgent generator; + private final ChecklistPromptProvider checklistCatalog; @Override public List check( diff --git a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java b/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java similarity index 57% rename from src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java rename to src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java index c90ffb21..1e6c4162 100644 --- a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java +++ b/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java @@ -1,38 +1,37 @@ -package starlight.adapter.ai.infra; +package starlight.adapter.businessplan.checklist.agent; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; -import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.stereotype.Component; -import starlight.application.infrastructure.provided.LlmGenerator; +import starlight.adapter.businessplan.checklist.provider.ChecklistPromptProvider; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; import starlight.domain.businessplan.enumerate.SubSectionType; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.core.type.TypeReference; import java.util.List; @Slf4j @Component @RequiredArgsConstructor -public class OpenAiGenerator implements LlmGenerator { +public class SpringAiChecklistAgent { private final ChatClient.Builder chatClientBuilder; - private final PromptProvider promptProvider; - private final AdvisorProvider advisorProvider; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ChecklistPromptProvider checklistPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + private final ObjectMapper objectMapper; - @Override public List generateChecklistArray( SubSectionType subSectionType, String content, List criteria, List detailedCriteria ) { - Prompt prompt = promptProvider.createChecklistGradingPrompt( + Prompt prompt = checklistPromptProvider.createChecklistGradingPrompt( subSectionType, content, criteria, detailedCriteria ); @@ -57,24 +56,4 @@ public List generateChecklistArray( return List.of(false, false, false, false, false); } } - - @Override - public String generateReport(String content) { - Prompt prompt = promptProvider.createReportGradingPrompt(content); - - ChatClient chatClient = chatClientBuilder.build(); - QuestionAnswerAdvisor qaAdvisor = advisorProvider - .getQuestionAnswerAdvisor(0.6, 3, null); - SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); - - return chatClient - .prompt(prompt) - .options(ChatOptions.builder() - .temperature(0.0) - .topP(0.1) - .build()) - .advisors(qaAdvisor, slAdvisor) - .call() - .content(); - } } diff --git a/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java b/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java new file mode 100644 index 00000000..42bf7a3a --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java @@ -0,0 +1,118 @@ +package starlight.adapter.businessplan.checklist.provider; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@ConfigurationProperties(prefix = "prompt.checklist") +@Getter +@Setter +public class ChecklistPromptProvider { + + private Map catalog; + + @Value("${prompt.checklist.grading.system}") + private String checklistGradingSystemPrompt; + + @Value("${prompt.checklist.grading.user.template}") + private String checklistGradingUserPromptTemplate; + + @Getter + @Setter + public static class CatalogSection { + private List items; + } + + @Getter + @Setter + public static class ChecklistItem { + private String criteria; + private String detailed; + } + + // 서브섹션 타입에 해당하는 criteria 리스트를 반환합니다 + public List getCriteriaBySubSectionType(SubSectionType subSectionType) { + String tag = subSectionType.getTag(); + if (catalog == null || !catalog.containsKey(tag)) { + return List.of(); + } + CatalogSection section = catalog.get(tag); + if (section == null || section.getItems() == null) { + return List.of(); + } + return section.getItems().stream() + .map(ChecklistItem::getCriteria) + .filter(c -> c != null && !c.isEmpty()) + .collect(Collectors.toList()); + } + + // 서브섹션 타입에 해당하는 detailed-criteria 리스트를 반환합니다. + public List getDetailedCriteriaBySubSectionType(SubSectionType subSectionType) { + String tag = subSectionType.getTag(); + if (catalog == null || !catalog.containsKey(tag)) { + return List.of(); + } + CatalogSection section = catalog.get(tag); + if (section == null || section.getItems() == null) { + return List.of(); + } + return section.getItems().stream() + .map(ChecklistItem::getDetailed) + .filter(d -> d != null && !d.isEmpty()) + .collect(Collectors.toList()); + } + + /** + * 체크리스트 채점용 Prompt 객체 생성 + */ + public Prompt createChecklistGradingPrompt( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria) { + String userPrompt = buildChecklistGradingUserPrompt(subSectionType, content, criteria, detailedCriteria); + Message systemMessage = new SystemMessage(checklistGradingSystemPrompt); + Message userMessage = new UserMessage(userPrompt); + return new Prompt(List.of(systemMessage, userMessage)); + } + + /** + * 체크리스트 채점용 사용자 프롬프트 생성 + */ + private String buildChecklistGradingUserPrompt( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria) { + // 체크리스트 상세 기준 포맷팅 + StringBuilder criteriaBuilder = new StringBuilder(); + for (int i = 0; i < criteria.size() && i < detailedCriteria.size(); i++) { + criteriaBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); + criteriaBuilder.append(detailedCriteria.get(i)).append("\n\n"); + } + String formattedCriteria = criteriaBuilder.toString().trim(); + + Map variables = new HashMap<>(); + variables.put("subsectionType", subSectionType.getDescription()); + variables.put("checklistCriteria", formattedCriteria); + variables.put("input", content); + variables.put("requestLength", criteria.size()); + + PromptTemplate promptTemplate = new PromptTemplate(checklistGradingUserPromptTemplate); + return promptTemplate.render(variables); + } +} diff --git a/src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java b/src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java new file mode 100644 index 00000000..c87df3b8 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java @@ -0,0 +1,29 @@ +package starlight.adapter.businessplan.creation; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import starlight.application.businessplan.provided.BusinessPlanUseCase; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.aireport.required.BusinessPlanCreationPort; + +/** + * BusinessPlanCreationPort의 구현체 + * BusinessPlanUseCase를 래핑하여 필요한 기능만 노출합니다. + */ +@Component +@RequiredArgsConstructor +public class BusinessPlanCreationAdapter implements BusinessPlanCreationPort { + + private final BusinessPlanUseCase businessPlanUseCase; + + @Override + public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlanResult.Result result = businessPlanUseCase.createBusinessPlanWithPdf( + title, + pdfUrl, + memberId + ); + return result.businessPlanId(); + } +} + diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java index 8966aef2..ada1b46e 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java @@ -4,7 +4,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.expert.required.BusinessPlanLookupPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.exception.BusinessPlanErrorType; @@ -14,7 +15,7 @@ @Repository @RequiredArgsConstructor -public class BusinessPlanJpa implements BusinessPlanQuery, BusinessPlanLookupPort { +public class BusinessPlanJpa implements BusinessPlanCommandPort, BusinessPlanQueryPort, BusinessPlanLookupPort { private final BusinessPlanRepository businessPlanRepository; @@ -26,7 +27,7 @@ public BusinessPlan findByIdOrThrow(Long id) { } @Override - public BusinessPlan getOrThrowWithAllSubSections(Long id) { + public BusinessPlan findWithAllSubSectionsOrThrow(Long id) { return businessPlanRepository.findByIdWithAllSubSections(id).orElseThrow( () -> new BusinessPlanException(BusinessPlanErrorType.BUSINESS_PLAN_NOT_FOUND) ); diff --git a/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java b/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java index 875bd0d3..82ef67c2 100644 --- a/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java +++ b/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java @@ -8,7 +8,7 @@ import org.springframework.web.client.RestClient; import starlight.adapter.businessplan.spellcheck.dto.Finding; import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil; -import starlight.application.businessplan.required.SpellChecker; +import starlight.application.businessplan.required.SpellCheckerPort; import java.util.ArrayList; import java.util.Comparator; @@ -16,7 +16,7 @@ @Service @RequiredArgsConstructor -public class DaumSpellChecker implements SpellChecker { +public class DaumSpellChecker implements SpellCheckerPort { private static final int MAX_CHARS = 1000; // 요청 글자 수 제한 private static final long DAUM_MIN_INTERVAL_MS = 400L; // 호출 간 최소 간격 diff --git a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java index 50affe2c..63f84448 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java @@ -17,9 +17,9 @@ import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateRequest; import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; -import starlight.application.businessplan.provided.dto.SubSectionResponse; -import starlight.application.businessplan.provided.BusinessPlanService; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.application.businessplan.provided.BusinessPlanUseCase; import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.shared.apiPayload.response.ApiResponse; @@ -33,12 +33,12 @@ @SecurityRequirement(name = "bearerAuth") public class BusinessPlanController { - private final BusinessPlanService businessPlanService; + private final BusinessPlanUseCase businessPlanService; private final ObjectMapper objectMapper; @GetMapping @Operation(summary = "사업 계획서 목록을 조회합니다. (마이페이지 용)") - public ApiResponse getBusinessPlanList( + public ApiResponse getBusinessPlanList( @AuthenticationPrincipal AuthDetails authDetails, @Parameter(description = "페이지 번호 (1 이상 정수 / 기본 1)") @RequestParam(defaultValue = "1") @Min(1)int page, @Parameter(description = "페이지 크기 (1 이상 정수 / 기본 3)") @RequestParam(defaultValue = "3") @Min(1) int size @@ -52,7 +52,7 @@ public ApiResponse getBusinessPlanList( @GetMapping("/{planId}/subsections") @Operation(summary = "사업 계획서의 제목과 모든 서브섹션 내용을 조회합니다. (미리보기 용)") - public ApiResponse getBusinessPlanDetail( + public ApiResponse getBusinessPlanDetail( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { @@ -75,7 +75,7 @@ public ApiResponse getBusinessPlanTitle( @PostMapping @Operation(summary = "사업 계획서를 생성합니다.") - public ApiResponse createBusinessPlan( + public ApiResponse createBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails ) { return ApiResponse.success(businessPlanService.createBusinessPlan(authDetails.getMemberId())); @@ -83,7 +83,7 @@ public ApiResponse createBusinessPlan( @PostMapping("/pdf") @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성합니다.") - public ApiResponse createBusinessPlanWithPdfAndAiReport( + public ApiResponse createBusinessPlanWithPdfAndAiReport( @AuthenticationPrincipal AuthDetails authDetails, @Valid @RequestBody BusinessPlanCreateWithPdfRequest request ) { @@ -106,7 +106,7 @@ public ApiResponse updateBusinessPlanTitle( @Operation(summary = "사업 계획서를 삭제합니다.") @DeleteMapping("/{planId}") - public ApiResponse deleteBusinessPlan( + public ApiResponse deleteBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { @@ -117,7 +117,7 @@ public ApiResponse deleteBusinessPlan( @Operation(summary = "서브섹션을 생성 또는 수정합니다.") @PostMapping("/{planId}/subsections") - public ApiResponse upsertSubSection( + public ApiResponse upsertSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, @Valid @RequestBody SubSectionCreateRequest request @@ -129,7 +129,7 @@ public ApiResponse upsertSubSection( @Operation(summary = "서브섹션을 조회합니다.") @GetMapping("/{planId}/subsections/{subSectionType}") - public ApiResponse getSubSection( + public ApiResponse getSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, @PathVariable SubSectionType subSectionType @@ -153,7 +153,7 @@ public ApiResponse> checkAndUpdateSubSection( @Operation(summary = "서브섹션을 삭제합니다.") @DeleteMapping("/{planId}/subsections/{subSectionType}") - public ApiResponse deleteSubSection( + public ApiResponse deleteSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, @PathVariable SubSectionType subSectionType diff --git a/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java index 71df82ec..635d4e35 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java @@ -9,7 +9,7 @@ import starlight.adapter.businessplan.webapi.dto.SpellCheckResponse; import starlight.adapter.businessplan.spellcheck.dto.Finding; import starlight.adapter.businessplan.webapi.swagger.SpellCheckApiDoc; -import starlight.application.businessplan.required.SpellChecker; +import starlight.application.businessplan.required.SpellCheckerPort; import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; import starlight.application.businessplan.util.PlainTextExtractUtils; import starlight.shared.apiPayload.response.ApiResponse; @@ -22,7 +22,7 @@ public class SpellController implements SpellCheckApiDoc { private final ObjectMapper objectMapper; - private final SpellChecker spellChecker; + private final SpellCheckerPort spellChecker; @Override public ApiResponse check( diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java similarity index 97% rename from src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java rename to src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java index a9a9a860..b8a4ef3c 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java @@ -16,7 +16,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class ExpertApplicationJpaPort implements ExpertApplicationQueryPort, +public class ExpertApplicationJpa implements ExpertApplicationQueryPort, starlight.application.expert.required.ExpertApplicationCountLookupPort, starlight.application.expertReport.required.ExpertApplicationCountLookupPort { diff --git a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java index 1c400b76..2033fbf9 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java @@ -7,7 +7,7 @@ import starlight.adapter.expertReport.webapi.dto.UpsertExpertReportRequest; import starlight.adapter.expertReport.webapi.mapper.ExpertReportMapper; import starlight.adapter.expertReport.webapi.swagger.ExpertReportApiDoc; -import starlight.application.expertReport.provided.ExpertReportServiceUseCase; +import starlight.application.expertReport.provided.ExpertReportUseCase; import starlight.application.expertReport.provided.dto.ExpertReportWithExpertResult; import starlight.domain.expertReport.entity.ExpertReport; import starlight.domain.expertReport.entity.ExpertReportComment; @@ -21,7 +21,7 @@ public class ExpertReportController implements ExpertReportApiDoc { private final ExpertReportMapper mapper; - private final ExpertReportServiceUseCase expertReportService; + private final ExpertReportUseCase expertReportService; @GetMapping public ApiResponse> getExpertReports( diff --git a/src/main/java/starlight/adapter/member/webapi/MemberController.java b/src/main/java/starlight/adapter/member/webapi/MemberController.java index bfa19d73..5bbc0113 100644 --- a/src/main/java/starlight/adapter/member/webapi/MemberController.java +++ b/src/main/java/starlight/adapter/member/webapi/MemberController.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RestController; import starlight.adapter.member.webapi.swagger.MemberApiDoc; import starlight.adapter.member.webapi.dto.MemberDetailResponse; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; @@ -18,7 +18,7 @@ @RequestMapping("/v1/members") public class MemberController implements MemberApiDoc { - private final MemberQueryUseCase memberQueryUseCase; + private final MemberUseCase memberQueryUseCase; @GetMapping public ApiResponse getMemberDetail( diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java new file mode 100644 index 00000000..e4b7ce60 --- /dev/null +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -0,0 +1,186 @@ +package starlight.application.aireport; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.aireport.provided.AiReportUseCase; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.AiReportCommandPort; +import starlight.application.aireport.required.AiReportQueryPort; +import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.aireport.required.BusinessPlanCreationPort; +import starlight.application.businessplan.required.BusinessPlanQueryPort; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.aireport.required.OcrProviderPort; +import starlight.domain.aireport.entity.AiReport; +import starlight.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.shared.enumerate.SectionType; + +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class AiReportService implements AiReportUseCase { + + private final BusinessPlanCommandPort businessPlanCommandPort; + private final BusinessPlanQueryPort businessPlanQueryPort; + private final BusinessPlanCreationPort businessPlanCreationPort; + private final AiReportQueryPort aiReportQueryPort; + private final AiReportCommandPort aiReportCommandPort; + private final ReportGraderPort reportGrader; + private final ObjectMapper objectMapper; + private final OcrProviderPort ocrProvider; + private final AiReportResponseParser responseParser; + private final BusinessPlanContentExtractor contentExtractor; + + @Override + public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { + log.info("사업계획서 AI 채점 시작. planId: {}, memberId: {}", planId, memberId); + + BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(planId); + checkBusinessPlanOwned(plan, memberId); + checkBusinessPlanWritingCompleted(plan); + + // 섹션별 내용 추출 + Map sectionContents = contentExtractor.extractSectionContents(plan); + log.debug("사업계획서 섹션별 내용 추출 완료. 섹션 수: {}", sectionContents.size()); + + // 전체 내용도 추출 (Supervisor용) + String fullContent = contentExtractor.extractContent(plan); + if (fullContent == null || fullContent.trim().isEmpty()) { + log.error("추출된 사업계획서 내용이 비어있습니다. planId: {}", planId); + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + AiReportResult gradingResult = reportGrader.gradeWithSectionAgents(sectionContents, fullContent); + + // 채점 결과 검증 + if (isInvalidGradingResult(gradingResult)) { + log.error("채점 결과가 유효하지 않습니다. 모든 점수가 0이고 빈 배열입니다. planId: {}", planId); + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + log.info("채점 완료. 총점: {}, planId: {}", gradingResult.totalScore(), planId); + + String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); + + AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); + + return responseParser.toResponse(aiReport); + } + + @Override + public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { + log.info("PDF 사업계획서 생성 및 AI 채점 시작. title: {}, pdfUrl: {}, memberId: {}", title, pdfUrl, memberId); + + Long businessPlanId = businessPlanCreationPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); + BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(businessPlanId); + + log.debug("OCR 시작. pdfUrl: {}", pdfUrl); + String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); + log.debug("OCR 완료. 텍스트 길이: {}", pdfText != null ? pdfText.length() : 0); + + if (pdfText == null || pdfText.trim().isEmpty()) { + log.error("OCR로 추출된 텍스트가 비어있습니다. pdfUrl: {}", pdfUrl); + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + // PDF의 경우 기존 한 번에 LLM에 돌리는 방식을 사용 + AiReportResult gradingResult = reportGrader.gradeWithFullPrompt(pdfText); + + // 채점 결과 검증 + if (isInvalidGradingResult(gradingResult)) { + log.error("채점 결과가 유효하지 않습니다. 모든 점수가 0이고 빈 배열입니다. businessPlanId: {}", businessPlanId); + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + log.info("PDF 채점 완료. 총점: {}, businessPlanId: {}", gradingResult.totalScore(), businessPlanId); + + String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); + + AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); + + return responseParser.toResponse(aiReport); + } + + @Override + @Transactional(readOnly = true) + public AiReportResult getAiReport(Long planId, Long memberId) { + BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(planId); + checkBusinessPlanOwned(plan, memberId); + + AiReport aiReport = aiReportQueryPort.findByBusinessPlanId(planId) + .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND)); + + return responseParser.toResponse(aiReport); + } + + private String getRawJsonAiReportResponseFromGradingResult(AiReportResult gradingResult) { + JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); + String rawJsonString; + try { + rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to convert JsonNode to string", e); + } + return rawJsonString; + } + + private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan plan) { + Optional existingReport = aiReportQueryPort.findByBusinessPlanId(plan.getId()); + + AiReport aiReport; + if (existingReport.isPresent()) { + aiReport = existingReport.get(); + aiReport.update(rawJsonString); + } else { + aiReport = AiReport.create(plan.getId(), rawJsonString); + } + + plan.updateStatus(PlanStatus.AI_REVIEWED); + businessPlanCommandPort.save(plan); + + return aiReportCommandPort.save(aiReport); + } + + private void checkBusinessPlanOwned(BusinessPlan plan, Long memberId) { + if (!plan.isOwnedBy(memberId)) { + throw new AiReportException(AiReportErrorType.UNAUTHORIZED_ACCESS); + } + } + + private void checkBusinessPlanWritingCompleted(BusinessPlan plan) { + if (!plan.areWritingCompleted()) { + throw new AiReportException(AiReportErrorType.NOT_READY_FOR_AI_REPORT); + } + } + + /** + * 채점 결과가 유효한지 검증 + * 모든 점수가 0이고 빈 배열인 경우 유효하지 않음 + */ + private boolean isInvalidGradingResult(AiReportResult result) { + boolean allScoresZero = (result.problemRecognitionScore() == null || result.problemRecognitionScore() == 0) && + (result.feasibilityScore() == null || result.feasibilityScore() == 0) && + (result.growthStrategyScore() == null || result.growthStrategyScore() == 0) && + (result.teamCompetenceScore() == null || result.teamCompetenceScore() == 0); + + boolean allArraysEmpty = (result.strengths() == null || result.strengths().isEmpty()) && + (result.weaknesses() == null || result.weaknesses().isEmpty()) && + (result.sectionScores() == null || result.sectionScores().isEmpty()); + + return allScoresZero && allArraysEmpty; + } +} diff --git a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java b/src/main/java/starlight/application/aireport/AiReportServiceImpl.java deleted file mode 100644 index 6a7e123f..00000000 --- a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java +++ /dev/null @@ -1,129 +0,0 @@ -package starlight.application.aireport; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.provided.AiReportService; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.required.AiReportGrader; -import starlight.application.aireport.required.AiReportQuery; -import starlight.application.businessplan.provided.BusinessPlanService; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; -import starlight.application.businessplan.required.BusinessPlanQuery; -import starlight.application.businessplan.util.BusinessPlanContentExtractor; -import starlight.application.aireport.required.OcrProvider; -import starlight.domain.aireport.entity.AiReport; -import starlight.domain.aireport.exception.AiReportErrorType; -import starlight.domain.aireport.exception.AiReportException; -import starlight.domain.businessplan.entity.BusinessPlan; -import starlight.domain.businessplan.enumerate.PlanStatus; - -import java.util.Optional; - -@Service -@RequiredArgsConstructor -@Transactional -public class AiReportServiceImpl implements AiReportService { - - private final BusinessPlanQuery businessPlanQuery; - private final BusinessPlanService businessPlanService; - private final AiReportQuery aiReportQuery; - private final AiReportGrader aiReportGrader; - private final ObjectMapper objectMapper; - private final OcrProvider ocrProvider; - private final AiReportResponseParser responseParser; - private final BusinessPlanContentExtractor contentExtractor; - - @Override - public AiReportResponse gradeBusinessPlan(Long planId, Long memberId) { - - BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); - checkBusinessPlanOwned(plan, memberId); - checkBusinessPlanWritingCompleted(plan); - - AiReportResponse gradingResult = aiReportGrader.gradeContent(contentExtractor.extractContent(plan)); - - String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); - - AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - - return responseParser.toResponse(aiReportQuery.save(aiReport)); - } - - @Override - public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { - - BusinessPlanResponse.Result businessPlanResult = businessPlanService.createBusinessPlanWithPdf( - title, - pdfUrl, - memberId - ); - Long businessPlanId = businessPlanResult.businessPlanId(); - BusinessPlan plan = businessPlanQuery.findByIdOrThrow(businessPlanId); - - String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); - - AiReportResponse gradingResult = aiReportGrader.gradeContent(pdfText); - - String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); - - AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - - return responseParser.toResponse(aiReportQuery.save(aiReport)); - } - - @Override - @Transactional(readOnly = true) - public AiReportResponse getAiReport(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); - checkBusinessPlanOwned(plan, memberId); - - AiReport aiReport = aiReportQuery.findByBusinessPlanId(planId) - .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND)); - - return responseParser.toResponse(aiReport); - } - - private String getRawJsonAiReportResponseFromGradingResult(AiReportResponse gradingResult) { - JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); - String rawJsonString; - try { - rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); - } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to convert JsonNode to string", e); - } - return rawJsonString; - } - - private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan plan) { - Optional existingReport = aiReportQuery.findByBusinessPlanId(plan.getId()); - - AiReport aiReport; - if (existingReport.isPresent()) { - aiReport = existingReport.get(); - aiReport.update(rawJsonString); - } else { - aiReport = AiReport.create(plan.getId(), rawJsonString); - } - plan.updateStatus(PlanStatus.AI_REVIEWED); - businessPlanQuery.save(plan); - - return aiReport; - } - - private void checkBusinessPlanOwned(BusinessPlan plan, Long memberId) { - if (!plan.isOwnedBy(memberId)) { - throw new AiReportException(AiReportErrorType.UNAUTHORIZED_ACCESS); - } - } - - private void checkBusinessPlanWritingCompleted(BusinessPlan plan) { - if (!plan.areWritingCompleted()) { - throw new AiReportException(AiReportErrorType.NOT_READY_FOR_AI_REPORT); - } - } -} diff --git a/src/main/java/starlight/application/aireport/provided/AiReportService.java b/src/main/java/starlight/application/aireport/provided/AiReportService.java deleted file mode 100644 index 6c618fd6..00000000 --- a/src/main/java/starlight/application/aireport/provided/AiReportService.java +++ /dev/null @@ -1,11 +0,0 @@ -package starlight.application.aireport.provided; - -import starlight.application.aireport.provided.dto.AiReportResponse; - -public interface AiReportService { - AiReportResponse gradeBusinessPlan(Long businessPlanId, Long memberId); - - AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId); - - AiReportResponse getAiReport(Long businessPlanId, Long memberId); -} \ No newline at end of file diff --git a/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java b/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java new file mode 100644 index 00000000..1f8f4a21 --- /dev/null +++ b/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java @@ -0,0 +1,11 @@ +package starlight.application.aireport.provided; + +import starlight.application.aireport.provided.dto.AiReportResult; + +public interface AiReportUseCase { + AiReportResult gradeBusinessPlan(Long businessPlanId, Long memberId); + + AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId); + + AiReportResult getAiReport(Long businessPlanId, Long memberId); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java b/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java similarity index 94% rename from src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java rename to src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java index 93ec0c04..a5135d4e 100644 --- a/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java +++ b/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java @@ -7,7 +7,7 @@ * AI 리포트 응답 DTO * LLM 채점 결과와 API 응답을 모두 담는 통합 DTO */ -public record AiReportResponse( +public record AiReportResult( Long id, // null 가능 (LLM 결과 파싱 시에는 null) Long businessPlanId, // null 가능 (LLM 결과 파싱 시에는 null) Integer totalScore, @@ -32,7 +32,7 @@ public record StrengthWeakness( /** * LLM 결과만으로 AiReportResponse 생성 (id, businessPlanId는 null) */ - public static AiReportResponse fromGradingResult( + public static AiReportResult fromGradingResult( Integer problemRecognitionScore, Integer feasibilityScore, Integer growthStrategyScore, @@ -43,7 +43,7 @@ public static AiReportResponse fromGradingResult( ) { Integer totalScore = sumTotalScore(problemRecognitionScore, feasibilityScore, growthStrategyScore, teamCompetenceScore); - return new AiReportResponse( + return new AiReportResult( null, null, totalScore, diff --git a/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java b/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java new file mode 100644 index 00000000..5acf707d --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java @@ -0,0 +1,8 @@ +package starlight.application.aireport.required; + +import starlight.domain.aireport.entity.AiReport; + +public interface AiReportCommandPort { + + AiReport save(AiReport aiReport); +} diff --git a/src/main/java/starlight/application/aireport/required/AiReportGrader.java b/src/main/java/starlight/application/aireport/required/AiReportGrader.java deleted file mode 100644 index 0ba2d255..00000000 --- a/src/main/java/starlight/application/aireport/required/AiReportGrader.java +++ /dev/null @@ -1,7 +0,0 @@ -package starlight.application.aireport.required; - -import starlight.application.aireport.provided.dto.AiReportResponse; - -public interface AiReportGrader { - AiReportResponse gradeContent(String content); -} \ No newline at end of file diff --git a/src/main/java/starlight/application/aireport/required/AiReportQuery.java b/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java similarity index 73% rename from src/main/java/starlight/application/aireport/required/AiReportQuery.java rename to src/main/java/starlight/application/aireport/required/AiReportQueryPort.java index 8e18704a..29520365 100644 --- a/src/main/java/starlight/application/aireport/required/AiReportQuery.java +++ b/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java @@ -4,8 +4,8 @@ import java.util.Optional; -public interface AiReportQuery { - AiReport save(AiReport aiReport); +public interface AiReportQueryPort { + Optional findByBusinessPlanId(Long businessPlanId); } diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java new file mode 100644 index 00000000..58179bf3 --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java @@ -0,0 +1,7 @@ +package starlight.application.aireport.required; + +public interface BusinessPlanCreationPort { + + Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); +} + diff --git a/src/main/java/starlight/application/aireport/required/OcrProvider.java b/src/main/java/starlight/application/aireport/required/OcrProviderPort.java similarity index 85% rename from src/main/java/starlight/application/aireport/required/OcrProvider.java rename to src/main/java/starlight/application/aireport/required/OcrProviderPort.java index 0e8050d5..6fe3adbc 100644 --- a/src/main/java/starlight/application/aireport/required/OcrProvider.java +++ b/src/main/java/starlight/application/aireport/required/OcrProviderPort.java @@ -2,7 +2,7 @@ import starlight.shared.dto.infrastructure.OcrResponse; -public interface OcrProvider { +public interface OcrProviderPort { OcrResponse ocrPdfByUrl(String pdfUrl) ; diff --git a/src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java b/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java similarity index 84% rename from src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java rename to src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java index 0a262be0..4417c3e9 100644 --- a/src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java +++ b/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java @@ -2,7 +2,7 @@ import starlight.shared.dto.infrastructure.PreSignedUrlResponse; -public interface PresignedUrlProvider { +public interface PresignedUrlProviderPort { PreSignedUrlResponse getPreSignedUrl(Long userId, String originalFileName); diff --git a/src/main/java/starlight/application/aireport/required/ReportGraderPort.java b/src/main/java/starlight/application/aireport/required/ReportGraderPort.java new file mode 100644 index 00000000..f7ffcdfa --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/ReportGraderPort.java @@ -0,0 +1,14 @@ +package starlight.application.aireport.required; + +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.shared.enumerate.SectionType; + +import java.util.Map; + +public interface ReportGraderPort { + + AiReportResult gradeWithSectionAgents(Map sectionContents, String fullContent); + + AiReportResult gradeWithFullPrompt(String content); +} + diff --git a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java similarity index 52% rename from src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java rename to src/main/java/starlight/application/aireport/util/AiReportResponseParser.java index 7d5c44b8..581ab3a9 100644 --- a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java +++ b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java @@ -1,4 +1,4 @@ -package starlight.adapter.ai.util; +package starlight.application.aireport.util; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -7,7 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.application.aireport.provided.dto.AiReportResult; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.aireport.exception.AiReportErrorType; @@ -15,9 +15,6 @@ import java.util.ArrayList; import java.util.List; -/** - * LLM 응답을 파싱하여 AiReportResponse로 변환하는 컴포넌트 - */ @Slf4j @Component @RequiredArgsConstructor @@ -30,7 +27,7 @@ public class AiReportResponseParser { * 또는 JsonNode에서 AiReportResponse로 변환 (조회용) * 통합된 변환 메소드 */ - public JsonNode convertToJsonNode(AiReportResponse response) { + public JsonNode convertToJsonNode(AiReportResult response) { ObjectNode rootNode = objectMapper.createObjectNode(); // 점수 필드 @@ -46,7 +43,7 @@ public JsonNode convertToJsonNode(AiReportResponse response) { // 강점 배열 ArrayNode strengthsArray = rootNode.putArray("strengths"); if (response.strengths() != null) { - for (AiReportResponse.StrengthWeakness strength : response.strengths()) { + for (AiReportResult.StrengthWeakness strength : response.strengths()) { ObjectNode strengthNode = strengthsArray.addObject(); strengthNode.put("title", strength.title() != null ? strength.title() : ""); strengthNode.put("content", strength.content() != null ? strength.content() : ""); @@ -56,7 +53,7 @@ public JsonNode convertToJsonNode(AiReportResponse response) { // 약점 배열 ArrayNode weaknessesArray = rootNode.putArray("weaknesses"); if (response.weaknesses() != null) { - for (AiReportResponse.StrengthWeakness weakness : response.weaknesses()) { + for (AiReportResult.StrengthWeakness weakness : response.weaknesses()) { ObjectNode weaknessNode = weaknessesArray.addObject(); weaknessNode.put("title", weakness.title() != null ? weakness.title() : ""); weaknessNode.put("content", weakness.content() != null ? weakness.content() : ""); @@ -66,7 +63,7 @@ public JsonNode convertToJsonNode(AiReportResponse response) { // 섹션별 점수 배열: sectionType과 gradingListScores ArrayNode sectionScoresArray = rootNode.putArray("sectionScores"); if (response.sectionScores() != null) { - for (AiReportResponse.SectionScoreDetailResponse sectionScore : response.sectionScores()) { + for (AiReportResult.SectionScoreDetailResponse sectionScore : response.sectionScores()) { ObjectNode sectionScoreNode = sectionScoresArray.addObject(); sectionScoreNode.put("sectionType", sectionScore.sectionType() != null ? sectionScore.sectionType() : ""); @@ -82,20 +79,21 @@ public JsonNode convertToJsonNode(AiReportResponse response) { * AiReport에서 AiReportResponse로 변환 * 파싱 로직은 AiReportResponseParser를 재사용하고, id와 businessPlanId만 추가 */ - public AiReportResponse toResponse(AiReport aiReport) { + public AiReportResult toResponse(AiReport aiReport) { JsonNode jsonNode = aiReport.getRawJson().asTree(); // 공통 파싱 로직 재사용 - AiReportResponse baseResponse = parseFromJsonNode(jsonNode); + AiReportResult baseResponse = parseFromJsonNode(jsonNode); // totalScore 계산 - Integer totalScore = (baseResponse.problemRecognitionScore() != null ? baseResponse.problemRecognitionScore() : 0) + + Integer totalScore = (baseResponse.problemRecognitionScore() != null ? baseResponse.problemRecognitionScore() + : 0) + (baseResponse.feasibilityScore() != null ? baseResponse.feasibilityScore() : 0) + (baseResponse.growthStrategyScore() != null ? baseResponse.growthStrategyScore() : 0) + (baseResponse.teamCompetenceScore() != null ? baseResponse.teamCompetenceScore() : 0); // id와 businessPlanId를 포함하여 새 인스턴스 생성 - return new AiReportResponse( + return new AiReportResult( aiReport.getId(), aiReport.getBusinessPlanId(), totalScore, @@ -105,64 +103,126 @@ public AiReportResponse toResponse(AiReport aiReport) { baseResponse.teamCompetenceScore(), baseResponse.sectionScores(), baseResponse.strengths(), - baseResponse.weaknesses() - ); + baseResponse.weaknesses()); } /** * 응답이 기본값(파싱 실패 시 반환되는 값)인지 확인 */ - private boolean isDefaultResponse(AiReportResponse response) { + private boolean isDefaultResponse(AiReportResult response) { return (response.problemRecognitionScore() == null || response.problemRecognitionScore() == 0) && - (response.feasibilityScore() == null || response.feasibilityScore() == 0) && - (response.growthStrategyScore() == null || response.growthStrategyScore() == 0) && - (response.teamCompetenceScore() == null || response.teamCompetenceScore() == 0) && - (response.strengths() == null || response.strengths().isEmpty()) && - (response.weaknesses() == null || response.weaknesses().isEmpty()) && - (response.sectionScores() == null || response.sectionScores().isEmpty()); + (response.feasibilityScore() == null || response.feasibilityScore() == 0) && + (response.growthStrategyScore() == null || response.growthStrategyScore() == 0) && + (response.teamCompetenceScore() == null || response.teamCompetenceScore() == 0) && + (response.strengths() == null || response.strengths().isEmpty()) && + (response.weaknesses() == null || response.weaknesses().isEmpty()) && + (response.sectionScores() == null || response.sectionScores().isEmpty()); } /** - * LLM 응답 문자열을 AiReportResponse로 파싱 - * 파싱 실패 시 예외를 던집니다. + * LLM 응답 문자열을 AiReportResponse로 파싱 (전체 리포트용) + * 4개의 전체 점수 필드를 모두 요구 */ - public AiReportResponse parse(String llmResponse) { - log.debug("Raw LLM response: {}", llmResponse); - + public AiReportResult parse(String llmResponse) { + // 1. 기본 검증 if (llmResponse == null || llmResponse.trim().isEmpty()) { log.error("LLM response is null or empty"); throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } - + try { // 2. JSON 문자열 정리 String cleanedJson = cleanJsonResponse(llmResponse); - log.debug("Cleaned JSON: {}", cleanedJson); - + // 3. JSON 파싱 시도 JsonNode jsonNode = objectMapper.readTree(cleanedJson); - - // 4. 필수 필드 존재 여부 확인 + + // 4. 필수 필드 존재 여부 확인 (전체 리포트는 4개 필드 모두 필요) if (!jsonNode.has("problemRecognitionScore") || - !jsonNode.has("feasibilityScore") || - !jsonNode.has("growthStrategyScore") || - !jsonNode.has("teamCompetenceScore")) { + !jsonNode.has("feasibilityScore") || + !jsonNode.has("growthStrategyScore") || + !jsonNode.has("teamCompetenceScore")) { throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } - + // 5. 파싱 시도 - AiReportResponse response = parseFromJsonNode(jsonNode); - + AiReportResult response = parseFromJsonNode(jsonNode); + // 6. 파싱된 값이 기본값인지 확인 if (isDefaultResponse(response)) { log.error("Parsed response is default (all zeros), likely parsing failure"); throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } - + return response; } catch (Exception e) { - log.error("Failed to parse LLM response. Response: {}", llmResponse, e); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + } + + /** + * 섹션별 채점 응답을 파싱 (섹션별 Agent용) + * 하나의 섹션 점수만 포함하는 응답을 처리합니다. + * 예: {"feasibilityScore": 0, "sectionScores": [...]} + */ + public AiReportResult parseSectionResponse(String llmResponse) { + + // 1. 기본 검증 + if (llmResponse == null || llmResponse.trim().isEmpty()) { + log.error("Section LLM response is null or empty"); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + try { + // 2. JSON 문자열 정리 + String cleanedJson = cleanJsonResponse(llmResponse); + + // 3. JSON 파싱 시도 + JsonNode jsonNode = objectMapper.readTree(cleanedJson); + + // 4. 섹션별 응답은 하나의 점수 필드만 있으면 됨 + // 어떤 섹션 점수 필드가 있는지 확인 + Integer problemRecognitionScore = null; + Integer feasibilityScore = null; + Integer growthStrategyScore = null; + Integer teamCompetenceScore = null; + + if (jsonNode.has("problemRecognitionScore") && !jsonNode.path("problemRecognitionScore").isNull()) { + problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(0); + } + if (jsonNode.has("feasibilityScore") && !jsonNode.path("feasibilityScore").isNull()) { + feasibilityScore = jsonNode.path("feasibilityScore").asInt(0); + } + if (jsonNode.has("growthStrategyScore") && !jsonNode.path("growthStrategyScore").isNull()) { + growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(0); + } + if (jsonNode.has("teamCompetenceScore") && !jsonNode.path("teamCompetenceScore").isNull()) { + teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(0); + } + + // 최소 하나의 점수 필드는 있어야 함 + if (problemRecognitionScore == null && feasibilityScore == null + && growthStrategyScore == null && teamCompetenceScore == null) { + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + // 5. 섹션별 응답 파싱 (없는 필드는 null로 설정) + List sectionScores = parseSectionScores( + jsonNode.path("sectionScores")); + + // strengths와 weaknesses는 섹션별 응답에는 없음 + return AiReportResult.fromGradingResult( + problemRecognitionScore != null ? problemRecognitionScore : 0, + feasibilityScore != null ? feasibilityScore : 0, + growthStrategyScore != null ? growthStrategyScore : 0, + teamCompetenceScore != null ? teamCompetenceScore : 0, + sectionScores, + List.of(), // strengths는 빈 리스트 + List.of() // weaknesses는 빈 리스트 + ); + + } catch (Exception e) { throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } } @@ -174,9 +234,9 @@ private String cleanJsonResponse(String json) { if (json == null || json.trim().isEmpty()) { return "{}"; } - + String cleaned = json.trim(); - + // 1. JSON 코드 블록 마커 제거 (```json ... ``` 또는 ``` ... ```) if (cleaned.startsWith("```json")) { cleaned = cleaned.substring(7); @@ -187,7 +247,7 @@ private String cleanJsonResponse(String json) { cleaned = cleaned.substring(0, cleaned.length() - 3); } cleaned = cleaned.trim(); - + // 2. "text" 필드에서 JSON 추출 (더 강력한 추출) // 정규식으로 "text" 필드 추출 시도 if (cleaned.contains("\"text\"") || cleaned.contains("'text'")) { @@ -202,21 +262,18 @@ private String cleanJsonResponse(String json) { try { // "text" : "..." 패턴 찾기 java.util.regex.Pattern pattern = java.util.regex.Pattern.compile( - "\"text\"\\s*:\\s*\"(.*)\"", - java.util.regex.Pattern.DOTALL - ); + "\"text\"\\s*:\\s*\"(.*)\"", + java.util.regex.Pattern.DOTALL); java.util.regex.Matcher matcher = pattern.matcher(cleaned); if (matcher.find()) { String extracted = matcher.group(1); // 이스케이프된 문자 처리 extracted = extracted.replace("\\n", "\n") - .replace("\\\"", "\"") - .replace("\\\\", "\\"); + .replace("\\\"", "\"") + .replace("\\\\", "\\"); cleaned = extracted; - log.debug("Extracted text field using regex"); } } catch (Exception e2) { - log.warn("Failed to extract text field using regex: {}", e2.getMessage()); } } } @@ -224,29 +281,108 @@ private String cleanJsonResponse(String json) { // 3. 잘못된 따옴표 패턴 수정 (공백이 포함된 필드명) cleaned = cleaned.replaceAll("\"\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s+\"", "\"$1\""); + // 4. 불완전한 JSON 복구 (닫히지 않은 배열/객체 감지 및 복구) + cleaned = repairIncompleteJson(cleaned); + return cleaned; } + /** + * 불완전한 JSON을 복구 (닫히지 않은 배열/객체 감지 및 복구) + */ + private String repairIncompleteJson(String json) { + if (json == null || json.trim().isEmpty()) { + return json; + } + + // 괄호 균형 확인 + int openBraces = 0; // { + int closeBraces = 0; // } + int openBrackets = 0; // [ + int closeBrackets = 0; // ] + + boolean inString = false; + boolean escaped = false; + + for (int i = 0; i < json.length(); i++) { + char c = json.charAt(i); + + if (escaped) { + escaped = false; + continue; + } + + if (c == '\\') { + escaped = true; + continue; + } + + if (c == '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + switch (c) { + case '{': + openBraces++; + break; + case '}': + closeBraces++; + break; + case '[': + openBrackets++; + break; + case ']': + closeBrackets++; + break; + } + } + + // 닫히지 않은 괄호 추가 + StringBuilder repaired = new StringBuilder(json); + int missingCloseBrackets = openBrackets - closeBrackets; + int missingCloseBraces = openBraces - closeBraces; + + // 배열을 먼저 닫고, 그 다음 객체를 닫음 + for (int i = 0; i < missingCloseBrackets; i++) { + repaired.append(']'); + } + for (int i = 0; i < missingCloseBraces; i++) { + repaired.append('}'); + } + + if (missingCloseBrackets > 0 || missingCloseBraces > 0) { + log.warn("불완전한 JSON 감지 및 복구. 누락된 괄호: ] {}개, }} {}개", + missingCloseBrackets, missingCloseBraces); + } + + return repaired.toString(); + } + /** * JsonNode를 파싱하여 AiReportResponse로 변환 */ - private AiReportResponse parseFromJsonNode(JsonNode jsonNode) { + private AiReportResult parseFromJsonNode(JsonNode jsonNode) { Integer problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(0); Integer feasibilityScore = jsonNode.path("feasibilityScore").asInt(0); Integer growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(0); Integer teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(0); // 강점 파싱 - List strengths = parseStrengthWeaknessList(jsonNode.path("strengths")); + List strengths = parseStrengthWeaknessList(jsonNode.path("strengths")); // 약점 파싱 - List weaknesses = parseStrengthWeaknessList(jsonNode.path("weaknesses")); + List weaknesses = parseStrengthWeaknessList(jsonNode.path("weaknesses")); // sectionScores 파싱: sectionType과 gradingListScores만 포함 - List sectionScores = parseSectionScores( + List sectionScores = parseSectionScores( jsonNode.path("sectionScores")); - return AiReportResponse.fromGradingResult( + return AiReportResult.fromGradingResult( problemRecognitionScore, feasibilityScore, growthStrategyScore, @@ -256,14 +392,30 @@ private AiReportResponse parseFromJsonNode(JsonNode jsonNode) { weaknesses); } + /** + * 강점/약점 리스트 파싱 (슈퍼바이저용) + */ + public List parseStrengthWeakness(String llmResponse, String type) { + try { + String cleanedJson = cleanJsonResponse(llmResponse); + JsonNode jsonNode = objectMapper.readTree(cleanedJson); + + JsonNode targetNode = jsonNode.path(type); + return parseStrengthWeaknessList(targetNode); + } catch (Exception e) { + log.error("Failed to parse strength/weakness from supervisor response. Type: {}", type, e); + return List.of(); + } + } + /** * 강점/약점 리스트 파싱 */ - private List parseStrengthWeaknessList(JsonNode node) { - List list = new ArrayList<>(); + private List parseStrengthWeaknessList(JsonNode node) { + List list = new ArrayList<>(); if (node.isArray()) { for (JsonNode itemNode : node) { - list.add(new AiReportResponse.StrengthWeakness( + list.add(new AiReportResult.StrengthWeakness( itemNode.path("title").asText(""), itemNode.path("content").asText(""))); } @@ -275,36 +427,34 @@ private List parseStrengthWeaknessList(JsonNo * 섹션 점수 리스트 파싱 * 불완전한 항목은 건너뛰거나 기본값으로 대체 */ - private List parseSectionScores(JsonNode node) { - List list = new ArrayList<>(); + private List parseSectionScores(JsonNode node) { + List list = new ArrayList<>(); if (node.isArray()) { for (JsonNode sectionScoreNode : node) { try { String sectionType = sectionScoreNode.path("sectionType").asText(""); String gradingListScores = sectionScoreNode.path("gradingListScores").asText("[]"); - + // gradingListScores가 유효한 JSON 문자열인지 검증 if (!gradingListScores.equals("[]")) { try { // JSON 배열 형식인지 확인 if (!gradingListScores.trim().startsWith("[")) { - log.warn("Invalid gradingListScores format for sectionType: {}, using default", sectionType); + log.warn("Invalid gradingListScores format for sectionType: {}, using default", + sectionType); gradingListScores = "[]"; } else { // JSON 파싱 가능 여부 확인 objectMapper.readTree(gradingListScores); } } catch (Exception e) { - log.warn("Failed to parse gradingListScores for sectionType: {}, using default. Value: {}", - sectionType, gradingListScores); gradingListScores = "[]"; } } - - list.add(new AiReportResponse.SectionScoreDetailResponse(sectionType, gradingListScores)); + + list.add(new AiReportResult.SectionScoreDetailResponse(sectionType, gradingListScores)); } catch (Exception e) { log.warn("Failed to parse sectionScore item, skipping: {}", e.getMessage()); - // 불완전한 항목은 건너뛰기 } } } diff --git a/src/main/java/starlight/application/aireport/util/SectionScoreExtractor.java b/src/main/java/starlight/application/aireport/util/SectionScoreExtractor.java new file mode 100644 index 00000000..e92917af --- /dev/null +++ b/src/main/java/starlight/application/aireport/util/SectionScoreExtractor.java @@ -0,0 +1,35 @@ +package starlight.application.aireport.util; + +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.shared.enumerate.SectionType; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +public class SectionScoreExtractor { + + private static final Map> SCORE_EXTRACTORS = new HashMap<>(); + + static { + SCORE_EXTRACTORS.put(SectionType.PROBLEM_RECOGNITION, AiReportResult::problemRecognitionScore); + SCORE_EXTRACTORS.put(SectionType.FEASIBILITY, AiReportResult::feasibilityScore); + SCORE_EXTRACTORS.put(SectionType.GROWTH_STRATEGY, AiReportResult::growthStrategyScore); + SCORE_EXTRACTORS.put(SectionType.TEAM_COMPETENCE, AiReportResult::teamCompetenceScore); + } + + public static Integer extractScore(SectionType sectionType, AiReportResult result) { + if (sectionType == null || result == null) { + return 0; + } + + Function extractor = SCORE_EXTRACTORS.get(sectionType); + if (extractor == null) { + return 0; + } + + Integer score = extractor.apply(result); + return score != null ? score : 0; + } +} + diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java b/src/main/java/starlight/application/businessplan/BusinessPlanService.java similarity index 72% rename from src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java rename to src/main/java/starlight/application/businessplan/BusinessPlanService.java index 157b89b7..c26181c7 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanService.java @@ -9,11 +9,12 @@ import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; -import starlight.application.businessplan.provided.dto.SubSectionResponse; -import starlight.application.businessplan.provided.BusinessPlanService; -import starlight.application.businessplan.required.BusinessPlanQuery; -import starlight.application.businessplan.required.ChecklistGrader; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.application.businessplan.provided.BusinessPlanUseCase; +import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.businessplan.required.BusinessPlanQueryPort; +import starlight.application.businessplan.required.ChecklistGraderPort; import starlight.application.businessplan.util.PlainTextExtractUtils; import starlight.application.businessplan.util.SubSectionSupportUtils; import starlight.application.member.required.MemberQueryPort; @@ -32,69 +33,70 @@ @Service @RequiredArgsConstructor @Transactional -public class BusinessPlanServiceImpl implements BusinessPlanService { +public class BusinessPlanService implements BusinessPlanUseCase { - private final BusinessPlanQuery businessPlanQuery; - private final MemberQueryPort memberQuery; - private final ChecklistGrader checklistGrader; + private final BusinessPlanCommandPort businessPlanCommandPort; + private final BusinessPlanQueryPort businessPlanQueryPort; + private final MemberQueryPort memberQueryPort; + private final ChecklistGraderPort checklistGrader; private final ObjectMapper objectMapper; @Override - public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { - Member member = memberQuery.findByIdOrThrow(memberId); + public BusinessPlanResult.Result createBusinessPlan(Long memberId) { + Member member = memberQueryPort.findByIdOrThrow(memberId); String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; BusinessPlan plan = BusinessPlan.create(planTitle, memberId); - return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "Business plan created"); + return BusinessPlanResult.Result.from(businessPlanCommandPort.save(plan), "Business plan created"); } @Override - public BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + public BusinessPlanResult.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { BusinessPlan plan = BusinessPlan.createWithPdf( title, memberId, pdfUrl ); - return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "PDF Business plan created"); + return BusinessPlanResult.Result.from(businessPlanCommandPort.save(plan), "PDF Business plan created"); } @Override @Transactional(readOnly = true) - public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) { + public BusinessPlanResult.Result getBusinessPlanInfo(Long planId, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - return BusinessPlanResponse.Result.from(plan, "Business plan retrieved"); + return BusinessPlanResult.Result.from(plan, "Business plan retrieved"); } @Override @Transactional(readOnly = true) - public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQuery.getOrThrowWithAllSubSections(planId); + public BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberId) { + BusinessPlan plan = businessPlanQueryPort.findWithAllSubSectionsOrThrow(planId); if (!plan.isOwnedBy(memberId)) { throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); } - List subSectionDetailList = Arrays.stream(SubSectionType.values()) + List subSectionDetailList = Arrays.stream(SubSectionType.values()) .map(type -> getSectionByPlanAndType(plan, type.getSectionType()).getSubSectionByType(type)) .filter(Objects::nonNull) - .map(SubSectionResponse.Detail::from) + .map(SubSectionResult.Detail::from) .toList(); - return BusinessPlanResponse.Detail.from(plan, subSectionDetailList); + return BusinessPlanResult.Detail.from(plan, subSectionDetailList); } @Override @Transactional(readOnly = true) - public BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable) { - Page page = businessPlanQuery.findPreviewPage(memberId, pageable); - List content = page.getContent().stream() - .map(BusinessPlanResponse.Preview::from) + public BusinessPlanResult.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable) { + Page page = businessPlanQueryPort.findPreviewPage(memberId, pageable); + List content = page.getContent().stream() + .map(BusinessPlanResult.Preview::from) .toList(); - return BusinessPlanResponse.PreviewPage.from(content, page); + return BusinessPlanResult.PreviewPage.from(content, page); } @Override @@ -103,23 +105,23 @@ public String updateBusinessPlanTitle(Long planId, String title, Long memberId) plan.updateTitle(title); - businessPlanQuery.save(plan); + businessPlanCommandPort.save(plan); return plan.getTitle(); } @Override - public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) { + public BusinessPlanResult.Result deleteBusinessPlan(Long planId, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - BusinessPlanResponse.Result result = BusinessPlanResponse.Result.from(plan, "Business plan deleted"); - businessPlanQuery.delete(plan); + BusinessPlanResult.Result result = BusinessPlanResult.Result.from(plan, "Business plan deleted"); + businessPlanCommandPort.delete(plan); return result; } @Override - public SubSectionResponse.Result upsertSubSection( + public SubSectionResult.Result upsertSubSection( Long planId, JsonNode jsonNode, List checks, @@ -151,16 +153,16 @@ public SubSectionResponse.Result upsertSubSection( message = "Subsection writing completed"; } - BusinessPlan savedPlan = businessPlanQuery.save(plan); + BusinessPlan savedPlan = businessPlanCommandPort.save(plan); SubSection persistedSubSection = getSectionByPlanAndType(savedPlan, sectionType) .getSubSectionByType(subSectionType); - return SubSectionResponse.Result.from(persistedSubSection, message); + return SubSectionResult.Result.from(persistedSubSection, message); } @Override @Transactional(readOnly = true) - public SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId) { + public SubSectionResult.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); SectionType sectionType = subSectionType.getSectionType(); @@ -169,7 +171,7 @@ public SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); } - return SubSectionResponse.Detail.from(subSection); + return SubSectionResult.Detail.from(subSection); } @Override @@ -195,13 +197,13 @@ public List checkAndUpdateSubSection( subSection.update(content, rawJsonStr, checks); - businessPlanQuery.save(plan); + businessPlanCommandPort.save(plan); return checks; } @Override - public SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) { + public SubSectionResult.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); SectionType sectionType = subSectionType.getSectionType(); @@ -210,10 +212,10 @@ public SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType su if (target == null) { throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); } - SubSectionResponse.Result result = SubSectionResponse.Result.from(target, "Subsection deleted"); + SubSectionResult.Result result = SubSectionResult.Result.from(target, "Subsection deleted"); section.removeSubSection(subSectionType); - businessPlanQuery.save(plan); + businessPlanCommandPort.save(plan); return result; } @@ -236,7 +238,7 @@ private String getSerializedJsonNodesWithUpdatedChecks(JsonNode jsonNode, List checks, - SubSectionType subSectionType, Long memberId); - - SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId); - - List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, SubSectionType subSectionType, - Long memberId); - - SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId); - - -} diff --git a/src/main/java/starlight/application/businessplan/provided/BusinessPlanUseCase.java b/src/main/java/starlight/application/businessplan/provided/BusinessPlanUseCase.java new file mode 100644 index 00000000..2c9d431b --- /dev/null +++ b/src/main/java/starlight/application/businessplan/provided/BusinessPlanUseCase.java @@ -0,0 +1,38 @@ +package starlight.application.businessplan.provided; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.data.domain.Pageable; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +public interface BusinessPlanUseCase { + + BusinessPlanResult.Result createBusinessPlan(Long memberId); + + BusinessPlanResult.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); + + BusinessPlanResult.Result getBusinessPlanInfo(Long planId, Long memberId); + + BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberId); + + BusinessPlanResult.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable); + + String updateBusinessPlanTitle(Long planId, String title, Long memberId); + + BusinessPlanResult.Result deleteBusinessPlan(Long planId, Long memberId); + + SubSectionResult.Result upsertSubSection(Long planId, JsonNode jsonNode, List checks, + SubSectionType subSectionType, Long memberId); + + SubSectionResult.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId); + + List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, SubSectionType subSectionType, + Long memberId); + + SubSectionResult.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId); + + +} diff --git a/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java similarity index 88% rename from src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java rename to src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java index 6f837466..b4c6a971 100644 --- a/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java +++ b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java @@ -8,7 +8,7 @@ import java.time.LocalDateTime; import java.util.List; -public record BusinessPlanResponse() { +public record BusinessPlanResult() { public record Result( Long businessPlanId, @@ -30,11 +30,11 @@ public record Detail( Long businessPlanId, String title, PlanStatus planStatus, - List subSectionDetailList + List subSectionDetailList ) { public static Detail from( BusinessPlan businessPlan, - List subSectionDetailList + List subSectionDetailList ) { return new Detail( businessPlan.getId(), @@ -77,8 +77,8 @@ public record PreviewPage( boolean first, boolean last ) { - public static PreviewPage from(List content, Page page) { - return new BusinessPlanResponse.PreviewPage( + public static PreviewPage from(List content, Page page) { + return new BusinessPlanResult.PreviewPage( content, page.getNumber() + 1, page.getSize(), diff --git a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java similarity index 96% rename from src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java rename to src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java index 019721d6..8607e7cf 100644 --- a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java +++ b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java @@ -4,7 +4,7 @@ import starlight.domain.businessplan.entity.SubSection; import starlight.domain.businessplan.enumerate.SubSectionType; -public record SubSectionResponse() { +public record SubSectionResult() { public record Result( SubSectionType subSectionType, diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanCommandPort.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanCommandPort.java new file mode 100644 index 00000000..4adbed40 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanCommandPort.java @@ -0,0 +1,10 @@ +package starlight.application.businessplan.required; + +import starlight.domain.businessplan.entity.BusinessPlan; + +public interface BusinessPlanCommandPort { + + BusinessPlan save(BusinessPlan businessPlan); + + void delete(BusinessPlan businessPlan); +} diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java similarity index 63% rename from src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java rename to src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java index 355ec8f7..1784bc82 100644 --- a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java +++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java @@ -4,15 +4,11 @@ import org.springframework.data.domain.Page; import starlight.domain.businessplan.entity.BusinessPlan; -public interface BusinessPlanQuery { +public interface BusinessPlanQueryPort { BusinessPlan findByIdOrThrow(Long id); - BusinessPlan getOrThrowWithAllSubSections(Long id); - - BusinessPlan save(BusinessPlan businessPlan); - - void delete(BusinessPlan businessPlan); + BusinessPlan findWithAllSubSectionsOrThrow(Long id); Page findPreviewPage(Long memberId, Pageable pageable); } diff --git a/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java b/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java deleted file mode 100644 index 33049e2a..00000000 --- a/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java +++ /dev/null @@ -1,20 +0,0 @@ -package starlight.application.businessplan.required; - -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; - -public interface ChecklistGrader { - - /** - * 서브섹션 내용을 체크리스트 기준에 따라 체크합니다. - * - * @param subSectionType 서브섹션 타입 - * @param content 서브섹션 내용 - * @return 체크리스트 결과 - */ - List check( - SubSectionType subSectionType, - String content - ); -} diff --git a/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java b/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java new file mode 100644 index 00000000..0646b976 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java @@ -0,0 +1,10 @@ +package starlight.application.businessplan.required; + +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +public interface ChecklistGraderPort { + + List check(SubSectionType subSectionType, String content); +} diff --git a/src/main/java/starlight/application/businessplan/required/SpellChecker.java b/src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java similarity index 87% rename from src/main/java/starlight/application/businessplan/required/SpellChecker.java rename to src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java index 347abd57..3c3bb666 100644 --- a/src/main/java/starlight/application/businessplan/required/SpellChecker.java +++ b/src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java @@ -4,7 +4,7 @@ import java.util.List; -public interface SpellChecker { +public interface SpellCheckerPort { List check(String sentence); diff --git a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java index 5f3e2eed..5555c488 100644 --- a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java +++ b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java @@ -1,5 +1,6 @@ package starlight.application.businessplan.util; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import starlight.domain.businessplan.entity.BaseSection; import starlight.domain.businessplan.entity.BusinessPlan; @@ -8,11 +9,14 @@ import starlight.shared.enumerate.SectionType; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * BusinessPlan에서 LLM 채점을 위한 텍스트 컨텐츠를 추출하는 컴포넌트 */ +@Slf4j @Component public class BusinessPlanContentExtractor { @@ -88,5 +92,59 @@ private String extractSectionContent(BaseSection section, SectionType sectionTyp return sectionBuilder.toString(); } + + /** + * BusinessPlan에서 섹션별로 컨텐츠를 추출하여 Map으로 반환 + */ + public Map extractSectionContents(BusinessPlan businessPlan) { + Map sectionContents = new HashMap<>(); + + String problemRecognition = extractSectionContent( + businessPlan.getProblemRecognition(), + SectionType.PROBLEM_RECOGNITION, "문제 인식"); + sectionContents.put(SectionType.PROBLEM_RECOGNITION, problemRecognition); + + String feasibility = extractSectionContent( + businessPlan.getFeasibility(), + SectionType.FEASIBILITY, "실현 가능성"); + sectionContents.put(SectionType.FEASIBILITY, feasibility); + + String growthStrategy = extractSectionContent( + businessPlan.getGrowthTactic(), + SectionType.GROWTH_STRATEGY, "성장 전략"); + sectionContents.put(SectionType.GROWTH_STRATEGY, growthStrategy); + + String teamCompetence = extractSectionContent( + businessPlan.getTeamCompetence(), + SectionType.TEAM_COMPETENCE, "팀 역량"); + sectionContents.put(SectionType.TEAM_COMPETENCE, teamCompetence); + + return sectionContents; + } + + /** + * 전체 텍스트에서 섹션별로 내용을 추출 (PDF 케이스용) + * + * 현재 PDF 입력은 섹션별 채점 대신 FullReportGradeAgent를 사용 중 + * 현재 PDF 처리는 {@link starlight.application.aireport.required.ReportGraderPort#gradeWithFullPrompt(String)}를 사용 + * + * @param fullContent 전체 텍스트 내용 + * @return 섹션별 내용 맵 (현재는 모든 섹션에 전체 내용을 할당) + * TODO: 실제 구현 필요 - 섹션 제목을 기준으로 파싱 + */ +// public Map extractSectionContentsFromText(String fullContent) { +// // 간단한 구현: 전체 내용을 각 섹션에 동일하게 할당 +// // 나중에 실제 파싱 로직으로 개선 필요 +// Map sectionContents = new HashMap<>(); +// +// // 섹션 제목을 찾아서 분리하는 로직 필요 +// // 현재는 전체 내용을 각 섹션에 할당 +// sectionContents.put(SectionType.PROBLEM_RECOGNITION, fullContent); +// sectionContents.put(SectionType.FEASIBILITY, fullContent); +// sectionContents.put(SectionType.GROWTH_STRATEGY, fullContent); +// sectionContents.put(SectionType.TEAM_COMPETENCE, fullContent); +// +// return sectionContents; +// } } diff --git a/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java index 82e00040..da6e409d 100644 --- a/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java +++ b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java @@ -7,12 +7,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.expertApplication.event.FeedbackRequestInput; import starlight.application.expertApplication.provided.ExpertApplicationCommandUseCase; import starlight.application.expertApplication.required.ExpertLookupPort; import starlight.application.expertApplication.required.ExpertApplicationQueryPort; -import starlight.application.expertReport.provided.ExpertReportServiceUseCase; +import starlight.application.expertReport.provided.ExpertReportUseCase; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.PlanStatus; import starlight.domain.businessplan.exception.BusinessPlanException; @@ -32,10 +32,10 @@ public class ExpertApplicationCommandService implements ExpertApplicationCommandUseCase { private final ExpertLookupPort expertLookupPort; - private final BusinessPlanQuery planQuery; + private final BusinessPlanQueryPort planQuery; private final ExpertApplicationQueryPort applicationQueryPort; private final ApplicationEventPublisher eventPublisher; - private final ExpertReportServiceUseCase expertReportUseCase; + private final ExpertReportUseCase expertReportUseCase; private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB private static final String ALLOWED_CONTENT_TYPE = "application/pdf"; diff --git a/src/main/java/starlight/application/expertReport/ExpertReportService.java b/src/main/java/starlight/application/expertReport/ExpertReportService.java index 37d77dee..4d1a546d 100644 --- a/src/main/java/starlight/application/expertReport/ExpertReportService.java +++ b/src/main/java/starlight/application/expertReport/ExpertReportService.java @@ -4,8 +4,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import starlight.application.businessplan.required.BusinessPlanQuery; -import starlight.application.expertReport.provided.ExpertReportServiceUseCase; +import starlight.application.businessplan.required.BusinessPlanQueryPort; +import starlight.application.expertReport.provided.ExpertReportUseCase; import starlight.application.expertReport.provided.dto.ExpertReportWithExpertResult; import starlight.application.expertReport.required.ExpertApplicationCountLookupPort; import starlight.application.expertReport.required.ExpertLookupPort; @@ -29,7 +29,7 @@ @Service @RequiredArgsConstructor @Transactional -public class ExpertReportService implements ExpertReportServiceUseCase { +public class ExpertReportService implements ExpertReportUseCase { @Value("${feedback-token.token-length}") private int tokenLength; @@ -44,7 +44,7 @@ public class ExpertReportService implements ExpertReportServiceUseCase { private final ExpertReportCommandPort expertReportCommand; private final ExpertLookupPort expertLookupPort; private final ExpertApplicationCountLookupPort expertApplicationLookupPort; - private final BusinessPlanQuery businessPlanQuery; + private final BusinessPlanQueryPort businessPlanQuery; private final SecureRandom secureRandom = new SecureRandom(); @Override diff --git a/src/main/java/starlight/application/expertReport/provided/ExpertReportServiceUseCase.java b/src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java similarity index 94% rename from src/main/java/starlight/application/expertReport/provided/ExpertReportServiceUseCase.java rename to src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java index 6e6e9f29..1eb15e34 100644 --- a/src/main/java/starlight/application/expertReport/provided/ExpertReportServiceUseCase.java +++ b/src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java @@ -7,7 +7,7 @@ import java.util.List; -public interface ExpertReportServiceUseCase{ +public interface ExpertReportUseCase { String createExpertReportLink(Long expertId, Long businessPlanId); diff --git a/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java b/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java deleted file mode 100644 index 58b78684..00000000 --- a/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java +++ /dev/null @@ -1,12 +0,0 @@ -package starlight.application.infrastructure.provided; - -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; - -public interface LlmGenerator { - - List generateChecklistArray(SubSectionType subSectionType, String content, List criteria, List detailedCriteria); - - String generateReport(String content); -} diff --git a/src/main/java/starlight/application/member/CredentialServiceImpl.java b/src/main/java/starlight/application/member/CredentialService.java similarity index 90% rename from src/main/java/starlight/application/member/CredentialServiceImpl.java rename to src/main/java/starlight/application/member/CredentialService.java index 04977d25..789073da 100644 --- a/src/main/java/starlight/application/member/CredentialServiceImpl.java +++ b/src/main/java/starlight/application/member/CredentialService.java @@ -3,7 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import starlight.application.member.provided.CredentialService; +import starlight.application.member.provided.CredentialUseCase; import starlight.domain.member.auth.exception.AuthErrorType; import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Credential; @@ -11,7 +11,7 @@ @Service @RequiredArgsConstructor -public class CredentialServiceImpl implements CredentialService { +public class CredentialService implements CredentialUseCase { private final PasswordEncoder passwordEncoder; diff --git a/src/main/java/starlight/application/member/MemberQueryService.java b/src/main/java/starlight/application/member/MemberService.java similarity index 92% rename from src/main/java/starlight/application/member/MemberQueryService.java rename to src/main/java/starlight/application/member/MemberService.java index d635f8f0..d4cb9149 100644 --- a/src/main/java/starlight/application/member/MemberQueryService.java +++ b/src/main/java/starlight/application/member/MemberService.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.application.member.required.MemberCommandPort; import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Credential; @@ -13,7 +13,7 @@ @Service @RequiredArgsConstructor -public class MemberQueryService implements MemberQueryUseCase { +public class MemberService implements MemberUseCase { private final MemberQueryPort memberQueryPort; private final MemberCommandPort memberCommandPort; diff --git a/src/main/java/starlight/application/member/auth/AuthServiceImpl.java b/src/main/java/starlight/application/member/auth/AuthServiceImpl.java index e5fd1d0e..346add17 100644 --- a/src/main/java/starlight/application/member/auth/AuthServiceImpl.java +++ b/src/main/java/starlight/application/member/auth/AuthServiceImpl.java @@ -11,8 +11,8 @@ import starlight.application.member.auth.provided.dto.SignUpInput; import starlight.application.member.auth.required.KeyValueMap; import starlight.application.member.auth.required.TokenProvider; -import starlight.application.member.provided.CredentialService; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.CredentialUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.domain.member.auth.exception.AuthErrorType; import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Credential; @@ -25,8 +25,8 @@ @RequiredArgsConstructor public class AuthServiceImpl implements AuthUseCase { - private final MemberQueryUseCase memberQueryUseCase; - private final CredentialService credentialService; + private final MemberUseCase memberQueryUseCase; + private final CredentialUseCase credentialService; private final TokenProvider tokenProvider; private final KeyValueMap redisClient; diff --git a/src/main/java/starlight/application/member/provided/CredentialService.java b/src/main/java/starlight/application/member/provided/CredentialUseCase.java similarity index 90% rename from src/main/java/starlight/application/member/provided/CredentialService.java rename to src/main/java/starlight/application/member/provided/CredentialUseCase.java index d67b5e3d..f2d5051a 100644 --- a/src/main/java/starlight/application/member/provided/CredentialService.java +++ b/src/main/java/starlight/application/member/provided/CredentialUseCase.java @@ -3,7 +3,7 @@ import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; -public interface CredentialService { +public interface CredentialUseCase { Credential createCredential(String rawPassword); diff --git a/src/main/java/starlight/application/member/provided/MemberQueryUseCase.java b/src/main/java/starlight/application/member/provided/MemberUseCase.java similarity index 89% rename from src/main/java/starlight/application/member/provided/MemberQueryUseCase.java rename to src/main/java/starlight/application/member/provided/MemberUseCase.java index 6977ddf8..4f0bbb9a 100644 --- a/src/main/java/starlight/application/member/provided/MemberQueryUseCase.java +++ b/src/main/java/starlight/application/member/provided/MemberUseCase.java @@ -3,7 +3,7 @@ import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; -public interface MemberQueryUseCase { +public interface MemberUseCase { Member createUser(Credential credential, String name, String email, String phoneNumber); diff --git a/src/main/java/starlight/bootstrap/AsyncConfig.java b/src/main/java/starlight/bootstrap/AsyncConfig.java index fee3c177..13ed1b22 100644 --- a/src/main/java/starlight/bootstrap/AsyncConfig.java +++ b/src/main/java/starlight/bootstrap/AsyncConfig.java @@ -32,6 +32,20 @@ public Executor emailTaskExecutor() { return executor; } + @Bean(name = "sectionGradingExecutor") + public Executor sectionGradingExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(20); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("section-grading-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(120); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } + @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> { diff --git a/src/main/java/starlight/domain/aireport/entity/AiReport.java b/src/main/java/starlight/domain/aireport/entity/AiReport.java index 234053c8..3af44f5e 100644 --- a/src/main/java/starlight/domain/aireport/entity/AiReport.java +++ b/src/main/java/starlight/domain/aireport/entity/AiReport.java @@ -11,6 +11,14 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + uniqueConstraints = { + @UniqueConstraint( + name = "uk_ai_report_business_plan", + columnNames = {"business_plan_id"} + ) + } +) public class AiReport extends AbstractEntity { @Column(name = "business_plan_id", nullable = false) diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java index 95b85996..56353ae7 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -12,7 +12,9 @@ public enum AiReportErrorType implements ErrorType { AI_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 AI 리포트가 존재하지 않습니다."), NOT_READY_FOR_AI_REPORT(HttpStatus.BAD_REQUEST, "사업계획서가 작성 완료되지 않아 AI 리포트를 생성할 수 없습니다."), UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), - AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."); + AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."), + AI_GRADING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 채점에 실패했습니다."), + AI_AGENT_DUPLICATED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 리포트 에이전트가 중복입니다."); ; private final HttpStatus status; diff --git a/src/main/java/starlight/shared/enumerate/SectionType.java b/src/main/java/starlight/shared/enumerate/SectionType.java index 6a4828dc..97c94542 100644 --- a/src/main/java/starlight/shared/enumerate/SectionType.java +++ b/src/main/java/starlight/shared/enumerate/SectionType.java @@ -7,11 +7,12 @@ @RequiredArgsConstructor public enum SectionType { - OVERVIEW("개요"), - PROBLEM_RECOGNITION("문제 인식"), - FEASIBILITY("실현 가능성"), - GROWTH_STRATEGY("성장 전략"), - TEAM_COMPETENCE("팀 역량"); + OVERVIEW("개요", null), + PROBLEM_RECOGNITION("문제 인식", "problem_recognition"), + FEASIBILITY("실현 가능성", "feasibility"), + GROWTH_STRATEGY("성장 전략", "growth_strategy"), + TEAM_COMPETENCE("팀 역량", "team_competence"); private final String description; + private final String tag; } \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java b/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java deleted file mode 100644 index c26b05d8..00000000 --- a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package starlight.adapter.ai; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import starlight.adapter.ai.infra.OpenAiGenerator; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.provided.dto.AiReportResponse; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@DisplayName("OpenAiReportGrader 테스트") -class OpenAiReportGraderTest { - - @Test - @DisplayName("컨텐츠를 채점하여 AiReportResponse를 반환한다") - void gradeContent_returnsAiReportResponse() { - // given - String content = "사업계획서 내용"; - String llmResponse = """ - { - "problemRecognitionScore": 20, - "feasibilityScore": 25, - "growthStrategyScore": 30, - "teamCompetenceScore": 20, - "sectionScores": [ - { - "sectionType": "PROBLEM_RECOGNITION", - "gradingListScores": "[{\\"item\\":\\"항목1\\",\\"score\\":5,\\"maxScore\\":5}]" - } - ], - "strengths": [ - {"title": "강점1", "content": "내용1"} - ], - "weaknesses": [ - {"title": "약점1", "content": "내용1"} - ] - } - """; - - OpenAiGenerator generator = mock(OpenAiGenerator.class); - when(generator.generateReport(content)).thenReturn(llmResponse); - - AiReportResponseParser parser = mock(AiReportResponseParser.class); - AiReportResponse expectedResponse = AiReportResponse.fromGradingResult( - 20, 25, 30, 20, - List.of(new AiReportResponse.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), - List.of(new AiReportResponse.StrengthWeakness("강점1", "내용1")), - List.of(new AiReportResponse.StrengthWeakness("약점1", "내용1")) - ); - when(parser.parse(llmResponse)).thenReturn(expectedResponse); - - OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); - - // when - AiReportResponse result = sut.gradeContent(content); - - // then - assertThat(result).isNotNull(); - assertThat(result.problemRecognitionScore()).isEqualTo(20); - assertThat(result.feasibilityScore()).isEqualTo(25); - assertThat(result.growthStrategyScore()).isEqualTo(30); - assertThat(result.teamCompetenceScore()).isEqualTo(20); - assertThat(result.totalScore()).isEqualTo(95); - assertThat(result.strengths()).hasSize(1); - assertThat(result.weaknesses()).hasSize(1); - assertThat(result.sectionScores()).hasSize(1); - - verify(generator).generateReport(content); - verify(parser).parse(llmResponse); - } - - @Test - @DisplayName("각 컴포넌트가 순서대로 호출된다") - void gradeContent_callsComponentsInOrder() { - // given - String content = "사업계획서 내용"; - String llmResponse = "{}"; - - OpenAiGenerator generator = mock(OpenAiGenerator.class); - when(generator.generateReport(any())).thenReturn(llmResponse); - - AiReportResponseParser parser = mock(AiReportResponseParser.class); - when(parser.parse(any())).thenReturn(AiReportResponse.fromGradingResult(0, 0, 0, 0, List.of(), List.of(), List.of())); - - OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); - - // when - sut.gradeContent(content); - - // then - var inOrder = inOrder(generator, parser); - inOrder.verify(generator).generateReport(content); - inOrder.verify(parser).parse(llmResponse); - } -} - diff --git a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java b/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java deleted file mode 100644 index 73858498..00000000 --- a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package starlight.adapter.ai.infra; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.prompt.Prompt; -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.mockito.Mockito.RETURNS_DEEP_STUBS; - -class OpenAiGeneratorTest { - - @Test - @DisplayName("올바른 JSON 배열을 파싱해 반환") - void generateChecklistArray_parsesJson() { - ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); - ChatClient.Builder builder = mock(ChatClient.Builder.class); - when(builder.build()).thenReturn(chatClient); - - // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 - // 마지막 content()만 반환값 설정 - when(chatClient.prompt(any(Prompt.class)) - .options(any()) - .advisors(any(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)) - .call() - .content()).thenReturn("[true,false,true,false,true]"); - - PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) - .thenReturn(mock(Prompt.class)); - - AdvisorProvider advisorProvider = mock(AdvisorProvider.class); - when(advisorProvider.getSimpleLoggerAdvisor()) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - - OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); - - List result = sut.generateChecklistArray( - SubSectionType.OVERVIEW_BASIC, - "test content", - List.of("c1", "c2", "c3", "c4", "c5"), - List.of("d1", "d2", "d3", "d4", "d5") - ); - assertThat(result).containsExactly(true, false, true, false, true); - } - - @Test - @DisplayName("파싱 실패 시 보수적으로 모두 false 반환") - void generateChecklistArray_parseFail_returnsAllFalse() { - ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); - ChatClient.Builder builder = mock(ChatClient.Builder.class); - when(builder.build()).thenReturn(chatClient); - - // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 - // 마지막 content()만 반환값 설정 - when(chatClient.prompt(any(Prompt.class)) - .options(any()) - .advisors(any(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)) - .call() - .content()).thenReturn("not-json"); - - PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) - .thenReturn(mock(Prompt.class)); - - AdvisorProvider advisorProvider = mock(AdvisorProvider.class); - when(advisorProvider.getSimpleLoggerAdvisor()) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - - OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); - - List result = sut.generateChecklistArray( - SubSectionType.OVERVIEW_BASIC, - "test content", - List.of("c1", "c2", "c3", "c4", "c5"), - List.of("d1", "d2", "d3", "d4", "d5") - ); - assertThat(result).containsExactly(false, false, false, false, false); - } - - @Test - @DisplayName("generateReport는 OpenAI 응답 문자열을 반환한다") - void generateReport_returnsString() { - ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); - ChatClient.Builder builder = mock(ChatClient.Builder.class); - when(builder.build()).thenReturn(chatClient); - - String expectedResponse = """ - { - "problemRecognitionScore": 20, - "feasibilityScore": 25, - "growthStrategyScore": 30, - "teamCompetenceScore": 20, - "sectionScores": [], - "strengths": [], - "weaknesses": [] - } - """.trim(); - - // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 - // 마지막 content()만 반환값 설정 - when(chatClient.prompt(any(Prompt.class)) - .options(any()) - .advisors(any(), any()) - .call() - .content()).thenReturn(expectedResponse); - - PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createReportGradingPrompt(anyString())) - .thenReturn(mock(Prompt.class)); - - AdvisorProvider advisorProvider = mock(AdvisorProvider.class); - when(advisorProvider.getQuestionAnswerAdvisor(anyDouble(), anyInt(), any())) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor.class)); - when(advisorProvider.getSimpleLoggerAdvisor()) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - - OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); - - String result = sut.generateReport("test content"); - - assertThat(result).isEqualTo(expectedResponse); - } -} diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java index ef1ce442..80891a04 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java @@ -14,7 +14,7 @@ import starlight.adapter.aireport.webapi.ImageController; import starlight.adapter.member.auth.security.auth.AuthDetails; import starlight.adapter.member.auth.security.filter.JwtFilter; -import starlight.application.aireport.required.PresignedUrlProvider; +import starlight.application.aireport.required.PresignedUrlProviderPort; import starlight.bootstrap.SecurityConfig; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -43,7 +43,7 @@ class ImageControllerIntegrationTest { private MockMvc mockMvc; @MockitoBean - private PresignedUrlProvider presignedUrlProvider; + private PresignedUrlProviderPort presignedUrlProvider; @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; diff --git a/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java b/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java new file mode 100644 index 00000000..79f770e2 --- /dev/null +++ b/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java @@ -0,0 +1,170 @@ +package starlight.adapter.aireport.report; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.adapter.aireport.report.agent.FullReportGradeAgent; +import starlight.adapter.aireport.report.agent.SectionGradeAgent; +import starlight.adapter.aireport.report.dto.SectionGradingResult; +import starlight.adapter.aireport.report.supervisor.SpringAiReportSupervisor; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.shared.enumerate.SectionType; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@DisplayName("SpringAiReportGrader 테스트") +class SpringAiReportGraderTest { + + @Test + @DisplayName("전체 프롬프트로 채점하여 AiReportResult를 반환한다") + void gradeWithFullPrompt_returnsAiReportResult() { + // given + String content = "사업계획서 내용"; + + FullReportGradeAgent fullReportGradeAgent = mock(FullReportGradeAgent.class); + AiReportResult expectedResponse = AiReportResult.fromGradingResult( + 20, 25, 30, 20, + List.of(new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), + List.of(new AiReportResult.StrengthWeakness("강점1", "내용1")), + List.of(new AiReportResult.StrengthWeakness("약점1", "내용1")) + ); + when(fullReportGradeAgent.gradeFullReport(content)).thenReturn(expectedResponse); + + SpringAiReportGrader sut = new SpringAiReportGrader( + List.of(), + fullReportGradeAgent, + mock(SpringAiReportSupervisor.class), + mock(BusinessPlanContentExtractor.class), + mock(Executor.class) + ); + + // when + AiReportResult result = sut.gradeWithFullPrompt(content); + + // then + assertThat(result).isNotNull(); + assertThat(result.problemRecognitionScore()).isEqualTo(20); + assertThat(result.feasibilityScore()).isEqualTo(25); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.totalScore()).isEqualTo(95); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + assertThat(result.sectionScores()).hasSize(1); + + verify(fullReportGradeAgent).gradeFullReport(content); + } + + @Test + @DisplayName("섹션별 에이전트로 채점하여 AiReportResult를 반환한다") + void gradeWithSectionAgents_returnsAiReportResult() { + // given + Map sectionContents = new HashMap<>(); + sectionContents.put(SectionType.PROBLEM_RECOGNITION, "문제인식 내용"); + sectionContents.put(SectionType.FEASIBILITY, "실현가능성 내용"); + sectionContents.put(SectionType.GROWTH_STRATEGY, "성장전략 내용"); + sectionContents.put(SectionType.TEAM_COMPETENCE, "팀역량 내용"); + String fullContent = "전체 사업계획서 내용"; + + // 각 섹션에 맞는 Agent 모킹 + SectionGradeAgent problemRecognitionAgent = mock(SectionGradeAgent.class); + when(problemRecognitionAgent.getSectionType()).thenReturn(SectionType.PROBLEM_RECOGNITION); + when(problemRecognitionAgent.gradeSection(anyString())).thenReturn( + SectionGradingResult.success( + SectionType.PROBLEM_RECOGNITION, + 20, + new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"근본 원인 논리 분석\",\"score\":5,\"maxScore\":5}]") + ) + ); + + SectionGradeAgent feasibilityAgent = mock(SectionGradeAgent.class); + when(feasibilityAgent.getSectionType()).thenReturn(SectionType.FEASIBILITY); + when(feasibilityAgent.gradeSection(anyString())).thenReturn( + SectionGradingResult.success( + SectionType.FEASIBILITY, + 25, + new AiReportResult.SectionScoreDetailResponse("FEASIBILITY", "[{\"item\":\"로드맵 구체성\",\"score\":6,\"maxScore\":6}]") + ) + ); + + SectionGradeAgent growthStrategyAgent = mock(SectionGradeAgent.class); + when(growthStrategyAgent.getSectionType()).thenReturn(SectionType.GROWTH_STRATEGY); + when(growthStrategyAgent.gradeSection(anyString())).thenReturn( + SectionGradingResult.success( + SectionType.GROWTH_STRATEGY, + 30, + new AiReportResult.SectionScoreDetailResponse("GROWTH_STRATEGY", "[{\"item\":\"BM 9요소 완결·연계성\",\"score\":6,\"maxScore\":6}]") + ) + ); + + SectionGradeAgent teamCompetenceAgent = mock(SectionGradeAgent.class); + when(teamCompetenceAgent.getSectionType()).thenReturn(SectionType.TEAM_COMPETENCE); + when(teamCompetenceAgent.gradeSection(anyString())).thenReturn( + SectionGradingResult.success( + SectionType.TEAM_COMPETENCE, + 20, + new AiReportResult.SectionScoreDetailResponse("TEAM_COMPETENCE", "[{\"item\":\"창업자 전문성·연관성\",\"score\":5,\"maxScore\":5}]") + ) + ); + + List sectionAgents = List.of( + problemRecognitionAgent, + feasibilityAgent, + growthStrategyAgent, + teamCompetenceAgent + ); + + FullReportGradeAgent fullReportGradeAgent = mock(FullReportGradeAgent.class); + SpringAiReportSupervisor supervisor = mock(SpringAiReportSupervisor.class); + BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); + // 실제 Executor 사용 (비동기 실행을 위해) + Executor executor = Executors.newFixedThreadPool(4); + + SpringAiReportGrader sut = new SpringAiReportGrader( + sectionAgents, + fullReportGradeAgent, + supervisor, + contentExtractor, + executor + ); + + when(supervisor.generateStrengths(anyString(), anyList())).thenReturn( + List.of(new AiReportResult.StrengthWeakness("강점1", "내용1")) + ); + when(supervisor.generateWeaknesses(anyString(), anyList())).thenReturn( + List.of(new AiReportResult.StrengthWeakness("약점1", "내용1")) + ); + + // when + AiReportResult result = sut.gradeWithSectionAgents(sectionContents, fullContent); + + // then + assertThat(result).isNotNull(); + assertThat(result.problemRecognitionScore()).isEqualTo(20); + assertThat(result.feasibilityScore()).isEqualTo(25); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.totalScore()).isEqualTo(95); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + + // 각 Agent가 호출되었는지 확인 + verify(problemRecognitionAgent).gradeSection("문제인식 내용"); + verify(feasibilityAgent).gradeSection("실현가능성 내용"); + verify(growthStrategyAgent).gradeSection("성장전략 내용"); + verify(teamCompetenceAgent).gradeSection("팀역량 내용"); + verify(supervisor).generateStrengths(eq(fullContent), anyList()); + verify(supervisor).generateWeaknesses(eq(fullContent), anyList()); + } +} + diff --git a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java b/src/test/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGraderTest.java similarity index 74% rename from src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java rename to src/test/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGraderTest.java index 460483f4..3ca07ca6 100644 --- a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java +++ b/src/test/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGraderTest.java @@ -1,9 +1,9 @@ -package starlight.adapter.ai; +package starlight.adapter.businessplan.checklist; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.adapter.ai.infra.OpenAiGenerator; -import starlight.adapter.ai.util.ChecklistCatalog; +import starlight.adapter.businessplan.checklist.agent.SpringAiChecklistAgent; +import starlight.adapter.businessplan.checklist.provider.ChecklistPromptProvider; import starlight.domain.businessplan.enumerate.SubSectionType; import java.util.List; @@ -12,22 +12,23 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -class AiChecklistGraderTest { +@DisplayName("SpringAiChecklistGrader 테스트") +class SpringAiChecklistGraderTest { @Test @DisplayName("criteria별 컨텍스트를 합치고 LLM 결과를 반환") void check_returnsFromLlm() { - OpenAiGenerator generator = mock(OpenAiGenerator.class); + SpringAiChecklistAgent generator = mock(SpringAiChecklistAgent.class); when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(List.of(true, false, true, false, true)); - ChecklistCatalog catalog = mock(ChecklistCatalog.class); + ChecklistPromptProvider catalog = mock(ChecklistPromptProvider.class); when(catalog.getCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("c1", "c2", "c3", "c4", "c5")); when(catalog.getDetailedCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("d1", "d2", "d3", "d4", "d5")); - OpenAiChecklistGrader sut = new OpenAiChecklistGrader(generator, catalog); + SpringAiChecklistGrader sut = new SpringAiChecklistGrader(generator, catalog); List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text"); assertThat(result).containsExactly(true, false, true, false, true); @@ -44,17 +45,17 @@ void check_returnsFromLlm() { @Test @DisplayName("LLM 결과 길이가 5보다 짧으면 false로 패딩") void check_normalizesToFive() { - OpenAiGenerator generator = mock(OpenAiGenerator.class); + SpringAiChecklistAgent generator = mock(SpringAiChecklistAgent.class); when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(List.of(true)); - ChecklistCatalog catalog = mock(ChecklistCatalog.class); + ChecklistPromptProvider catalog = mock(ChecklistPromptProvider.class); when(catalog.getCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("c1", "c2", "c3", "c4", "c5")); when(catalog.getDetailedCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("d1", "d2", "d3", "d4", "d5")); - OpenAiChecklistGrader sut = new OpenAiChecklistGrader(generator, catalog); + SpringAiChecklistGrader sut = new SpringAiChecklistGrader(generator, catalog); List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text"); assertThat(result).containsExactly(true, false, false, false, false); } diff --git a/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java b/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java index 10abb979..813147fa 100644 --- a/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java +++ b/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java @@ -1,24 +1,19 @@ package starlight.adapter.businessplan.webapi; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import starlight.adapter.businessplan.spellcheck.dto.Finding; -import starlight.application.businessplan.required.SpellChecker; +import starlight.application.businessplan.required.SpellCheckerPort; import java.util.List; -import java.util.Map; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc(addFilters = false) @@ -32,8 +27,8 @@ class SpellControllerTest { @TestConfiguration static class TestBeans { @Bean - SpellChecker spellChecker() { - return new SpellChecker() { + SpellCheckerPort spellChecker() { + return new SpellCheckerPort() { @Override public List check(String sentence) { if (sentence.contains("teh")) { diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java similarity index 62% rename from src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java rename to src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index 98f8b997..6dd19176 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -10,16 +10,19 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import starlight.adapter.ai.util.AiReportResponseParser; +import starlight.application.aireport.util.AiReportResponseParser; import starlight.adapter.aireport.persistence.AiReportJpa; import starlight.adapter.aireport.persistence.AiReportRepository; import starlight.adapter.businessplan.persistence.BusinessPlanJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.required.AiReportGrader; -import starlight.application.aireport.required.OcrProvider; -import starlight.application.businessplan.provided.BusinessPlanService; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.AiReportCommandPort; +import starlight.application.aireport.required.AiReportQueryPort; +import starlight.application.aireport.required.BusinessPlanCreationPort; +import starlight.application.aireport.required.OcrProviderPort; +import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; @@ -34,12 +37,12 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({AiReportServiceImpl.class, AiReportJpa.class, BusinessPlanJpa.class, AiReportServiceImplIntegrationTest.TestBeans.class}) -@DisplayName("AiReportServiceImpl 통합 테스트") -class AiReportServiceImplIntegrationTest { +@Import({AiReportService.class, AiReportJpa.class, BusinessPlanJpa.class, AiReportServiceIntegrationTest.TestBeans.class}) +@DisplayName("AiReportService 통합 테스트") +class AiReportServiceIntegrationTest { @Autowired - AiReportServiceImpl sut; + AiReportService sut; @Autowired BusinessPlanRepository businessPlanRepository; @Autowired @@ -51,15 +54,53 @@ class AiReportServiceImplIntegrationTest { static class TestBeans { @Bean - AiReportGrader aiReportGrader() { - return content -> { - // 간단한 mock 응답 반환 - return AiReportResponse.fromGradingResult( - 20, 25, 30, 20, - List.of(new AiReportResponse.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), - List.of(new AiReportResponse.StrengthWeakness("강점1", "내용1")), - List.of(new AiReportResponse.StrengthWeakness("약점1", "내용1")) - ); + ReportGraderPort aiReportGrader() { + return new ReportGraderPort() { + @Override + public AiReportResult gradeWithSectionAgents(java.util.Map sectionContents, String fullContent) { + return AiReportResult.fromGradingResult( + 20, 25, 30, 20, + List.of( + new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"근본 원인 논리 분석\",\"score\":5,\"maxScore\":5}]"), + new AiReportResult.SectionScoreDetailResponse("FEASIBILITY", "[{\"item\":\"로드맵 구체성\",\"score\":6,\"maxScore\":6}]"), + new AiReportResult.SectionScoreDetailResponse("GROWTH_STRATEGY", "[{\"item\":\"BM 9요소 완결·연계성\",\"score\":6,\"maxScore\":6}]"), + new AiReportResult.SectionScoreDetailResponse("TEAM_COMPETENCE", "[{\"item\":\"창업자 전문성·연관성\",\"score\":5,\"maxScore\":5}]") + ), + List.of( + new AiReportResult.StrengthWeakness("강점1", "내용1"), + new AiReportResult.StrengthWeakness("강점2", "내용2"), + new AiReportResult.StrengthWeakness("강점3", "내용3") + ), + List.of( + new AiReportResult.StrengthWeakness("약점1", "내용1"), + new AiReportResult.StrengthWeakness("약점2", "내용2"), + new AiReportResult.StrengthWeakness("약점3", "내용3") + ) + ); + } + + @Override + public AiReportResult gradeWithFullPrompt(String content) { + return AiReportResult.fromGradingResult( + 20, 25, 30, 20, + List.of( + new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"근본 원인 논리 분석\",\"score\":5,\"maxScore\":5}]"), + new AiReportResult.SectionScoreDetailResponse("FEASIBILITY", "[{\"item\":\"로드맵 구체성\",\"score\":6,\"maxScore\":6}]"), + new AiReportResult.SectionScoreDetailResponse("GROWTH_STRATEGY", "[{\"item\":\"BM 9요소 완결·연계성\",\"score\":6,\"maxScore\":6}]"), + new AiReportResult.SectionScoreDetailResponse("TEAM_COMPETENCE", "[{\"item\":\"창업자 전문성·연관성\",\"score\":5,\"maxScore\":5}]") + ), + List.of( + new AiReportResult.StrengthWeakness("강점1", "내용1"), + new AiReportResult.StrengthWeakness("강점2", "내용2"), + new AiReportResult.StrengthWeakness("강점3", "내용3") + ), + List.of( + new AiReportResult.StrengthWeakness("약점1", "내용1"), + new AiReportResult.StrengthWeakness("약점2", "내용2"), + new AiReportResult.StrengthWeakness("약점3", "내용3") + ) + ); + } }; } @@ -74,76 +115,77 @@ AiReportResponseParser responseParser() { } @Bean - BusinessPlanService businessPlanService(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanService() { - @Override - public starlight.application.businessplan.provided.dto.BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, org.springframework.data.domain.Pageable pageable) { - throw new UnsupportedOperationException("Not implemented in test"); - } - @Override - public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { - BusinessPlan plan = BusinessPlan.create("default title", memberId); - BusinessPlan saved = businessPlanRepository.save(plan); - return BusinessPlanResponse.Result.from(saved, "Business plan created"); - } - - @Override - public BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { - BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); - BusinessPlan saved = businessPlanRepository.save(plan); - return BusinessPlanResponse.Result.from(saved, "PDF Business plan created"); - } - + BusinessPlanCommandPort businessPlanCommandPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCommandPort() { @Override - public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public BusinessPlan save(BusinessPlan businessPlan) { + return businessPlanRepository.save(businessPlan); } @Override - public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public void delete(BusinessPlan businessPlan) { + businessPlanRepository.delete(businessPlan); } + }; + } + @Bean + BusinessPlanQueryPort businessPlanQueryPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanQueryPort() { @Override - public String updateBusinessPlanTitle(Long planId, String title, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public BusinessPlan findByIdOrThrow(Long id) { + return businessPlanRepository.findById(id) + .orElseThrow(() -> new RuntimeException("BusinessPlan not found: " + id)); } @Override - public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public BusinessPlan findWithAllSubSectionsOrThrow(Long id) { + return businessPlanRepository.findByIdWithAllSubSections(id) + .orElseThrow(() -> new RuntimeException("BusinessPlan not found: " + id)); } @Override - public starlight.application.businessplan.provided.dto.SubSectionResponse.Result upsertSubSection( - Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, List checks, - starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public org.springframework.data.domain.Page findPreviewPage(Long memberId, org.springframework.data.domain.Pageable pageable) { + return businessPlanRepository.findAllByMemberIdOrderedByLastSavedAt(memberId, pageable); } + }; + } + @Bean + BusinessPlanCreationPort businessPlanCreationPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCreationPort() { @Override - public starlight.application.businessplan.provided.dto.SubSectionResponse.Detail getSubSectionDetail( - Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); + BusinessPlan saved = businessPlanRepository.save(plan); + return saved.getId(); } + }; + } + @Bean + AiReportCommandPort aiReportCommandPort(AiReportRepository aiReportRepository) { + return new AiReportCommandPort() { @Override - public List checkAndUpdateSubSection(Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, - starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public starlight.domain.aireport.entity.AiReport save(starlight.domain.aireport.entity.AiReport aiReport) { + return aiReportRepository.save(aiReport); } + }; + } + @Bean + AiReportQueryPort aiReportQueryPort(AiReportRepository aiReportRepository) { + return new AiReportQueryPort() { @Override - public starlight.application.businessplan.provided.dto.SubSectionResponse.Result deleteSubSection( - Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public Optional findByBusinessPlanId(Long businessPlanId) { + return aiReportRepository.findByBusinessPlanId(businessPlanId); } }; } @Bean - OcrProvider ocrProvider() { - return new OcrProvider() { + OcrProviderPort ocrProvider() { + return new OcrProviderPort() { @Override public starlight.shared.dto.infrastructure.OcrResponse ocrPdfByUrl(String pdfUrl) { throw new UnsupportedOperationException("Not implemented in test"); @@ -160,6 +202,7 @@ public String ocrPdfTextByUrl(String pdfUrl) { BusinessPlanContentExtractor businessPlanContentExtractor() { return new BusinessPlanContentExtractor(); } + } /** @@ -213,7 +256,7 @@ void gradeBusinessPlan_createsNewReport() { Long planId = plan.getId(); // when - AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + AiReportResult result = sut.gradeBusinessPlan(planId, memberId); // then assertThat(result).isNotNull(); @@ -224,9 +267,9 @@ void gradeBusinessPlan_createsNewReport() { assertThat(result.feasibilityScore()).isEqualTo(25); assertThat(result.growthStrategyScore()).isEqualTo(30); assertThat(result.teamCompetenceScore()).isEqualTo(20); - assertThat(result.strengths()).hasSize(1); - assertThat(result.weaknesses()).hasSize(1); - assertThat(result.sectionScores()).hasSize(1); + assertThat(result.strengths()).hasSize(3); + assertThat(result.weaknesses()).hasSize(3); + assertThat(result.sectionScores()).hasSize(4); // DB에 저장되었는지 확인 Optional savedReport = aiReportRepository.findByBusinessPlanId(planId); @@ -252,12 +295,12 @@ void gradeBusinessPlan_updatesExistingReport() { Long planId = plan.getId(); // 첫 번째 채점 - AiReportResponse firstResult = sut.gradeBusinessPlan(planId, memberId); + AiReportResult firstResult = sut.gradeBusinessPlan(planId, memberId); em.flush(); em.clear(); // 두 번째 채점 (업데이트) - AiReportResponse secondResult = sut.gradeBusinessPlan(planId, memberId); + AiReportResult secondResult = sut.gradeBusinessPlan(planId, memberId); // then assertThat(secondResult).isNotNull(); @@ -288,16 +331,16 @@ void getAiReport_returnsResponse() { em.clear(); // when - AiReportResponse result = sut.getAiReport(planId, memberId); + AiReportResult result = sut.getAiReport(planId, memberId); // then assertThat(result).isNotNull(); assertThat(result.id()).isNotNull(); assertThat(result.businessPlanId()).isEqualTo(planId); assertThat(result.totalScore()).isEqualTo(95); - assertThat(result.strengths()).hasSize(1); - assertThat(result.weaknesses()).hasSize(1); - assertThat(result.sectionScores()).hasSize(1); + assertThat(result.strengths()).hasSize(3); + assertThat(result.weaknesses()).hasSize(3); + assertThat(result.sectionScores()).hasSize(4); } @Test @@ -314,12 +357,12 @@ void convertToJsonNode_and_toResponse_workCorrectly() { Long planId = plan.getId(); // 채점하여 리포트 생성 - AiReportResponse gradingResult = sut.gradeBusinessPlan(planId, memberId); + AiReportResult gradingResult = sut.gradeBusinessPlan(planId, memberId); em.flush(); em.clear(); // when - 조회 - AiReportResponse retrievedResult = sut.getAiReport(planId, memberId); + AiReportResult retrievedResult = sut.getAiReport(planId, memberId); // then - 저장된 데이터와 조회된 데이터가 일치하는지 확인 assertThat(retrievedResult.problemRecognitionScore()).isEqualTo(gradingResult.problemRecognitionScore()); @@ -341,7 +384,7 @@ void createAndGradePdfBusinessPlan_createsBusinessPlanAndReport() { String pdfUrl = "https://example.com/test.pdf"; // when - AiReportResponse result = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); + AiReportResult result = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); // then assertThat(result).isNotNull(); @@ -352,9 +395,9 @@ void createAndGradePdfBusinessPlan_createsBusinessPlanAndReport() { assertThat(result.feasibilityScore()).isEqualTo(25); assertThat(result.growthStrategyScore()).isEqualTo(30); assertThat(result.teamCompetenceScore()).isEqualTo(20); - assertThat(result.strengths()).hasSize(1); - assertThat(result.weaknesses()).hasSize(1); - assertThat(result.sectionScores()).hasSize(1); + assertThat(result.strengths()).hasSize(3); + assertThat(result.weaknesses()).hasSize(3); + assertThat(result.sectionScores()).hasSize(4); // BusinessPlan이 생성되었는지 확인 BusinessPlan createdPlan = businessPlanRepository.findById(result.businessPlanId()).orElseThrow(); @@ -378,13 +421,13 @@ void createAndGradePdfBusinessPlan_canRetrieveReport() { String pdfUrl = "https://example.com/test.pdf"; // when - PDF로 사업계획서 생성 및 채점 - AiReportResponse createdResult = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); + AiReportResult createdResult = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); Long planId = createdResult.businessPlanId(); em.flush(); em.clear(); // when - 리포트 조회 - AiReportResponse retrievedResult = sut.getAiReport(planId, memberId); + AiReportResult retrievedResult = sut.getAiReport(planId, memberId); // then assertThat(retrievedResult).isNotNull(); diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java similarity index 64% rename from src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java rename to src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index 64a4cd93..724be39f 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -3,22 +3,27 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.required.AiReportGrader; -import starlight.application.aireport.required.AiReportQuery; -import starlight.application.aireport.required.OcrProvider; -import starlight.application.businessplan.provided.BusinessPlanService; -import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.BusinessPlanCreationPort; +import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.aireport.required.AiReportQueryPort; +import starlight.application.aireport.required.AiReportCommandPort; +import starlight.application.aireport.required.OcrProviderPort; +import starlight.application.businessplan.required.BusinessPlanQueryPort; +import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.shared.enumerate.SectionType; import starlight.shared.valueobject.RawJson; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -26,19 +31,21 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@DisplayName("AiReportServiceImpl 유닛 테스트") -class AiReportServiceImplUnitTest { +@DisplayName("AiReportService 유닛 테스트") +class AiReportServiceUnitTest { - private final BusinessPlanQuery businessPlanQuery = mock(BusinessPlanQuery.class); - private final BusinessPlanService businessPlanService = mock(BusinessPlanService.class); - private final AiReportQuery aiReportQuery = mock(AiReportQuery.class); - private final AiReportGrader aiReportGrader = mock(AiReportGrader.class); + private final BusinessPlanCommandPort businessPlanCommand = mock(BusinessPlanCommandPort.class); + private final BusinessPlanQueryPort businessPlanQuery = mock(BusinessPlanQueryPort.class); + private final BusinessPlanCreationPort businessPlanCreationPort = mock(BusinessPlanCreationPort.class); + private final AiReportQueryPort aiReportQuery = mock(AiReportQueryPort.class); + private final AiReportCommandPort aiReportCommand = mock(AiReportCommandPort.class); + private final ReportGraderPort aiReportGrader = mock(ReportGraderPort.class); private final ObjectMapper objectMapper = new ObjectMapper(); - private final OcrProvider ocrProvider = mock(OcrProvider.class); + private final OcrProviderPort ocrProvider = mock(OcrProviderPort.class); private final AiReportResponseParser responseParser = new AiReportResponseParser(objectMapper); private final BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); - private AiReportServiceImpl sut; + private AiReportService sut; @Test @DisplayName("채점 성공 시 새로운 AiReport를 생성하고 저장한다") @@ -55,14 +62,21 @@ void gradeBusinessPlan_createsNewReport() { String extractedContent = "사업계획서 내용"; when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); - - AiReportResponse gradingResult = AiReportResponse.fromGradingResult( + + Map sectionContents = new HashMap<>(); + sectionContents.put(SectionType.PROBLEM_RECOGNITION, "문제인식 내용"); + sectionContents.put(SectionType.FEASIBILITY, "실현가능성 내용"); + sectionContents.put(SectionType.GROWTH_STRATEGY, "성장전략 내용"); + sectionContents.put(SectionType.TEAM_COMPETENCE, "팀역량 내용"); + when(contentExtractor.extractSectionContents(plan)).thenReturn(sectionContents); + + AiReportResult gradingResult = AiReportResult.fromGradingResult( 20, 25, 30, 20, List.of(), List.of(), List.of() ); - when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); + when(aiReportGrader.gradeWithSectionAgents(sectionContents, extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -79,17 +93,18 @@ void gradeBusinessPlan_createsNewReport() { when(savedReport.getId()).thenReturn(1L); when(savedReport.getBusinessPlanId()).thenReturn(planId); when(savedReport.getRawJson()).thenReturn(RawJson.create(rawJson)); - when(aiReportQuery.save(any(AiReport.class))).thenReturn(savedReport); + when(aiReportCommand.save(any(AiReport.class))).thenReturn(savedReport); + when(businessPlanCommand.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when - AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + AiReportResult result = sut.gradeBusinessPlan(planId, memberId); // then assertThat(result).isNotNull(); verify(plan).updateStatus(PlanStatus.AI_REVIEWED); - verify(aiReportQuery).save(any(AiReport.class)); + verify(aiReportCommand).save(any(AiReport.class)); } @Test @@ -109,14 +124,21 @@ void gradeBusinessPlan_updatesExistingReport() { String extractedContent = "사업계획서 내용"; when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); - - AiReportResponse gradingResult = AiReportResponse.fromGradingResult( + + Map sectionContents = new HashMap<>(); + sectionContents.put(SectionType.PROBLEM_RECOGNITION, "문제인식 내용"); + sectionContents.put(SectionType.FEASIBILITY, "실현가능성 내용"); + sectionContents.put(SectionType.GROWTH_STRATEGY, "성장전략 내용"); + sectionContents.put(SectionType.TEAM_COMPETENCE, "팀역량 내용"); + when(contentExtractor.extractSectionContents(plan)).thenReturn(sectionContents); + + AiReportResult gradingResult = AiReportResult.fromGradingResult( 20, 25, 30, 20, List.of(), List.of(), List.of() ); - when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); + when(aiReportGrader.gradeWithSectionAgents(sectionContents, extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -132,12 +154,13 @@ void gradeBusinessPlan_updatesExistingReport() { when(existingReport.getId()).thenReturn(1L); when(existingReport.getBusinessPlanId()).thenReturn(planId); when(existingReport.getRawJson()).thenReturn(RawJson.create(rawJson)); - when(aiReportQuery.save(existingReport)).thenReturn(existingReport); + when(aiReportCommand.save(existingReport)).thenReturn(existingReport); + when(businessPlanCommand.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when - AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + AiReportResult result = sut.gradeBusinessPlan(planId, memberId); // then assertThat(result).isNotNull(); @@ -156,7 +179,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { when(plan.isOwnedBy(memberId)).thenReturn(false); when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -176,7 +199,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { when(plan.areWritingCompleted()).thenReturn(false); when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -214,10 +237,10 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when - AiReportResponse result = sut.getAiReport(planId, memberId); + AiReportResult result = sut.getAiReport(planId, memberId); // then assertThat(result).isNotNull(); @@ -238,7 +261,7 @@ void getAiReport_throwsExceptionWhenNotFound() { when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java similarity index 94% rename from src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java rename to src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java index 1f5426d4..80fff9ea 100644 --- a/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java +++ b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java @@ -1,9 +1,10 @@ -package starlight.adapter.ai.util; +package starlight.application.aireport.util; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.aireport.exception.AiReportErrorType; @@ -42,7 +43,7 @@ void parse_validJson_returnsResponse() { """; // when - AiReportResponse result = parser.parse(validJson); + AiReportResult result = parser.parse(validJson); // then assertThat(result).isNotNull(); @@ -127,7 +128,7 @@ void parse_textFieldResponse_parsesCorrectly() { """; // when - AiReportResponse result = parser.parse(textFieldJson); + AiReportResult result = parser.parse(textFieldJson); // then assertThat(result).isNotNull(); diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java similarity index 95% rename from src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java rename to src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java index 7c076efe..802cd2ef 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java @@ -11,7 +11,7 @@ import org.springframework.context.annotation.Import; import starlight.adapter.businessplan.persistence.BusinessPlanJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; -import starlight.application.businessplan.required.ChecklistGrader; +import starlight.application.businessplan.required.ChecklistGraderPort; import starlight.application.member.required.MemberQueryPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; @@ -26,12 +26,12 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({ BusinessPlanServiceImpl.class, BusinessPlanJpa.class, - BusinessPlanServiceImplIntegrationTest.TestBeans.class }) -class BusinessPlanServiceImplIntegrationTest { +@Import({ BusinessPlanService.class, BusinessPlanJpa.class, + BusinessPlanServiceIntegrationTest.TestBeans.class }) +class BusinessPlanServiceIntegrationTest { @Autowired - BusinessPlanServiceImpl sut; + BusinessPlanService sut; @Autowired BusinessPlanRepository businessPlanRepository; @Autowired @@ -40,7 +40,7 @@ class BusinessPlanServiceImplIntegrationTest { @TestConfiguration static class TestBeans { @Bean - ChecklistGrader checklistGrader() { + ChecklistGraderPort checklistGrader() { return (subSectionType, content) -> List.of(false, false, false, false, false); } diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java similarity index 88% rename from src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java rename to src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java index fcc2707a..b7a09fc6 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java @@ -9,10 +9,11 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; -import starlight.application.businessplan.provided.dto.SubSectionResponse; -import starlight.application.businessplan.required.BusinessPlanQuery; -import starlight.application.businessplan.required.ChecklistGrader; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.businessplan.required.BusinessPlanQueryPort; +import starlight.application.businessplan.required.ChecklistGraderPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.Overview; import starlight.domain.businessplan.entity.SubSection; @@ -34,13 +35,16 @@ @ExtendWith(MockitoExtension.class) @org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) -class BusinessPlanServiceImplUnitTest { +class BusinessPlanServiceUnitTest { @Mock - private BusinessPlanQuery businessPlanQuery; + private BusinessPlanCommandPort businessPlanCommand; @Mock - private ChecklistGrader checklistGrader; + private BusinessPlanQueryPort businessPlanQuery; + + @Mock + private ChecklistGraderPort checklistGrader; @Mock private ObjectMapper objectMapper; @@ -49,7 +53,7 @@ class BusinessPlanServiceImplUnitTest { private MemberQueryPort memberQuery; @InjectMocks - private BusinessPlanServiceImpl sut; + private BusinessPlanService sut; private BusinessPlan buildPlanWithSections(Long memberId) { return BusinessPlan.create("default title", memberId); @@ -72,32 +76,32 @@ void setup() { @Test @DisplayName("사업계획서 생성 시 루트가 저장된다") void createBusinessPlan_savesRoot() { - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - BusinessPlanResponse.Result created = sut.createBusinessPlan(1L); + BusinessPlanResult.Result created = sut.createBusinessPlan(1L); assertThat(created).isNotNull(); assertThat(created.message()).isEqualTo("Business plan created"); - verify(businessPlanQuery).save(any(BusinessPlan.class)); + verify(businessPlanCommand).save(any(BusinessPlan.class)); } @Test @DisplayName("PDF URL을 기반으로 사업계획서를 생성하면 저장된다") void createBusinessPlanWithPdf_savesRoot() { - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); String title = "테스트 사업계획서"; String pdfUrl = "https://example.com/test.pdf"; Long memberId = 1L; - BusinessPlanResponse.Result created = sut.createBusinessPlanWithPdf(title, pdfUrl, memberId); + BusinessPlanResult.Result created = sut.createBusinessPlanWithPdf(title, pdfUrl, memberId); assertThat(created).isNotNull(); assertThat(created.message()).isEqualTo("PDF Business plan created"); assertThat(created.title()).isEqualTo(title); - verify(businessPlanQuery).save(any(BusinessPlan.class)); + verify(businessPlanCommand).save(any(BusinessPlan.class)); } @Test @@ -106,13 +110,13 @@ void updateTitle_checksOwnership_thenSaves() { BusinessPlan plan = spy(buildPlanWithSections(10L)); doReturn(true).when(plan).isOwnedBy(10L); when(businessPlanQuery.findByIdOrThrow(100L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); String updatedTitle = sut.updateBusinessPlanTitle(100L, "new-title", 10L); assertThat(updatedTitle).isEqualTo("new-title"); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -134,13 +138,13 @@ void deleteBusinessPlan_cascadeDeletesSubSections() { when(plan.getId()).thenReturn(100L); when(businessPlanQuery.findByIdOrThrow(100L)).thenReturn(plan); - BusinessPlanResponse.Result deleted = sut.deleteBusinessPlan(100L, 10L); + BusinessPlanResult.Result deleted = sut.deleteBusinessPlan(100L, 10L); assertThat(deleted).isNotNull(); assertThat(deleted.businessPlanId()).isEqualTo(100L); assertThat(deleted.message()).isEqualTo("Business plan deleted"); - verify(businessPlanQuery).delete(plan); + verify(businessPlanCommand).delete(plan); } @Test @@ -151,7 +155,7 @@ void upsertSubSection_creates_whenNotExists() { Overview overview = plan.getOverview(); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -165,7 +169,7 @@ void upsertSubSection_creates_whenNotExists() { // when List checks = List.of(false, false, false, false, false); - SubSectionResponse.Result res = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionResult.Result res = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L); // then @@ -175,7 +179,7 @@ void upsertSubSection_creates_whenNotExists() { assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNotNull(); assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC).getSubSectionType()) .isEqualTo(SubSectionType.OVERVIEW_BASIC); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -190,7 +194,7 @@ void upsertSubSection_updates_whenExists() { overview.putSubSection(existing); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -203,11 +207,11 @@ void upsertSubSection_updates_whenExists() { } List checks = List.of(false, false, false, false, false); - SubSectionResponse.Result res = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionResult.Result res = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L); assertThat(res.message()).isEqualTo("Subsection updated"); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -238,7 +242,7 @@ void getSubSectionDetail_returnsContent() { when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - SubSectionResponse.Detail detail = sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L); + SubSectionResult.Detail detail = sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L); assertThat(detail).isNotNull(); assertThat(detail.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); @@ -277,17 +281,17 @@ void deleteSubSection_success() { overview.putSubSection(sub); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - SubSectionResponse.Result res = sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); + SubSectionResult.Result res = sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); assertThat(res).isNotNull(); assertThat(res.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); assertThat(res.subSectionId()).isNull(); assertThat(res.message()).isEqualTo("Subsection deleted"); assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNull(); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -311,7 +315,7 @@ void getBusinessPlanList_returnsPreviewPage() { .thenReturn(new PageImpl<>(List.of(plan), pageable, 7)); // when - BusinessPlanResponse.PreviewPage res = sut.getBusinessPlanList(1L, pageable); + BusinessPlanResult.PreviewPage res = sut.getBusinessPlanList(1L, pageable); // then assertThat(res.totalElements()).isEqualTo(7); @@ -337,14 +341,14 @@ void getBusinessPlanSubSections_returnsExistingSubSectionList() { List.of(false, false, false, false, false)); plan.getProblemRecognition().putSubSection(problem); - when(businessPlanQuery.getOrThrowWithAllSubSections(1L)).thenReturn(plan); + when(businessPlanQuery.findWithAllSubSectionsOrThrow(1L)).thenReturn(plan); - BusinessPlanResponse.Detail detail = sut.getBusinessPlanDetail(1L, 10L); + BusinessPlanResult.Detail detail = sut.getBusinessPlanDetail(1L, 10L); assertThat(detail.title()).isEqualTo(plan.getTitle()); assertThat(detail.subSectionDetailList()).hasSize(2); assertThat(detail.subSectionDetailList()) - .extracting(SubSectionResponse.Detail::subSectionType) + .extracting(SubSectionResult.Detail::subSectionType) .containsExactly(SubSectionType.OVERVIEW_BASIC, SubSectionType.PROBLEM_BACKGROUND); assertThat(detail.subSectionDetailList().get(0).content().path("text").asText()).isEqualTo("overview"); assertThat(detail.subSectionDetailList().get(1).content().path("text").asText()).isEqualTo("problem"); @@ -355,7 +359,7 @@ void getBusinessPlanSubSections_returnsExistingSubSectionList() { void getBusinessPlanDetail_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); - when(businessPlanQuery.getOrThrowWithAllSubSections(1L)).thenReturn(plan); + when(businessPlanQuery.findWithAllSubSectionsOrThrow(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, () -> sut.getBusinessPlanDetail(1L, 10L)); @@ -372,7 +376,7 @@ void checkAndUpdateSubSection_savesChecks() { overview.putSubSection(sub); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); List updatedChecks = List.of(true, true, true, true, true); @@ -404,7 +408,7 @@ void checkAndUpdateSubSection_savesChecks() { assertThat(result).containsExactlyElementsOf(updatedChecks); assertThat(sub.getChecks()).containsExactlyElementsOf(updatedChecks); assertThat(sub.getContent()).isEqualTo("updated content"); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -435,7 +439,7 @@ void checkAndUpdateSubSection_unauthorized_throws() { void createSubSection_forEachSectionType() { BusinessPlan plan = buildPlanWithSections(10L); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -448,13 +452,13 @@ void createSubSection_forEachSectionType() { } List checks = List.of(false, false, false, false, false); - SubSectionResponse.Result r1 = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionResult.Result r1 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.PROBLEM_BACKGROUND, 10L); - SubSectionResponse.Result r2 = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionResult.Result r2 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.FEASIBILITY_STRATEGY, 10L); - SubSectionResponse.Result r3 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.GROWTH_MODEL, + SubSectionResult.Result r3 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.GROWTH_MODEL, 10L); - SubSectionResponse.Result r4 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.TEAM_FOUNDER, + SubSectionResult.Result r4 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.TEAM_FOUNDER, 10L); assertThat(r1.message()).isEqualTo("Subsection created"); @@ -484,7 +488,7 @@ void upsertSubSection_allSubSectionsCreated_updatesStatusToDrafted() { } when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -512,7 +516,7 @@ void upsertSubSection_partialSubSections_noStatusChange() { doReturn(true).when(plan).isOwnedBy(10L); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -546,7 +550,7 @@ void deleteSubSection_noStatusChange() { } when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); // when - 서브섹션 삭제 diff --git a/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java b/src/test/java/starlight/application/member/CredentialServiceIntegrationTest.java similarity index 87% rename from src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java rename to src/test/java/starlight/application/member/CredentialServiceIntegrationTest.java index 2cd7ba80..287b389a 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceIntegrationTest.java @@ -5,7 +5,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ContextConfiguration; @@ -18,15 +17,16 @@ import static org.mockito.Mockito.when; @ExtendWith(SpringExtension.class) -@ContextConfiguration(classes = {CredentialServiceImpl.class, CredentialServiceImplIntegrationTest.TestBeans.class}) -class CredentialServiceImplIntegrationTest { +@ContextConfiguration(classes = {CredentialService.class, CredentialServiceIntegrationTest.TestBeans.class}) +class CredentialServiceIntegrationTest { @TestConfiguration static class TestBeans { @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } - @Autowired CredentialServiceImpl sut; + @Autowired + CredentialService sut; @Autowired PasswordEncoder passwordEncoder; @Test diff --git a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java b/src/test/java/starlight/application/member/CredentialServiceUnitTest.java similarity index 95% rename from src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java rename to src/test/java/starlight/application/member/CredentialServiceUnitTest.java index 91a936ac..08597bfb 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceUnitTest.java @@ -14,10 +14,11 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -class CredentialServiceImplUnitTest { +class CredentialServiceUnitTest { @Mock PasswordEncoder passwordEncoder; - @InjectMocks CredentialServiceImpl sut; + @InjectMocks + CredentialService sut; @Test void createCredential_정상_해싱후_저장() { diff --git a/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java b/src/test/java/starlight/application/member/MemberServiceIntegrationTest.java similarity index 94% rename from src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java rename to src/test/java/starlight/application/member/MemberServiceIntegrationTest.java index 8a53170f..01a0d724 100644 --- a/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java +++ b/src/test/java/starlight/application/member/MemberServiceIntegrationTest.java @@ -18,10 +18,11 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({MemberQueryService.class, MemberJpa.class}) -class MemberQueryServiceIntegrationTest { +@Import({MemberService.class, MemberJpa.class}) +class MemberServiceIntegrationTest { - @Autowired MemberQueryService sut; + @Autowired + MemberService sut; @Autowired MemberRepository memberRepository; @Test diff --git a/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java b/src/test/java/starlight/application/member/MemberServiceUnitTest.java similarity index 96% rename from src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java rename to src/test/java/starlight/application/member/MemberServiceUnitTest.java index dcd8009d..dcfaec2f 100644 --- a/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java +++ b/src/test/java/starlight/application/member/MemberServiceUnitTest.java @@ -18,11 +18,12 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -class MemberQueryServiceUnitTest { +class MemberServiceUnitTest { @Mock MemberQueryPort memberQueryPort; @Mock MemberCommandPort memberCommandPort; - @InjectMocks MemberQueryService sut; + @InjectMocks + MemberService sut; @Test void createUser_중복이메일이면_예외() { diff --git a/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java b/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java index d13032a1..36c868e0 100644 --- a/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java @@ -11,8 +11,8 @@ import starlight.application.member.auth.provided.dto.SignUpInput; import starlight.application.member.auth.required.KeyValueMap; import starlight.application.member.auth.required.TokenProvider; -import starlight.application.member.provided.CredentialService; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.CredentialUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; @@ -28,8 +28,10 @@ }) class AuthServiceImplIntegrationTest { - @MockitoBean MemberQueryUseCase memberQueryUseCase; - @MockitoBean CredentialService credentialService; + @MockitoBean + MemberUseCase memberQueryUseCase; + @MockitoBean + CredentialUseCase credentialService; @MockitoBean TokenProvider tokenProvider; @MockitoBean KeyValueMap redisClient; diff --git a/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java b/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java index d80e9af6..aa4d8a75 100644 --- a/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java +++ b/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java @@ -11,8 +11,8 @@ import starlight.application.member.auth.provided.dto.SignInInput; import starlight.application.member.auth.required.KeyValueMap; import starlight.application.member.auth.required.TokenProvider; -import starlight.application.member.provided.CredentialService; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.CredentialUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -24,8 +24,10 @@ @ExtendWith(MockitoExtension.class) class AuthServiceImplUnitTest { - @Mock MemberQueryUseCase memberQueryUseCase; - @Mock CredentialService credentialService; + @Mock + MemberUseCase memberQueryUseCase; + @Mock + CredentialUseCase credentialService; @Mock TokenProvider tokenProvider; @Mock KeyValueMap redisClient;