88import com .webservice .algorithmchef .model .User ;
99import com .webservice .algorithmchef .model .UserAllergy ;
1010import com .webservice .algorithmchef .model .UserHealthGoal ;
11+ import com .webservice .algorithmchef .model .UserPreference ;
1112import com .webservice .algorithmchef .repository .UserRepository ;
1213import lombok .RequiredArgsConstructor ;
1314import 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