Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,17 @@ SELECT CAST(FLOOR(RANDOM() * (SELECT MAX(id) FROM recipe)) + 1 AS BIGINT)
LIMIT :limit
""", nativeQuery = true)
List<Recipe> 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<Recipe> findRandomRecipesByCategoriesLimited(@Param("categories") List<String> categories,
@Param("limit") int limit);
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,17 +43,17 @@ public class RecipeServiceImpl implements RecipeService {
private final RecipeRepository recipeRepository;
private final UserRecipeRepository userRecipeRepository;
private final RedisTemplate<String, Object> redisTemplate;
private final OpenAiClient openAiClient;
private final GeminiClient geminiClient;
private final ObjectMapper objectMapper;
private final IngredientService ingredientService;

public RecipeServiceImpl(RecipeRepository recipeRepository, UserRecipeRepository userRecipeRepository, RedisTemplate<String, Object> 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;
}
Expand Down Expand Up @@ -425,42 +425,44 @@ private String buildIngredientMatchingPrompt(List<String> 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 형태로 응답:
{
Expand All @@ -481,7 +483,7 @@ private String buildIngredientMatchingPrompt(List<String> 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);
Expand Down
16 changes: 8 additions & 8 deletions src/main/java/com/mumuk/global/apiPayload/code/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 파싱에 실패했습니다."),

Expand Down
148 changes: 148 additions & 0 deletions src/main/java/com/mumuk/global/client/GeminiClient.java
Original file line number Diff line number Diff line change
@@ -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<String> chat(String prompt) {
Map<String, Object> body = createRequestBody(prompt);

return webClient.post()
.uri("/v1beta/models/" + model + ":generateContent")
.bodyValue(body)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.map(this::extractContent);
}

private Map<String, Object> createRequestBody(String prompt) {
Map<String, Object> body = new HashMap<>();

// Gemini API 요청 구조
Map<String, Object> contents = new HashMap<>();
List<Map<String, Object>> parts = new ArrayList<>();

Map<String, Object> part = new HashMap<>();
part.put("text", prompt);
parts.add(part);

contents.put("parts", parts);

List<Map<String, Object>> contentsList = new ArrayList<>();
contentsList.add(contents);

body.put("contents", contentsList);

// 안전 설정 추가
Map<String, Object> safetySettings = new HashMap<>();
safetySettings.put("category", "HARM_CATEGORY_HARASSMENT");
safetySettings.put("threshold", "BLOCK_NONE");

List<Map<String, Object>> safetySettingsList = new ArrayList<>();
safetySettingsList.add(safetySettings);

body.put("safetySettings", safetySettingsList);

return body;
}

private String extractContent(Map<String, Object> response) {
if (response == null || !response.containsKey("candidates")) {
throw new BusinessException(ErrorCode.OPENAI_INVALID_RESPONSE);
}

List<Map<String, Object>> candidates = (List<Map<String, Object>>) response.get("candidates");
if (candidates.isEmpty()) {
throw new BusinessException(ErrorCode.OPENAI_NO_CHOICES);
}

Map<String, Object> candidate = candidates.get(0);
if (candidate == null || !candidate.containsKey("content")) {
throw new BusinessException(ErrorCode.OPENAI_MISSING_CONTENT);
}

Map<String, Object> content = (Map<String, Object>) candidate.get("content");
if (content == null || !content.containsKey("parts")) {
throw new BusinessException(ErrorCode.OPENAI_MISSING_CONTENT);
}

List<Map<String, Object>> parts = (List<Map<String, Object>>) content.get("parts");
if (parts.isEmpty()) {
throw new BusinessException(ErrorCode.OPENAI_MISSING_CONTENT);
}

Map<String, Object> 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<Map<String, Object>> getModels() {
return webClient.get()
.uri("/v1beta/models")
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.onErrorMap(ex -> new BusinessException(ErrorCode.OPENAI_API_ERROR));
}
}
94 changes: 0 additions & 94 deletions src/main/java/com/mumuk/global/client/OpenAiClient.java

This file was deleted.

Loading