Skip to content

Commit be2a50d

Browse files
committed
develop 브랜치에 gemini 호출 로직 '맵기' 정보 참조 관련 hotfix / commit
1 parent 3230fc7 commit be2a50d

4 files changed

Lines changed: 97 additions & 60 deletions

File tree

src/main/java/com/webservice/algorithmchef/controller/GeminiRecipeController.java

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,29 @@ public class GeminiRecipeController {
2222
private final GeminiRecipeService recipeService;
2323

2424
// 1. 유저 성향 및 식습관 기반 추천 (요청주소 /recipes/recommend/prefer)
25-
@PostMapping("/prefer")
26-
public ResponseEntity<List<GeminiRecipeResponse>> recommendPrefer(@RequestBody PreferRequest request) {
27-
log.info("성향 추천 요청 - UserPkId: {}", request.userPkId());
28-
return ResponseEntity.ok(
29-
recipeService.recommendPrefer(request.userPkId(), request.excludedTitles())
30-
);
31-
}
25+
@PostMapping("/prefer")
26+
public ResponseEntity<List<GeminiRecipeResponse>> recommendPrefer(@RequestBody PreferRequest request) {
27+
log.info("성향 추천 요청 - userId: {}", request.userId());
28+
return ResponseEntity.ok(
29+
recipeService.recommendPrefer(request.userId(), request.excludedTitles())
30+
);
31+
}
3232

3333
// 2. speech to text 조건 기반 추천 (요청주소 /recipes/recommend/condition)
3434
@PostMapping("/condition")
3535
public ResponseEntity<List<GeminiRecipeResponse>> recommendCondition(@RequestBody ConditionRequest request) {
36-
log.info("조건 추천 요청 - UserPkId: {}, Condition: {}", request.userPkId(), request.condition());
36+
log.info("조건 추천 요청 - userId: {}, Condition: {}", request.userId(), request.condition());
3737
return ResponseEntity.ok(
38-
recipeService.recommendCondition(request.userPkId(), request.condition(), request.excludedTitles())
38+
recipeService.recommendCondition(request.userId(), request.condition(), request.excludedTitles())
3939
);
4040
}
4141

4242
// 3. 임박재료 기반 추천 (요청주소 /recipes/recommend/expir)
4343
@PostMapping("/expir")
4444
public ResponseEntity<List<GeminiRecipeResponse>> recommendExpir(@RequestBody ExpirRequest request) {
45-
log.info("재료 추천 요청 - UserPkId: {}", request.userPkId());
45+
log.info("재료 추천 요청 - userId: {}", request.userId());
4646
return ResponseEntity.ok(
47-
recipeService.recommendExpir(request.userPkId(), request.ingredients(), request.excludedTitles())
47+
recipeService.recommendExpir(request.userId(), request.ingredients(), request.excludedTitles())
4848
);
4949
}
5050
}

src/main/java/com/webservice/algorithmchef/controller/SttController.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ public class SttController {
2626
@PostMapping("/stt")
2727
public ResponseEntity<?> stt(
2828
@RequestParam("audio") MultipartFile audioFile,
29-
@RequestParam("userId") String userIdStr
29+
@RequestParam("userId") String userId
3030
) {
31-
log.info("음성 기반 레시피 추천 요청 - UserId: {}, 파일크기: {} bytes", userIdStr, audioFile.getSize());
31+
log.info("음성 기반 레시피 추천 요청 - UserId: {}, 파일크기: {} bytes", userId, audioFile.getSize());
3232

3333
try {
3434
// 1. 음성 -> 텍스트 변환 (STT)
@@ -41,8 +41,6 @@ public ResponseEntity<?> stt(
4141

4242
log.info("STT 변환 결과: \"{}\"", convertedText);
4343

44-
// userId String -> Long 변환
45-
Long userId = Long.parseLong(userIdStr);
4644

4745
// 음성 검색은 보통 '새로운 검색'이므로 excludedTitles는 빈 리스트로 전달
4846
List<String> excludedTitles = Collections.emptyList();
@@ -60,7 +58,7 @@ public ResponseEntity<?> stt(
6058
return ResponseEntity.ok(recipes);
6159

6260
} catch (NumberFormatException e) {
63-
log.error("UserId 형식 오류: {}", userIdStr, e);
61+
log.error("UserId 형식 오류: {}", userId, e);
6462
return ResponseEntity.badRequest().body("유효하지 않은 사용자 ID입니다.");
6563
} catch (Exception e) {
6664
log.error("음성 레시피 추천 중 오류 발생", e);

src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,22 @@ public class GeminiRecipeRequest {
66

77
// 1. 성향 기반 추천 요청
88
public record PreferRequest(
9-
Long userPkId, // DB의 User PK (id)
9+
String userId, // DB의 User (id)
1010
List<String> excludedTitles
1111
// client의 첫 요청시 string 리스트는 비어있음.
1212
// 재요청 시 front에서 요리명만 해당 리스트에 담아 전송. 그러면 gemini는 해당 레시피는 제외하여 탐색.
1313
) {}
1414

1515
// 2. Speech-to-Text 조건 기반 추천 요청
1616
public record ConditionRequest(
17-
Long userPkId,
17+
String userId,
1818
List<String> excludedTitles,
1919
String condition // 기분, 상황 등 조건
2020
) {}
2121

2222
// 3. 임박한 재료 추천 요청
2323
public record ExpirRequest(
24-
Long userPkId,
24+
String userId,
2525
List<String> excludedTitles,
2626
List<String> ingredients // 소비기한 임박 재료
2727
) {}

src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java

Lines changed: 80 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.webservice.algorithmchef.model.User;
99
import com.webservice.algorithmchef.model.UserAllergy;
1010
import com.webservice.algorithmchef.model.UserHealthGoal;
11+
import com.webservice.algorithmchef.model.UserPreference;
1112
import com.webservice.algorithmchef.repository.UserRepository;
1213
import lombok.RequiredArgsConstructor;
1314
import lombok.extern.slf4j.Slf4j;
@@ -32,29 +33,30 @@ public class GeminiRecipeService {
3233

3334
// 1. [주소 1] 유저 성향 및 식습관 기반 추천 (prefer)
3435
@Transactional(readOnly = true)
35-
public List<GeminiRecipeResponse> recommendPrefer(Long userPkId, List<String> excludedTitles) {
36-
String[] profile = extractUserProfile(userPkId);
36+
public List<GeminiRecipeResponse> recommendPrefer(String userId, List<String> excludedTitles) {
37+
String[] profile = extractUserProfile(userId);
3738

3839
String specificPrompt = """
3940
[요청 타입: 사용자 맞춤형 건강/성향 추천]
40-
사용자의 알레르기 정보와 건강 목표를 최우선으로 고려하여,
41-
가장 적합하고 실패 없는 '정석적인 건강 레시피' 3가지를 추천해줘.
41+
사용자의 알레르기 정보, 기피하는 재료, 건강 목표를 최우선으로 고려하여,
42+
사용자의 입맛(선호 재료, 맵기)에 딱 맞는 '정석적인 건강 레시피' 3가지를 추천해줘.
4243
""";
4344

4445
return generateAndCallGemini(specificPrompt, profile, excludedTitles, false);
4546
}
4647

4748
// 2. [주소 2] speech-to-text 조건 기반 추천 (condition)
4849
@Transactional(readOnly = true)
49-
public List<GeminiRecipeResponse> recommendCondition(Long userPkId, String condition, List<String> excludedTitles) {
50-
String[] profile = extractUserProfile(userPkId);
50+
public List<GeminiRecipeResponse> recommendCondition(String userId, String condition, List<String> excludedTitles) {
51+
String[] profile = extractUserProfile(userId);
5152

5253
String specificPrompt = String.format("""
5354
[요청 타입: 상황 및 기분 등의 조건에 기반한 추천]
5455
사용자가 입력한 상황: "%s"
5556
56-
위 상황에 적절하고 사용자의 기분을 맞춰줄 수 있는 레시피를 3가지 추천해줘.
57-
(단, 사용자의 알레르기 재료는 절대 포함하면 안 돼. 사용자의 건강 목표에 반하는 레시피도 절대 추천해서는 안 돼.)
57+
1. 위 상황에 적절하고 사용자의 기분을 맞춰줄 수 있는 레시피를 3가지 추천해줘.
58+
2. 단, 사용자의 알레르기 및 기피 재료는 절대 포함하면 안 돼.
59+
3. 사용자의 선호 맵기와 좋아하는 식재료를 최대한 반영해줘.
5860
""", condition);
5961

6062
// 기분 등 정성적 요소에 기반한 추천은 검색의 유연성을 부여 (isFlexible = true)
@@ -63,67 +65,93 @@ public List<GeminiRecipeResponse> recommendCondition(Long userPkId, String condi
6365

6466
// 3. [주소 3] 임박재료 기반 추천 (expir)
6567
@Transactional(readOnly = true)
66-
public List<GeminiRecipeResponse> recommendExpir(Long userPkId, List<String> ingredients, List<String> excludedTitles) {
67-
String[] profile = extractUserProfile(userPkId);
68+
public List<GeminiRecipeResponse> recommendExpir(String userId, List<String> ingredients, List<String> excludedTitles) {
69+
String[] profile = extractUserProfile(userId);
6870
String ingredientStr = String.join(", ", ingredients);
6971

7072
String specificPrompt = String.format("""
7173
[요청 타입: 냉장고 재료털기 (소비기한이 임박한 재료 소진)]
72-
사용자가 반드시 사용해야 하는 재료: [%s]
74+
사용자가 반드시 사용해야 하는 냉장고 재료: [%s]
7375
74-
1. 위 재료들을 메인으로 활용하는 레시피 3가지를 추천해줘.
75-
2. 사용자의 알레르기 유발 재료는 절대 사용하지 마.
76-
3. 사용자의 건강 목표에 반하는 레시피도 절대 추천하지 마.
77-
4. 추가 구매가 필요한 재료를 최소화해줘.
76+
1. 위 냉장고 재료들을 메인으로 활용하는 레시피 3가지를 추천해줘.
77+
2. 사용자의 알레르기 및 기피 재료는 절대 사용하지 마.
78+
3. 사용자의 건강 목표에 반하는 레시피도 추천하지 마.
79+
4. 사용자의 맵기 취향을 고려하되, 추가 구매가 필요한 재료를 최소화해줘.
7880
""", ingredientStr);
7981

8082
return generateAndCallGemini(specificPrompt, profile, excludedTitles, false);
8183
}
8284

8385

84-
// DB 정보 조회 및 문자열 변환
85-
private String[] extractUserProfile(Long userPkId) {
86+
// DB 정보 조회 및 문자열 변환 (프로필 정보 추출)
87+
private String[] extractUserProfile(String userId) {
8688
// 1. User 조회
87-
User user = userRepository.findById(userPkId)
88-
.orElseThrow(() -> new RuntimeException("User not found: " + userPkId));
89+
User user = userRepository.findByUserId(userId)
90+
.orElseThrow(() -> new RuntimeException("User not found: " + userId));
8991

90-
// 2. 알레르기 정보 추출 (User -> UserAllergy -> Allergy -> Name)
92+
// 2. 알레르기 정보 추출
9193
String allergyInfo = "없음";
9294
if (user.getUserAllergyList() != null && !user.getUserAllergyList().isEmpty()) {
9395
allergyInfo = user.getUserAllergyList().stream()
94-
.map(UserAllergy::getAllergy) // Allergy 객체 가져오기
95-
.map(allergy -> allergy.getName()) // 이름 추출
96+
.map(UserAllergy::getAllergy)
97+
.map(allergy -> allergy.getName())
9698
.collect(Collectors.joining(", "));
9799
}
98100

99-
// 3. 건강 목표 정보 추출 (User -> UserHealthGoal -> HealthGoal -> Description)
101+
// 3. 건강 목표 정보 추출
100102
String goalInfo = "없음";
101103
if (user.getUserHealthGoals() != null && !user.getUserHealthGoals().isEmpty()) {
102104
goalInfo = user.getUserHealthGoals().stream()
103-
.map(UserHealthGoal::getHealthGoal) // HealthGoal 객체 가져오기
104-
.map(goal -> goal.getName() + "(" + goal.getDescription() + ")") // 이름+설명 추출
105+
.map(UserHealthGoal::getHealthGoal)
106+
.map(goal -> goal.getName() + "(" + goal.getDescription() + ")")
105107
.collect(Collectors.joining(", "));
106108
}
107109

108-
return new String[]{allergyInfo, goalInfo};
110+
// 4. UserPreference (취향) 정보 추출
111+
UserPreference pref = user.getUserPreference();
112+
String disLiked = "없음";
113+
String liked = "없음";
114+
String spice = "보통"; // 기본값
115+
116+
if (pref != null) {
117+
// 싫어하는 재료 (기피 재료)
118+
if (pref.getDisLikedIngredients() != null && !pref.getDisLikedIngredients().isBlank()) {
119+
disLiked = pref.getDisLikedIngredients();
120+
}
121+
// 좋아하는 재료 (선호 재료)
122+
if (pref.getLikedIngredients() != null && !pref.getLikedIngredients().isBlank()) {
123+
liked = pref.getLikedIngredients();
124+
}
125+
// 맵기 정도
126+
if (pref.getSpiceLevel() != null && !pref.getSpiceLevel().isBlank()) {
127+
spice = pref.getSpiceLevel();
128+
}
129+
}
130+
131+
// 배열 인덱스 매핑:
132+
// [0]:알레르기, [1]:건강목표, [2]:기피재료(disLiked), [3]:선호재료(liked), [4]:맵기(spice)
133+
return new String[]{allergyInfo, goalInfo, disLiked, liked, spice};
109134
}
110135

111136

112137
// 프롬프트 생성 및 Gemini 호출
113138
private List<GeminiRecipeResponse> generateAndCallGemini(
114139
String specificTask,
115-
String[] profile, // [0]:알레르기, [1]:목표
140+
String[] profile,
116141
List<String> excludedTitles,
117142
boolean isFlexible
118143
) {
119144
String allergyInfo = profile[0];
120145
String goalInfo = profile[1];
146+
String disLikedInfo = profile[2];
147+
String likedInfo = profile[3];
148+
String spiceLevel = profile[4];
121149

122150
// 재요청시의 기추천 레시피 제외 필터
123151
String exclusionPrompt = "";
124152
if (excludedTitles != null && !excludedTitles.isEmpty()) {
125153
exclusionPrompt = String.format("""
126-
[제외 메뉴] 이전에 추천한 [%s]와 동일한 메뉴는 절대 추천하지 마.
154+
[중복 방지] 이전에 추천한 [%s]와 동일한 메뉴는 절대 다시 추천하지 마.
127155
""", String.join(", ", excludedTitles));
128156
}
129157

@@ -133,13 +161,20 @@ private List<GeminiRecipeResponse> generateAndCallGemini(
133161
searchInstruction += "\n(기분이나 느낌과 같은 정성적 요소에 대한 해석에는 네 지식을 써도 되지만, 레시피 재료와 조리법은 반드시 위 사이트 검색 결과만 인용해야 해.)";
134162
}
135163

136-
// 최종 프롬프트
164+
// 최종 프롬프트 구성
137165
String finalPrompt = String.format("""
138-
너는 '팩트 기반' 레시피 추천 AI야. 절대롤 상상하거나 지어내지 마. 아래 정보를 바탕으로 레시피를 추천해줘.
166+
너는 '팩트 기반' 개인 맞춤형 레시피 추천 AI야. 절대 상상하거나 지어내지 마. 아래 사용자의 프로필 정보를 철저히 분석하여 레시피를 추천해줘.
139167
140168
=== 1. 사용자 프로필 (DB 정보) ===
141-
- **알레르기(절대 포함 금지)**: %s
169+
[제약 사항 - 절대 준수]
170+
- **알레르기(섭취 시 위험)**: %s
171+
- **기피하는 재료(섭취 거부)**: %s
172+
* 위 재료들이 포함된 레시피는 절대 추천하지 마. 재료 목록뿐만 아니라 소스나 양념에 포함된 경우도 제외해.
173+
174+
[건강 및 취향 - 적극 반영]
142175
- **식습관/건강 목표**: %s
176+
- **선호하는 재료(가산점)**: %s (가능한 경우 이 재료들을 활용한 레시피를 우선해줘)
177+
- **선호하는 맵기 정도**: %s (이 맵기 수준에 맞는 레시피를 선정해줘)
143178
144179
=== 2. 요청 사항 ===
145180
%s
@@ -151,25 +186,28 @@ private List<GeminiRecipeResponse> generateAndCallGemini(
151186
1. %s
152187
2. **[Grounding 원칙]**: 네가 알고 있는 사전 지식을 사용하여 레시피를 창조하지 마.
153188
3. 오직 **Google Search 도구가 반환한 검색 결과(Snippet)**에 있는 내용만 정리해서 답변해.
154-
4. 만약 위 사이트에서 적절한 레시피를 찾을 수 없다면, 차라리 추천하지 .
189+
4. 만약 위 사이트에서 사용자 조건(특히 알레르기 및 기피재료 제외 조건)을 만족하는 레시피를 찾을 수 없다면, 무리해서 추천하지 말고 빈 리스트를 반환해.
155190
156191
=== 5. 출력 형식 (JSON Only) ===
157192
인사말, 설명, 마크다운(```) 없이 오직 아래 JSON 배열만 반환해. 찾을 수 없는 항목은 자료형에 맞게 0.0 혹은 "정보 없음"으로 반환해.
158193
[
159194
{
160195
"name": "요리명",
161-
"description": "요리에 대한 간단한 개요"
162-
"kcal": 실수, 소숫점 첫째자리까지(예시: 365.0)
163-
"portions": "몇 인분"
196+
"description": "요리에 대한 간단한 개요 및 추천 이유(사용자 취향 반영 여부 언급)",
197+
"kcal": 실수(double), // 소숫점 첫째자리까지(: 365.0)
198+
"portions": "몇 인분",
164199
"time": "예상 조리 시간",
165-
"imageUrl" : "완성된 요리 사진 url 주소"
200+
"imageUrl" : "완성된 요리 사진 url 주소",
166201
"ingredients": "재료1, 재료2, 재료3",
167202
"instructions": "1. ..., 2. ..., 3. ...",
168-
"tip": "레시피에 관한 기타 참고사항 및 추천사항",
203+
"tip": "레시피 팁 및 맵기 조절 팁",
169204
"type": "gemini"
170205
}
171206
]
172-
""", allergyInfo, goalInfo, specificTask, exclusionPrompt, searchInstruction);
207+
""",
208+
allergyInfo, disLikedInfo, // 제약 사항
209+
goalInfo, likedInfo, spiceLevel, // 취향 정보
210+
specificTask, exclusionPrompt, searchInstruction); // 요청 및 규칙
173211

174212
return callGeminiWithRetry(finalPrompt);
175213
}
@@ -183,7 +221,7 @@ private List<GeminiRecipeResponse> callGeminiWithRetry(String prompt) {
183221
.tools(Collections.singletonList(searchTool))
184222
.build();
185223

186-
// gemini 모델 설정, api 호출이 한번에 안 될 경우 반복요청
224+
// gemini 모델 설정
187225
String modelName = "gemini-2.5-flash-lite";
188226
int maxRetries = 3;
189227

@@ -194,14 +232,15 @@ private List<GeminiRecipeResponse> callGeminiWithRetry(String prompt) {
194232
String text = response.text();
195233

196234
if (text != null) {
235+
// 마크다운 제거 및 JSON 파싱
197236
text = text.replace("```json", "").replace("```", "").trim();
198237
return objectMapper.readValue(text, new TypeReference<List<GeminiRecipeResponse>>() {});
199238
}
200239
} catch (Exception e) {
201-
log.warn("호출 실패: {}", e.getMessage());
240+
log.warn("Gemini 호출 실패 (시도 {}): {}", i + 1, e.getMessage());
202241
try { Thread.sleep(1500); } catch (InterruptedException ignored) {}
203242
}
204243
}
205-
throw new RuntimeException("Gemini API 응답 실패");
244+
throw new RuntimeException("Gemini API 응답 실패: 최대 재시도 횟수 초과");
206245
}
207246
}

0 commit comments

Comments
 (0)