diff --git a/src/main/java/com/webservice/algorithmchef/controller/GeminiRecipeController.java b/src/main/java/com/webservice/algorithmchef/controller/GeminiRecipeController.java index a5034c8..4613899 100644 --- a/src/main/java/com/webservice/algorithmchef/controller/GeminiRecipeController.java +++ b/src/main/java/com/webservice/algorithmchef/controller/GeminiRecipeController.java @@ -22,29 +22,29 @@ public class GeminiRecipeController { private final GeminiRecipeService recipeService; // 1. 유저 성향 및 식습관 기반 추천 (요청주소 /recipes/recommend/prefer) - @PostMapping("/prefer") - public ResponseEntity> recommendPrefer(@RequestBody PreferRequest request) { - log.info("성향 추천 요청 - UserPkId: {}", request.userPkId()); - return ResponseEntity.ok( - recipeService.recommendPrefer(request.userPkId(), request.excludedTitles()) - ); - } + @PostMapping("/prefer") + public ResponseEntity> recommendPrefer(@RequestBody PreferRequest request) { + log.info("성향 추천 요청 - userId: {}", request.userId()); + return ResponseEntity.ok( + recipeService.recommendPrefer(request.userId(), request.excludedTitles()) + ); + } // 2. speech to text 조건 기반 추천 (요청주소 /recipes/recommend/condition) @PostMapping("/condition") public ResponseEntity> recommendCondition(@RequestBody ConditionRequest request) { - log.info("조건 추천 요청 - UserPkId: {}, Condition: {}", request.userPkId(), request.condition()); + log.info("조건 추천 요청 - userId: {}, Condition: {}", request.userId(), request.condition()); return ResponseEntity.ok( - recipeService.recommendCondition(request.userPkId(), request.condition(), request.excludedTitles()) + recipeService.recommendCondition(request.userId(), request.condition(), request.excludedTitles()) ); } // 3. 임박재료 기반 추천 (요청주소 /recipes/recommend/expir) @PostMapping("/expir") public ResponseEntity> recommendExpir(@RequestBody ExpirRequest request) { - log.info("재료 추천 요청 - UserPkId: {}", request.userPkId()); + log.info("재료 추천 요청 - userId: {}", request.userId()); return ResponseEntity.ok( - recipeService.recommendExpir(request.userPkId(), request.ingredients(), request.excludedTitles()) + recipeService.recommendExpir(request.userId(), request.ingredients(), request.excludedTitles()) ); } } diff --git a/src/main/java/com/webservice/algorithmchef/controller/SttController.java b/src/main/java/com/webservice/algorithmchef/controller/SttController.java index e21c94e..c8b4b3e 100644 --- a/src/main/java/com/webservice/algorithmchef/controller/SttController.java +++ b/src/main/java/com/webservice/algorithmchef/controller/SttController.java @@ -26,9 +26,9 @@ public class SttController { @PostMapping("/stt") public ResponseEntity stt( @RequestParam("audio") MultipartFile audioFile, - @RequestParam("userId") String userIdStr + @RequestParam("userId") String userId ) { - log.info("음성 기반 레시피 추천 요청 - UserId: {}, 파일크기: {} bytes", userIdStr, audioFile.getSize()); + log.info("음성 기반 레시피 추천 요청 - UserId: {}, 파일크기: {} bytes", userId, audioFile.getSize()); try { // 1. 음성 -> 텍스트 변환 (STT) @@ -41,8 +41,6 @@ public ResponseEntity stt( log.info("STT 변환 결과: \"{}\"", convertedText); - // userId String -> Long 변환 - Long userId = Long.parseLong(userIdStr); // 음성 검색은 보통 '새로운 검색'이므로 excludedTitles는 빈 리스트로 전달 List excludedTitles = Collections.emptyList(); @@ -60,7 +58,7 @@ public ResponseEntity stt( return ResponseEntity.ok(recipes); } catch (NumberFormatException e) { - log.error("UserId 형식 오류: {}", userIdStr, e); + log.error("UserId 형식 오류: {}", userId, e); return ResponseEntity.badRequest().body("유효하지 않은 사용자 ID입니다."); } catch (Exception e) { log.error("음성 레시피 추천 중 오류 발생", e); diff --git a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java index c9ea517..e9cbd74 100644 --- a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java +++ b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java @@ -6,7 +6,7 @@ public class GeminiRecipeRequest { // 1. 성향 기반 추천 요청 public record PreferRequest( - Long userPkId, // DB의 User PK (id) + String userId, // DB의 User (id) List excludedTitles // client의 첫 요청시 string 리스트는 비어있음. // 재요청 시 front에서 요리명만 해당 리스트에 담아 전송. 그러면 gemini는 해당 레시피는 제외하여 탐색. @@ -14,14 +14,14 @@ public record PreferRequest( // 2. Speech-to-Text 조건 기반 추천 요청 public record ConditionRequest( - Long userPkId, + String userId, List excludedTitles, String condition // 기분, 상황 등 조건 ) {} // 3. 임박한 재료 추천 요청 public record ExpirRequest( - Long userPkId, + String userId, List excludedTitles, List ingredients // 소비기한 임박 재료 ) {} diff --git a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java index ed0a691..4aa0c4b 100644 --- a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java +++ b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java @@ -8,6 +8,7 @@ import com.webservice.algorithmchef.model.User; import com.webservice.algorithmchef.model.UserAllergy; import com.webservice.algorithmchef.model.UserHealthGoal; +import com.webservice.algorithmchef.model.UserPreference; import com.webservice.algorithmchef.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,13 +33,13 @@ public class GeminiRecipeService { // 1. [주소 1] 유저 성향 및 식습관 기반 추천 (prefer) @Transactional(readOnly = true) - public List recommendPrefer(Long userPkId, List excludedTitles) { - String[] profile = extractUserProfile(userPkId); + public List recommendPrefer(String userId, List excludedTitles) { + String[] profile = extractUserProfile(userId); String specificPrompt = """ [요청 타입: 사용자 맞춤형 건강/성향 추천] - 사용자의 알레르기 정보와 건강 목표를 최우선으로 고려하여, - 가장 적합하고 실패 없는 '정석적인 건강 레시피' 3가지를 추천해줘. + 사용자의 알레르기 정보, 기피하는 재료, 건강 목표를 최우선으로 고려하여, + 사용자의 입맛(선호 재료, 맵기)에 딱 맞는 '정석적인 건강 레시피' 3가지를 추천해줘. """; return generateAndCallGemini(specificPrompt, profile, excludedTitles, false); @@ -46,15 +47,16 @@ public List recommendPrefer(Long userPkId, List ex // 2. [주소 2] speech-to-text 조건 기반 추천 (condition) @Transactional(readOnly = true) - public List recommendCondition(Long userPkId, String condition, List excludedTitles) { - String[] profile = extractUserProfile(userPkId); + public List recommendCondition(String userId, String condition, List excludedTitles) { + String[] profile = extractUserProfile(userId); String specificPrompt = String.format(""" [요청 타입: 상황 및 기분 등의 조건에 기반한 추천] 사용자가 입력한 상황: "%s" - 위 상황에 적절하고 사용자의 기분을 맞춰줄 수 있는 레시피를 3가지 추천해줘. - (단, 사용자의 알레르기 재료는 절대 포함하면 안 돼. 사용자의 건강 목표에 반하는 레시피도 절대 추천해서는 안 돼.) + 1. 위 상황에 적절하고 사용자의 기분을 맞춰줄 수 있는 레시피를 3가지 추천해줘. + 2. 단, 사용자의 알레르기 및 기피 재료는 절대 포함하면 안 돼. + 3. 사용자의 선호 맵기와 좋아하는 식재료를 최대한 반영해줘. """, condition); // 기분 등 정성적 요소에 기반한 추천은 검색의 유연성을 부여 (isFlexible = true) @@ -63,67 +65,93 @@ public List recommendCondition(Long userPkId, String condi // 3. [주소 3] 임박재료 기반 추천 (expir) @Transactional(readOnly = true) - public List recommendExpir(Long userPkId, List ingredients, List excludedTitles) { - String[] profile = extractUserProfile(userPkId); + public List recommendExpir(String userId, List ingredients, List excludedTitles) { + String[] profile = extractUserProfile(userId); String ingredientStr = String.join(", ", ingredients); String specificPrompt = String.format(""" [요청 타입: 냉장고 재료털기 (소비기한이 임박한 재료 소진)] - 사용자가 반드시 사용해야 하는 재료: [%s] + 사용자가 반드시 사용해야 하는 냉장고 재료: [%s] - 1. 위 재료들을 메인으로 활용하는 레시피 3가지를 추천해줘. - 2. 사용자의 알레르기 유발 재료는 절대 사용하지 마. - 3. 사용자의 건강 목표에 반하는 레시피도 절대 추천하지 마. - 4. 추가 구매가 필요한 재료를 최소화해줘. + 1. 위 냉장고 재료들을 메인으로 활용하는 레시피 3가지를 추천해줘. + 2. 사용자의 알레르기 및 기피 재료는 절대 사용하지 마. + 3. 사용자의 건강 목표에 반하는 레시피도 추천하지 마. + 4. 사용자의 맵기 취향을 고려하되, 추가 구매가 필요한 재료를 최소화해줘. """, ingredientStr); return generateAndCallGemini(specificPrompt, profile, excludedTitles, false); } - // DB 정보 조회 및 문자열 변환 - private String[] extractUserProfile(Long userPkId) { + // DB 정보 조회 및 문자열 변환 (프로필 정보 추출) + private String[] extractUserProfile(String userId) { // 1. User 조회 - User user = userRepository.findById(userPkId) - .orElseThrow(() -> new RuntimeException("User not found: " + userPkId)); + User user = userRepository.findByUserId(userId) + .orElseThrow(() -> new RuntimeException("User not found: " + userId)); - // 2. 알레르기 정보 추출 (User -> UserAllergy -> Allergy -> Name) + // 2. 알레르기 정보 추출 String allergyInfo = "없음"; if (user.getUserAllergyList() != null && !user.getUserAllergyList().isEmpty()) { allergyInfo = user.getUserAllergyList().stream() - .map(UserAllergy::getAllergy) // Allergy 객체 가져오기 - .map(allergy -> allergy.getName()) // 이름 추출 + .map(UserAllergy::getAllergy) + .map(allergy -> allergy.getName()) .collect(Collectors.joining(", ")); } - // 3. 건강 목표 정보 추출 (User -> UserHealthGoal -> HealthGoal -> Description) + // 3. 건강 목표 정보 추출 String goalInfo = "없음"; if (user.getUserHealthGoals() != null && !user.getUserHealthGoals().isEmpty()) { goalInfo = user.getUserHealthGoals().stream() - .map(UserHealthGoal::getHealthGoal) // HealthGoal 객체 가져오기 - .map(goal -> goal.getName() + "(" + goal.getDescription() + ")") // 이름+설명 추출 + .map(UserHealthGoal::getHealthGoal) + .map(goal -> goal.getName() + "(" + goal.getDescription() + ")") .collect(Collectors.joining(", ")); } - return new String[]{allergyInfo, goalInfo}; + // 4. UserPreference (취향) 정보 추출 + UserPreference pref = user.getUserPreference(); + String disLiked = "없음"; + String liked = "없음"; + String spice = "보통"; // 기본값 + + if (pref != null) { + // 싫어하는 재료 (기피 재료) + if (pref.getDisLikedIngredients() != null && !pref.getDisLikedIngredients().isBlank()) { + disLiked = pref.getDisLikedIngredients(); + } + // 좋아하는 재료 (선호 재료) + if (pref.getLikedIngredients() != null && !pref.getLikedIngredients().isBlank()) { + liked = pref.getLikedIngredients(); + } + // 맵기 정도 + if (pref.getSpiceLevel() != null && !pref.getSpiceLevel().isBlank()) { + spice = pref.getSpiceLevel(); + } + } + + // 배열 인덱스 매핑: + // [0]:알레르기, [1]:건강목표, [2]:기피재료(disLiked), [3]:선호재료(liked), [4]:맵기(spice) + return new String[]{allergyInfo, goalInfo, disLiked, liked, spice}; } // 프롬프트 생성 및 Gemini 호출 private List generateAndCallGemini( String specificTask, - String[] profile, // [0]:알레르기, [1]:목표 + String[] profile, List excludedTitles, boolean isFlexible ) { String allergyInfo = profile[0]; String goalInfo = profile[1]; + String disLikedInfo = profile[2]; + String likedInfo = profile[3]; + String spiceLevel = profile[4]; // 재요청시의 기추천 레시피 제외 필터 String exclusionPrompt = ""; if (excludedTitles != null && !excludedTitles.isEmpty()) { exclusionPrompt = String.format(""" - [제외 메뉴] 이전에 추천한 [%s]와 동일한 메뉴는 절대 추천하지 마. + [중복 방지] 이전에 추천한 [%s]와 동일한 메뉴는 절대 다시 추천하지 마. """, String.join(", ", excludedTitles)); } @@ -133,13 +161,20 @@ private List generateAndCallGemini( searchInstruction += "\n(기분이나 느낌과 같은 정성적 요소에 대한 해석에는 네 지식을 써도 되지만, 레시피 재료와 조리법은 반드시 위 사이트 검색 결과만 인용해야 해.)"; } - // 최종 프롬프트 + // 최종 프롬프트 구성 String finalPrompt = String.format(""" - 너는 '팩트 기반' 레시피 추천 AI야. 절대롤 상상하거나 지어내지 마. 아래 정보를 바탕으로 레시피를 추천해줘. + 너는 '팩트 기반' 개인 맞춤형 레시피 추천 AI야. 절대 상상하거나 지어내지 마. 아래 사용자의 프로필 정보를 철저히 분석하여 레시피를 추천해줘. === 1. 사용자 프로필 (DB 정보) === - - **알레르기(절대 포함 금지)**: %s + [제약 사항 - 절대 준수] + - **알레르기(섭취 시 위험)**: %s + - **기피하는 재료(섭취 거부)**: %s + * 위 재료들이 포함된 레시피는 절대 추천하지 마. 재료 목록뿐만 아니라 소스나 양념에 포함된 경우도 제외해. + + [건강 및 취향 - 적극 반영] - **식습관/건강 목표**: %s + - **선호하는 재료(가산점)**: %s (가능한 경우 이 재료들을 활용한 레시피를 우선해줘) + - **선호하는 맵기 정도**: %s (이 맵기 수준에 맞는 레시피를 선정해줘) === 2. 요청 사항 === %s @@ -151,25 +186,28 @@ private List generateAndCallGemini( 1. %s 2. **[Grounding 원칙]**: 네가 알고 있는 사전 지식을 사용하여 레시피를 창조하지 마. 3. 오직 **Google Search 도구가 반환한 검색 결과(Snippet)**에 있는 내용만 정리해서 답변해. - 4. 만약 위 사이트에서 적절한 레시피를 찾을 수 없다면, 차라리 추천하지 마. + 4. 만약 위 사이트에서 사용자 조건(특히 알레르기 및 기피재료 제외 조건)을 만족하는 레시피를 찾을 수 없다면, 무리해서 추천하지 말고 빈 리스트를 반환해. === 5. 출력 형식 (JSON Only) === 인사말, 설명, 마크다운(```) 없이 오직 아래 JSON 배열만 반환해. 찾을 수 없는 항목은 자료형에 맞게 0.0 혹은 "정보 없음"으로 반환해. [ { "name": "요리명", - "description": "요리에 대한 간단한 개요" - "kcal": 실수, 소숫점 첫째자리까지(예시: 365.0) - "portions": "몇 인분" + "description": "요리에 대한 간단한 개요 및 추천 이유(사용자 취향 반영 여부 언급)", + "kcal": 실수(double), // 소숫점 첫째자리까지(예: 365.0) + "portions": "몇 인분", "time": "예상 조리 시간", - "imageUrl" : "완성된 요리 사진 url 주소" + "imageUrl" : "완성된 요리 사진 url 주소", "ingredients": "재료1, 재료2, 재료3", "instructions": "1. ..., 2. ..., 3. ...", - "tip": "레시피에 관한 기타 참고사항 및 추천사항", + "tip": "레시피 팁 및 맵기 조절 팁", "type": "gemini" } ] - """, allergyInfo, goalInfo, specificTask, exclusionPrompt, searchInstruction); + """, + allergyInfo, disLikedInfo, // 제약 사항 + goalInfo, likedInfo, spiceLevel, // 취향 정보 + specificTask, exclusionPrompt, searchInstruction); // 요청 및 규칙 return callGeminiWithRetry(finalPrompt); } @@ -183,7 +221,7 @@ private List callGeminiWithRetry(String prompt) { .tools(Collections.singletonList(searchTool)) .build(); - // gemini 모델 설정, api 호출이 한번에 안 될 경우 반복요청 + // gemini 모델 설정 String modelName = "gemini-2.5-flash-lite"; int maxRetries = 3; @@ -194,14 +232,15 @@ private List callGeminiWithRetry(String prompt) { String text = response.text(); if (text != null) { + // 마크다운 제거 및 JSON 파싱 text = text.replace("```json", "").replace("```", "").trim(); return objectMapper.readValue(text, new TypeReference>() {}); } } catch (Exception e) { - log.warn("호출 실패: {}", e.getMessage()); + log.warn("Gemini 호출 실패 (시도 {}): {}", i + 1, e.getMessage()); try { Thread.sleep(1500); } catch (InterruptedException ignored) {} } } - throw new RuntimeException("Gemini API 응답 실패"); + throw new RuntimeException("Gemini API 응답 실패: 최대 재시도 횟수 초과"); } }