Skip to content

Commit 6972249

Browse files
committed
feat: 식재로 인식 API 구현
1 parent 6539819 commit 6972249

File tree

11 files changed

+136
-53
lines changed

11 files changed

+136
-53
lines changed

.github/workflows/cicd-workflow.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: RecipAI CD with Gradle and Docker
1+
name: RecipAI CI/CD with Gradle and Docker
22

33
on:
44
push:

src/main/java/com/recipAI/server/common/response/BaseResponseStatus.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public enum BaseResponseStatus {
3939
INVALID_API_KEY(3006, HttpStatus.UNAUTHORIZED, "OPEN AI API 키 형식이 유효하지 않습니다."),
4040
MISSING_API_KEY(3007, HttpStatus.UNAUTHORIZED, "요청 헤더에 API 키가 포함되지 않았습니다."),
4141
INSUFFICIENT_QUOTA(3008, HttpStatus.UNAUTHORIZED, "사용자의 API 크레딧 또는 사용량을 초과했습니다."),
42+
UNRECOGNIZED_DISH(3009, HttpStatus.BAD_REQUEST, "인식할 수 없는 요리 이름입니다."),
4243

4344

4445
//- 3000번대 : DB 관련 코드

src/main/java/com/recipAI/server/common/s3/S3Service.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public class S3Service {
3131
@Value("${cloud.aws.s3.bucket}")
3232
private String bucketName;
3333

34-
private final String DIR_NAME = "/ingredients";
34+
private final String DIR_NAME = "ingredients";
3535

3636
public String uploadImage(MultipartFile multipartFile) throws IOException {
3737
log.info("[uploadImage] 이미지 업로드 요청");

src/main/java/com/recipAI/server/common/utils/GptResponseParser.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import com.fasterxml.jackson.databind.JsonNode;
44
import com.fasterxml.jackson.databind.ObjectMapper;
55

6+
import java.util.List;
7+
68
public class GptResponseParser {
79
private static final ObjectMapper objectMapper = new ObjectMapper();
810

@@ -20,4 +22,15 @@ public static String extractMessageContent(String rawJson) {
2022
}
2123
}
2224

25+
public static List<String> parseIngredients(String gptResponse) {
26+
try {
27+
ObjectMapper mapper = new ObjectMapper();
28+
JsonNode root = mapper.readTree(gptResponse);
29+
String content = root.path("choices").get(0).path("message").path("content").asText();
30+
return mapper.readValue(content, List.class);
31+
} catch (Exception e) {
32+
throw new RuntimeException("GPT 응답 파싱 실패: " + gptResponse, e);
33+
}
34+
}
35+
2336
}

src/main/java/com/recipAI/server/domain/chat/ChatController.java

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.recipAI.server.domain.chat.dto.IngredientsResponse;
66
import com.recipAI.server.domain.chat.dto.IngredientsStringRequest;
77
import com.recipAI.server.domain.chat.dto.MenusResponse;
8+
import com.recipAI.server.domain.chat.dto.RecipeResponse;
89
import lombok.RequiredArgsConstructor;
910
import lombok.extern.slf4j.Slf4j;
1011
import org.springframework.http.ResponseEntity;
@@ -27,24 +28,11 @@ public class ChatController {
2728
private final ChatService chatService;
2829

2930
@PostMapping("/ingredients")
30-
public ResponseEntity<IngredientsResponse> detectIngredients(@RequestPart("images") List<MultipartFile> uploadImages) {
31+
public ResponseEntity<IngredientsResponse> detectIngredients(@RequestPart("image") MultipartFile image) {
3132
log.info("[detectIngredients] 재료 이미지 요청");
32-
// List<String> imageUrls = uploadImages.stream()
33-
// .map(this::uploadImage)
34-
// .peek(imageUrl -> log.debug("[detectIngredients] imageUrl = {}", imageUrl))
35-
// .toString();
36-
List<String> encodedImages = uploadImages.stream()
37-
.map(image -> {
38-
try {
39-
String base64Image = toBase64DataUrl(image);
40-
log.info("[detectIngredients] base64Image size = {} Byte", image.getSize());
41-
return base64Image;
42-
} catch (IOException e) {
43-
throw new RecipAIException(IMAGE_UPLOAD_FAIL);
44-
}
45-
})
46-
.toList();
47-
IngredientsResponse response = chatService.requestIngredients(encodedImages);
33+
String imageUrl = uploadImage(image);
34+
log.info("[detectIngredients] imageUrl = {}", imageUrl);
35+
IngredientsResponse response = chatService.requestIngredients(imageUrl);
4836
return ResponseEntity.ok(response);
4937
}
5038

@@ -56,6 +44,14 @@ public ResponseEntity<MenusResponse> getMenus(@RequestBody List<String> ingredie
5644
return ResponseEntity.ok(response);
5745
}
5846

47+
@PostMapping("/recipe")
48+
public ResponseEntity<RecipeResponse> getRecipe(@RequestBody String menuName) {
49+
log.info("[getRecipe] 사용자가 선택한 메뉴의 레시피 요청. 메뉴 이름 = {}", menuName);
50+
RecipeResponse response = chatService.requestRecipe(menuName);
51+
log.info("[getRecipe] GPT 응답 = {}", response);
52+
return ResponseEntity.ok(response);
53+
}
54+
5955
private String uploadImage(MultipartFile image) throws RecipAIException {
6056
try {
6157
return s3Service.uploadImage(image);

src/main/java/com/recipAI/server/domain/chat/ChatService.java

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.recipAI.server.common.exception.RecipAIException;
66
import com.recipAI.server.domain.chat.dto.IngredientsResponse;
77
import com.recipAI.server.domain.chat.dto.MenusResponse;
8+
import com.recipAI.server.domain.chat.dto.prompt.RecipeRequest;
9+
import com.recipAI.server.domain.chat.dto.RecipeResponse;
810
import com.recipAI.server.domain.chat.dto.prompt.IngredientsImageRequest;
911
import com.recipAI.server.domain.chat.dto.prompt.MenusRequest;
1012
import lombok.extern.slf4j.Slf4j;
@@ -21,6 +23,7 @@
2123

2224
import static com.recipAI.server.common.response.BaseResponseStatus.*;
2325
import static com.recipAI.server.common.utils.GptResponseParser.extractMessageContent;
26+
import static com.recipAI.server.common.utils.GptResponseParser.parseIngredients;
2427
import static com.recipAI.server.common.utils.Serializer.serializeObject;
2528

2629
@Slf4j
@@ -43,11 +46,12 @@ public ChatService(RestClient.Builder builder, ObjectMapper objectMapper, Enviro
4346
this.objectMapper = objectMapper;
4447
}
4548

46-
public IngredientsResponse requestIngredients(List<String> encodedImages) {
47-
IngredientsImageRequest request = new IngredientsImageRequest(OPENAI_MODEL, encodedImages);
49+
public IngredientsResponse requestIngredients(String imageUrl) {
50+
IngredientsImageRequest request = new IngredientsImageRequest(OPENAI_MODEL, imageUrl);
4851
log.info("[requestIngredients] IngredientsRequest = {}", request.toString());
49-
String GptResponse = callGpt(request);
50-
return new IngredientsResponse(GptResponse);
52+
List<String> ingredients = parseIngredients(callGpt(request));
53+
log.info("[requestIngredients] ingredients = {}", ingredients.toString());
54+
return new IngredientsResponse(ingredients);
5155
}
5256

5357
public MenusResponse requestMenus(List<String> ingredients) {
@@ -59,6 +63,18 @@ public MenusResponse requestMenus(List<String> ingredients) {
5963
}
6064

6165

66+
public RecipeResponse requestRecipe(String menuName) {
67+
RecipeRequest request = new RecipeRequest(OPENAI_MODEL, menuName);
68+
log.info("[requestRecipe] RecipeRequest = {}", request.toString());
69+
String gptResponse = extractMessageContent(callGpt(request));
70+
log.info("[requestRecipe] GPT가 준 응답 = {}", gptResponse);
71+
if (gptResponse.trim().equalsIgnoreCase("UnrecognizedDishException")) {
72+
throw new RecipAIException(UNRECOGNIZED_DISH);
73+
}
74+
return new RecipeResponse(gptResponse);
75+
}
76+
77+
6278
private <T> String callGpt(T request) {
6379
log.info("[OpenAI Request] Body = {}", serializeObject(request));
6480
try {
@@ -124,4 +140,5 @@ private RecipAIException handleGptException(HttpClientErrorException e) {
124140
throw new RecipAIException(INVALID_REQUEST_ERROR);
125141
}
126142
}
143+
127144
}
Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,6 @@
11
package com.recipAI.server.domain.chat.dto;
22

3-
import com.fasterxml.jackson.core.type.TypeReference;
4-
import com.fasterxml.jackson.databind.ObjectMapper;
5-
import lombok.Getter;
6-
import lombok.Setter;
7-
83
import java.util.List;
94

10-
@Getter
11-
@Setter
12-
public class IngredientsResponse {
13-
private final List<String> ingredients;
14-
15-
public IngredientsResponse(String gptResponse) {
16-
try {
17-
ObjectMapper objectMapper = new ObjectMapper();
18-
this.ingredients = objectMapper.readValue(gptResponse, new TypeReference<List<String>>() {});
19-
} catch (Exception e) {
20-
throw new RuntimeException("GPT 응답 파싱 실패: " + gptResponse, e);
21-
}
22-
}
5+
public record IngredientsResponse(List<String> ingredients) {
236
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.recipAI.server.domain.chat.dto;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import lombok.AccessLevel;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
import lombok.ToString;
9+
import lombok.extern.slf4j.Slf4j;
10+
11+
import java.util.ArrayList;
12+
import java.util.List;
13+
14+
@Slf4j
15+
@ToString
16+
@Getter
17+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
18+
public class RecipeResponse {
19+
private List<String> recipe;
20+
private List<String> youtube;
21+
22+
public RecipeResponse(String gptResponse) {
23+
parseGptResponse(gptResponse);
24+
}
25+
26+
private void parseGptResponse(String gptResponse) {
27+
try {
28+
String jsonPart = gptResponse.substring(gptResponse.indexOf("{"));
29+
30+
ObjectMapper objectMapper = new ObjectMapper();
31+
JsonNode root = objectMapper.readTree(jsonPart);
32+
33+
this.recipe = new ArrayList<>();
34+
root.get("recipe").forEach(step -> this.recipe.add(step.asText()));
35+
36+
this.youtube = new ArrayList<>();
37+
root.get("youtube").forEach(link -> this.youtube.add(link.asText()));
38+
} catch (Exception e) {
39+
log.error("응답 파싱 중 오류 발생", e);
40+
throw new RuntimeException("응답 파싱 중 오류 발생", e);
41+
}
42+
}
43+
}

src/main/java/com/recipAI/server/domain/chat/dto/prompt/Content.java

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.recipAI.server.domain.chat.dto.prompt;
22

3+
import com.fasterxml.jackson.annotation.JsonInclude;
34
import lombok.Getter;
45
import lombok.ToString;
56

7+
@JsonInclude(JsonInclude.Include.NON_NULL)
68
@ToString
79
@Getter
810
public class Content {
@@ -20,10 +22,5 @@ public Content(String type, ImageUrl image_url) {
2022
this.image_url = image_url;
2123
}
2224

23-
public record ImageUrl(String url) {
24-
@Override
25-
public String toString() {
26-
return "이미지url";
27-
}
28-
}
25+
public record ImageUrl(String url) {}
2926
}

src/main/java/com/recipAI/server/domain/chat/dto/prompt/IngredientsImageRequest.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,13 @@ public class IngredientsImageRequest {
1616
private final String model;
1717
private final List<Message> messages;
1818

19-
public IngredientsImageRequest(String model, List<String> encodedImages) {
19+
public IngredientsImageRequest(String model, String imageUrl) {
2020
this.model = model;
2121
List<Content> contents = new ArrayList<>();
2222
contents.add(new Content("text", "첨부한 이미지에 보이는 재료들을 JSON 배열 형식의 리스트로만 반환해줘. \n" +
2323
"설명이나 문장 없이, 예를 들어 [\"사과\", \"연어\", \"브로콜리\"] 형식으로 응답해줘."));
24-
for (String imageUrl : encodedImages) {
25-
log.info("[IngredientsRequest] imageUrl = {}", imageUrl);
26-
contents.add(new Content("image_url", new ImageUrl(imageUrl)));
27-
}
24+
log.info("[IngredientsRequest] imageUrl = {}", imageUrl);
25+
contents.add(new Content("image_url", new ImageUrl(imageUrl)));
2826
this.messages = List.of(new Message("user", contents));
2927
}
30-
}
28+
}

0 commit comments

Comments
 (0)