From 41713156435e670c85d4db25233cb9827e33ba51 Mon Sep 17 00:00:00 2001 From: johnbosco0414 Date: Tue, 14 Oct 2025 14:15:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FIX]:=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=EC=83=9D=EC=84=B1=EB=94=94=EB=B2=84=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- back/.gitignore | 4 -- .../repository/ScenarioRepository.java | 20 ++++++ .../scenario/service/ScenarioService.java | 32 ++++----- .../service/ScenarioTransactionService.java | 69 +++++++++++++------ .../image/StableDiffusionImageClient.java | 47 ++++++++----- .../ai/client/text/GeminiTextClient.java | 37 ++++++++-- .../ai/dto/result/BaseScenarioResult.java | 34 ++++++++- .../ai/dto/result/DecisionScenarioResult.java | 57 ++++++++++++++- .../global/ai/prompt/BaseScenarioPrompt.java | 15 ++-- .../ai/prompt/DecisionScenarioPrompt.java | 24 +++---- .../back/global/ai/service/AiServiceImpl.java | 34 ++++++++- back/src/main/resources/application.yml | 12 ++-- .../scenario/service/ScenarioServiceTest.java | 10 ++- .../global/ai/service/AiServiceImplTest.java | 62 ++++++++++++----- 15 files changed, 344 insertions(+), 118 deletions(-) diff --git a/.gitignore b/.gitignore index 7b7be94..00a1cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ uploads/ # 테스트 이미지 경로 (LocalStorageServiceTest) test-uploads/ -./test-uploads/ \ No newline at end of file +./test-uploads/ + +### Environment Variables ### +.env \ No newline at end of file diff --git a/back/.gitignore b/back/.gitignore index 21cee85..b562344 100644 --- a/back/.gitignore +++ b/back/.gitignore @@ -39,10 +39,6 @@ out/ ### Environment Variables ### .env -# AI 생성 이미지 저장 경로 -uploads/ -./uploads/ - # 테스트 이미지 경로 (LocalStorageServiceTest) test-uploads/ ./test-uploads/ diff --git a/back/src/main/java/com/back/domain/scenario/repository/ScenarioRepository.java b/back/src/main/java/com/back/domain/scenario/repository/ScenarioRepository.java index fa624a0..a3b8420 100644 --- a/back/src/main/java/com/back/domain/scenario/repository/ScenarioRepository.java +++ b/back/src/main/java/com/back/domain/scenario/repository/ScenarioRepository.java @@ -23,6 +23,10 @@ public interface ScenarioRepository extends JpaRepository { @Query("SELECT s FROM Scenario s LEFT JOIN FETCH s.baseLine bl LEFT JOIN FETCH bl.scenarios WHERE s.id = :id AND s.user.id = :userId") Optional findByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId); + // 시나리오 상태 폴링용 조회 (권한 검증 포함, 최소 데이터만 로딩) + @Query("SELECT s FROM Scenario s WHERE s.id = :id AND s.user.id = :userId") + Optional findByIdAndUserIdForStatusCheck(@Param("id") Long id, @Param("userId") Long userId); + // 베이스 시나리오 조회 Optional findByBaseLineIdAndDecisionLineIsNull(Long baseLineId); @@ -33,6 +37,22 @@ public interface ScenarioRepository extends JpaRepository { @Query("SELECT s FROM Scenario s WHERE s.decisionLine.id = :decisionLineId") Optional findByDecisionLineId(@Param("decisionLineId") Long decisionLineId); + // AI 생성용 시나리오 조회 - 1단계: DecisionLine + DecisionNodes + @Query("SELECT DISTINCT s FROM Scenario s " + + "LEFT JOIN FETCH s.decisionLine dl " + + "LEFT JOIN FETCH dl.decisionNodes dn " + + "WHERE s.id = :id") + Optional findByIdWithDecisionNodes(@Param("id") Long id); + + // AI 생성용 시나리오 조회 - 2단계: BaseLine + BaseNodes + User + @Query("SELECT DISTINCT s FROM Scenario s " + + "LEFT JOIN FETCH s.decisionLine dl " + + "LEFT JOIN FETCH dl.baseLine bl " + + "LEFT JOIN FETCH bl.baseNodes bn " + + "LEFT JOIN FETCH bl.user " + + "WHERE s.id = :id") + Optional findByIdWithDecisionLineAndBaseLine(@Param("id") Long id); + // DecisionLine 기반 시나리오 조회 (권한 검증 포함, baseLine.scenarios fetch) @Query("SELECT s FROM Scenario s LEFT JOIN FETCH s.baseLine bl LEFT JOIN FETCH bl.scenarios WHERE s.decisionLine.id = :decisionLineId AND s.user.id = :userId") Optional findByDecisionLineIdAndUserId( diff --git a/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java b/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java index 4ebbc6f..1a353e9 100644 --- a/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java +++ b/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java @@ -108,8 +108,8 @@ private ScenarioValidationResult validateScenarioCreation( ScenarioCreateRequest request, @Nullable DecisionNodeNextRequest lastDecision) { - // DecisionLine 존재 여부 확인 - DecisionLine decisionLine = decisionLineRepository.findById(request.decisionLineId()) + // DecisionLine 존재 여부 확인 (User EAGER 로딩) + DecisionLine decisionLine = decisionLineRepository.findWithUserById(request.decisionLineId()) .orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND)); // 권한 검증 @@ -312,17 +312,20 @@ public void processScenarioGenerationAsync(Long scenarioId) { // 1. 상태를 PROCESSING으로 업데이트 (별도 트랜잭션) scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.PROCESSING, null); - // 2. AI 시나리오 생성 (트랜잭션 외부에서 실행) - AiScenarioGenerationResult result = executeAiGeneration(scenarioId); + // 2. AI 생성에 필요한 모든 데이터를 트랜잭션 내에서 미리 로드 + Scenario scenarioWithData = scenarioTransactionService.prepareScenarioData(scenarioId); - // 3. 결과 저장 및 완료 상태 업데이트 (별도 트랜잭션) + // 3. AI 시나리오 생성 (트랜잭션 외부에서 실행) + AiScenarioGenerationResult result = executeAiGeneration(scenarioWithData); + + // 4. 결과 저장 및 완료 상태 업데이트 (별도 트랜잭션) scenarioTransactionService.saveAiResult(scenarioId, result); scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.COMPLETED, null); log.info("Scenario generation completed successfully for ID: {}", scenarioId); } catch (Exception e) { - // 4. 실패 상태 업데이트 (별도 트랜잭션) + // 5. 실패 상태 업데이트 (별도 트랜잭션) scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.FAILED, "시나리오 생성 실패: " + e.getMessage()); log.error("Scenario generation failed for ID: {}, error: {}", @@ -331,11 +334,8 @@ public void processScenarioGenerationAsync(Long scenarioId) { } // AI 호출 전용 메서드 (트랜잭션 없음) - private AiScenarioGenerationResult executeAiGeneration(Long scenarioId) { - Scenario scenario = scenarioRepository.findById(scenarioId) - .orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND)); - - // AI 호출 로직 (트랜잭션 외부에서 실행) + private AiScenarioGenerationResult executeAiGeneration(Scenario scenario) { + // AI 호출 로직 (미리 로드된 데이터 사용) DecisionLine decisionLine = scenario.getDecisionLine(); BaseLine baseLine = decisionLine.getBaseLine(); @@ -347,7 +347,7 @@ private AiScenarioGenerationResult executeAiGeneration(Long scenarioId) { .generateDecisionScenario(decisionLine, baseScenario) .orTimeout(60, java.util.concurrent.TimeUnit.SECONDS) .exceptionally(ex -> { - log.error("Decision scenario generation timeout or error for scenario ID: {}", scenarioId, ex); + log.error("Decision scenario generation timeout or error for scenario ID: {}", scenario.getId(), ex); throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "시나리오 생성 시간 초과 (60초)"); }) .join(); @@ -365,12 +365,12 @@ private Scenario ensureBaseScenarioExists(BaseLine baseLine) { private Scenario createBaseScenario(BaseLine baseLine) { log.info("Creating base scenario for BaseLine ID: {}", baseLine.getId()); - // 1. AI 호출 with 타임아웃 (60초) + // 1. AI 호출 with 타임아웃 (180초 - 테스트용) BaseScenarioResult aiResult = aiService.generateBaseScenario(baseLine) - .orTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .orTimeout(180, java.util.concurrent.TimeUnit.SECONDS) .exceptionally(ex -> { log.error("Base scenario generation timeout or error for BaseLine ID: {}", baseLine.getId(), ex); - throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "베이스 시나리오 생성 시간 초과 (60초)"); + throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "베이스 시나리오 생성 시간 초과 (180초)"); }) .join(); @@ -394,7 +394,7 @@ private Scenario createBaseScenario(BaseLine baseLine) { @Transactional(readOnly = true) public ScenarioStatusResponse getScenarioStatus(Long scenarioId, Long userId) { // 권한 검증 및 조회 - Scenario scenario = scenarioRepository.findByIdAndUserId(scenarioId, userId) + Scenario scenario = scenarioRepository.findByIdAndUserIdForStatusCheck(scenarioId, userId) .orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND)); // DTO 변환 및 반환 diff --git a/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java b/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java index 337f841..da1309c 100644 --- a/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java +++ b/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java @@ -9,6 +9,7 @@ import com.back.domain.scenario.repository.SceneTypeRepository; import com.back.global.ai.dto.result.BaseScenarioResult; import com.back.global.ai.dto.result.DecisionScenarioResult; +import com.back.global.ai.exception.AiParsingException; import com.back.global.ai.service.AiService; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; @@ -164,24 +165,30 @@ private void handleImageGeneration(Scenario scenario, String imagePrompt) { } private int calculateTotalScore(Map indicatorScores) { + if (indicatorScores == null || indicatorScores.isEmpty()) { + log.warn("indicatorScores is null or empty, returning 0"); + return 0; + } return indicatorScores.values().stream() .mapToInt(Integer::intValue) .sum(); } private void createBaseSceneTypes(Scenario scenario, BaseScenarioResult aiResult) { - List sceneTypes = aiResult.indicatorScores() - .entrySet().stream() - .map(entry -> { - Type type = entry.getKey(); - int point = entry.getValue(); - String analysis = aiResult.indicatorAnalysis().get(type); + if (aiResult.indicators() == null || aiResult.indicators().isEmpty()) { + log.warn("No indicator scores available for scenario {}", scenario.getId()); + return; + } + + List sceneTypes = aiResult.indicators().stream() + .map(indicator -> { + Type type = Type.valueOf(indicator.type()); return com.back.domain.scenario.entity.SceneType.builder() .scenario(scenario) .type(type) - .point(point) // AI가 분석한 실제 점수 - .analysis(analysis != null ? analysis : "현재 " + type.name() + " 상황 분석") + .point(indicator.point()) // AI가 분석한 실제 점수 + .analysis(indicator.analysis() != null ? indicator.analysis() : "현재 " + type.name() + " 상황 분석") .build(); }) .toList(); @@ -190,18 +197,19 @@ private void createBaseSceneTypes(Scenario scenario, BaseScenarioResult aiResult } private void createDecisionSceneTypes(Scenario scenario, DecisionScenarioResult aiResult) { - List sceneTypes = aiResult.indicatorScores() - .entrySet().stream() - .map(entry -> { - Type type = entry.getKey(); - int point = entry.getValue(); - String analysis = aiResult.indicatorAnalysis().get(type); + if (aiResult.indicators() == null || aiResult.indicators().isEmpty()) { + throw new AiParsingException("Indicator scores are missing in AI response for scenario " + scenario.getId()); + } + + List sceneTypes = aiResult.indicators().stream() + .map(indicator -> { + Type type = Type.valueOf(indicator.type()); return com.back.domain.scenario.entity.SceneType.builder() .scenario(scenario) .type(type) - .point(point) - .analysis(analysis != null ? analysis : "분석 정보를 가져올 수 없습니다.") + .point(indicator.point()) + .analysis(indicator.analysis() != null ? indicator.analysis() : "분석 정보를 가져올 수 없습니다.") .build(); }) .toList(); @@ -210,10 +218,12 @@ private void createDecisionSceneTypes(Scenario scenario, DecisionScenarioResult } private void createSceneCompare(Scenario scenario, DecisionScenarioResult aiResult) { - List compares = aiResult.comparisonResults() - .entrySet().stream() - .map(entry -> { - String resultTypeStr = entry.getKey().toUpperCase(); + if (aiResult.comparisons() == null || aiResult.comparisons().isEmpty()) { + throw new AiParsingException("Comparison results are missing in AI response for scenario " + scenario.getId()); + } + List compares = aiResult.comparisons().stream() + .map(comparison -> { + String resultTypeStr = comparison.type().toUpperCase(); com.back.domain.scenario.entity.SceneCompareResultType resultType; try { resultType = com.back.domain.scenario.entity.SceneCompareResultType.valueOf(resultTypeStr); @@ -225,7 +235,7 @@ private void createSceneCompare(Scenario scenario, DecisionScenarioResult aiResu return SceneCompare.builder() .scenario(scenario) .resultType(resultType) - .compareResult(entry.getValue()) + .compareResult(comparison.analysis()) .build(); }) .toList(); @@ -240,4 +250,21 @@ private void updateBaseLineTitle(BaseLine baseLine, String baselineTitle) { log.info("BaseLine title updated for ID: {}", baseLine.getId()); } } + + @Transactional(readOnly = true) + public Scenario prepareScenarioData(Long scenarioId) { + // MultipleBagFetchException을 피하기 위해 별도 쿼리로 각 컬렉션을 초기화 + // 1. DecisionNodes 초기화 + Scenario scenario = scenarioRepository.findById(scenarioId) + .orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND)); + scenario.getDecisionLine().getDecisionNodes().size(); // 프록시 초기화 + + // 2. BaseNodes 초기화 + scenario.getBaseLine().getBaseNodes().size(); // 프록시 초기화 + + // 3. User 엔티티 초기화 (새로운 오류 방지) + scenario.getUser().getMbti(); // User 프록시 초기화 + + return scenario; + } } diff --git a/back/src/main/java/com/back/global/ai/client/image/StableDiffusionImageClient.java b/back/src/main/java/com/back/global/ai/client/image/StableDiffusionImageClient.java index a1c2f66..1980010 100644 --- a/back/src/main/java/com/back/global/ai/client/image/StableDiffusionImageClient.java +++ b/back/src/main/java/com/back/global/ai/client/image/StableDiffusionImageClient.java @@ -118,31 +118,42 @@ private Mono extractImageData(String response) { try { JsonNode rootNode = objectMapper.readTree(response); - // Stability AI 공식 응답 구조: { "artifacts": [{ "base64": "..." }] } - if (rootNode.has("artifacts") && rootNode.get("artifacts").isArray()) { + // 1. 단순 응답 구조 체크: {"image": "..."} + if (rootNode.has("image")) { + String base64Data = rootNode.get("image").asText(); + if (base64Data != null && !base64Data.isEmpty()) { + log.info("Image generated successfully (simple structure). Base64 length: {}", base64Data.length()); + return Mono.just(base64Data); + } + } + + // 2. 공식 응답 구조 체크: { "artifacts": [{...}] } + if (rootNode.has("artifacts") && rootNode.get("artifacts").isArray() && !rootNode.get("artifacts").isEmpty()) { JsonNode firstArtifact = rootNode.get("artifacts").get(0); - // finishReason 검증 - if (firstArtifact.has("finishReason")) { - String finishReason = firstArtifact.get("finishReason").asText(); - if (!"SUCCESS".equals(finishReason)) { - log.error("Image generation failed with reason: {}", finishReason); - return Mono.error(new AiServiceException( - ErrorCode.AI_GENERATION_FAILED, - "Image generation failed: " + finishReason - )); + if (firstArtifact != null) { + // finishReason 검증 + if (firstArtifact.has("finishReason")) { + String finishReason = firstArtifact.get("finishReason").asText(); + if (!"SUCCESS".equals(finishReason)) { + log.error("Image generation failed with reason: {}", finishReason); + return Mono.error(new AiServiceException( + ErrorCode.AI_GENERATION_FAILED, + "Image generation failed: " + finishReason + )); + } } - } - // Base64 데이터 추출 - if (firstArtifact.has("base64")) { - String base64Data = firstArtifact.get("base64").asText(); - log.info("Image generated successfully. Base64 length: {}", base64Data.length()); - return Mono.just(base64Data); + // Base64 데이터 추출 + if (firstArtifact.has("base64")) { + String base64Data = firstArtifact.get("base64").asText(); + log.info("Image generated successfully (artifacts structure). Base64 length: {}", base64Data.length()); + return Mono.just(base64Data); + } } } - // 응답 구조가 예상과 다를 경우 + // 두 구조 모두 해당하지 않을 경우 log.error("Unexpected Stable Diffusion API response structure: {}", response); return Mono.error(new AiServiceException( ErrorCode.AI_GENERATION_FAILED, diff --git a/back/src/main/java/com/back/global/ai/client/text/GeminiTextClient.java b/back/src/main/java/com/back/global/ai/client/text/GeminiTextClient.java index 9b88ac3..f8be86d 100644 --- a/back/src/main/java/com/back/global/ai/client/text/GeminiTextClient.java +++ b/back/src/main/java/com/back/global/ai/client/text/GeminiTextClient.java @@ -54,10 +54,13 @@ public CompletableFuture generateText(AiRequest aiRequest) { .retrieve() .onStatus(HttpStatusCode::isError, this::handleErrorResponse) .bodyToMono(GeminiResponse.class) + .doOnNext(response -> log.debug("Gemini API response received: candidates={}, finishReason={}", + response.candidates().size(), + response.candidates().isEmpty() ? "N/A" : response.candidates().get(0).finishReason())) .map(this::extractContent) .timeout(Duration.ofSeconds(textAiConfig.getTimeoutSeconds())) .retryWhen(Retry.backoff(textAiConfig.getMaxRetries(), Duration.ofSeconds(textAiConfig.getRetryDelaySeconds()))) - .doOnError(error -> log.error("Gemini API call failed: {}", error.getMessage())) + .doOnError(error -> log.error("Gemini API call failed: {}", error.getMessage(), error)) .toFuture(); } @@ -77,25 +80,47 @@ private Map createGeminiRequest(String prompt, int maxTokens) { private String extractContent(GeminiResponse response) { try { + log.debug("Extracting content from Gemini response: candidates count={}", + response.candidates() != null ? response.candidates().size() : "null"); + // candidates가 비어있는 경우 - if (response.candidates().isEmpty()) { + if (response.candidates() == null || response.candidates().isEmpty()) { + log.error("No candidates in Gemini response"); throw new AiParsingException("No candidates in Gemini response"); } GeminiResponse.Candidate candidate = response.candidates().get(0); + log.debug("Candidate finishReason: {}, content: {}", + candidate.finishReason(), + candidate.content() != null ? "present" : "null"); // 안전성 필터에 걸린 경우 if ("SAFETY".equals(candidate.finishReason())) { + log.error("Content blocked by safety filters"); throw new AiParsingException("Content blocked by safety filters"); } - // 내용이 없는 경우 - if (candidate.content().parts().isEmpty()) { - throw new AiParsingException("No parts in candidate content"); + // content가 null인 경우 + if (candidate.content() == null) { + log.error("Content is null in candidate, finishReason: {}", candidate.finishReason()); + throw new AiParsingException("Content is null in candidate (finishReason: " + candidate.finishReason() + ")"); + } + + // parts가 null이거나 비어있는 경우 + if (candidate.content().parts() == null || candidate.content().parts().isEmpty()) { + log.error("No parts in candidate content, finishReason: {}, parts: {}", + candidate.finishReason(), + candidate.content().parts() == null ? "null" : "empty"); + throw new AiParsingException("No parts in candidate content (finishReason: " + candidate.finishReason() + ")"); } - return candidate.content().parts().get(0).text(); + String text = candidate.content().parts().get(0).text(); + log.debug("Successfully extracted text, length: {}", text != null ? text.length() : 0); + return text; + } catch (AiParsingException e) { + throw e; } catch (Exception e) { + log.error("Unexpected error while extracting content", e); throw new AiParsingException("Failed to extract content from Gemini response: " + e.getMessage()); } } diff --git a/back/src/main/java/com/back/global/ai/dto/result/BaseScenarioResult.java b/back/src/main/java/com/back/global/ai/dto/result/BaseScenarioResult.java index 54b7258..14a706f 100644 --- a/back/src/main/java/com/back/global/ai/dto/result/BaseScenarioResult.java +++ b/back/src/main/java/com/back/global/ai/dto/result/BaseScenarioResult.java @@ -2,6 +2,7 @@ import com.back.domain.scenario.entity.Type; +import java.util.List; import java.util.Map; /** @@ -15,7 +16,36 @@ public record BaseScenarioResult( int total, // 5개 지표 점수 합계 Map timelineTitles, String baselineTitle, - Map indicatorScores, - Map indicatorAnalysis + List indicators // AI 응답 구조와 일치 (배열) ) { + /** + * AI 응답의 indicators 배열 요소 + */ + public record Indicator( + String type, + int point, + String analysis + ) {} + + /** + * indicators 배열을 Map로 변환 + */ + public Map indicatorScores() { + return indicators.stream() + .collect(java.util.stream.Collectors.toMap( + ind -> Type.valueOf(ind.type), + Indicator::point + )); + } + + /** + * indicators 배열을 Map로 변환 + */ + public Map indicatorAnalysis() { + return indicators.stream() + .collect(java.util.stream.Collectors.toMap( + ind -> Type.valueOf(ind.type), + Indicator::analysis + )); + } } diff --git a/back/src/main/java/com/back/global/ai/dto/result/DecisionScenarioResult.java b/back/src/main/java/com/back/global/ai/dto/result/DecisionScenarioResult.java index 0875178..764db98 100644 --- a/back/src/main/java/com/back/global/ai/dto/result/DecisionScenarioResult.java +++ b/back/src/main/java/com/back/global/ai/dto/result/DecisionScenarioResult.java @@ -2,6 +2,7 @@ import com.back.domain.scenario.entity.Type; +import java.util.List; import java.util.Map; /** @@ -15,8 +16,58 @@ public record DecisionScenarioResult( int total, // 5개 지표 점수 합계 String imagePrompt, Map timelineTitles, - Map indicatorScores, // 각 지표별 점수 - Map indicatorAnalysis, // 각 지표별 분석 내용 - Map comparisonResults // 비교 분석 결과 + List indicators, // AI 응답 구조와 일치 (배열) + List comparisons // AI 응답 구조와 일치 (배열) ) { + /** + * AI 응답의 indicators 배열 요소 + */ + public record Indicator( + String type, + int point, + String analysis + ) {} + + /** + * AI 응답의 comparisons 배열 요소 + */ + public record Comparison( + String type, + int baseScore, + int newScore, + String analysis + ) {} + + /** + * indicators 배열을 Map로 변환 + */ + public Map indicatorScores() { + return indicators.stream() + .collect(java.util.stream.Collectors.toMap( + ind -> Type.valueOf(ind.type), + Indicator::point + )); + } + + /** + * indicators 배열을 Map로 변환 + */ + public Map indicatorAnalysis() { + return indicators.stream() + .collect(java.util.stream.Collectors.toMap( + ind -> Type.valueOf(ind.type), + Indicator::analysis + )); + } + + /** + * comparisons 배열을 Map로 변환 + */ + public Map comparisonResults() { + return comparisons.stream() + .collect(java.util.stream.Collectors.toMap( + Comparison::type, + Comparison::analysis + )); + } } diff --git a/back/src/main/java/com/back/global/ai/prompt/BaseScenarioPrompt.java b/back/src/main/java/com/back/global/ai/prompt/BaseScenarioPrompt.java index 16d1b11..3e93af5 100644 --- a/back/src/main/java/com/back/global/ai/prompt/BaseScenarioPrompt.java +++ b/back/src/main/java/com/back/global/ai/prompt/BaseScenarioPrompt.java @@ -42,15 +42,15 @@ public class BaseScenarioPrompt { { "job": "3년 후 예상 직업 (구체적으로)", "summary": "3년 후 삶의 요약 (50자 내외)", - "description": "3년 후 상세 시나리오 (500-800자)", + "description": "3년 후 상세 시나리오 (300-500자)", "total": 240~260, "baselineTitle": "3년 후 상황 제목 (5-8자)", "indicators": [ - {"type": "경제", "point": 40~60, "analysis": "분석 (200자)"}, - {"type": "행복", "point": 40~60, "analysis": "분석 (200자)"}, - {"type": "관계", "point": 40~60, "analysis": "분석 (200자)"}, - {"type": "직업", "point": 40~60, "analysis": "분석 (200자)"}, - {"type": "건강", "point": 40~60, "analysis": "분석 (200자)"} + {"type": "경제", "point": 40~60, "analysis": "분석 (150자)"}, + {"type": "행복", "point": 40~60, "analysis": "분석 (150자)"}, + {"type": "관계", "point": 40~60, "analysis": "분석 (150자)"}, + {"type": "직업", "point": 40~60, "analysis": "분석 (150자)"}, + {"type": "건강", "point": 40~60, "analysis": "분석 (150자)"} ], "timelineTitles": { "{timelineYears}": "연도별 제목 (5단어 이내)" @@ -67,7 +67,8 @@ public class BaseScenarioPrompt { ## 예시 (간략) - 직업: "중견기업 마케팅팀 과장" - 요약: "안정적인 직장생활 속에서 가족과의 시간을 소중히 여기는 삶" - - 경제(50점): "평균적 연봉 수준으로 안정적이나 자산 증식은 제한적입니다." + - description: "현재 직장에서 경력을 쌓으며 중간관리자로 성장했습니다. 업무와 개인 생활의 균형을 유지하며, 주말에는 가족과 시간을 보냅니다. 경제적으로 안정적이지만 큰 변화는 없는 평범한 일상이 이어집니다." + - 경제(50점): "평균적 연봉 수준으로 안정적이나 자산 증식은 제한적입니다. 월급으로 생활하며 저축을 하고 있지만, 큰 투자나 창업 등의 도전은 하지 않아 경제적 성장은 완만합니다." 반드시 유효한 JSON 형식으로만 응답하세요. """; diff --git a/back/src/main/java/com/back/global/ai/prompt/DecisionScenarioPrompt.java b/back/src/main/java/com/back/global/ai/prompt/DecisionScenarioPrompt.java index 8f9b5e0..9a42393 100644 --- a/back/src/main/java/com/back/global/ai/prompt/DecisionScenarioPrompt.java +++ b/back/src/main/java/com/back/global/ai/prompt/DecisionScenarioPrompt.java @@ -47,24 +47,24 @@ public class DecisionScenarioPrompt { { "job": "3년 후 예상 직업 (대안 선택 결과)", "summary": "3년 후 삶의 요약 (50자 내외)", - "description": "3년 후 상세 시나리오 (500-800자, 나비효과 포함)", + "description": "3년 후 상세 시나리오 (300-500자, 나비효과 포함)", "total": 250~350, "imagePrompt": "이미지 프롬프트 (영문, 50단어)", "indicators": [ - {"type": "경제", "point": 30~96, "analysis": "분석 (200자, 기본과 차이점)"}, - {"type": "행복", "point": 30~96, "analysis": "분석 (200자, 변화 강조)"}, - {"type": "관계", "point": 30~96, "analysis": "분석 (200자)"}, - {"type": "직업", "point": 30~96, "analysis": "분석 (200자)"}, - {"type": "건강", "point": 30~96, "analysis": "분석 (200자)"} + {"type": "경제", "point": 30~96, "analysis": "분석 (150자, 기본과 차이점)"}, + {"type": "행복", "point": 30~96, "analysis": "분석 (150자, 변화 강조)"}, + {"type": "관계", "point": 30~96, "analysis": "분석 (150자)"}, + {"type": "직업", "point": 30~96, "analysis": "분석 (150자)"}, + {"type": "건강", "point": 30~96, "analysis": "분석 (150자)"} ], "timelineTitles": {"{timelineYears}": "연도별 제목 (5단어 이내)"}, "comparisons": [ - {"type": "TOTAL", "baseScore": {baseTotal}, "newScore": "합계", "analysis": "비교 (300자)"}, - {"type": "경제", "baseScore": {baseEconomyScore}, "newScore": "점수", "analysis": "비교 (300자)"}, - {"type": "행복", "baseScore": {baseHappinessScore}, "newScore": "점수", "analysis": "비교 (300자)"}, - {"type": "관계", "baseScore": {baseRelationshipScore}, "newScore": "점수", "analysis": "비교 (300자)"}, - {"type": "직업", "baseScore": {baseCareerScore}, "newScore": "점수", "analysis": "비교 (300자)"}, - {"type": "건강", "baseScore": {baseHealthScore}, "newScore": "점수", "analysis": "비교 (300자)"} + {"type": "TOTAL", "baseScore": {baseTotal}, "newScore": "합계", "analysis": "비교 (200자)"}, + {"type": "경제", "baseScore": {baseEconomyScore}, "newScore": "점수", "analysis": "비교 (200자)"}, + {"type": "행복", "baseScore": {baseHappinessScore}, "newScore": "점수", "analysis": "비교 (200자)"}, + {"type": "관계", "baseScore": {baseRelationshipScore}, "newScore": "점수", "analysis": "비교 (200자)"}, + {"type": "직업", "baseScore": {baseCareerScore}, "newScore": "점수", "analysis": "비교 (200자)"}, + {"type": "건강", "baseScore": {baseHealthScore}, "newScore": "점수", "analysis": "비교 (200자)"} ] } ``` diff --git a/back/src/main/java/com/back/global/ai/service/AiServiceImpl.java b/back/src/main/java/com/back/global/ai/service/AiServiceImpl.java index 4c17f82..ca03210 100644 --- a/back/src/main/java/com/back/global/ai/service/AiServiceImpl.java +++ b/back/src/main/java/com/back/global/ai/service/AiServiceImpl.java @@ -62,13 +62,28 @@ public CompletableFuture generateBaseScenario(BaseLine baseL log.debug("Generated base scenario prompt for BaseLine ID: {}", baseLine.getId()); // Step 2: AI 호출 및 파싱 - AiRequest request = new AiRequest(baseScenarioPrompt, Map.of(), baseScenarioAiProperties.getMaxOutputTokens()); + int maxTokens = baseScenarioAiProperties.getMaxOutputTokens(); + log.info("Using maxOutputTokens: {} for base scenario generation", maxTokens); + AiRequest request = new AiRequest(baseScenarioPrompt, Map.of(), maxTokens); return textAiClient.generateText(request) .thenApply(aiResponse -> { try { log.debug("Received AI response for BaseLine ID: {}, length: {}", baseLine.getId(), aiResponse.length()); - return objectMapper.readValue(aiResponse, BaseScenarioResult.class); + // Remove markdown code block wrappers (```json ... ```) + String cleanedResponse = aiResponse.trim(); + if (cleanedResponse.startsWith("```json")) { + cleanedResponse = cleanedResponse.substring(7); // Remove ```json + } else if (cleanedResponse.startsWith("```")) { + cleanedResponse = cleanedResponse.substring(3); // Remove ``` + } + if (cleanedResponse.endsWith("```")) { + cleanedResponse = cleanedResponse.substring(0, cleanedResponse.length() - 3); + } + cleanedResponse = cleanedResponse.trim(); + + log.info("Cleaned AI response for BaseLine ID: {}: {}", baseLine.getId(), cleanedResponse); + return objectMapper.readValue(cleanedResponse, BaseScenarioResult.class); } catch (Exception e) { log.error("Failed to parse BaseScenario AI response for BaseLine ID: {}, error: {}", baseLine.getId(), e.getMessage(), e); @@ -121,7 +136,20 @@ public CompletableFuture generateDecisionScenario(Decisi try { log.debug("Received AI response for DecisionLine ID: {}, length: {}", decisionLine.getId(), aiResponse.length()); - return objectMapper.readValue(aiResponse, DecisionScenarioResult.class); + // Remove markdown code block wrappers (```json ... ```) + String cleanedResponse = aiResponse.trim(); + if (cleanedResponse.startsWith("```json")) { + cleanedResponse = cleanedResponse.substring(7); + } else if (cleanedResponse.startsWith("```")) { + cleanedResponse = cleanedResponse.substring(3); + } + if (cleanedResponse.endsWith("```")) { + cleanedResponse = cleanedResponse.substring(0, cleanedResponse.length() - 3); + } + cleanedResponse = cleanedResponse.trim(); + + log.info("Cleaned AI response for DecisionLine ID: {}: {}", decisionLine.getId(), cleanedResponse); + return objectMapper.readValue(cleanedResponse, DecisionScenarioResult.class); } catch (Exception e) { log.error("Failed to parse DecisionScenario AI response for DecisionLine ID: {}, error: {}", decisionLine.getId(), e.getMessage(), e); diff --git a/back/src/main/resources/application.yml b/back/src/main/resources/application.yml index ee335f7..e5735a4 100644 --- a/back/src/main/resources/application.yml +++ b/back/src/main/resources/application.yml @@ -6,7 +6,7 @@ spring: # - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration # - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration config: - import: optional:file:.env[.properties] + import: optional:file:./.env[.properties] flyway: enabled: false profiles: @@ -85,8 +85,8 @@ ai: api-key: ${GEMINI_API_KEY} model: gemini-2.5-flash base-url: https://generativelanguage.googleapis.com - timeout-seconds: 30 - max-retries: 3 + timeout-seconds: 180 # 테스트용 타임아웃 증가 (120 → 180초) + max-retries: 0 # 재시도 비활성화 (테스트용) retry-delay-seconds: 2 # 재시도 간격 (초) image: enabled: true @@ -106,10 +106,10 @@ ai: maxOutputTokens: 128 # 성능 최적화 완료 timeout-seconds: 30 # 상황 생성 타임아웃 (30초, 실시간 응답용) base-scenario: - maxOutputTokens: 1000 - timeout-seconds: 60 # 베이스 시나리오 생성 타임아웃 (60초) + maxOutputTokens: 16384 # 8192 → 16384 (gemini-2.5-flash 최대 65536, 충분한 여유) + timeout-seconds: 180 # 베이스 시나리오 생성 타임아웃 (60 → 180초, 테스트용) decision-scenario: - maxOutputTokens: 1200 + maxOutputTokens: 16384 # 8192 -> 16384 (gemini-2.5-flash 최대 65536, 충분한 여유) timeout-seconds: 60 # 선택 시나리오 생성 타임아웃 (60초) server: diff --git a/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java b/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java index a605cb3..0c17941 100644 --- a/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java +++ b/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java @@ -477,9 +477,13 @@ private DecisionScenarioResult mockDecisionScenarioResult() { 100, // total "테스트 이미지", Map.of("2025", "테스트 타이틀"), - Map.of(), - Map.of(), - Map.of() + java.util.List.of( + new DecisionScenarioResult.Indicator("경제", 50, "테스트 경제 분석"), + new DecisionScenarioResult.Indicator("행복", 50, "테스트 행복 분석") + ), + java.util.List.of( + new DecisionScenarioResult.Comparison("TOTAL", 200, 250, "테스트 비교 분석") + ) ); } } \ No newline at end of file diff --git a/back/src/test/java/com/back/global/ai/service/AiServiceImplTest.java b/back/src/test/java/com/back/global/ai/service/AiServiceImplTest.java index 45e7e15..a206e16 100644 --- a/back/src/test/java/com/back/global/ai/service/AiServiceImplTest.java +++ b/back/src/test/java/com/back/global/ai/service/AiServiceImplTest.java @@ -173,8 +173,13 @@ void generateBaseScenario_Success() throws Exception { "total": 250, "timelineTitles": {"2022": "대학원 입학", "2024": "졸업"}, "baselineTitle": "현재 삶 유지", - "indicatorScores": {"경제": 50, "행복": 50, "관계": 50, "직업": 50, "건강": 50}, - "indicatorAnalysis": {"경제": "평균", "행복": "평균", "관계": "평균", "직업": "평균", "건강": "평균"} + "indicators": [ + {"type": "경제", "point": 50, "analysis": "평균"}, + {"type": "행복", "point": 50, "analysis": "평균"}, + {"type": "관계", "point": 50, "analysis": "평균"}, + {"type": "직업", "point": 50, "analysis": "평균"}, + {"type": "건강", "point": 50, "analysis": "평균"} + ] } """; @@ -185,8 +190,13 @@ void generateBaseScenario_Success() throws Exception { 250, Map.of("2022", "대학원 입학", "2024", "졸업"), "현재 삶 유지", - Map.of(Type.경제, 50, Type.행복, 50, Type.관계, 50, Type.직업, 50, Type.건강, 50), - Map.of(Type.경제, "평균", Type.행복, "평균", Type.관계, "평균", Type.직업, "평균", Type.건강, "평균") + List.of( + new BaseScenarioResult.Indicator("경제", 50, "평균"), + new BaseScenarioResult.Indicator("행복", 50, "평균"), + new BaseScenarioResult.Indicator("관계", 50, "평균"), + new BaseScenarioResult.Indicator("직업", 50, "평균"), + new BaseScenarioResult.Indicator("건강", 50, "평균") + ) ); given(baseScenarioAiProperties.getMaxOutputTokens()).willReturn(1500); @@ -259,9 +269,18 @@ void generateDecisionScenario_Success() throws Exception { "total": 300, "imagePrompt": "Young CTO working in startup office", "timelineTitles": {"2023": "연구실 변경", "2024": "해외 학회"}, - "indicatorScores": {"경제": 60, "행복": 65, "관계": 55, "직업": 70, "건강": 50}, - "indicatorAnalysis": {"경제": "향상", "행복": "향상", "관계": "유지", "직업": "향상", "건강": "유지"}, - "comparisonResults": {"TOTAL": "전체적으로 향상", "경제": "10점 상승", "행복": "15점 상승"} + "indicators": [ + {"type": "경제", "point": 60, "analysis": "향상"}, + {"type": "행복", "point": 65, "analysis": "향상"}, + {"type": "관계", "point": 55, "analysis": "유지"}, + {"type": "직업", "point": 70, "analysis": "향상"}, + {"type": "건강", "point": 50, "analysis": "유지"} + ], + "comparisons": [ + {"type": "TOTAL", "baseScore": 250, "newScore": 300, "analysis": "전체적으로 향상"}, + {"type": "경제", "baseScore": 50, "newScore": 60, "analysis": "10점 상승"}, + {"type": "행복", "baseScore": 50, "newScore": 65, "analysis": "15점 상승"} + ] } """; @@ -272,9 +291,18 @@ void generateDecisionScenario_Success() throws Exception { 300, "Young CTO working in startup office", Map.of("2023", "연구실 변경", "2024", "해외 학회"), - Map.of(Type.경제, 60, Type.행복, 65, Type.관계, 55, Type.직업, 70, Type.건강, 50), - Map.of(Type.경제, "향상", Type.행복, "향상", Type.관계, "유지", Type.직업, "향상", Type.건강, "유지"), - Map.of("TOTAL", "전체적으로 향상", "경제", "10점 상승", "행복", "15점 상승") + List.of( + new DecisionScenarioResult.Indicator("경제", 60, "향상"), + new DecisionScenarioResult.Indicator("행복", 65, "향상"), + new DecisionScenarioResult.Indicator("관계", 55, "유지"), + new DecisionScenarioResult.Indicator("직업", 70, "향상"), + new DecisionScenarioResult.Indicator("건강", 50, "유지") + ), + List.of( + new DecisionScenarioResult.Comparison("TOTAL", 250, 300, "전체적으로 향상"), + new DecisionScenarioResult.Comparison("경제", 50, 60, "10점 상승"), + new DecisionScenarioResult.Comparison("행복", 50, 65, "15점 상승") + ) ); List baseSceneTypes = createMockSceneTypes(); @@ -339,9 +367,12 @@ void generateDecisionScenario_Success_EmptySceneTypes() throws Exception { "total": 300, "imagePrompt": "Young CTO working in startup office", "timelineTitles": {"2023": "연구실 변경"}, - "indicatorScores": {"경제": 60}, - "indicatorAnalysis": {"경제": "향상"}, - "comparisonResults": {"TOTAL": "전체적으로 향상"} + "indicators": [ + {"type": "경제", "point": 60, "analysis": "향상"} + ], + "comparisons": [ + {"type": "TOTAL", "baseScore": 250, "newScore": 300, "analysis": "전체적으로 향상"} + ] } """; @@ -352,9 +383,8 @@ void generateDecisionScenario_Success_EmptySceneTypes() throws Exception { 300, "Young CTO working in startup office", Map.of("2023", "연구실 변경"), - Map.of(Type.경제, 60), - Map.of(Type.경제, "향상"), - Map.of("TOTAL", "전체적으로 향상") + List.of(new DecisionScenarioResult.Indicator("경제", 60, "향상")), + List.of(new DecisionScenarioResult.Comparison("TOTAL", 250, 300, "전체적으로 향상")) ); given(decisionScenarioAiProperties.getMaxOutputTokens()).willReturn(2000); From b1576028576fc6bc3a6e7ef47535a6e5ccdd2094 Mon Sep 17 00:00:00 2001 From: johnbosco0414 Date: Tue, 14 Oct 2025 15:01:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[Refactor]=20=EB=A7=88=EC=9D=B4=EB=84=88?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ScenarioTransactionService.java | 3 +- .../ai/config/BaseScenarioAiProperties.java | 3 + .../config/DecisionScenarioAiProperties.java | 3 + back/src/main/resources/application.yml | 8 +- .../scenario/service/ScenarioServiceTest.java | 45 ++++++------ .../global/ai/service/AiServiceImplTest.java | 73 +++++-------------- 6 files changed, 54 insertions(+), 81 deletions(-) diff --git a/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java b/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java index da1309c..c44aac3 100644 --- a/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java +++ b/back/src/main/java/com/back/domain/scenario/service/ScenarioTransactionService.java @@ -45,6 +45,7 @@ public class ScenarioTransactionService { private final BaseLineRepository baseLineRepository; private final AiService aiService; private final ObjectMapper objectMapper; + private final com.back.global.ai.config.ImageAiConfig imageAiConfig; // 상태 업데이트 전용 트랜잭션 메서드 @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -139,7 +140,7 @@ private void handleImageGeneration(Scenario scenario, String imagePrompt) { try { if (imagePrompt != null && !imagePrompt.trim().isEmpty()) { String imageUrl = aiService.generateImage(imagePrompt) - .orTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .orTimeout(imageAiConfig.getTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS) .exceptionally(ex -> { log.warn("Image generation timeout or error for scenario {}: {}", scenario.getId(), ex.getMessage()); diff --git a/back/src/main/java/com/back/global/ai/config/BaseScenarioAiProperties.java b/back/src/main/java/com/back/global/ai/config/BaseScenarioAiProperties.java index cb600cd..e71ecb9 100644 --- a/back/src/main/java/com/back/global/ai/config/BaseScenarioAiProperties.java +++ b/back/src/main/java/com/back/global/ai/config/BaseScenarioAiProperties.java @@ -9,8 +9,11 @@ @ConfigurationProperties(prefix = "ai.base-scenario") public class BaseScenarioAiProperties { private int maxOutputTokens = 1000; + private int timeoutSeconds = 60; // getters/setters public int getMaxOutputTokens() { return maxOutputTokens; } public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; } + public int getTimeoutSeconds() { return timeoutSeconds; } + public void setTimeoutSeconds(int timeoutSeconds) { this.timeoutSeconds = timeoutSeconds; } } diff --git a/back/src/main/java/com/back/global/ai/config/DecisionScenarioAiProperties.java b/back/src/main/java/com/back/global/ai/config/DecisionScenarioAiProperties.java index b756c52..84050b8 100644 --- a/back/src/main/java/com/back/global/ai/config/DecisionScenarioAiProperties.java +++ b/back/src/main/java/com/back/global/ai/config/DecisionScenarioAiProperties.java @@ -9,8 +9,11 @@ @ConfigurationProperties(prefix = "ai.decision-scenario") public class DecisionScenarioAiProperties { private int maxOutputTokens = 1200; + private int timeoutSeconds = 60; // getters/setters public int getMaxOutputTokens() { return maxOutputTokens; } public void setMaxOutputTokens(int maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; } + public int getTimeoutSeconds() { return timeoutSeconds; } + public void setTimeoutSeconds(int timeoutSeconds) { this.timeoutSeconds = timeoutSeconds; } } diff --git a/back/src/main/resources/application.yml b/back/src/main/resources/application.yml index d604991..c60f398 100644 --- a/back/src/main/resources/application.yml +++ b/back/src/main/resources/application.yml @@ -90,8 +90,8 @@ ai: api-key: ${GEMINI_API_KEY} model: gemini-2.5-flash base-url: https://generativelanguage.googleapis.com - timeout-seconds: 180 # 테스트용 타임아웃 증가 (120 → 180초) - max-retries: 0 # 재시도 비활성화 (테스트용) + timeout-seconds: 70 # AI 응답 대기 시간 (시나리오 생성: 30-40초 + 여유 30초) + max-retries: 0 # 재시도 비활성화 (타임아웃 방지) retry-delay-seconds: 2 # 재시도 간격 (초) image: enabled: true @@ -112,10 +112,10 @@ ai: timeout-seconds: 30 # 상황 생성 타임아웃 (30초, 실시간 응답용) base-scenario: maxOutputTokens: 16384 # 8192 → 16384 (gemini-2.5-flash 최대 65536, 충분한 여유) - timeout-seconds: 180 # 베이스 시나리오 생성 타임아웃 (60 → 180초, 테스트용) + timeout-seconds: 60 # 베이스 시나리오 생성 타임아웃 (실제: 30-40초 + 여유) decision-scenario: maxOutputTokens: 16384 # 8192 -> 16384 (gemini-2.5-flash 최대 65536, 충분한 여유) - timeout-seconds: 60 # 선택 시나리오 생성 타임아웃 (60초) + timeout-seconds: 60 # 결정 시나리오 생성 타임아웃 (실제: 30-40초 + 여유) server: servlet: diff --git a/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java b/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java index 0c17941..09549c8 100644 --- a/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java +++ b/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java @@ -81,7 +81,7 @@ class CreateScenarioTests { ReflectionTestUtils.setField(savedScenario, "id", 1001L); // 실제 ScenarioService 구현에 맞춘 모킹 - given(decisionLineRepository.findById(decisionLineId)) + given(decisionLineRepository.findWithUserById(decisionLineId)) .willReturn(Optional.of(mockDecisionLine)); given(scenarioRepository.findByDecisionLineId(decisionLineId)) .willReturn(Optional.empty()); // 기존 시나리오 없음 @@ -101,7 +101,7 @@ class CreateScenarioTests { assertThat(result.message()).isEqualTo("시나리오 생성이 시작되었습니다."); // 동기 부분의 핵심 비즈니스 로직만 검증 - verify(decisionLineRepository).findById(decisionLineId); + verify(decisionLineRepository).findWithUserById(decisionLineId); verify(scenarioRepository).findByDecisionLineId(decisionLineId); verify(scenarioRepository).save(any(Scenario.class)); @@ -117,7 +117,7 @@ class CreateScenarioTests { Long decisionLineId = 999L; ScenarioCreateRequest request = new ScenarioCreateRequest(decisionLineId); - given(decisionLineRepository.findById(decisionLineId)) + given(decisionLineRepository.findWithUserById(decisionLineId)) .willReturn(Optional.empty()); // When & Then @@ -125,7 +125,7 @@ class CreateScenarioTests { .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DECISION_LINE_NOT_FOUND); - verify(decisionLineRepository).findById(decisionLineId); + verify(decisionLineRepository).findWithUserById(decisionLineId); verify(scenarioRepository, never()).save(any()); } @@ -146,7 +146,7 @@ class CreateScenarioTests { .build(); ReflectionTestUtils.setField(mockDecisionLine, "id", decisionLineId); - given(decisionLineRepository.findById(decisionLineId)) + given(decisionLineRepository.findWithUserById(decisionLineId)) .willReturn(Optional.of(mockDecisionLine)); // When & Then @@ -154,7 +154,7 @@ class CreateScenarioTests { .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.HANDLE_ACCESS_DENIED); - verify(decisionLineRepository).findById(decisionLineId); + verify(decisionLineRepository).findWithUserById(decisionLineId); verify(scenarioRepository, never()).save(any()); } @@ -182,7 +182,7 @@ class CreateScenarioTests { .build(); ReflectionTestUtils.setField(existingScenario, "id", 999L); - given(decisionLineRepository.findById(decisionLineId)) + given(decisionLineRepository.findWithUserById(decisionLineId)) .willReturn(Optional.of(mockDecisionLine)); given(scenarioRepository.findByDecisionLineId(decisionLineId)) .willReturn(Optional.of(existingScenario)); // 기존 PENDING 시나리오 존재 @@ -220,6 +220,7 @@ class ProcessScenarioGenerationAsyncTests { Scenario mockScenario = Scenario.builder() .user(mockUser) .decisionLine(mockDecisionLine) + .baseLine(mockBaseLine) .status(ScenarioStatus.PENDING) .build(); ReflectionTestUtils.setField(mockScenario, "id", scenarioId); @@ -228,9 +229,9 @@ class ProcessScenarioGenerationAsyncTests { doNothing().when(scenarioTransactionService).updateScenarioStatus(anyLong(), any(), any()); doNothing().when(scenarioTransactionService).saveAiResult(anyLong(), any()); - // executeAiGeneration에서 사용되는 repository 조회 모킹 - given(scenarioRepository.findById(scenarioId)) - .willReturn(Optional.of(mockScenario)); + // prepareScenarioData 모킹 추가 (핵심!) + given(scenarioTransactionService.prepareScenarioData(scenarioId)) + .willReturn(mockScenario); // 베이스 시나리오가 이미 존재하도록 설정 (AI 호출 방지) Scenario mockBaseScenario = Scenario.builder() @@ -253,6 +254,7 @@ class ProcessScenarioGenerationAsyncTests { // Then // 트랜잭션 서비스 호출 검증 verify(scenarioTransactionService).updateScenarioStatus(scenarioId, ScenarioStatus.PROCESSING, null); + verify(scenarioTransactionService).prepareScenarioData(scenarioId); verify(scenarioTransactionService).updateScenarioStatus(scenarioId, ScenarioStatus.COMPLETED, null); } @@ -265,9 +267,9 @@ class ProcessScenarioGenerationAsyncTests { // 트랜잭션 서비스는 정상 동작하도록 모킹 doNothing().when(scenarioTransactionService).updateScenarioStatus(anyLong(), any(), any()); - // executeAiGeneration에서 시나리오를 찾을 수 없음 - given(scenarioRepository.findById(scenarioId)) - .willReturn(Optional.empty()); + // prepareScenarioData에서 시나리오를 찾을 수 없음 (예외 발생) + given(scenarioTransactionService.prepareScenarioData(scenarioId)) + .willThrow(new ApiException(ErrorCode.SCENARIO_NOT_FOUND)); // When scenarioService.processScenarioGenerationAsync(scenarioId); @@ -275,6 +277,7 @@ class ProcessScenarioGenerationAsyncTests { // Then // 실패 시 FAILED 상태 업데이트가 호출되어야 함 verify(scenarioTransactionService).updateScenarioStatus(scenarioId, ScenarioStatus.PROCESSING, null); + verify(scenarioTransactionService).prepareScenarioData(scenarioId); verify(scenarioTransactionService).updateScenarioStatus(eq(scenarioId), eq(ScenarioStatus.FAILED), anyString()); } } @@ -295,7 +298,7 @@ class GetScenarioStatusTests { .build(); ReflectionTestUtils.setField(mockScenario, "id", scenarioId); - given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + given(scenarioRepository.findByIdAndUserIdForStatusCheck(scenarioId, userId)) .willReturn(Optional.of(mockScenario)); // When @@ -307,7 +310,7 @@ class GetScenarioStatusTests { assertThat(result.status()).isEqualTo(ScenarioStatus.COMPLETED); assertThat(result.message()).isEqualTo("시나리오 생성이 완료되었습니다."); - verify(scenarioRepository).findByIdAndUserId(scenarioId, userId); + verify(scenarioRepository).findByIdAndUserIdForStatusCheck(scenarioId, userId); } @Test @@ -317,7 +320,7 @@ class GetScenarioStatusTests { Long scenarioId = 999L; Long userId = 1L; - given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + given(scenarioRepository.findByIdAndUserIdForStatusCheck(scenarioId, userId)) .willReturn(Optional.empty()); // When & Then @@ -325,7 +328,7 @@ class GetScenarioStatusTests { .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SCENARIO_NOT_FOUND); - verify(scenarioRepository).findByIdAndUserId(scenarioId, userId); + verify(scenarioRepository).findByIdAndUserIdForStatusCheck(scenarioId, userId); } } @@ -427,7 +430,7 @@ class GetStatusMessageTests { .status(ScenarioStatus.PENDING) .build(); ReflectionTestUtils.setField(pendingScenario, "id", scenarioId); - given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + given(scenarioRepository.findByIdAndUserIdForStatusCheck(scenarioId, userId)) .willReturn(Optional.of(pendingScenario)); ScenarioStatusResponse pendingResult = scenarioService.getScenarioStatus(scenarioId, userId); @@ -438,7 +441,7 @@ class GetStatusMessageTests { .status(ScenarioStatus.PROCESSING) .build(); ReflectionTestUtils.setField(processingScenario, "id", scenarioId); - given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + given(scenarioRepository.findByIdAndUserIdForStatusCheck(scenarioId, userId)) .willReturn(Optional.of(processingScenario)); ScenarioStatusResponse processingResult = scenarioService.getScenarioStatus(scenarioId, userId); @@ -449,7 +452,7 @@ class GetStatusMessageTests { .status(ScenarioStatus.COMPLETED) .build(); ReflectionTestUtils.setField(completedScenario, "id", scenarioId); - given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + given(scenarioRepository.findByIdAndUserIdForStatusCheck(scenarioId, userId)) .willReturn(Optional.of(completedScenario)); ScenarioStatusResponse completedResult = scenarioService.getScenarioStatus(scenarioId, userId); @@ -460,7 +463,7 @@ class GetStatusMessageTests { .status(ScenarioStatus.FAILED) .build(); ReflectionTestUtils.setField(failedScenario, "id", scenarioId); - given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + given(scenarioRepository.findByIdAndUserIdForStatusCheck(scenarioId, userId)) .willReturn(Optional.of(failedScenario)); ScenarioStatusResponse failedResult = scenarioService.getScenarioStatus(scenarioId, userId); diff --git a/back/src/test/java/com/back/global/ai/service/AiServiceImplTest.java b/back/src/test/java/com/back/global/ai/service/AiServiceImplTest.java index a206e16..bcc0989 100644 --- a/back/src/test/java/com/back/global/ai/service/AiServiceImplTest.java +++ b/back/src/test/java/com/back/global/ai/service/AiServiceImplTest.java @@ -165,6 +165,8 @@ class GenerateBaseScenarioTests { @DisplayName("성공 - 유효한 BaseLine으로 베이스 시나리오 생성") void generateBaseScenario_Success() throws Exception { // given + setField(aiService, "objectMapper", realObjectMapper); // 실제 ObjectMapper 사용 + String mockAiResponse = """ { "job": "중견기업 개발자", @@ -183,27 +185,9 @@ void generateBaseScenario_Success() throws Exception { } """; - BaseScenarioResult expectedResult = new BaseScenarioResult( - "중견기업 개발자", - "안정적인 직장 생활", - "대학원을 졸업하고 IT 기업에 취직", - 250, - Map.of("2022", "대학원 입학", "2024", "졸업"), - "현재 삶 유지", - List.of( - new BaseScenarioResult.Indicator("경제", 50, "평균"), - new BaseScenarioResult.Indicator("행복", 50, "평균"), - new BaseScenarioResult.Indicator("관계", 50, "평균"), - new BaseScenarioResult.Indicator("직업", 50, "평균"), - new BaseScenarioResult.Indicator("건강", 50, "평균") - ) - ); - given(baseScenarioAiProperties.getMaxOutputTokens()).willReturn(1500); given(textAiClient.generateText(any(AiRequest.class))) .willReturn(CompletableFuture.completedFuture(mockAiResponse)); - given(objectMapper.readValue(mockAiResponse, BaseScenarioResult.class)) - .willReturn(expectedResult); // when CompletableFuture future = aiService.generateBaseScenario(testBaseLine); @@ -216,7 +200,12 @@ void generateBaseScenario_Success() throws Exception { assertThat(result.summary()).isEqualTo("안정적인 직장 생활"); verify(textAiClient, times(1)).generateText(any(AiRequest.class)); - verify(objectMapper, times(1)).readValue(mockAiResponse, BaseScenarioResult.class); + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + var field = AiServiceImpl.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); } @Test @@ -261,6 +250,8 @@ class GenerateDecisionScenarioTests { @DisplayName("성공 - 유효한 DecisionLine과 BaseScenario로 시나리오 생성") void generateDecisionScenario_Success() throws Exception { // given + setField(aiService, "objectMapper", realObjectMapper); // 실제 ObjectMapper 사용 + String mockAiResponse = """ { "job": "스타트업 CTO", @@ -284,27 +275,6 @@ void generateDecisionScenario_Success() throws Exception { } """; - DecisionScenarioResult expectedResult = new DecisionScenarioResult( - "스타트업 CTO", - "도전적인 창업 생활", - "연구실 경험을 바탕으로 AI 스타트업 창업", - 300, - "Young CTO working in startup office", - Map.of("2023", "연구실 변경", "2024", "해외 학회"), - List.of( - new DecisionScenarioResult.Indicator("경제", 60, "향상"), - new DecisionScenarioResult.Indicator("행복", 65, "향상"), - new DecisionScenarioResult.Indicator("관계", 55, "유지"), - new DecisionScenarioResult.Indicator("직업", 70, "향상"), - new DecisionScenarioResult.Indicator("건강", 50, "유지") - ), - List.of( - new DecisionScenarioResult.Comparison("TOTAL", 250, 300, "전체적으로 향상"), - new DecisionScenarioResult.Comparison("경제", 50, 60, "10점 상승"), - new DecisionScenarioResult.Comparison("행복", 50, 65, "15점 상승") - ) - ); - List baseSceneTypes = createMockSceneTypes(); given(decisionScenarioAiProperties.getMaxOutputTokens()).willReturn(2000); @@ -312,8 +282,6 @@ void generateDecisionScenario_Success() throws Exception { .willReturn(baseSceneTypes); given(textAiClient.generateText(any(AiRequest.class))) .willReturn(CompletableFuture.completedFuture(mockAiResponse)); - given(objectMapper.readValue(mockAiResponse, DecisionScenarioResult.class)) - .willReturn(expectedResult); // when CompletableFuture future = @@ -331,6 +299,12 @@ void generateDecisionScenario_Success() throws Exception { verify(sceneTypeRepository, times(1)).findByScenarioIdOrderByTypeAsc(testScenario.getId()); } + private void setField(Object target, String fieldName, Object value) throws Exception { + var field = AiServiceImpl.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + @Test @DisplayName("실패 - DecisionLine이 null인 경우") void generateDecisionScenario_Fail_NullDecisionLine() { @@ -359,6 +333,8 @@ void generateDecisionScenario_Fail_NullBaseScenario() { @DisplayName("성공 - SceneType이 비어있어도 정상 처리") void generateDecisionScenario_Success_EmptySceneTypes() throws Exception { // given + setField(aiService, "objectMapper", realObjectMapper); // 실제 ObjectMapper 사용 + String mockAiResponse = """ { "job": "스타트업 CTO", @@ -376,24 +352,11 @@ void generateDecisionScenario_Success_EmptySceneTypes() throws Exception { } """; - DecisionScenarioResult expectedResult = new DecisionScenarioResult( - "스타트업 CTO", - "도전적인 창업 생활", - "연구실 경험을 바탕으로 AI 스타트업 창업", - 300, - "Young CTO working in startup office", - Map.of("2023", "연구실 변경"), - List.of(new DecisionScenarioResult.Indicator("경제", 60, "향상")), - List.of(new DecisionScenarioResult.Comparison("TOTAL", 250, 300, "전체적으로 향상")) - ); - given(decisionScenarioAiProperties.getMaxOutputTokens()).willReturn(2000); given(sceneTypeRepository.findByScenarioIdOrderByTypeAsc(testScenario.getId())) .willReturn(List.of()); // 빈 리스트 반환 given(textAiClient.generateText(any(AiRequest.class))) .willReturn(CompletableFuture.completedFuture(mockAiResponse)); - given(objectMapper.readValue(mockAiResponse, DecisionScenarioResult.class)) - .willReturn(expectedResult); // when CompletableFuture future =