diff --git a/src/main/java/com/mumuk/domain/recipe/repository/RecipeRepository.java b/src/main/java/com/mumuk/domain/recipe/repository/RecipeRepository.java index 765631c9..d4e5151a 100644 --- a/src/main/java/com/mumuk/domain/recipe/repository/RecipeRepository.java +++ b/src/main/java/com/mumuk/domain/recipe/repository/RecipeRepository.java @@ -81,4 +81,17 @@ SELECT CAST(FLOOR(RANDOM() * (SELECT MAX(id) FROM recipe)) + 1 AS BIGINT) LIMIT :limit """, nativeQuery = true) List findRandomRecipesByPkRange(@Param("limit") int limit); + + // 카테고리 조건을 만족하는 레시피를 랜덤으로 제한 개수만큼 조회 + @Query(value = """ + SELECT r.* + FROM recipe r + INNER JOIN recipe_category_map rcm ON r.id = rcm.recipe_id + WHERE rcm.category IN (:categories) + GROUP BY r.id + ORDER BY RANDOM() + LIMIT :limit + """, nativeQuery = true) + List findRandomRecipesByCategoriesLimited(@Param("categories") List categories, + @Param("limit") int limit); } \ No newline at end of file diff --git a/src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java b/src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java index e2d10a27..f3b3a79c 100644 --- a/src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java +++ b/src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java @@ -18,7 +18,7 @@ import com.mumuk.domain.user.entity.UserRecipe; import com.mumuk.global.apiPayload.code.ErrorCode; import com.mumuk.global.apiPayload.exception.BusinessException; -import com.mumuk.global.client.OpenAiClient; +import com.mumuk.global.client.GeminiClient; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -48,7 +48,7 @@ @Service public class RecipeRecommendServiceImpl implements RecipeRecommendService { - private final OpenAiClient openAiClient; + private final GeminiClient geminiClient; private final ObjectMapper objectMapper; private final UserRepository userRepository; private final UserRecipeRepository userRecipeRepository; @@ -73,21 +73,23 @@ public class RecipeRecommendServiceImpl implements RecipeRecommendService { private static final int MAX_RECOMMENDATIONS = 4; /** 무작위 샘플 크기 (GET API용) */ - private static final int RANDOM_SAMPLE_SIZE = 8; + private static final int RANDOM_SAMPLE_SIZE = 12; /** POST API로 생성할 레시피 개수 */ private static final int POST_RECIPE_COUNT = 5; /** 이미지 URL 최대 길이 (엔티티 컬럼 길이와 일치) */ private static final int MAX_IMAGE_URL_LENGTH = 500; + /** 성능 최적화를 위한 상세 로그 출력 여부 (운영에서는 false 권장) */ + private static final boolean ENABLE_VERBOSE_LOG = false; - public RecipeRecommendServiceImpl(OpenAiClient openAiClient, ObjectMapper objectMapper, + public RecipeRecommendServiceImpl(GeminiClient geminiClient, ObjectMapper objectMapper, UserRepository userRepository, UserRecipeRepository userRecipeRepository, IngredientService ingredientService, AllergyService allergyService, RecipeRepository recipeRepository, RedisTemplate redisTemplate, UserHealthDataRepository userHealthDataRepository, HealthGoalService healthGoalService, RecipeBlogImageService recipeBlogImageService) { - this.openAiClient = openAiClient; + this.geminiClient = geminiClient; this.objectMapper = objectMapper; this.userRepository = userRepository; this.userRecipeRepository = userRecipeRepository; @@ -208,7 +210,7 @@ public List recommendRandomRecipes(Long use */ @Override public List recommendRecipesByOcr(Long userId) { - log.info("OCR 기반 레시피 추천 시작 - userId: {}", userId); + if (ENABLE_VERBOSE_LOG) log.info("OCR 기반 레시피 추천 시작 - userId: {}", userId); // 사용자 정보 조회 (사용자 존재 검증) getUser(userId); @@ -234,7 +236,7 @@ public List recommendRecipesByOcr(Long user return new ArrayList<>(); } - log.info("랜덤 선택된 레시피 수: {}", sampledRecipes.size()); + if (ENABLE_VERBOSE_LOG) log.info("랜덤 선택된 레시피 수: {}", sampledRecipes.size()); // AI가 각 레시피의 적합도를 평가 (랜덤 선택된 레시피 평가) List recipesWithScores = evaluateRecipeSuitabilityByHealth( @@ -253,7 +255,7 @@ public List recommendRecipesByOcr(Long user .collect(Collectors.toList()); Map likedMap = getUserRecipeLikedMap(userId, recipeIds); - log.info("OCR 기반 레시피 추천 완료 - 추천된 레시피 수: {}", topRecipes.size()); + if (ENABLE_VERBOSE_LOG) log.info("OCR 기반 레시피 추천 완료 - 추천된 레시피 수: {}", topRecipes.size()); return topRecipes.stream() .map(rws -> RecipeConverter.toRecipeSummaryDTO(rws.recipe, likedMap.get(rws.recipe.getId()))) .collect(Collectors.toList()); @@ -264,7 +266,7 @@ public List recommendRecipesByOcr(Long user */ @Override public List recommendRecipesByHealthGoal(Long userId) { - log.info("HealthGoal 기반 레시피 추천 시작 - userId: {}", userId); + if (ENABLE_VERBOSE_LOG) log.info("HealthGoal 기반 레시피 추천 시작 - userId: {}", userId); // 사용자 정보 조회 (사용자 존재 검증) getUser(userId); @@ -287,7 +289,7 @@ public List recommendRecipesByHealthGoal(Lo return new ArrayList<>(); } - log.info("랜덤 선택된 레시피 수: {}", sampledRecipes.size()); + if (ENABLE_VERBOSE_LOG) log.info("랜덤 선택된 레시피 수: {}", sampledRecipes.size()); // AI가 각 레시피의 적합도를 평가 (랜덤 선택된 레시피 평가) List scoredRecipes = evaluateRecipeSuitabilityByHealthGoal( @@ -306,7 +308,7 @@ public List recommendRecipesByHealthGoal(Lo .collect(Collectors.toList()); Map likedMap = getUserRecipeLikedMap(userId, recipeIds); - log.info("HealthGoal 기반 레시피 추천 완료 - 추천된 레시피 수: {}", topRecipes.size()); + if (ENABLE_VERBOSE_LOG) log.info("HealthGoal 기반 레시피 추천 완료 - 추천된 레시피 수: {}", topRecipes.size()); return topRecipes.stream() .map(rws -> RecipeConverter.toRecipeSummaryDTO(rws.recipe, likedMap.get(rws.recipe.getId()))) .collect(Collectors.toList()); @@ -317,7 +319,7 @@ public List recommendRecipesByHealthGoal(Lo */ @Override public List recommendRecipesByCombined(Long userId) { - log.info("통합 레시피 추천 시작 - userId: {}", userId); + if (ENABLE_VERBOSE_LOG) log.info("통합 레시피 추천 시작 - userId: {}", userId); // 사용자 정보 조회 (사용자 존재 검증) getUser(userId); @@ -339,7 +341,7 @@ public List recommendRecipesByCombined(Long return new ArrayList<>(); } - log.info("랜덤 선택된 레시피 수: {}", sampledRecipes.size()); + if (ENABLE_VERBOSE_LOG) log.info("랜덤 선택된 레시피 수: {}", sampledRecipes.size()); // AI가 각 레시피의 적합도를 평가 (랜덤 선택된 레시피 평가) List scoredRecipes = evaluateRecipeSuitabilityByCombined( @@ -358,7 +360,7 @@ public List recommendRecipesByCombined(Long .collect(Collectors.toList()); Map likedMap = getUserRecipeLikedMap(userId, recipeIds); - log.info("통합 레시피 추천 완료 - 추천된 레시피 수: {}", topRecipes.size()); + if (ENABLE_VERBOSE_LOG) log.info("통합 레시피 추천 완료 - 추천된 레시피 수: {}", topRecipes.size()); return topRecipes.stream() .map(rws -> RecipeConverter.toRecipeSummaryDTO(rws.recipe, likedMap.get(rws.recipe.getId()))) .collect(Collectors.toList()); @@ -386,13 +388,13 @@ private List evaluateRecipeSuitabilityByIngredient(List // 중복 제거만 수행 List uniqueIngredients = new ArrayList<>(new LinkedHashSet<>(availableIngredients)); - log.info("=== 적합도 평가 시작 ==="); - log.info("사용자 보유 재료: {}", String.join(", ", uniqueIngredients)); - log.info("사용자 알레르기 정보: {}", allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes)); - log.info("전체 레시피 수: {}", recipes.size()); - - // 모든 레시피 평가 (무작위 샘플링된 레시피들) - log.info("평가할 레시피 수: {}", recipes.size()); + if (ENABLE_VERBOSE_LOG) { + log.info("=== 적합도 평가 시작 ==="); + log.info("사용자 보유 재료: {}", String.join(", ", uniqueIngredients)); + log.info("사용자 알레르기 정보: {}", allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes)); + log.info("전체 레시피 수: {}", recipes.size()); + log.info("평가할 레시피 수: {}", recipes.size()); + } try { // 배치 처리 @@ -402,7 +404,7 @@ private List evaluateRecipeSuitabilityByIngredient(List recipesWithScores.addAll(processIndividual(recipes, uniqueIngredients, allergyTypes)); } - log.info("=== 적합도 평가 완료 ==="); + if (ENABLE_VERBOSE_LOG) log.info("=== 적합도 평가 완료 ==="); return recipesWithScores; } @@ -482,17 +484,19 @@ private List processBatch(List recipes, List allergyTypes) { List recipesWithScores = new ArrayList<>(); - log.info("배치 처리 시작 - 사용자 재료: {}, 알레르기: {}", - String.join(", ", availableIngredients), - allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes)); + if (ENABLE_VERBOSE_LOG) { + log.info("배치 처리 시작 - 사용자 재료: {}, 알레르기: {}", + String.join(", ", availableIngredients), + allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes)); + } try { // 배치 처리: 모든 레시피를 한 번에 AI에게 전달 String batchPrompt = createBatchIngredientSuitabilityPrompt(recipes, availableIngredients, allergyTypes); - log.info("배치 프롬프트 생성 완료"); + if (ENABLE_VERBOSE_LOG) log.info("배치 프롬프트 생성 완료"); String batchResponse = callAI(batchPrompt); - log.info("AI 배치 응답: {}", batchResponse); + if (ENABLE_VERBOSE_LOG) log.info("AI 배치 응답: {}", batchResponse); // AI 응답에서 각 레시피의 점수 파싱 Map scores = parseBatchScores(batchResponse, recipes); @@ -500,7 +504,7 @@ private List processBatch(List recipes, for (Recipe recipe : recipes) { double score = scores.getOrDefault(recipe.getTitle(), 5.0); - log.info("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score); if (score > 0) { recipesWithScores.add(new RecipeWithScore(recipe, score)); @@ -525,9 +529,11 @@ private List processIndividual(List recipes, List allergyTypes) { List recipesWithScores = new ArrayList<>(); - log.info("개별 처리 시작 - 사용자 재료: {}, 알레르기: {}", - String.join(", ", availableIngredients), - allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes)); + if (ENABLE_VERBOSE_LOG) { + log.info("개별 처리 시작 - 사용자 재료: {}, 알레르기: {}", + String.join(", ", availableIngredients), + allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes)); + } for (Recipe recipe : recipes) { try { @@ -536,7 +542,7 @@ private List processIndividual(List recipes, String prompt = createIngredientSuitabilityPrompt(recipe, availableIngredients, allergyTypes); double score = callAIForSuitabilityScore(prompt); - log.info("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score); if (score > 0) { recipesWithScores.add(new RecipeWithScore(recipe, score)); @@ -561,18 +567,20 @@ private List processBatchByHealth(List recipes, String healthInfo) { List recipesWithScores = new ArrayList<>(); - log.info("건강 정보 기반 배치 처리 시작 - 사용자 재료: {}, 알레르기: {}, 건강정보: {}", - String.join(", ", availableIngredients), - allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes), - healthInfo); + if (ENABLE_VERBOSE_LOG) { + log.info("건강 정보 기반 배치 처리 시작 - 사용자 재료: {}, 알레르기: {}, 건강정보: {}", + String.join(", ", availableIngredients), + allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes), + healthInfo); + } try { // 배치 처리: 모든 레시피를 한 번에 AI에게 전달 String batchPrompt = createBatchHealthSuitabilityPrompt(recipes, availableIngredients, allergyTypes, healthInfo); - log.info("건강 정보 기반 배치 프롬프트 생성 완료"); + if (ENABLE_VERBOSE_LOG) log.info("건강 정보 기반 배치 프롬프트 생성 완료"); String batchResponse = callAI(batchPrompt); - log.info("AI 배치 응답: {}", batchResponse); + if (ENABLE_VERBOSE_LOG) log.info("AI 배치 응답: {}", batchResponse); // AI 응답에서 각 레시피의 점수 파싱 Map scores = parseBatchHealthScores(batchResponse, recipes); @@ -580,7 +588,7 @@ private List processBatchByHealth(List recipes, for (Recipe recipe : recipes) { double score = scores.getOrDefault(recipe.getTitle(), 5.0); - log.info("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score); if (score > 0) { recipesWithScores.add(new RecipeWithScore(recipe, score)); @@ -606,10 +614,12 @@ private List processIndividualByHealth(List recipes, String healthInfo) { List recipesWithScores = new ArrayList<>(); - log.info("건강 정보 기반 개별 처리 시작 - 사용자 재료: {}, 알레르기: {}, 건강정보: {}", - String.join(", ", availableIngredients), - allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes), - healthInfo); + if (ENABLE_VERBOSE_LOG) { + log.info("건강 정보 기반 개별 처리 시작 - 사용자 재료: {}, 알레르기: {}, 건강정보: {}", + String.join(", ", availableIngredients), + allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes), + healthInfo); + } for (Recipe recipe : recipes) { try { @@ -618,7 +628,7 @@ private List processIndividualByHealth(List recipes, String prompt = createHealthSuitabilityPrompt(recipe, availableIngredients, allergyTypes, healthInfo); double score = callAIForSuitabilityScore(prompt); - log.info("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score); if (score > 0) { recipesWithScores.add(new RecipeWithScore(recipe, score)); @@ -646,14 +656,14 @@ private List evaluateRecipeSuitabilityByHealth(List rec // 중복 제거만 수행 List uniqueIngredients = new ArrayList<>(new LinkedHashSet<>(availableIngredients)); - log.info("=== 건강 정보 기반 적합도 평가 시작 ==="); - log.info("사용자 보유 재료: {}", String.join(", ", uniqueIngredients)); - log.info("사용자 알레르기 정보: {}", allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes)); - log.info("사용자 건강 정보: {}", healthInfo); - log.info("전체 레시피 수: {}", recipes.size()); - - // 모든 레시피 평가 (무작위 샘플링된 레시피들) - log.info("평가할 레시피 수: {}", recipes.size()); + if (ENABLE_VERBOSE_LOG) { + log.info("=== 건강 정보 기반 적합도 평가 시작 ==="); + log.info("사용자 보유 재료: {}", String.join(", ", uniqueIngredients)); + log.info("사용자 알레르기 정보: {}", allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes)); + log.info("사용자 건강 정보: {}", healthInfo); + log.info("전체 레시피 수: {}", recipes.size()); + log.info("평가할 레시피 수: {}", recipes.size()); + } try { // 배치 처리 @@ -663,7 +673,7 @@ private List evaluateRecipeSuitabilityByHealth(List rec recipesWithScores.addAll(processIndividualByHealth(recipes, uniqueIngredients, allergyTypes, healthInfo)); } - log.info("=== 건강 정보 기반 적합도 평가 완료 ==="); + if (ENABLE_VERBOSE_LOG) log.info("=== 건강 정보 기반 적합도 평가 완료 ==="); return recipesWithScores; } @@ -780,18 +790,12 @@ private List callAIAndSaveRecipes(String prompt) { throw new BusinessException(ErrorCode.OPENAI_INVALID_RESPONSE); } - // OpenAI API 응답에서 content 추출 - JsonNode root = objectMapper.readTree(response); - String aiContent = root.path("choices").path(0).path("message").path("content").asText(); - if (aiContent.isEmpty()) { - throw new BusinessException(ErrorCode.OPENAI_MISSING_CONTENT); - } - - log.info("AI 원본 응답: {}", aiContent); - + // Gemini 클라이언트는 이미 텍스트 콘텐츠를 반환하므로 바로 사용 + if (ENABLE_VERBOSE_LOG) log.info("AI 원본 응답: {}", response); + // AI 응답에서 JSON 부분 추출 (코드블록 제거) - String jsonContent = extractJsonFromAIResponse(aiContent); - log.info("추출된 JSON: {}", jsonContent); + String jsonContent = extractJsonFromAIResponse(response); + if (ENABLE_VERBOSE_LOG) log.info("추출된 JSON: {}", jsonContent); // AI 응답을 JSON으로 파싱 JsonNode recommendationsRoot = objectMapper.readTree(jsonContent); @@ -979,48 +983,14 @@ private List parseCategories(String category) { } } - // callAIWithSmartModelSwitch 메서드 완전 대체 및 간결화 + // Gemini API를 사용하여 AI 호출 private String callAI(String prompt) { - String model = "gpt-4o-mini"; - String apiKey = System.getenv("OPEN_AI_KEY"); - if (apiKey == null || apiKey.trim().isEmpty() || apiKey.startsWith("dummy") || apiKey.startsWith("test")) { - log.error("API 키가 설정되지 않았습니다."); - throw new BusinessException(ErrorCode.OPENAI_API_ERROR); - } - - // API 키 길이 검증 (OpenAI API 키는 보통 51자) - if (apiKey.length() < 20) { - log.error("유효하지 않은 API 키 형식입니다."); - throw new BusinessException(ErrorCode.OPENAI_API_ERROR); - } - try { - WebClient webClient = WebClient.builder() - .baseUrl("https://api.openai.com/v1") - .defaultHeader("Authorization", "Bearer " + apiKey) - .build(); - Map body = new HashMap<>(); - body.put("model", model); - List> messages = new ArrayList<>(); - Map message = new HashMap<>(); - message.put("role", "user"); - message.put("content", prompt); - messages.add(message); - body.put("messages", messages); - String response = webClient.post() - .uri("/chat/completions") - .bodyValue(body) - .retrieve() - .bodyToMono(String.class) - .timeout(Duration.ofSeconds(30)) - .block(); - if (response != null && !response.isEmpty()) { - return response; - } + return geminiClient.chat(prompt).block(Duration.ofSeconds(30)); } catch (Exception e) { - log.warn("gpt-4o-mini 모델로 AI 호출 실패: {}", e.getMessage()); + log.error("Gemini API 호출 실패: {}", e.getMessage()); + throw new BusinessException(ErrorCode.OPENAI_API_ERROR); } - throw new BusinessException(ErrorCode.OPENAI_API_ERROR); } @@ -1068,7 +1038,6 @@ private String buildRecipePostPromptCommon() { "- 실제 존재하는 보편적인 요리만 추천 (억지 조합 금지)\n" + "- 레시피 제목은 검색으로 조리법을 찾을 수 있을 정도로 대중적이고 보편적\n" + "- 예시: 된장찌개 O, 미나리 된장찌개 O, 돼지고기 앞다리살 감자 상추 된장찌개 X\n" + - "- 메인 요리, 반찬, 국물 요리, 볶음 요리, 구이 요리 등 다양한 조리법 포함\n" + "- 고기 요리, 생선 요리, 채식 요리, 면 요리 등 다양한 재료 활용\n" + "- 레시피 제목에는 사용 재료가 명확히 보이도록 작성 (토마토 바질 파스타)\n" + "- 레시피 제목에 포함된 재료는 2개 이하로, 주식(면(파스타, 우동 등), 밥(덮밥, 볶음밥 등))의 경우 3개까지 가능 (토마토 바질 파스타 O, 고추장 돼지고기 볶음 O, 고추장 양파 돼지고기 볶음 X)\n" + @@ -1144,7 +1113,13 @@ private String buildRecipePostPromptIngredient(List availableIngredients private String buildRecipePostPromptRandom(String topic) { StringBuilder prompt = new StringBuilder(); - if (topic != null && !topic.trim().isEmpty()) { + // 주제가 null이거나 빈 문자열인 경우 로깅 + if (topic == null) { + log.info("주제가 null이므로 완전 랜덤 레시피 생성"); + } else if (topic.trim().isEmpty()) { + log.info("주제가 빈 문자열이므로 완전 랜덤 레시피 생성"); + } else { + log.info("주제 '{}' 기반 레시피 생성", topic.trim()); prompt.append(String.format("'%s' 주제와 연관된 ", topic.trim())); } @@ -1155,7 +1130,10 @@ private String buildRecipePostPromptRandom(String topic) { .append("- 사용자가 개별적으로 알레르기 정보를 확인하고 선택하도록 안내\n\n") .append("총 ").append(POST_RECIPE_COUNT).append("개의 다양한 보편적인 요리를 추천해줘."); - return prompt.toString(); + String finalPrompt = prompt.toString(); + log.info("생성된 프롬프트 길이: {} 문자", finalPrompt.length()); + + return finalPrompt; } /** @@ -1249,27 +1227,23 @@ private Map parseBatchScores(String response, List recip Map scores = new HashMap<>(); try { - // OpenAI API 응답에서 content 추출 - JsonNode jsonNode = objectMapper.readTree(response); - String aiContent = jsonNode.path("choices").path(0).path("message").path("content").asText(); + if (ENABLE_VERBOSE_LOG) log.info("AI 응답 내용: {}", response); - log.info("AI 응답 내용: {}", aiContent); - - // AI 응답에서 JSON 부분 추출 (코드블록 제거) - String jsonContent = extractJsonFromAIResponse(aiContent); - log.info("추출된 JSON: {}", jsonContent); + // Gemini API 응답에서 JSON 부분 추출 (코드블록 제거) + String jsonContent = extractJsonFromAIResponse(response); + if (ENABLE_VERBOSE_LOG) log.info("추출된 JSON: {}", jsonContent); // AI 응답을 JSON으로 파싱 JsonNode scoresJson = objectMapper.readTree(jsonContent); JsonNode scoresNode = scoresJson.path("scores"); if (!scoresNode.isMissingNode()) { - log.info("점수 노드 발견: {}", scoresNode.toString()); + if (ENABLE_VERBOSE_LOG) log.info("점수 노드 발견: {}", scoresNode.toString()); for (Recipe recipe : recipes) { double score = scoresNode.path(recipe.getTitle()).asDouble(5.0); scores.put(recipe.getTitle(), score); - log.info("레시피 '{}' 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 점수: {}", recipe.getTitle(), score); } } else { log.warn("scores 노드를 찾을 수 없습니다."); @@ -1286,7 +1260,7 @@ private Map parseBatchScores(String response, List recip } } - log.info("최종 파싱 결과: {}", scores); + if (ENABLE_VERBOSE_LOG) log.info("최종 파싱 결과: {}", scores); return scores; } @@ -1362,16 +1336,25 @@ private String buildOcrHealthInfo(Map ocrHealthData) { */ private List getUserHealthGoals(Long userId) { try { + log.info("사용자 {}의 HealthGoal 조회 시작", userId); + // HealthGoalService를 통해 사용자의 건강 목표 조회 var healthGoalResponse = healthGoalService.getHealthGoalList(userId); + if (healthGoalResponse != null && healthGoalResponse.getHealthGoalList() != null) { - return healthGoalResponse.getHealthGoalList().stream() + List healthGoals = healthGoalResponse.getHealthGoalList().stream() + .filter(goal -> goal != null && goal.getHealthGoalType() != null) .map(goal -> goal.getHealthGoalType().name()) .collect(Collectors.toList()); + + log.info("사용자 {}의 HealthGoal 조회 성공: {}", userId, healthGoals); + return healthGoals; + } else { + log.warn("사용자 {}의 HealthGoal 응답이 null이거나 빈 목록", userId); + return new ArrayList<>(); } - return new ArrayList<>(); } catch (Exception e) { - log.warn("HealthGoal 조회 실패: {}", e.getMessage()); + log.error("사용자 {}의 HealthGoal 조회 실패: {}", userId, e.getMessage(), e); return new ArrayList<>(); } } @@ -1662,15 +1645,11 @@ private Map parseBatchHealthScores(String response, List Map scores = new HashMap<>(); try { - // OpenAI API 응답에서 content 추출 - JsonNode jsonNode = objectMapper.readTree(response); - String aiContent = jsonNode.path("choices").path(0).path("message").path("content").asText(); - - log.info("AI 응답 내용: {}", aiContent); + if (ENABLE_VERBOSE_LOG) log.info("AI 응답 내용: {}", response); - // AI 응답에서 JSON 부분 추출 (코드블록 제거) - String jsonContent = extractJsonFromAIResponse(aiContent); - log.info("추출된 JSON: {}", jsonContent); + // Gemini API 응답에서 JSON 부분 추출 (코드블록 제거) + String jsonContent = extractJsonFromAIResponse(response); + if (ENABLE_VERBOSE_LOG) log.info("추출된 JSON: {}", jsonContent); // AI 응답을 JSON으로 파싱 JsonNode scoresJson = objectMapper.readTree(jsonContent); @@ -1682,7 +1661,7 @@ private Map parseBatchHealthScores(String response, List for (Recipe recipe : recipes) { double score = scoresNode.path(recipe.getTitle()).asDouble(5.0); scores.put(recipe.getTitle(), score); - log.info("레시피 '{}' 건강 적합도 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 건강 적합도 점수: {}", recipe.getTitle(), score); } } else { log.warn("scores 노드를 찾을 수 없습니다."); @@ -1699,7 +1678,7 @@ private Map parseBatchHealthScores(String response, List } } - log.info("최종 건강 정보 기반 파싱 결과: {}", scores); + if (ENABLE_VERBOSE_LOG) log.info("최종 건강 정보 기반 파싱 결과: {}", scores); return scores; } @@ -1713,18 +1692,20 @@ private List processBatchByCombined(List recipes, List healthGoals) { List recipesWithScores = new ArrayList<>(); - log.info("통합 정보 기반 배치 처리 시작 - 사용자 재료: {}, 알레르기: {}, 건강목표: {}", - String.join(", ", availableIngredients), - allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes), - healthGoals.isEmpty() ? "없음" : String.join(", ", healthGoals)); + if (ENABLE_VERBOSE_LOG) { + log.info("통합 정보 기반 배치 처리 시작 - 사용자 재료: {}, 알레르기: {}, 건강목표: {}", + String.join(", ", availableIngredients), + allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes), + healthGoals.isEmpty() ? "없음" : String.join(", ", healthGoals)); + } try { // 배치 처리: 모든 레시피를 한 번에 AI에게 전달 String batchPrompt = createBatchCombinedSuitabilityPrompt(recipes, availableIngredients, allergyTypes, ocrHealthData, healthGoals); - log.info("통합 정보 기반 배치 프롬프트 생성 완료"); + if (ENABLE_VERBOSE_LOG) log.info("통합 정보 기반 배치 프롬프트 생성 완료"); String batchResponse = callAI(batchPrompt); - log.info("AI 배치 응답: {}", batchResponse); + if (ENABLE_VERBOSE_LOG) log.info("AI 배치 응답: {}", batchResponse); // AI 응답에서 각 레시피의 점수 파싱 Map scores = parseBatchCombinedScores(batchResponse, recipes); @@ -1734,13 +1715,13 @@ private List processBatchByCombined(List recipes, double score = scores.getOrDefault(recipe.getTitle(), 5.0); if (score > 0) { recipesWithScores.add(new RecipeWithScore(recipe, score)); - log.info("레시피 '{}' 통합 적합도 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 통합 적합도 점수: {}", recipe.getTitle(), score); } else { - log.info("레시피 {} 제외됨 (AI가 부적합으로 판단)", recipe.getTitle()); + if (ENABLE_VERBOSE_LOG) log.info("레시피 {} 제외됨 (AI가 부적합으로 판단)", recipe.getTitle()); } } - log.info("통합 정보 기반 배치 처리 완료 - {} 개 레시피 처리됨", recipesWithScores.size()); + if (ENABLE_VERBOSE_LOG) log.info("통합 정보 기반 배치 처리 완료 - {} 개 레시피 처리됨", recipesWithScores.size()); } catch (Exception e) { log.warn("통합 정보 기반 배치 처리 실패: {}", e.getMessage()); @@ -1761,19 +1742,19 @@ private List processIndividualByCombined(List recipes, List healthGoals) { List recipesWithScores = new ArrayList<>(); - log.info("통합 정보 기반 개별 처리 시작 - {} 개 레시피", recipes.size()); + if (ENABLE_VERBOSE_LOG) log.info("통합 정보 기반 개별 처리 시작 - {} 개 레시피", recipes.size()); for (Recipe recipe : recipes) { try { String prompt = createCombinedSuitabilityPrompt(recipe, availableIngredients, allergyTypes, ocrHealthData, healthGoals); double score = callAIForSuitabilityScore(prompt); - log.info("레시피 '{}' 통합 적합도 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 통합 적합도 점수: {}", recipe.getTitle(), score); if (score > 0) { recipesWithScores.add(new RecipeWithScore(recipe, score)); } else { - log.info("레시피 {} 제외됨 (AI가 부적합으로 판단)", recipe.getTitle()); + if (ENABLE_VERBOSE_LOG) log.info("레시피 {} 제외됨 (AI가 부적합으로 판단)", recipe.getTitle()); } } catch (Exception e) { log.warn("레시피 {} 통합 적합도 평가 실패: {}", recipe.getTitle(), e.getMessage()); @@ -1851,27 +1832,23 @@ private Map parseBatchCombinedScores(String response, List scores = new HashMap<>(); try { - // OpenAI API 응답에서 content 추출 - JsonNode jsonNode = objectMapper.readTree(response); - String aiContent = jsonNode.path("choices").path(0).path("message").path("content").asText(); + if (ENABLE_VERBOSE_LOG) log.info("AI 응답 내용: {}", response); - log.info("AI 응답 내용: {}", aiContent); - - // AI 응답에서 JSON 부분 추출 (코드블록 제거) - String jsonContent = extractJsonFromAIResponse(aiContent); - log.info("추출된 JSON: {}", jsonContent); + // Gemini API 응답에서 JSON 부분 추출 (코드블록 제거) + String jsonContent = extractJsonFromAIResponse(response); + if (ENABLE_VERBOSE_LOG) log.info("추출된 JSON: {}", jsonContent); // AI 응답을 JSON으로 파싱 JsonNode scoresJson = objectMapper.readTree(jsonContent); JsonNode scoresNode = scoresJson.path("scores"); if (!scoresNode.isMissingNode()) { - log.info("점수 노드 발견: {}", scoresNode.toString()); + if (ENABLE_VERBOSE_LOG) log.info("점수 노드 발견: {}", scoresNode.toString()); for (Recipe recipe : recipes) { double score = scoresNode.path(recipe.getTitle()).asDouble(5.0); scores.put(recipe.getTitle(), score); - log.info("레시피 '{}' 통합 적합도 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 통합 적합도 점수: {}", recipe.getTitle(), score); } } else { log.warn("scores 노드를 찾을 수 없습니다."); @@ -1888,7 +1865,7 @@ private Map parseBatchCombinedScores(String response, List parseBatchHealthGoalScores(String response, List scores = new HashMap<>(); try { - // OpenAI API 응답에서 content 추출 - JsonNode jsonNode = objectMapper.readTree(response); - String aiContent = jsonNode.path("choices").path(0).path("message").path("content").asText(); + if (ENABLE_VERBOSE_LOG) log.info("AI 응답 내용: {}", response); - log.info("AI 응답 내용: {}", aiContent); - - // AI 응답에서 JSON 부분 추출 (코드블록 제거) - String jsonContent = extractJsonFromAIResponse(aiContent); - log.info("추출된 JSON: {}", jsonContent); + // Gemini API 응답에서 JSON 부분 추출 (코드블록 제거) + String jsonContent = extractJsonFromAIResponse(response); + if (ENABLE_VERBOSE_LOG) log.info("추출된 JSON: {}", jsonContent); // AI 응답을 JSON으로 파싱 JsonNode scoresJson = objectMapper.readTree(jsonContent); JsonNode scoresNode = scoresJson.path("scores"); if (!scoresNode.isMissingNode()) { - log.info("점수 노드 발견: {}", scoresNode.toString()); + if (ENABLE_VERBOSE_LOG) log.info("점수 노드 발견: {}", scoresNode.toString()); for (Recipe recipe : recipes) { double score = scoresNode.path(recipe.getTitle()).asDouble(5.0); scores.put(recipe.getTitle(), score); - log.info("레시피 '{}' 건강 목표 적합도 점수: {}", recipe.getTitle(), score); + if (ENABLE_VERBOSE_LOG) log.info("레시피 '{}' 건강 목표 적합도 점수: {}", recipe.getTitle(), score); } } else { log.warn("scores 노드를 찾을 수 없습니다."); @@ -1996,7 +1969,7 @@ private Map parseBatchHealthGoalScores(String response, List createAndSaveRandomRecipes(Long userId, St log.info("AI 랜덤 레시피 생성 및 저장 시작 - userId: {}, topic: {}", userId, topic); try { + // 사용자 정보 검증 + User user = getUser(userId); + log.info("사용자 검증 완료: userId={}, user={}", userId, user != null ? user.getId() : "null"); + // 주제 기반 또는 완전 랜덤 프롬프트 생성 String prompt = buildRecipePostPromptRandom(topic); - log.info("랜덤 레시피 생성 프롬프트 생성 완료 - 주제: {}", topic); + log.info("랜덤 레시피 생성 프롬프트 생성 완료 - 주제: {}, 프롬프트 길이: {}", topic, prompt.length()); // AI 호출하여 레시피 생성 및 저장 List recipes = callAIAndSaveRecipes(prompt); diff --git a/src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java b/src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java index 8edf9e27..7fa02430 100644 --- a/src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java +++ b/src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java @@ -20,7 +20,7 @@ import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.RedisTemplate; import java.time.Duration; -import com.mumuk.global.client.OpenAiClient; +import com.mumuk.global.client.GeminiClient; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; import com.mumuk.domain.ingredient.service.IngredientService; @@ -43,17 +43,17 @@ public class RecipeServiceImpl implements RecipeService { private final RecipeRepository recipeRepository; private final UserRecipeRepository userRecipeRepository; private final RedisTemplate redisTemplate; - private final OpenAiClient openAiClient; + private final GeminiClient geminiClient; private final ObjectMapper objectMapper; private final IngredientService ingredientService; public RecipeServiceImpl(RecipeRepository recipeRepository, UserRecipeRepository userRecipeRepository, RedisTemplate redisTemplate, - OpenAiClient openAiClient, ObjectMapper objectMapper, + GeminiClient geminiClient, ObjectMapper objectMapper, IngredientService ingredientService) { this.recipeRepository = recipeRepository; this.userRecipeRepository = userRecipeRepository; this.redisTemplate = redisTemplate; - this.openAiClient = openAiClient; + this.geminiClient = geminiClient; this.objectMapper = objectMapper; this.ingredientService = ingredientService; } @@ -425,42 +425,44 @@ private String buildIngredientMatchingPrompt(List userIngredients, List< 분류 로직: 각 레시피 재료에 대해: 1) 사용자 재료 목록에서 정확히 일치하는 것이 있으면 → match - 2) 정확히 일치하지 않지만 같은 종류의 사용자 재료가 있으면 → replaceable + 2) 정확히 일치하지 않지만 아래 조건에 맞춰 확실한 대체 가능한 사용자 재료가 있으면 → replaceable 3) 그 외의 경우 → mismatch - 대체 가능한 경우 (3가지 기준): + 대체 가능한 경우는 3가지 기준 중 적어도 하나를 만족하고, + 레시피 재료를 기준으로 확실하게 조건을 만족하는 경우에만 대체가능: 1. 포함 관계 (상위 개념 ↔ 하위 개념): - 돼지고기 ↔ 앞다리살, 목살, 삼겹살 (돼지고기가 상위, 구체적 부위가 하위) - 소고기 ↔ 등심, 안심, 갈비 (소고기가 상위, 구체적 부위가 하위) - 닭고기 ↔ 닭가슴살, 닭다리, 닭날개 (닭고기가 상위, 구체적 부위가 하위) + - 간장 ↔ 진간장, 국간장, 노추, 쯔유 (간장에 포함되는 개념) + - 치즈 ↔ 모짜렐라, 크림치즈, 브레드치즈 (치즈 종류) + - 파스타 ↔ 링귀니, 마카로니, 스파게티 (파스타 면 종류) - 2. 같은 종류 + 같은 용도의 대체재: - - 돼지고기(구이용) ↔ 삼겹살(구이용) (같은 돼지고기, 같은 구이용도) - - 돼지고기(구이용) ↔ 목살(구이용) (같은 돼지고기, 같은 구이용도) + 2. 같은 용도: + - 목살(구이용) ↔ 삼겹살(구이용) (같은 돼지고기, 같은 구이용도) + - 돼지고기(찌개용) ↔ 목살(찌개용) (같은 돼지고기, 같은 구이용도) - 돼지고기(탕용) ↔ 앞다리살(탕용) (같은 돼지고기, 같은 탕용도) - - 마늘 ↔ 흑마늘 (같은 마늘이지만 다른 종류) - - 상추 ↔ 로메인 (같은 상추 종류) - - 양파 ↔ 대파 (같은 파 종류) - - 간장 ↔ 진간장 (같은 간장 종류) - 3. 비슷한 역할의 조미료: - - 설탕 ↔ 올리고당 (단맛 조미료) - - 소금 ↔ 천일염 (염분 조미료) + 3. 조미료 대체: + - 설탕 ↔ 올리고당, 알룰로스 (단맛 조미료) + - 소금 ↔ 천일염, 새우젓 (염분 조미료) + - 미원 ↔ 다시다, 혼다시, 연두 (감칠맛 조미료) + - 식용유 ↔ 올리브유, 포도씨유, 케네프리유 (식용유 종류) - 대체 불가능한 경우: - - 돼지 등심(돈까스용) ↔ 삼겹살(구이용) (같은 돼지고기지만 용도가 완전히 다름) - - 돼지 등심(돈까스용) ↔ 목살(구이용) (같은 돼지고기지만 용도가 완전히 다름) - - 돼지 등심(돈까스용) ↔ 앞다리살(탕용) (같은 돼지고기지만 용도가 완전히 다름) + 대체 불가능한 경우: + - 실제 레시피를 고려했을 때 차이가 큰 경우 (식용유 알리오 올리오, 기름이 중요한 레시피인데 식용유는 적절X) + - 2칸 이상 차이나는 경우 (면 ↔ 파스타 ↔ 링귀니 : 면 링귀니는 대체불가, 돼지 등심(돈까스용) ↔ 돼지고기 ↔ 앞다리살(탕용)) + - 돼지 등심(돈까스용) ↔ 삼겹살(구이용),목살(구이용),앞다리살(탕용) (같은 돼지고기지만 용도가 완전히 다름) - 소고기 ↔ 돼지고기 (완전히 다른 고기) - - 고기 ↔ 생선 (완전히 다른 단백질) - - 채소 ↔ 고기 (완전히 다른 재료) - - 조미료 ↔ 주재료 (완전히 다른 역할) + - 올리브유 ↔ 올리브, 고추장 ↔ 고추, 케찹 ↔ 토마토 (소스와 재료의 구분 및 원재료로 제작이 힘든 경우) + - 양파 ↔ 올리브, 파, 마늘 (전혀 다른 재료) + - 기타 대체로 보기 어려운 경우 + - 조금이라도 애매한 경우 대체 불가능으로 본다. 절대 규칙: - - 하나의 레시피 재료는 반드시 한 곳에만 분류 - - replaceable의 recipeIngredient는 반드시 레시피 재료 목록에 있어야 함 - - 사용자 재료 목록에 없는 재료는 절대 replaceable에 포함하지 마세요! + - 레시피 재료는 사용자 재료에 대해서 각각 match, mismatch, replaceable 중 오직 하나에만 속함 + - 최종적으로 응답에 레시피 재료가 모두 포함되어 있어야 함 JSON 형태로 응답: { @@ -481,7 +483,7 @@ private String buildIngredientMatchingPrompt(List userIngredients, List< */ private String callAI(String prompt) { try { - return openAiClient.chat(prompt).block(java.time.Duration.ofSeconds(15)); + return geminiClient.chat(prompt).block(java.time.Duration.ofSeconds(15)); } catch (Exception e) { log.error("AI 호출 실패", e); // 스택트레이스 포함 로깅 throw new BusinessException(ErrorCode.OPENAI_API_ERROR); diff --git a/src/main/java/com/mumuk/global/apiPayload/code/ErrorCode.java b/src/main/java/com/mumuk/global/apiPayload/code/ErrorCode.java index e8febd8d..8c2ba2db 100644 --- a/src/main/java/com/mumuk/global/apiPayload/code/ErrorCode.java +++ b/src/main/java/com/mumuk/global/apiPayload/code/ErrorCode.java @@ -65,14 +65,14 @@ public enum ErrorCode implements BaseCode { INVALID_CURRENT_PASSWORD_FORMAT(HttpStatus.BAD_REQUEST, "AUTH_400_PW", "로그인 한 비밀번호랑 일치하지 않습니다."), INVALID_NICKNAME_FORMAT(HttpStatus.BAD_REQUEST, "AUTH_400_NICKNAME", "닉네임은 10자 이내만 가능합니다."), - // Open AI - OPENAI_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OPENAI_500", "OpenAI API에서 오류가 발생했습니다."), - OPENAI_INVALID_RESPONSE(HttpStatus.BAD_REQUEST, "OPENAI_400_INVALID_RESPONSE", "OpenAI API의 응답 포맷이 잘못되었습니다."), - OPENAI_NO_CHOICES(HttpStatus.BAD_REQUEST, "OPENAI_400_NO_CHOICES", "OpenAI API 응답에서 선택지가 없습니다."), - OPENAI_MISSING_CONTENT(HttpStatus.BAD_REQUEST, "OPENAI_400_MISSING_CONTENT", "OpenAI API 응답 메시지에 내용이 없습니다."), - OPENAI_API_TIMEOUT(HttpStatus.REQUEST_TIMEOUT, "OPENAI_408_TIMEOUT", "OpenAI API 호출 시간이 초과되었습니다."), - OPENAI_INVALID_API_KEY(HttpStatus.UNAUTHORIZED, "OPENAI_401_INVALID_API_KEY", "유효하지 않은 OpenAI API 키입니다."), - OPENAI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "OPENAI_503_SERVICE_UNAVAILABLE", "OpenAI 서비스가 일시적으로 사용 불가능합니다."), + // AI (Provider-agnostic) + OPENAI_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OPENAI_500", "AI API에서 오류가 발생했습니다."), + OPENAI_INVALID_RESPONSE(HttpStatus.BAD_REQUEST, "OPENAI_400_INVALID_RESPONSE", "AI API의 응답 포맷이 잘못되었습니다."), + OPENAI_NO_CHOICES(HttpStatus.BAD_REQUEST, "OPENAI_400_NO_CHOICES", "AI API 응답에서 선택지가 없습니다."), + OPENAI_MISSING_CONTENT(HttpStatus.BAD_REQUEST, "OPENAI_400_MISSING_CONTENT", "AI API 응답 메시지에 내용이 없습니다."), + OPENAI_API_TIMEOUT(HttpStatus.REQUEST_TIMEOUT, "OPENAI_408_TIMEOUT", "AI API 호출 시간이 초과되었습니다."), + OPENAI_INVALID_API_KEY(HttpStatus.UNAUTHORIZED, "OPENAI_401_INVALID_API_KEY", "유효하지 않은 AI API 키입니다."), + OPENAI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "OPENAI_503_SERVICE_UNAVAILABLE", "AI 서비스가 일시적으로 사용 불가능합니다."), OPENAI_EMPTY_RECOMMENDATIONS(HttpStatus.BAD_REQUEST, "OPENAI_400_EMPTY_RECOMMENDATIONS", "AI 추천 결과가 비어있습니다."), OPENAI_JSON_PARSE_ERROR(HttpStatus.BAD_REQUEST, "OPENAI_400_JSON_PARSE", "AI 응답 JSON 파싱에 실패했습니다."), diff --git a/src/main/java/com/mumuk/global/client/GeminiClient.java b/src/main/java/com/mumuk/global/client/GeminiClient.java new file mode 100644 index 00000000..582494ca --- /dev/null +++ b/src/main/java/com/mumuk/global/client/GeminiClient.java @@ -0,0 +1,148 @@ +package com.mumuk.global.client; + +import com.mumuk.global.apiPayload.code.ErrorCode; +import com.mumuk.global.apiPayload.exception.BusinessException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class GeminiClient { + + private final WebClient webClient; + private final String model; + + public GeminiClient(WebClient webClient, @Value("${gemini.api.model}") String model) { + this.webClient = webClient; + this.model = model; + } + + public Mono chat(String prompt) { + Map body = createRequestBody(prompt); + + return webClient.post() + .uri("/v1beta/models/" + model + ":generateContent") + .bodyValue(body) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .map(this::extractContent); + } + + private Map createRequestBody(String prompt) { + Map body = new HashMap<>(); + + // Gemini API 요청 구조 + Map contents = new HashMap<>(); + List> parts = new ArrayList<>(); + + Map part = new HashMap<>(); + part.put("text", prompt); + parts.add(part); + + contents.put("parts", parts); + + List> contentsList = new ArrayList<>(); + contentsList.add(contents); + + body.put("contents", contentsList); + + // 안전 설정 추가 + Map safetySettings = new HashMap<>(); + safetySettings.put("category", "HARM_CATEGORY_HARASSMENT"); + safetySettings.put("threshold", "BLOCK_NONE"); + + List> safetySettingsList = new ArrayList<>(); + safetySettingsList.add(safetySettings); + + body.put("safetySettings", safetySettingsList); + + return body; + } + + private String extractContent(Map response) { + if (response == null || !response.containsKey("candidates")) { + throw new BusinessException(ErrorCode.OPENAI_INVALID_RESPONSE); + } + + List> candidates = (List>) response.get("candidates"); + if (candidates.isEmpty()) { + throw new BusinessException(ErrorCode.OPENAI_NO_CHOICES); + } + + Map candidate = candidates.get(0); + if (candidate == null || !candidate.containsKey("content")) { + throw new BusinessException(ErrorCode.OPENAI_MISSING_CONTENT); + } + + Map content = (Map) candidate.get("content"); + if (content == null || !content.containsKey("parts")) { + throw new BusinessException(ErrorCode.OPENAI_MISSING_CONTENT); + } + + List> parts = (List>) content.get("parts"); + if (parts.isEmpty()) { + throw new BusinessException(ErrorCode.OPENAI_MISSING_CONTENT); + } + + Map firstPart = parts.get(0); + if (firstPart == null || !firstPart.containsKey("text")) { + throw new BusinessException(ErrorCode.OPENAI_MISSING_CONTENT); + } + + String text = (String) firstPart.get("text"); + + // Gemini 응답에서 JSON 부분만 추출 (코드블록 제거) + return extractJsonFromGeminiResponse(text); + } + + /** + * Gemini 응답에서 JSON 부분만 추출 + * ```json ... ``` 형태의 코드블록을 제거하고 JSON만 반환 + */ + private String extractJsonFromGeminiResponse(String response) { + if (response == null || response.trim().isEmpty()) { + throw new BusinessException(ErrorCode.OPENAI_INVALID_RESPONSE); + } + + String trimmedResponse = response.trim(); + + // ```json으로 시작하는 경우 + if (trimmedResponse.startsWith("```json")) { + int startIndex = trimmedResponse.indexOf("```json") + 7; + int endIndex = trimmedResponse.lastIndexOf("```"); + if (endIndex > startIndex) { + return trimmedResponse.substring(startIndex, endIndex).trim(); + } + } + + // ```으로 시작하는 경우 (json 태그 없음) + if (trimmedResponse.startsWith("```")) { + int startIndex = trimmedResponse.indexOf("```") + 3; + int endIndex = trimmedResponse.lastIndexOf("```"); + if (endIndex > startIndex) { + return trimmedResponse.substring(startIndex, endIndex).trim(); + } + } + + // 코드블록이 없는 경우 그대로 반환 + return trimmedResponse; + } + + /** + * Gemini API 모델 목록을 확인하는 메서드 + */ + public Mono> getModels() { + return webClient.get() + .uri("/v1beta/models") + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .onErrorMap(ex -> new BusinessException(ErrorCode.OPENAI_API_ERROR)); + } +} diff --git a/src/main/java/com/mumuk/global/client/OpenAiClient.java b/src/main/java/com/mumuk/global/client/OpenAiClient.java deleted file mode 100644 index 9d63f88b..00000000 --- a/src/main/java/com/mumuk/global/client/OpenAiClient.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.mumuk.global.client; - -import com.mumuk.global.apiPayload.code.ErrorCode; -import com.mumuk.global.apiPayload.exception.BusinessException; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - - -@Component -public class OpenAiClient { - - private final WebClient webClient; - private final String model; - - public OpenAiClient(WebClient webClient, @Value("${openai.api.model}") String model) { - this.webClient = webClient; - this.model = model; - } - - public Mono chat(String prompt) { - Map body = createRequestBody(prompt); - - return webClient.post() - .uri("/chat/completions") - .bodyValue(body) - .retrieve() - .bodyToMono(new ParameterizedTypeReference>() {}) - .map(this::extractContent); // map으로 content 추출 - } - - private Map createRequestBody(String prompt) { - Map body = new HashMap<>(); - body.put("model", model); - List> messages = new ArrayList<>(); - - Map message = new HashMap<>(); - message.put("role", "user"); - message.put("content", prompt); - messages.add(message); - - body.put("messages", messages); - return body; - } - - private String extractContent(Map response) { - if (response == null || !response.containsKey("choices")) { - throw new BusinessException(ErrorCode.OPENAI_INVALID_RESPONSE); - } - - List> choices = (List>) response.get("choices"); - if (choices.isEmpty()) { - throw new BusinessException(ErrorCode.OPENAI_NO_CHOICES); - } - - Map message = (Map) choices.get(0).get("message"); - if (message == null || !message.containsKey("content")) { - throw new BusinessException(ErrorCode.OPENAI_MISSING_CONTENT); - } - - return (String) message.get("content"); - } - - /** - * OpenAI API 사용량을 확인하는 메서드 - */ - public Mono> getUsageInfo() { - return webClient.get() - .uri("/dashboard/billing/usage") - .retrieve() - .bodyToMono(new ParameterizedTypeReference>() {}) - .onErrorMap(ex -> new BusinessException(ErrorCode.OPENAI_API_ERROR)); - } - - /** - * OpenAI API 모델 목록을 확인하는 메서드 - */ - public Mono> getModels() { - return webClient.get() - .uri("/models") - .retrieve() - .bodyToMono(new ParameterizedTypeReference>() {}) - .onErrorMap(ex -> new BusinessException(ErrorCode.OPENAI_API_ERROR)); - } -} diff --git a/src/main/java/com/mumuk/global/config/OpenAiClientConfig.java b/src/main/java/com/mumuk/global/config/GeminiClientConfig.java similarity index 53% rename from src/main/java/com/mumuk/global/config/OpenAiClientConfig.java rename to src/main/java/com/mumuk/global/config/GeminiClientConfig.java index e8666345..c181b848 100644 --- a/src/main/java/com/mumuk/global/config/OpenAiClientConfig.java +++ b/src/main/java/com/mumuk/global/config/GeminiClientConfig.java @@ -1,31 +1,30 @@ package com.mumuk.global.config; - -import com.mumuk.global.client.OpenAiClient; +import com.mumuk.global.client.GeminiClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; @Configuration -public class OpenAiClientConfig { +public class GeminiClientConfig { - @Value("${openai.api.url}") + @Value("${gemini.api.url}") private String baseUrl; - @Value("${openai.api.key}") + @Value("${gemini.api.key}") private String apiKey; @Bean - public WebClient webClient() { + public WebClient geminiWebClient() { return WebClient.builder() .baseUrl(baseUrl) - .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("x-goog-api-key", apiKey) .build(); } @Bean - public OpenAiClient openAiClient(WebClient webClient, @Value("${openai.api.model}") String model) { - return new OpenAiClient(webClient, model); + public GeminiClient geminiClient(WebClient geminiWebClient, @Value("${gemini.api.model}") String model) { + return new GeminiClient(geminiWebClient, model); } }