diff --git a/config b/config index 403ba835..eeb1c88c 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 403ba8356845b8b8d9c0cab9ec8a47253d6bdf74 +Subproject commit eeb1c88cdd8dff82ae5cb6a0392d02ba517140cf 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 5b318696..fe8b2a9f 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java @@ -5,11 +5,11 @@ import org.springframework.stereotype.Service; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; import starlight.adapter.aireport.infrastructure.ocr.infra.ClovaOcrClient; -import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; 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.OcrProviderPort; +import starlight.application.aireport.required.PdfDownloadPort; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.ArrayList; @@ -23,7 +23,7 @@ public class ClovaOcrProvider implements OcrProviderPort { private static final int MAX_PAGES_PER_REQUEST = 10; private final ClovaOcrClient clovaOcrClient; - private final PdfDownloadClient pdfDownloadClient; + private final PdfDownloadPort pdfDownloadPort; /** * 지정한 PDF URL을 전체 페이지 OCR 처리한 뒤, 단일 응답으로 병합해 반환한다. @@ -40,7 +40,7 @@ public class ClovaOcrProvider implements OcrProviderPort { */ @Override public OcrResponse ocrPdfByUrl(String pdfUrl) { - byte[] pdfBytes = pdfDownloadClient.downloadPdfFromUrl(pdfUrl); + byte[] pdfBytes = pdfDownloadPort.downloadFromUrl(pdfUrl); List chunks = PdfUtils.splitByPageLimit(pdfBytes, MAX_PAGES_PER_REQUEST); diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java index 1627a735..3c863290 100644 --- a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.expert.required.AiReportSummaryLookupPort; @@ -19,7 +19,6 @@ public class AiReportJpa implements AiReportCommandPort, AiReportQueryPort, AiReportSummaryLookupPort { private final AiReportRepository aiReportRepository; - private final AiReportResponseParser responseParser; @Override public AiReport save(AiReport aiReport) { @@ -41,7 +40,7 @@ public Map findTotalScoresByBusinessPlanIds(List businessPl Map totalScoreMap = new HashMap<>(); for (AiReport report : reports) { - Integer totalScore = responseParser.toResponse(report).totalScore(); + Integer totalScore = AiReportResult.from(report).totalScore(); totalScoreMap.put(report.getBusinessPlanId(), totalScore != null ? totalScore : 0); } diff --git a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java index fa1fe7b2..a5d4371c 100644 --- a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java +++ b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java @@ -9,7 +9,6 @@ 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; @@ -31,14 +30,12 @@ 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() @@ -51,7 +48,6 @@ public SpringAiReportGrader( } this.fullReportGradeAgent = fullReportGradeAgent; this.supervisor = supervisor; - this.contentExtractor = contentExtractor; this.sectionGradingExecutor = sectionGradingExecutor; } 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 index 01ca5479..93a3626a 100644 --- a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java @@ -11,7 +11,7 @@ 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.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; 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 index a94619b4..e4793d1c 100644 --- a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java @@ -12,7 +12,7 @@ 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.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.shared.enumerate.SectionType; @@ -20,6 +20,8 @@ @RequiredArgsConstructor public class SpringAiSectionGradeAgent implements SectionGradeAgent { + private static final int MAX_RETRIES = 3; + private final SectionType sectionType; private final ChatClient.Builder chatClientBuilder; private final ReportPromptProvider reportPromptProvider; @@ -40,47 +42,61 @@ public SectionGradingResult gradeSection(String sectionContent) { 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()); + Prompt prompt = reportPromptProvider.createSectionGradingPrompt( + getSectionType(), + sectionContent); + + ChatClient chatClient = chatClientBuilder.build(); + String filter = buildFilterExpression(); + QuestionAnswerAdvisor qaAdvisor = advisorProvider + .getQuestionAnswerAdvisor(0.6, 3, filter); + SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); + + String lastFailureMessage = null; + + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 1) { + try { + long delay = (long) Math.pow(2, attempt - 1) * 1000L; // 2s, 4s + log.info("[{}] 재시도 대기: {}ms (시도 {}/{})", getSectionType(), delay, attempt, MAX_RETRIES); + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + try { + 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()); + log.info("[{}] 채점 완료: score={}, filter={}", getSectionType(), result.score(), filter); + return result; + } + + lastFailureMessage = result.errorMessage(); + log.warn("[{}] 채점 실패 (시도 {}/{}): 파싱 결과 유효하지 않음", getSectionType(), attempt, MAX_RETRIES); + + } catch (Exception e) { + lastFailureMessage = "파싱 실패: " + e.getMessage(); + log.warn("[{}] 채점 실패 (시도 {}/{}): {}", getSectionType(), attempt, MAX_RETRIES, e.getMessage()); } - - 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()); } + + circuitBreaker.recordFailure(getSectionType()); + String errorMessage = lastFailureMessage != null ? lastFailureMessage : "모든 재시도 실패"; + log.error("[{}] 채점 최종 실패 ({}회 시도)", getSectionType(), MAX_RETRIES); + return SectionGradingResult.failure(getSectionType(), errorMessage); } private String buildFilterExpression() { diff --git a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java b/src/main/java/starlight/adapter/aireport/report/parser/AiReportResponseParser.java similarity index 56% rename from src/main/java/starlight/application/aireport/util/AiReportResponseParser.java rename to src/main/java/starlight/adapter/aireport/report/parser/AiReportResponseParser.java index c03d8d5d..ca71df65 100644 --- a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java +++ b/src/main/java/starlight/adapter/aireport/report/parser/AiReportResponseParser.java @@ -1,18 +1,14 @@ -package starlight.application.aireport.util; +package starlight.adapter.aireport.report.parser; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; 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; -import java.util.ArrayList; import java.util.List; @Slf4j @@ -22,103 +18,6 @@ public class AiReportResponseParser { private final ObjectMapper objectMapper; - /** - * AiReportResponse를 JsonNode로 변환 (저장용) - * 또는 JsonNode에서 AiReportResponse로 변환 (조회용) - * 통합된 변환 메소드 - */ - public JsonNode convertToJsonNode(AiReportResult response) { - ObjectNode rootNode = objectMapper.createObjectNode(); - - // 점수 필드 - rootNode.put("problemRecognitionScore", - response.problemRecognitionScore() != null ? response.problemRecognitionScore() : 0); - rootNode.put("feasibilityScore", - response.feasibilityScore() != null ? response.feasibilityScore() : 0); - rootNode.put("growthStrategyScore", - response.growthStrategyScore() != null ? response.growthStrategyScore() : 0); - rootNode.put("teamCompetenceScore", - response.teamCompetenceScore() != null ? response.teamCompetenceScore() : 0); - - // 강점 배열 - ArrayNode strengthsArray = rootNode.putArray("strengths"); - if (response.strengths() != null) { - 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() : ""); - } - } - - // 약점 배열 - ArrayNode weaknessesArray = rootNode.putArray("weaknesses"); - if (response.weaknesses() != null) { - 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() : ""); - } - } - - // 섹션별 점수 배열: sectionType과 gradingListScores - ArrayNode sectionScoresArray = rootNode.putArray("sectionScores"); - if (response.sectionScores() != null) { - for (AiReportResult.SectionScoreDetailResponse sectionScore : response.sectionScores()) { - ObjectNode sectionScoreNode = sectionScoresArray.addObject(); - sectionScoreNode.put("sectionType", - sectionScore.sectionType() != null ? sectionScore.sectionType() : ""); - sectionScoreNode.put("gradingListScores", - sectionScore.gradingListScores() != null ? sectionScore.gradingListScores() : "[]"); - } - } - - return rootNode; - } - - /** - * AiReport에서 AiReportResponse로 변환 - * 파싱 로직은 AiReportResponseParser를 재사용하고, id와 businessPlanId만 추가 - */ - public AiReportResult toResponse(AiReport aiReport) { - JsonNode jsonNode = aiReport.getRawJson().asTree(); - - // 공통 파싱 로직 재사용 - AiReportResult baseResponse = parseFromJsonNode(jsonNode); - - // totalScore 계산 - 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 AiReportResult( - aiReport.getId(), - aiReport.getBusinessPlanId(), - totalScore, - baseResponse.problemRecognitionScore(), - baseResponse.feasibilityScore(), - baseResponse.growthStrategyScore(), - baseResponse.teamCompetenceScore(), - baseResponse.sectionScores(), - baseResponse.strengths(), - baseResponse.weaknesses()); - } - - /** - * 응답이 기본값(파싱 실패 시 반환되는 값)인지 확인 - */ - 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()); - } - /** * LLM 응답 문자열을 AiReportResponse로 파싱 (전체 리포트용) * 4개의 전체 점수 필드를 모두 요구 @@ -147,7 +46,7 @@ public AiReportResult parse(String llmResponse) { } // 5. 파싱 시도 - AiReportResult response = parseFromJsonNode(jsonNode); + AiReportResult response = AiReportResult.fromJsonNode(jsonNode); // 6. 파싱된 값이 기본값인지 확인 if (isDefaultResponse(response)) { @@ -208,8 +107,7 @@ public AiReportResult parseSectionResponse(String llmResponse) { } // 5. 섹션별 응답 파싱 (없는 필드는 null로 설정) - List sectionScores = parseSectionScores( - jsonNode.path("sectionScores")); + List sectionScores = AiReportResult.fromJsonNode(jsonNode).sectionScores(); // strengths와 weaknesses는 섹션별 응답에는 없음 return AiReportResult.fromGradingResult( @@ -227,6 +125,35 @@ public AiReportResult parseSectionResponse(String llmResponse) { } } + /** + * 강점/약점 리스트 파싱 (슈퍼바이저용) + */ + public List parseStrengthWeakness(String llmResponse, String type) { + try { + String cleanedJson = cleanJsonResponse(llmResponse); + JsonNode jsonNode = objectMapper.readTree(cleanedJson); + + JsonNode targetNode = jsonNode.path(type); + return AiReportResult.StrengthWeakness.listFromJsonNode(targetNode); + } catch (Exception e) { + log.error("Failed to parse strength/weakness from supervisor response. Type: {}", type, e); + return List.of(); + } + } + + /** + * 응답이 기본값(파싱 실패 시 반환되는 값)인지 확인 + */ + 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()); + } + /** * JSON 응답 문자열 정리 및 복구 */ @@ -362,116 +289,4 @@ private String repairIncompleteJson(String json) { return repaired.toString(); } - - /** - * JsonNode를 파싱하여 AiReportResponse로 변환 - */ - private AiReportResult parseFromJsonNode(JsonNode jsonNode) { - 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(); - } - if (jsonNode.has("feasibilityScore") && !jsonNode.path("feasibilityScore").isNull()) { - feasibilityScore = jsonNode.path("feasibilityScore").asInt(); - } - if (jsonNode.has("growthStrategyScore") && !jsonNode.path("growthStrategyScore").isNull()) { - growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(); - } - if (jsonNode.has("teamCompetenceScore") && !jsonNode.path("teamCompetenceScore").isNull()) { - teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(); - } - - // 강점 파싱 - List strengths = parseStrengthWeaknessList(jsonNode.path("strengths")); - - // 약점 파싱 - List weaknesses = parseStrengthWeaknessList(jsonNode.path("weaknesses")); - - // sectionScores 파싱: sectionType과 gradingListScores만 포함 - List sectionScores = parseSectionScores( - jsonNode.path("sectionScores")); - - return AiReportResult.fromGradingResult( - problemRecognitionScore, - feasibilityScore, - growthStrategyScore, - teamCompetenceScore, - sectionScores, - strengths, - 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<>(); - if (node.isArray()) { - for (JsonNode itemNode : node) { - list.add(new AiReportResult.StrengthWeakness( - itemNode.path("title").asText(""), - itemNode.path("content").asText(""))); - } - } - return list; - } - - /** - * 섹션 점수 리스트 파싱 - * 불완전한 항목은 건너뛰거나 기본값으로 대체 - */ - 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); - gradingListScores = "[]"; - } else { - // JSON 파싱 가능 여부 확인 - objectMapper.readTree(gradingListScores); - } - } catch (Exception e) { - gradingListScores = "[]"; - } - } - - list.add(new AiReportResult.SectionScoreDetailResponse(sectionType, gradingListScores)); - } catch (Exception e) { - log.warn("Failed to parse sectionScore item, skipping: {}", e.getMessage()); - } - } - } - return list; - } - } diff --git a/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java index 16c742a8..832ff352 100644 --- a/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java +++ b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java @@ -4,8 +4,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.template.st.StTemplateRenderer; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.Ordered; import org.springframework.stereotype.Service; @@ -16,6 +19,9 @@ public class SpringAiAdvisorProvider { private final VectorStore vectorStore; + @Value("${prompt.report.qa-advisor.template}") + private String qaAdvisorTemplate; + public QuestionAnswerAdvisor getQuestionAnswerAdvisor(double similarityThreshold, int topK, String filter){ SearchRequest.Builder builder = SearchRequest.builder() .similarityThreshold(similarityThreshold) @@ -26,10 +32,15 @@ public QuestionAnswerAdvisor getQuestionAnswerAdvisor(double similarityThreshold } SearchRequest searchRequest = builder.build(); + PromptTemplate promptTemplate = PromptTemplate.builder() + .renderer(StTemplateRenderer.builder().startDelimiterToken('{').endDelimiterToken('}').build()) + .template(qaAdvisorTemplate) + .build(); return QuestionAnswerAdvisor .builder(vectorStore) .searchRequest(searchRequest) + .promptTemplate(promptTemplate) .build(); } diff --git a/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java index ca0178cd..e2d37ad8 100644 --- a/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java +++ b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java @@ -11,7 +11,7 @@ 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.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import java.util.HashMap; diff --git a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java index 854c6582..a5d3c118 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java +++ b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java @@ -1,14 +1,13 @@ package starlight.adapter.aireport.webapi.dto; import jakarta.validation.constraints.NotBlank; -import org.hibernate.validator.constraints.URL; +import starlight.adapter.shared.webapi.validation.ValidPdfUrl; public record AiReportCreateWithPdfRequest( @NotBlank(message = "제목은 필수입니다.") String title, - // TODO: 버킷 정책 등에 따라서 이후에 host 강제할 것 @NotBlank(message = "PDF URL은 필수입니다.") - @URL(protocol = "https", message = "https URL만 허용됩니다.") + @ValidPdfUrl String pdfUrl ) {} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java index 56ff7b47..31a450ef 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -13,8 +13,8 @@ import org.springframework.web.bind.annotation.RestController; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; -import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; -import starlight.application.aireport.required.PresignedUrlProviderPort; +import starlight.adapter.shared.webapi.validation.ValidImageFileName; +import starlight.application.backoffice.image.required.PresignedUrlProviderPort; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java index 32c220d8..c064fef5 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; -import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; +import starlight.adapter.shared.webapi.validation.ValidImageFileName; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; diff --git a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanImageController.java similarity index 69% rename from src/main/java/starlight/adapter/aireport/webapi/ImageController.java rename to src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanImageController.java index 19302ff0..24f37a50 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanImageController.java @@ -1,26 +1,27 @@ -package starlight.adapter.aireport.webapi; +package starlight.adapter.businessplan.webapi; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import starlight.adapter.shared.webapi.validation.ValidImageFileName; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; -import starlight.application.aireport.required.PresignedUrlProviderPort; -import starlight.adapter.aireport.webapi.swagger.ImageApiDoc; +import starlight.application.businessplan.required.PresignedUrlProviderPort; +import starlight.adapter.businessplan.webapi.swagger.BusinessPlanImageApiDoc; import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; @RestController -@RequestMapping("/v1/images") +@RequestMapping("/v1/business-plans/images") @RequiredArgsConstructor -public class ImageController implements ImageApiDoc { +public class BusinessPlanImageController implements BusinessPlanImageApiDoc { private final PresignedUrlProviderPort presignedUrlReader; @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( @AuthenticationPrincipal AuthenticatedMember authenticatedMember, - @RequestParam String fileName + @RequestParam @ValidImageFileName String fileName ) { return ApiResponse.success(presignedUrlReader.getPreSignedUrl(authenticatedMember.getMemberId(), fileName)); } diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java index a2b85000..64d491f7 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java @@ -1,12 +1,14 @@ package starlight.adapter.businessplan.webapi.dto; import jakarta.validation.constraints.NotBlank; +import starlight.adapter.shared.webapi.validation.ValidPdfUrl; public record BusinessPlanCreateWithPdfRequest( @NotBlank(message = "제목은 필수입니다.") String title, @NotBlank(message = "PDF URL은 필수입니다.") + @ValidPdfUrl String pdfUrl ) {} diff --git a/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java b/src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanImageApiDoc.java similarity index 93% rename from src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java rename to src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanImageApiDoc.java index c1e32213..3fd424c6 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanImageApiDoc.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.webapi.swagger; +package starlight.adapter.businessplan.webapi.swagger; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -17,7 +17,7 @@ import starlight.shared.apiPayload.response.ApiResponse; @Tag(name = "UTIL", description = "유틸리티 API") -public interface ImageApiDoc { +public interface BusinessPlanImageApiDoc { @Operation( summary = "Presigned URL 발급", @@ -46,7 +46,7 @@ public interface ImageApiDoc { ) ) }) - @GetMapping(value = "/v1/image/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(value = "/v1/business-plans/images/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) ApiResponse getPresignedUrl( @AuthenticationPrincipal AuthenticatedMember authenticatedMember, @io.swagger.v3.oas.annotations.Parameter(description = "파일명", required = true) @RequestParam String fileName @@ -75,7 +75,7 @@ ApiResponse getPresignedUrl( ) ) }) - @PostMapping("/v1/images/upload-url/public") + @PostMapping("/v1/business-plans/images/upload-url/public") ApiResponse finalizePublic( @io.swagger.v3.oas.annotations.Parameter(description = "S3 Object URL", required = true) @RequestParam String objectUrl ); diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClient.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java similarity index 63% rename from src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClient.java rename to src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java index 48f7f53f..26042b74 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClient.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java @@ -1,24 +1,26 @@ -package starlight.adapter.aireport.infrastructure.ocr.infra; +package starlight.adapter.shared.infrastructure.pdf; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadErrorType; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadException; import java.net.URI; + @Slf4j @Component -public class PdfDownloadClient { +public class PdfDownloadClient implements starlight.application.aireport.required.PdfDownloadPort, + starlight.application.expertApplication.required.PdfDownloadPort { private static final int MAX_PDF_BYTES = 30 * 1024 * 1024; // 30MB까지 허용 private final RestClient pdfDownloadClient; - public PdfDownloadClient(@Qualifier("pdfDownloadRestClient") RestClient downloadClient) { + PdfDownloadClient(@Qualifier("pdfDownloadRestClient") RestClient downloadClient) { this.pdfDownloadClient = downloadClient; } @@ -31,12 +33,13 @@ public PdfDownloadClient(@Qualifier("pdfDownloadRestClient") RestClient download * * @param url 다운로드할 PDF의 절대 URL(프리사인드/퍼센트 인코딩 포함 가능) * @return 다운로드한 PDF 바이트 배열 - * @throws OcrException 다음의 에러타입으로 발생 + * @throws PdfDownloadException 다음의 에러타입으로 발생 * - PDF_EMPTY_RESPONSE : 본문이 비어있음 * - PDF_TOO_LARGE : 허용 최대 크기 초과 * - PDF_DOWNLOAD_ERROR : 네트워크/HTTP/기타 예외 전반 */ - public byte[] downloadPdfFromUrl(String url) { + @Override + public byte[] downloadFromUrl(String url) { try { ResponseEntity entity = pdfDownloadClient.get() .uri(URI.create(url)) @@ -45,17 +48,17 @@ public byte[] downloadPdfFromUrl(String url) { byte[] data = entity.getBody(); if (data == null || data.length == 0) { - throw new OcrException(OcrErrorType.PDF_EMPTY_RESPONSE); + throw new PdfDownloadException(PdfDownloadErrorType.PDF_EMPTY_RESPONSE); } if (data.length > MAX_PDF_BYTES) { - throw new OcrException(OcrErrorType.PDF_TOO_LARGE); + throw new PdfDownloadException(PdfDownloadErrorType.PDF_TOO_LARGE); } return data; - } catch (OcrException e) { - throw e; // 이미 처리된 OcrException은 재던짐 + } catch (PdfDownloadException e) { + throw e; } catch (Exception e) { - log.error("PDF 다운로드 실패: {}", e.getMessage()); - throw new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR); + log.error("PDF 다운로드 실패", e); + throw new PdfDownloadException(PdfDownloadErrorType.PDF_DOWNLOAD_ERROR, e); } } } diff --git a/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadErrorType.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadErrorType.java new file mode 100644 index 00000000..5f44f055 --- /dev/null +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadErrorType.java @@ -0,0 +1,18 @@ +package starlight.adapter.shared.infrastructure.pdf.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum PdfDownloadErrorType implements ErrorType { + PDF_EMPTY_RESPONSE(HttpStatus.BAD_GATEWAY, "PDF 응답이 비어있음"), + PDF_TOO_LARGE(HttpStatus.INTERNAL_SERVER_ERROR, "PDF의 크기가 업로드 제한 크기를 넘습니다."), + PDF_DOWNLOAD_ERROR(HttpStatus.BAD_GATEWAY, "PDF 다운로드 실패"), + ; + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadException.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadException.java new file mode 100644 index 00000000..77fb0625 --- /dev/null +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadException.java @@ -0,0 +1,14 @@ +package starlight.adapter.shared.infrastructure.pdf.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class PdfDownloadException extends GlobalException { + public PdfDownloadException(ErrorType errorType) { + super(errorType); + } + + public PdfDownloadException(ErrorType errorType, Throwable cause) { + super(errorType, cause); + } +} diff --git a/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java index 06de3103..26ce2829 100644 --- a/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/shared/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.PresignedUrlProviderPort; +import starlight.application.businessplan.required.PresignedUrlProviderPort; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -24,7 +24,8 @@ @Slf4j @Service @RequiredArgsConstructor -public class NcpPresignedUrlProvider implements PresignedUrlProviderPort { +public class NcpPresignedUrlProvider implements PresignedUrlProviderPort, + starlight.application.backoffice.image.required.PresignedUrlProviderPort { private final S3Client ncpS3Client; private final S3Presigner ncpS3Presigner; diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java b/src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileName.java similarity index 91% rename from src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java rename to src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileName.java index 9727ada9..04738a32 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java +++ b/src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileName.java @@ -1,4 +1,4 @@ -package starlight.adapter.backoffice.image.webapi.validation; +package starlight.adapter.shared.webapi.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java b/src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileNameValidator.java similarity index 92% rename from src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java rename to src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileNameValidator.java index a43ef885..ffdc1ca5 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java +++ b/src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileNameValidator.java @@ -1,4 +1,4 @@ -package starlight.adapter.backoffice.image.webapi.validation; +package starlight.adapter.shared.webapi.validation; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrl.java b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrl.java new file mode 100644 index 00000000..82d0fd0b --- /dev/null +++ b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrl.java @@ -0,0 +1,23 @@ +package starlight.adapter.shared.webapi.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = ValidPdfUrlValidator.class) +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPdfUrl { + + String message() default "PDF URL 형식이 올바르지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java new file mode 100644 index 00000000..9b105894 --- /dev/null +++ b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java @@ -0,0 +1,25 @@ +package starlight.adapter.shared.webapi.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.util.StringUtils; + +import java.net.URI; + +public class ValidPdfUrlValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (!StringUtils.hasText(value)) { + return true; + } + try { + URI uri = URI.create(value); + return "https".equalsIgnoreCase(uri.getScheme()) + && uri.getHost() != null + && !uri.getHost().isBlank(); + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index 5de7c3b1..a5cebdce 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -10,8 +10,7 @@ import starlight.application.aireport.provided.AiReportUseCase; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.*; -import starlight.application.aireport.util.AiReportResponseParser; -import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.aireport.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -32,10 +31,9 @@ public class AiReportService implements AiReportUseCase { private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort; private final AiReportQueryPort aiReportQueryPort; private final AiReportCommandPort aiReportCommandPort; - private final ReportGraderPort reportGrader; + private final ReportGraderPort reportGraderPort; + private final OcrProviderPort ocrProviderPort; private final ObjectMapper objectMapper; - private final OcrProviderPort ocrProvider; - private final AiReportResponseParser responseParser; private final BusinessPlanContentExtractor contentExtractor; @Override @@ -57,7 +55,7 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); } - AiReportResult gradingResult = reportGrader.gradeWithSectionAgents(sectionContents, fullContent); + AiReportResult gradingResult = reportGraderPort.gradeWithSectionAgents(sectionContents, fullContent); // 채점 결과 검증 if (isInvalidGradingResult(gradingResult)) { @@ -67,11 +65,11 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { log.info("채점 완료. 총점: {}, planId: {}", gradingResult.totalScore(), planId); - String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); + String rawJsonString = getRawJsonStrFromAiReportResult(gradingResult); AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - return responseParser.toResponse(aiReport); + return AiReportResult.from(aiReport); } @Override @@ -82,7 +80,7 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(businessPlanId); log.debug("OCR 시작. pdfUrl: {}", pdfUrl); - String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); + String pdfText = ocrProviderPort.ocrPdfTextByUrl(pdfUrl); log.debug("OCR 완료. 텍스트 길이: {}", pdfText != null ? pdfText.length() : 0); if (pdfText == null || pdfText.trim().isEmpty()) { @@ -91,7 +89,7 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, } // PDF의 경우 기존 한 번에 LLM에 돌리는 방식을 사용 - AiReportResult gradingResult = reportGrader.gradeWithFullPrompt(pdfText); + AiReportResult gradingResult = reportGraderPort.gradeWithFullPrompt(pdfText); // 채점 결과 검증 if (isInvalidGradingResult(gradingResult)) { @@ -101,11 +99,11 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, log.info("PDF 채점 완료. 총점: {}, businessPlanId: {}", gradingResult.totalScore(), businessPlanId); - String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); + String rawJsonString = getRawJsonStrFromAiReportResult(gradingResult); AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - return responseParser.toResponse(aiReport); + return AiReportResult.from(aiReport); } @Override @@ -117,11 +115,11 @@ public AiReportResult getAiReport(Long planId, Long memberId) { AiReport aiReport = aiReportQueryPort.findByBusinessPlanId(planId) .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND)); - return responseParser.toResponse(aiReport); + return AiReportResult.from(aiReport); } - private String getRawJsonAiReportResponseFromGradingResult(AiReportResult gradingResult) { - JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); + private String getRawJsonStrFromAiReportResult(AiReportResult gradingResult) { + JsonNode gradingJsonNode = gradingResult.toJsonNode(); String rawJsonString; try { rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); diff --git a/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java b/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java index a5135d4e..bc5d9ce9 100644 --- a/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java +++ b/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java @@ -1,6 +1,13 @@ package starlight.application.aireport.provided.dto; import com.fasterxml.jackson.annotation.JsonRawValue; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import starlight.domain.aireport.entity.AiReport; + +import java.util.ArrayList; import java.util.List; /** @@ -22,12 +29,67 @@ public record AiReportResult( public record SectionScoreDetailResponse( String sectionType, @JsonRawValue String gradingListScores - ) {} - + ) { + public static SectionScoreDetailResponse fromJsonNode(JsonNode node) { + String sectionType = node.path("sectionType").asText(""); + JsonNode gradingListNode = node.path("gradingListScores"); + String gradingListScores; + if (gradingListNode != null && gradingListNode.isArray()) { + gradingListScores = gradingListNode.toString(); + } else { + gradingListScores = gradingListNode != null ? gradingListNode.asText("[]") : "[]"; + } + if (!gradingListScores.equals("[]") && !gradingListScores.isEmpty() && !gradingListScores.trim().startsWith("[")) { + gradingListScores = "[]"; + } + return new SectionScoreDetailResponse(sectionType, gradingListScores); + } + + /** + * JsonNode 배열에서 리스트 생성 + */ + public static List listFromJsonNode(JsonNode arrayNode) { + List list = new ArrayList<>(); + if (arrayNode == null || !arrayNode.isArray()) { + return list; + } + for (JsonNode node : arrayNode) { + try { + list.add(fromJsonNode(node)); + } catch (Exception e) { + // 항목 스킵 + } + } + return list; + } + } + public record StrengthWeakness( String title, String content - ) {} + ) { + /** + * 단일 JsonNode에서 인스턴스 생성 + */ + public static StrengthWeakness fromJsonNode(JsonNode node) { + return new StrengthWeakness( + node.path("title").asText(""), + node.path("content").asText("")); + } + + /** + * JsonNode 배열에서 리스트 생성 + */ + public static List listFromJsonNode(JsonNode arrayNode) { + List list = new ArrayList<>(); + if (arrayNode != null && arrayNode.isArray()) { + for (JsonNode node : arrayNode) { + list.add(fromJsonNode(node)); + } + } + return list; + } + } /** * LLM 결과만으로 AiReportResponse 생성 (id, businessPlanId는 null) @@ -63,5 +125,107 @@ private static Integer sumTotalScore(Integer problemRecognitionScore, Integer fe (growthStrategyScore != null ? growthStrategyScore : 0) + (teamCompetenceScore != null ? teamCompetenceScore : 0); } + + /** + * AiReport 엔티티에서 API 응답 DTO로 변환 (id, businessPlanId 포함) + */ + public static AiReportResult from(AiReport aiReport) { + JsonNode jsonNode = aiReport.getRawJson().asTree(); + + AiReportResult base = fromJsonNode(jsonNode); + + Integer totalScore = sumTotalScore( + base.problemRecognitionScore(), + base.feasibilityScore(), + base.growthStrategyScore(), + base.teamCompetenceScore()); + + return new AiReportResult( + aiReport.getId(), + aiReport.getBusinessPlanId(), + totalScore, + base.problemRecognitionScore(), + base.feasibilityScore(), + base.growthStrategyScore(), + base.teamCompetenceScore(), + base.sectionScores(), + base.strengths(), + base.weaknesses()); + } + + /** + * 저장된 JSON(JsonNode)에서 DTO로 변환 (id, businessPlanId는 null) + * 엔티티 변환 및 LLM 파싱 결과 조립 시 공통 사용 + */ + public static AiReportResult fromJsonNode(JsonNode jsonNode) { + 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(); + } + if (jsonNode.has("feasibilityScore") && !jsonNode.path("feasibilityScore").isNull()) { + feasibilityScore = jsonNode.path("feasibilityScore").asInt(); + } + if (jsonNode.has("growthStrategyScore") && !jsonNode.path("growthStrategyScore").isNull()) { + growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(); + } + if (jsonNode.has("teamCompetenceScore") && !jsonNode.path("teamCompetenceScore").isNull()) { + teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(); + } + + List strengths = StrengthWeakness.listFromJsonNode(jsonNode.path("strengths")); + List weaknesses = StrengthWeakness.listFromJsonNode(jsonNode.path("weaknesses")); + List sectionScores = SectionScoreDetailResponse.listFromJsonNode(jsonNode.path("sectionScores")); + + return fromGradingResult( + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + strengths, + weaknesses); + } + + /** + * 저장용 JsonNode로 변환 (엔티티 raw_json 형식과 동일) + */ + public JsonNode toJsonNode() { + ObjectNode rootNode = JsonNodeFactory.instance.objectNode(); + rootNode.put("problemRecognitionScore", problemRecognitionScore() != null ? problemRecognitionScore() : 0); + rootNode.put("feasibilityScore", feasibilityScore() != null ? feasibilityScore() : 0); + rootNode.put("growthStrategyScore", growthStrategyScore() != null ? growthStrategyScore() : 0); + rootNode.put("teamCompetenceScore", teamCompetenceScore() != null ? teamCompetenceScore() : 0); + + ArrayNode strengthsArray = rootNode.putArray("strengths"); + if (strengths() != null) { + for (StrengthWeakness s : strengths()) { + ObjectNode n = strengthsArray.addObject(); + n.put("title", s.title() != null ? s.title() : ""); + n.put("content", s.content() != null ? s.content() : ""); + } + } + ArrayNode weaknessesArray = rootNode.putArray("weaknesses"); + if (weaknesses() != null) { + for (StrengthWeakness w : weaknesses()) { + ObjectNode n = weaknessesArray.addObject(); + n.put("title", w.title() != null ? w.title() : ""); + n.put("content", w.content() != null ? w.content() : ""); + } + } + ArrayNode sectionScoresArray = rootNode.putArray("sectionScores"); + if (sectionScores() != null) { + for (SectionScoreDetailResponse ss : sectionScores()) { + ObjectNode n = sectionScoresArray.addObject(); + n.put("sectionType", ss.sectionType() != null ? ss.sectionType() : ""); + n.put("gradingListScores", ss.gradingListScores() != null ? ss.gradingListScores() : "[]"); + } + } + return rootNode; + } + } diff --git a/src/main/java/starlight/application/aireport/required/PdfDownloadPort.java b/src/main/java/starlight/application/aireport/required/PdfDownloadPort.java new file mode 100644 index 00000000..2f70bf64 --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/PdfDownloadPort.java @@ -0,0 +1,6 @@ +package starlight.application.aireport.required; + +public interface PdfDownloadPort { + + byte[] downloadFromUrl(String url); +} diff --git a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java b/src/main/java/starlight/application/aireport/util/BusinessPlanContentExtractor.java similarity index 99% rename from src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java rename to src/main/java/starlight/application/aireport/util/BusinessPlanContentExtractor.java index 5555c488..d3a2b080 100644 --- a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java +++ b/src/main/java/starlight/application/aireport/util/BusinessPlanContentExtractor.java @@ -1,4 +1,4 @@ -package starlight.application.businessplan.util; +package starlight.application.aireport.util; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/src/main/java/starlight/application/backoffice/image/required/PresignedUrlProviderPort.java b/src/main/java/starlight/application/backoffice/image/required/PresignedUrlProviderPort.java new file mode 100644 index 00000000..8fefbd27 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/image/required/PresignedUrlProviderPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.image.required; + +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +public interface PresignedUrlProviderPort { + + PreSignedUrlResponse getPreSignedUrl(Long userId, String originalFileName); + + String makePublic(String objectUrl); +} diff --git a/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java b/src/main/java/starlight/application/businessplan/required/PresignedUrlProviderPort.java similarity index 81% rename from src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java rename to src/main/java/starlight/application/businessplan/required/PresignedUrlProviderPort.java index 4417c3e9..37b548eb 100644 --- a/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java +++ b/src/main/java/starlight/application/businessplan/required/PresignedUrlProviderPort.java @@ -1,4 +1,4 @@ -package starlight.application.aireport.required; +package starlight.application.businessplan.required; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; diff --git a/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java index da6e409d..31eaf067 100644 --- a/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java +++ b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java @@ -12,6 +12,7 @@ import starlight.application.expertApplication.provided.ExpertApplicationCommandUseCase; import starlight.application.expertApplication.required.ExpertLookupPort; import starlight.application.expertApplication.required.ExpertApplicationQueryPort; +import starlight.application.expertApplication.required.PdfDownloadPort; import starlight.application.expertReport.provided.ExpertReportUseCase; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.PlanStatus; @@ -36,6 +37,7 @@ public class ExpertApplicationCommandService implements ExpertApplicationCommand private final ExpertApplicationQueryPort applicationQueryPort; private final ApplicationEventPublisher eventPublisher; private final ExpertReportUseCase expertReportUseCase; + private final PdfDownloadPort pdfDownloadPort; private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB private static final String ALLOWED_CONTENT_TYPE = "application/pdf"; @@ -46,17 +48,33 @@ public class ExpertApplicationCommandService implements ExpertApplicationCommand @Override @Transactional public void requestFeedback(Long expertId, Long planId, MultipartFile file, String menteeName) { - try { + BusinessPlan plan = planQuery.findByIdOrThrow(planId); + + final byte[] fileBytes; + final String filename; + + if (plan.isPdfBased()) { + fileBytes = pdfDownloadPort.downloadFromUrl(plan.getPdfUrl()); + filename = generateFilenameForPdfPlan(plan, menteeName); + } else { validateFile(file); + try { + fileBytes = file.getBytes(); + } catch (IOException e) { + log.error("Failed to read file. planId={}, expertId={}", planId, expertId, e); + throw new ExpertApplicationException(ExpertApplicationErrorType.FILE_READ_ERROR); + } + filename = generateFilename(file, plan, menteeName); + } - BusinessPlan plan = planQuery.findByIdOrThrow(planId); + try { Expert expert = expertLookupPort.findByIdOrThrow(expertId); plan.updateStatus(PlanStatus.EXPERT_MATCHED); registerApplicationRecord(expertId, planId); - publishEmailEvent(expert, plan, file, menteeName); + publishEmailEvent(expert, plan, fileBytes, filename, menteeName); } catch (ExpertApplicationException | BusinessPlanException | ExpertException e) { throw e; } catch (Exception e) { @@ -96,33 +114,30 @@ private String generateFilename(MultipartFile file, BusinessPlan plan, String me return originalFilename; } + return generateFilenameForPdfPlan(plan, menteeName); + } + + private String generateFilenameForPdfPlan(BusinessPlan plan, String menteeName) { return String.format("[사업계획서]%s_%s.pdf", plan.getTitle(), menteeName); } - protected void publishEmailEvent(Expert expert, BusinessPlan plan, MultipartFile file, String menteeName) { - try { - byte[] fileBytes = file.getBytes(); - String filename = generateFilename(file, plan, menteeName); - String feedbackUrl = buildFeedbackRequestUrl(expert.getId(), plan.getId()); - - FeedbackRequestInput event = FeedbackRequestInput.of( - expert.getEmail(), - expert.getName(), - menteeName, - plan.getTitle(), - LocalDate.now().plusDays(FEEDBACK_DEADLINE_DAYS).format(DateTimeFormatter.ISO_DATE), - feedbackUrl, - fileBytes, - filename - ); - - log.info("[EMAIL] publishing FeedbackRequestEvent expertId={}, planId={}", expert.getId(), plan.getId()); - - eventPublisher.publishEvent(event); - } catch (IOException e) { - log.error("Failed to read file. planId={}, expertId={}", plan.getId(), expert.getId(), e); - throw new ExpertApplicationException(ExpertApplicationErrorType.FILE_READ_ERROR); - } + protected void publishEmailEvent(Expert expert, BusinessPlan plan, byte[] fileBytes, String filename, String menteeName) { + String feedbackUrl = buildFeedbackRequestUrl(expert.getId(), plan.getId()); + + FeedbackRequestInput event = FeedbackRequestInput.of( + expert.getEmail(), + expert.getName(), + menteeName, + plan.getTitle(), + LocalDate.now().plusDays(FEEDBACK_DEADLINE_DAYS).format(DateTimeFormatter.ISO_DATE), + feedbackUrl, + fileBytes, + filename + ); + + log.info("[EMAIL] publishing FeedbackRequestEvent expertId={}, planId={}", expert.getId(), plan.getId()); + + eventPublisher.publishEvent(event); } private String buildFeedbackRequestUrl(Long expertId, Long planId) { diff --git a/src/main/java/starlight/application/expertApplication/required/PdfDownloadPort.java b/src/main/java/starlight/application/expertApplication/required/PdfDownloadPort.java new file mode 100644 index 00000000..8237848d --- /dev/null +++ b/src/main/java/starlight/application/expertApplication/required/PdfDownloadPort.java @@ -0,0 +1,6 @@ +package starlight.application.expertApplication.required; + +public interface PdfDownloadPort { + + byte[] downloadFromUrl(String url); +} diff --git a/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java b/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java index eacd22b8..2c859dd9 100644 --- a/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java +++ b/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java @@ -9,7 +9,7 @@ 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.adapter.aireport.report.parser.AiReportResponseParser; import starlight.shared.enumerate.SectionType; import java.util.Arrays; diff --git a/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java b/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java index 2c228e67..b1855138 100644 --- a/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java +++ b/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java @@ -17,6 +17,7 @@ public enum BusinessPlanErrorType implements ErrorType { UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), SECTIONAL_CONTENT_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 해당 Section 내용이 존재합니다."), SECTIONAL_CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 Section 내용이 존재하지 않습니다."), + INVALID_PDF_URL(HttpStatus.BAD_REQUEST, "PDF URL에 접근할 수 없거나 유효하지 않습니다."), ; private final HttpStatus status; diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java index 1a155607..db9306e8 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java @@ -8,14 +8,13 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; -import starlight.adapter.aireport.infrastructure.ocr.ClovaOcrProvider; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; import starlight.adapter.aireport.infrastructure.ocr.infra.ClovaOcrClient; -import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; 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.PdfDownloadPort; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.List; @@ -33,7 +32,7 @@ class ClovaOcrProviderTest { private ClovaOcrClient clovaOcrClient; @Mock - private PdfDownloadClient pdfDownloadClient; + private PdfDownloadPort pdfDownloadPort; @InjectMocks private ClovaOcrProvider clovaOcrProvider; @@ -57,7 +56,7 @@ void ocrPdfByUrl_Success_SingleChunk() { byte[] chunk = "chunk1".getBytes(); OcrResponse expectedResponse = OcrResponse.createEmpty(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { @@ -73,7 +72,7 @@ void ocrPdfByUrl_Success_SingleChunk() { // then assertThat(result).isEqualTo(expectedResponse); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verify(clovaOcrClient).recognizePdfBytes(chunk); } } @@ -86,7 +85,7 @@ void ocrPdfByUrl_Success_MultipleChunks() { byte[] chunk2 = "chunk2".getBytes(); OcrResponse mergedResponse = OcrResponse.createEmpty(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { @@ -103,7 +102,7 @@ void ocrPdfByUrl_Success_MultipleChunks() { // then assertThat(result).isEqualTo(mergedResponse); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verify(clovaOcrClient).recognizePdfBytes(chunk1); verify(clovaOcrClient).recognizePdfBytes(chunk2); } @@ -113,7 +112,7 @@ void ocrPdfByUrl_Success_MultipleChunks() { @DisplayName("PDF 다운로드 실패 시 예외 전파") void ocrPdfByUrl_ThrowsException_WhenDownloadFails() { // given - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)) + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)) .thenThrow(new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR)); // when & then @@ -121,7 +120,7 @@ void ocrPdfByUrl_ThrowsException_WhenDownloadFails() { .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verifyNoInteractions(clovaOcrClient); } @@ -129,7 +128,7 @@ void ocrPdfByUrl_ThrowsException_WhenDownloadFails() { @DisplayName("PDF 분할 실패 시 예외 전파") void ocrPdfByUrl_ThrowsException_WhenSplitFails() { // given - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class)) { pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) @@ -140,7 +139,7 @@ void ocrPdfByUrl_ThrowsException_WhenSplitFails() { .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_SPLIT_ERROR); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verifyNoInteractions(clovaOcrClient); } } @@ -151,7 +150,7 @@ void ocrPdfByUrl_ThrowsException_WhenOcrFails() { // given byte[] chunk = "chunk1".getBytes(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class)) { pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) @@ -176,7 +175,7 @@ void ocrPdfTextByUrl_Success() { OcrResponse ocrResponse = OcrResponse.createEmpty(); String expectedText = "Extracted text content"; - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class); @@ -195,7 +194,7 @@ void ocrPdfTextByUrl_Success() { // then assertThat(result).isEqualTo(expectedText); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verify(clovaOcrClient).recognizePdfBytes(chunk); } } @@ -204,7 +203,7 @@ void ocrPdfTextByUrl_Success() { @DisplayName("텍스트 추출 중 OCR 실패 시 예외 전파") void ocrPdfTextByUrl_ThrowsException_WhenOcrFails() { // given - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)) + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)) .thenThrow(new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR)); // when & then @@ -219,7 +218,7 @@ void ocrPdfByUrl_WithEmptyChunks() { // given OcrResponse emptyResponse = OcrResponse.createEmpty(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { @@ -234,7 +233,7 @@ void ocrPdfByUrl_WithEmptyChunks() { // then assertThat(result).isEqualTo(emptyResponse); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verifyNoInteractions(clovaOcrClient); } } @@ -247,7 +246,7 @@ void ocrPdfByUrl_ExactlyTwoChunks() { byte[] chunk2 = "chunk2".getBytes(); OcrResponse mergedResponse = OcrResponse.createEmpty(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { diff --git a/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java b/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java index 79f770e2..73ca7b88 100644 --- a/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java +++ b/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java @@ -7,7 +7,6 @@ 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; @@ -44,7 +43,6 @@ void gradeWithFullPrompt_returnsAiReportResult() { List.of(), fullReportGradeAgent, mock(SpringAiReportSupervisor.class), - mock(BusinessPlanContentExtractor.class), mock(Executor.class) ); @@ -126,7 +124,7 @@ void gradeWithSectionAgents_returnsAiReportResult() { FullReportGradeAgent fullReportGradeAgent = mock(FullReportGradeAgent.class); SpringAiReportSupervisor supervisor = mock(SpringAiReportSupervisor.class); - BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); + // 실제 Executor 사용 (비동기 실행을 위해) Executor executor = Executors.newFixedThreadPool(4); @@ -134,7 +132,6 @@ void gradeWithSectionAgents_returnsAiReportResult() { sectionAgents, fullReportGradeAgent, supervisor, - contentExtractor, executor ); diff --git a/src/test/java/starlight/application/member/required/DaumSpellCheckerHttpTest.java b/src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerHttpTest.java similarity index 97% rename from src/test/java/starlight/application/member/required/DaumSpellCheckerHttpTest.java rename to src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerHttpTest.java index 359575e7..42960cee 100644 --- a/src/test/java/starlight/application/member/required/DaumSpellCheckerHttpTest.java +++ b/src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerHttpTest.java @@ -1,4 +1,4 @@ -package starlight.application.member.required; +package starlight.adapter.businessplan.spellcheck; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,7 +16,6 @@ import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import starlight.adapter.businessplan.spellcheck.DaumSpellChecker; import starlight.adapter.businessplan.spellcheck.dto.Finding; import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil; diff --git a/src/test/java/starlight/application/member/required/DaumSpellCheckerLogicTest.java b/src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerLogicTest.java similarity index 94% rename from src/test/java/starlight/application/member/required/DaumSpellCheckerLogicTest.java rename to src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerLogicTest.java index 97ceed6f..a3a04fb0 100644 --- a/src/test/java/starlight/application/member/required/DaumSpellCheckerLogicTest.java +++ b/src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerLogicTest.java @@ -1,4 +1,4 @@ -package starlight.application.member.required; +package starlight.adapter.businessplan.spellcheck; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -8,7 +8,6 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.client.RestClient; -import starlight.adapter.businessplan.spellcheck.DaumSpellChecker; import starlight.adapter.businessplan.spellcheck.dto.Finding; import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil; diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java b/src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java similarity index 82% rename from src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java rename to src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java index 80891a04..94e02e93 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java +++ b/src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.webapi; +package starlight.adapter.businessplan.webapi; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,10 +11,9 @@ import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -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.PresignedUrlProviderPort; +import starlight.application.businessplan.required.PresignedUrlProviderPort; import starlight.bootstrap.SecurityConfig; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -27,7 +26,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest( - controllers = ImageController.class, + controllers = BusinessPlanImageController.class, excludeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { JwtFilter.class, @@ -36,8 +35,8 @@ } ) @AutoConfigureMockMvc(addFilters = false) -@DisplayName("ImageController 통합 테스트") -class ImageControllerIntegrationTest { +@DisplayName("BusinessPlanImageController 통합 테스트") +class BusinessPlanImageControllerIntegrationTest { @Autowired private MockMvc mockMvc; @@ -57,7 +56,7 @@ private AuthDetails createMockAuthDetails(Long memberId) { } // @Test -// @DisplayName("GET /v1/images/upload-url - Presigned URL 조회 성공") +// @DisplayName("GET /v1/business-plans/images/upload-url - Presigned URL 조회 성공") // @WithMockUser // (선택) user(...)와 중복이면 제거 가능 // void getPresignedUrl_Success() throws Exception { // // given @@ -70,7 +69,7 @@ private AuthDetails createMockAuthDetails(Long memberId) { // given(presignedUrlProvider.getPreSignedUrl(userId, fileName)).willReturn(response); // // // when & then -// mockMvc.perform(get("/v1/images/upload-url") +// mockMvc.perform(get("/v1/business-plans/images/upload-url") // .with(user(createMockAuthDetails(userId))) // .param("fileName", fileName) // .contentType(MediaType.APPLICATION_JSON)) @@ -84,10 +83,10 @@ private AuthDetails createMockAuthDetails(Long memberId) { // } @Test - @DisplayName("GET /v1/images/upload-url - fileName 누락 시 400 에러") + @DisplayName("GET /v1/business-plans/images/upload-url - fileName 누락 시 400 에러") void getPresignedUrl_MissingFileName() throws Exception { // when & then - mockMvc.perform(get("/v1/images/upload-url") + mockMvc.perform(get("/v1/business-plans/images/upload-url") .param("userId", "1") .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) @@ -97,14 +96,14 @@ void getPresignedUrl_MissingFileName() throws Exception { } @Test - @DisplayName("POST /v1/images/upload-url/public - 이미지 공개 처리 성공") + @DisplayName("POST /v1/business-plans/images/upload-url/public - 이미지 공개 처리 성공") void finalizePublic_Success() throws Exception { // given String objectUrl = "https://test-bucket.kr.object.ncloudstorage.com/1/test-image.jpg"; given(presignedUrlProvider.makePublic(objectUrl)).willReturn(objectUrl); // when & then - mockMvc.perform(post("/v1/images/upload-url/public") + mockMvc.perform(post("/v1/business-plans/images/upload-url/public") .param("objectUrl", objectUrl) .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) @@ -116,10 +115,10 @@ void finalizePublic_Success() throws Exception { } @Test - @DisplayName("POST /v1/images/upload-url/public - objectUrl 누락 시 400 에러") + @DisplayName("POST /v1/business-plans/images/upload-url/public - objectUrl 누락 시 400 에러") void finalizePublic_MissingObjectUrl() throws Exception { // when & then - mockMvc.perform(post("/v1/images/upload-url/public") + mockMvc.perform(post("/v1/business-plans/images/upload-url/public") .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpect(status().isBadRequest()); @@ -128,7 +127,7 @@ void finalizePublic_MissingObjectUrl() throws Exception { } @Test - @DisplayName("POST /v1/images/upload-url/public - 잘못된 URL 형식으로 예외 발생") + @DisplayName("POST /v1/business-plans/images/upload-url/public - 잘못된 URL 형식으로 예외 발생") void finalizePublic_InvalidUrl() throws Exception { // given String invalidUrl = "invalid-url"; @@ -136,7 +135,7 @@ void finalizePublic_InvalidUrl() throws Exception { .willThrow(new IllegalArgumentException("잘못된 URL 형식")); // when & then - mockMvc.perform(post("/v1/images/upload-url/public") + mockMvc.perform(post("/v1/business-plans/images/upload-url/public") .param("objectUrl", invalidUrl) .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientIntegrationTest.java b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java similarity index 73% rename from src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientIntegrationTest.java rename to src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java index 660b1980..173eb5bf 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientIntegrationTest.java +++ b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.ocr.infra; +package starlight.adapter.shared.infrastructure.pdf; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -9,9 +9,8 @@ import org.junit.jupiter.api.Test; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; -import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadErrorType; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadException; import java.io.IOException; import java.time.Duration; @@ -50,7 +49,7 @@ void tearDown() throws IOException { @Test @DisplayName("실제 HTTP 요청으로 PDF 다운로드 성공") - void downloadPdfFromUrl_RealHttpRequest_Success() throws InterruptedException { + void downloadFromUrl_RealHttpRequest_Success() throws InterruptedException { // given byte[] expectedBytes = "PDF content".getBytes(); mockWebServer.enqueue(new MockResponse() @@ -61,7 +60,7 @@ void downloadPdfFromUrl_RealHttpRequest_Success() throws InterruptedException { String url = baseUrl + "test.pdf"; // when - byte[] result = pdfDownloadClient.downloadPdfFromUrl(url); + byte[] result = pdfDownloadClient.downloadFromUrl(url); // then assertThat(result).isEqualTo(expectedBytes); @@ -74,7 +73,7 @@ void downloadPdfFromUrl_RealHttpRequest_Success() throws InterruptedException { @Test @DisplayName("404 응답 시 PDF_DOWNLOAD_ERROR 예외 발생") - void downloadPdfFromUrl_Returns404_ThrowsException() { + void downloadFromUrl_Returns404_ThrowsException() { // given mockWebServer.enqueue(new MockResponse() .setResponseCode(404) @@ -83,14 +82,14 @@ void downloadPdfFromUrl_Returns404_ThrowsException() { String url = baseUrl + "notfound.pdf"; // when & then - assertThatThrownBy(() -> pdfDownloadClient.downloadPdfFromUrl(url)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + assertThatThrownBy(() -> pdfDownloadClient.downloadFromUrl(url)) + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_DOWNLOAD_ERROR); } @Test @DisplayName("500 응답 시 PDF_DOWNLOAD_ERROR 예외 발생") - void downloadPdfFromUrl_Returns500_ThrowsException() { + void downloadFromUrl_Returns500_ThrowsException() { // given mockWebServer.enqueue(new MockResponse() .setResponseCode(500) @@ -99,14 +98,14 @@ void downloadPdfFromUrl_Returns500_ThrowsException() { String url = baseUrl + "error.pdf"; // when & then - assertThatThrownBy(() -> pdfDownloadClient.downloadPdfFromUrl(url)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + assertThatThrownBy(() -> pdfDownloadClient.downloadFromUrl(url)) + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_DOWNLOAD_ERROR); } @Test @DisplayName("빈 응답 본문인 경우 PDF_EMPTY_RESPONSE 예외 발생") - void downloadPdfFromUrl_EmptyBody_ThrowsException() { + void downloadFromUrl_EmptyBody_ThrowsException() { // given mockWebServer.enqueue(new MockResponse() .setResponseCode(200) @@ -115,14 +114,14 @@ void downloadPdfFromUrl_EmptyBody_ThrowsException() { String url = baseUrl + "empty.pdf"; // when & then - assertThatThrownBy(() -> pdfDownloadClient.downloadPdfFromUrl(url)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); + assertThatThrownBy(() -> pdfDownloadClient.downloadFromUrl(url)) + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_EMPTY_RESPONSE); } @Test @DisplayName("쿼리 파라미터가 포함된 URL 처리") - void downloadPdfFromUrl_WithQueryParams_Success() throws InterruptedException { + void downloadFromUrl_WithQueryParams_Success() throws InterruptedException { // given byte[] expectedBytes = "PDF with params".getBytes(); mockWebServer.enqueue(new MockResponse() @@ -132,7 +131,7 @@ void downloadPdfFromUrl_WithQueryParams_Success() throws InterruptedException { String url = baseUrl + "test.pdf?token=abc123&expires=2025-12-31"; // when - byte[] result = pdfDownloadClient.downloadPdfFromUrl(url); + byte[] result = pdfDownloadClient.downloadFromUrl(url); // then assertThat(result).isEqualTo(expectedBytes); @@ -144,7 +143,7 @@ void downloadPdfFromUrl_WithQueryParams_Success() throws InterruptedException { @Test @DisplayName("큰 PDF 파일 다운로드 성공 (10MB)") - void downloadPdfFromUrl_LargeFile_Success() { + void downloadFromUrl_LargeFile_Success() { // given byte[] largeBytes = new byte[10 * 1024 * 1024]; // 10MB mockWebServer.enqueue(new MockResponse() @@ -154,7 +153,7 @@ void downloadPdfFromUrl_LargeFile_Success() { String url = baseUrl + "large.pdf"; // when - byte[] result = pdfDownloadClient.downloadPdfFromUrl(url); + byte[] result = pdfDownloadClient.downloadFromUrl(url); // then assertThat(result).hasSize(10 * 1024 * 1024); diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientTest.java b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java similarity index 78% rename from src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientTest.java rename to src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java index 55f6aad0..8a673774 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientTest.java +++ b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java @@ -1,13 +1,12 @@ -package starlight.adapter.aireport.infrastructure.ocr.infra; +package starlight.adapter.shared.infrastructure.pdf; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestClient; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; -import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadErrorType; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadException; import java.net.URI; @@ -41,7 +40,7 @@ void setUp() { @Test @DisplayName("정상적인 PDF 다운로드 성공") - void downloadPdfFromUrl_Success() { + void downloadFromUrl_Success() { // given ResponseEntity responseEntity = ResponseEntity.ok(testPdfBytes); @@ -51,7 +50,7 @@ void downloadPdfFromUrl_Success() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when - byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL); + byte[] result = pdfDownloadClientInstance.downloadFromUrl(TEST_URL); // then assertThat(result).isEqualTo(testPdfBytes); @@ -61,7 +60,7 @@ void downloadPdfFromUrl_Success() { @Test @DisplayName("빈 응답인 경우 PDF_EMPTY_RESPONSE 예외 발생") - void downloadPdfFromUrl_ThrowsException_WhenResponseIsEmpty() { + void downloadFromUrl_ThrowsException_WhenResponseIsEmpty() { // given ResponseEntity responseEntity = ResponseEntity.ok(new byte[0]); @@ -71,14 +70,14 @@ void downloadPdfFromUrl_ThrowsException_WhenResponseIsEmpty() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when & then - assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_EMPTY_RESPONSE); } @Test @DisplayName("응답 Body가 null인 경우 PDF_EMPTY_RESPONSE 예외 발생") - void downloadPdfFromUrl_ThrowsException_WhenResponseBodyIsNull() { + void downloadFromUrl_ThrowsException_WhenResponseBodyIsNull() { // given ResponseEntity responseEntity = ResponseEntity.ok().build(); @@ -88,14 +87,14 @@ void downloadPdfFromUrl_ThrowsException_WhenResponseBodyIsNull() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when & then - assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_EMPTY_RESPONSE); } @Test @DisplayName("PDF 크기가 30MB를 초과하면 PDF_TOO_LARGE 예외 발생") - void downloadPdfFromUrl_ThrowsException_WhenPdfIsTooLarge() { + void downloadFromUrl_ThrowsException_WhenPdfIsTooLarge() { // given byte[] largePdfBytes = createPdfBytes(31 * 1024 * 1024); // 31MB ResponseEntity responseEntity = ResponseEntity.ok(largePdfBytes); @@ -106,14 +105,14 @@ void downloadPdfFromUrl_ThrowsException_WhenPdfIsTooLarge() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when & then - assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_TOO_LARGE); + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_TOO_LARGE); } @Test @DisplayName("정확히 30MB인 PDF는 정상 다운로드") - void downloadPdfFromUrl_Success_WhenPdfIsExactly30MB() { + void downloadFromUrl_Success_WhenPdfIsExactly30MB() { // given byte[] exactSizePdfBytes = createPdfBytes(30 * 1024 * 1024); // 정확히 30MB ResponseEntity responseEntity = ResponseEntity.ok(exactSizePdfBytes); @@ -124,7 +123,7 @@ void downloadPdfFromUrl_Success_WhenPdfIsExactly30MB() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when - byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL); + byte[] result = pdfDownloadClientInstance.downloadFromUrl(TEST_URL); // then assertThat(result).isEqualTo(exactSizePdfBytes); @@ -132,21 +131,21 @@ void downloadPdfFromUrl_Success_WhenPdfIsExactly30MB() { @Test @DisplayName("네트워크 예외 발생 시 PDF_DOWNLOAD_ERROR 예외 발생") - void downloadPdfFromUrl_ThrowsException_WhenNetworkError() { + void downloadFromUrl_ThrowsException_WhenNetworkError() { // given when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); when(requestHeadersUriSpec.retrieve()).thenThrow(new RuntimeException("Network error")); // when & then - assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_DOWNLOAD_ERROR); } @Test @DisplayName("특수문자가 포함된 URL도 정상 처리") - void downloadPdfFromUrl_Success_WithEncodedUrl() { + void downloadFromUrl_Success_WithEncodedUrl() { // given String encodedUrl = "https://example.com/test%20file.pdf?param=value&signed=abc123"; ResponseEntity responseEntity = ResponseEntity.ok(testPdfBytes); @@ -157,7 +156,7 @@ void downloadPdfFromUrl_Success_WithEncodedUrl() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when - byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(encodedUrl); + byte[] result = pdfDownloadClientInstance.downloadFromUrl(encodedUrl); // then assertThat(result).isEqualTo(testPdfBytes); @@ -166,7 +165,7 @@ void downloadPdfFromUrl_Success_WithEncodedUrl() { @Test @DisplayName("프리사인드 URL도 정상 처리") - void downloadPdfFromUrl_Success_WithPresignedUrl() { + void downloadFromUrl_Success_WithPresignedUrl() { // given String presignedUrl = "https://s3.amazonaws.com/bucket/file.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=xxx"; ResponseEntity responseEntity = ResponseEntity.ok(testPdfBytes); @@ -177,7 +176,7 @@ void downloadPdfFromUrl_Success_WithPresignedUrl() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when - byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(presignedUrl); + byte[] result = pdfDownloadClientInstance.downloadFromUrl(presignedUrl); // then assertThat(result).isEqualTo(testPdfBytes); diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java b/src/test/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java similarity index 99% rename from src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java rename to src/test/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java index 2b3d8c40..9d9c3510 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java +++ b/src/test/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.storage; +package starlight.adapter.shared.infrastructure.storage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index 5de51af5..01a08c8e 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -10,7 +10,7 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.adapter.aireport.persistence.AiReportJpa; import starlight.adapter.aireport.persistence.AiReportRepository; import starlight.adapter.businessplan.persistence.BusinessPlanQueryJpa; @@ -24,7 +24,7 @@ import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.aireport.required.BusinessPlanCommandLookupPort; import starlight.application.aireport.required.BusinessPlanQueryLookupPort; -import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.aireport.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; diff --git a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index 1cc0c191..9ba3371f 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.ReportGraderPort; import starlight.application.aireport.required.AiReportQueryPort; @@ -11,7 +11,7 @@ import starlight.application.aireport.required.OcrProviderPort; import starlight.application.aireport.required.BusinessPlanCommandLookupPort; import starlight.application.aireport.required.BusinessPlanQueryLookupPort; -import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.aireport.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -94,7 +94,7 @@ void gradeBusinessPlan_createsNewReport() { when(aiReportCommand.save(any(AiReport.class))).thenReturn(savedReport); when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -156,7 +156,7 @@ void gradeBusinessPlan_updatesExistingReport() { when(aiReportCommand.save(existingReport)).thenReturn(existingReport); when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -178,7 +178,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { when(plan.isOwnedBy(memberId)).thenReturn(false); when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -198,7 +198,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { when(plan.areWritingCompleted()).thenReturn(false); when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -236,7 +236,7 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when AiReportResult result = sut.getAiReport(planId, memberId); @@ -260,7 +260,7 @@ void getAiReport_throwsExceptionWhenNotFound() { when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java index 80fff9ea..4b67f16e 100644 --- a/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java +++ b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.aireport.exception.AiReportErrorType; diff --git "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" index f45704e2..a072bce8 100644 --- "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" +++ "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" @@ -76,3 +76,8 @@ ## 로컬 실행 - `./gradlew bootRun --args='--spring.profiles.active=dev'` + +## 어댑터 레이어 shared 규칙 +- adapter/shared/*(및 PDF·이미지 등 공용 인프라 어댑터)는 Lookup 포트가 아닌 직접 포트만 사용한다. +- 즉, PDF·사진(이미지) 등 여러 도메인에서 쓰는 기능은: application/pdf, application/storage 같은 공용 application 도메인을 두지 않는다. +- 사용하는 application 도메인마다 해당 도메인의 required에 직접 포트(예: PdfDownloadPort, PresignedUrlProviderPort)를 둔다.