본문 바로가기
프로젝트/AI명종원

GPT-4: 재료->음식->레시피 기능 구현

by HWK 2024. 5. 17.

이전 글: GPT-3.5: 재료->음식->레시피 기능 구현 (tistory.com)

 

GPT-3.5: 재료->음식->레시피 기능 구현

아래의 블로그를 참고해서 만들었다.https://adjh54.tistory.com/397 [Java] Spring Boot 환경에서 ChatGPT API 활용하기 -2 : 생태계, 레거시, 새로운 모델해당 글에서는 Spring Boot 환경에서 ChatGPT API를 사용할 때

hwk99.tistory.com

 

이전 글에서 소개하듯이 legacyPrompt를 이용한 기능구현은 문제점이 많았다.

API 호출을 줄이기 위해 질문을 너무 길게 했던 것도 문제였지만, GPT-3.5는 성능이 만족스럽지 않았다.

여러 테스트를 해본 결과 GPT-4의 성능이 GPT-3.5와 비교할 수 없을 정도로 좋았고,

이를 위해 GPT-4 이용이 가능한 API 호출 형식을 이용해야했다.

prompt 메서드는 비록 호출 형식이 legacyPrompt보다 복잡하지만 GPT-4를 이용할 수 있다.

 

1. 기본 메서드: prompt(), chatCompletionDtoBuilder(), extractContent()

public Map<String, Object> prompt(ChatCompletionDto chatCompletionDto) {
    log.debug("[+] 신규 프롬프트를 수행합니다.");

    Map<String, Object> resultMap = new HashMap<>();

    // [STEP1] 토큰 정보가 포함된 Header를 가져옵니다.
    HttpHeaders headers = chatGPTConfig.httpHeaders();

    // [STEP5] 통신을 위한 RestTemplate을 구성합니다.
    HttpEntity<ChatCompletionDto> requestEntity = new HttpEntity<>(chatCompletionDto, headers);
    ResponseEntity<String> response = chatGPTConfig
            .restTemplate()
            .exchange(promptUrl, HttpMethod.POST, requestEntity, String.class);
    try {
        // [STEP6] String -> HashMap 역직렬화를 구성합니다.
        ObjectMapper om = new ObjectMapper();
        resultMap = om.readValue(response.getBody(), new TypeReference<>() {
        });
    } catch (JsonProcessingException e) {
        log.debug("JsonMappingException :: " + e.getMessage());
    } catch (RuntimeException e) {
        log.debug("RuntimeException :: " + e.getMessage());
    }
    return resultMap;
}

legacyPrompt와 매우 유사하지만, ChatCompletionDto를 입력해야 한다.

다음은 필요한 Dto들이다.

public class ChatCompletionDto {
    // 사용할 모델
    private String model;

    private List<ChatRequestMsgDto> messages;

    @Builder
    public ChatCompletionDto(String model, List<ChatRequestMsgDto> messages) {
        this.model = model;
        this.messages = messages;
    }
}

public class ChatRequestMsgDto {

    private String role;

    private String content;

    @Builder
    public ChatRequestMsgDto(String role, String content) {
        this.role = role;
        this.content = content;
    }
}

model에 사용할 모델명(gpt-4-turbo)을 넣어주면 되고, role에는 "system" 이라고 적고, content에 질문을 적으면 된다.

질문을 적을때 한줄로 너무 길게 적지 말고, "\n"을 적절하게 포함시켜 적어줘야한다.

private ChatCompletionDto chatCompletionDtoBuilder(String question) {
    String model = "gpt-4-turbo";
    List<ChatRequestMsgDto> messages = new ArrayList<>();

    ChatRequestMsgDto message = ChatRequestMsgDto.builder()
            .role("system")
            .content(question)
            .build();

    messages.add(message);

    return ChatCompletionDto.builder()
            .model(model)
            .messages(messages)
            .build();
}

하지만 프론트엔드에서 Dto 형태까지 맞춰주기엔, 프론트의 할일만 많아진다.

고로 chatCompletionDtoBuilder()를 통해 위의 Dto 형식에 맞춰주도록 하자.

public String extractContent(Map<String, Object> resultMap) {
    try {
        List<Map<String, Object>> choices = (List<Map<String, Object>>) resultMap.get("choices");

        if (choices != null && !choices.isEmpty()) {
            Map<String, Object> choice = choices.get(0);

            Map<String, Object> message = (Map<String, Object>) choice.get("message");

            if (message != null) {
                return (String) message.get("content");
            }
        }
    } catch (ClassCastException e) {
        log.debug("ClassCastException :: " + e.getMessage());
    }
    return null;
}

 

extractContent메서드는 기존의 복잡한 반환형에서 사용자의 질문에 대한 GPT의 대답만 반환해준다.

 

방금 위에서 소개한 메서드들만 있으면, 질문을 받아 Gpt의 응답을 return 해줄 수 있다.

하지만 최대한 프론트엔드의 부하를 줄여줄 것이기 때문에 질문이 아닌, 재료와 음식 이름등만 입력받고,

반환형도 일반적인 String 형이 아닌, 새로운 Dto를 만들어 반환해줄 것이다.

2. 재료 -> 음식: extractFoodsPrompt(), foodNameQuestionBuilder()

프론트엔드에서 전달해주는 형식은 "재료1, 재료2, 재료3....."이 될 것이다.

해당 문장을 받으면 먼저 질문 형식을 만들어서 "음식1, 음식2, 음식3, 음식4, 음식5"형식으로 응답을 받아줄 것이다

private String foodNameQuestionBuilder(String ingredients) {
    return ingredients + "\\n 위 재료중 일부를 활용해 만들 수 있는 음식 딱 5개 추천해줘."
            + "\\n 형식은 반드시 아래와 같아야해 다른말은 하지 말아줘"
            + "\\n 음식이름, 음식이름, 음식이름, 음식이름, 음식이름.";
}

위 메서드는 질문의 형식을 지정해준다. gpt-4-turbo를 쓰면 99%정도는 형식에 맞춰서 반환형이 나온다.

public List<String> extractFoodsPrompt(String ingredients) {
    String question = foodNameQuestionBuilder(ingredients); // 질문 형성
    ChatCompletionDto completionDto = chatCompletionDtoBuilder(question); // 요청 형식 형성
    Map<String, Object> resultMap = prompt(completionDto); // gpt api에 요청 및 반환
    String responseContent = extractContent(resultMap); // 반환형에서 응답만 추출
    if (responseContent == null || responseContent.isEmpty()) {
        return new ArrayList<>();
    }
    // 추출한 응답 List<String>로 반환
    return Arrays.stream(responseContent.split(","))
            .map(String::trim)
            .collect(Collectors.toList());
}

위메서드는 재료 -> 음식 기능의 전체적인 역할을 수행한다.

먼저 질문을 형성해 요청 형식을 만들어주고 GPT에게 요청을 보낸다.
GPT가 응답을 하면 해당 반환형에서 GPT의 대답만을 추출한다.
GPT 대답 형식은 "음식1, 음식2, 음식3, 음식4, 음식5" 이니까, ","로 문장을 나누고 List<String>에 담아 반환해준다.

그 결과 프론트엔드에서는 편하게 "음식 선택 버튼"을 만들 수 있다.

3. 음식 + 재료 -> 필요한 재료: realIngredientsQuestionBuilder(), extractRealIngredients()

GPT에게 오이냉국 레시피를 물어볼 때 만일 삼겹살이 내가 가진 재료에 포함된다면, 이상한 레시피가 나온다.

이런 불상사를 막기 위해 필요 없는 재료들을 제거하는 과정을 만들 것이다.

private String realIngredientsQuestionBuilder(GptRecipeRequestDto requestDto) {
    String ingredientsStr = String.join(", ", requestDto.getIngredients());
    return requestDto.getMenu() + "를 만드는데 아래의 식재료들이 모두 필요할까?\\n"
            + ingredientsStr + "\\n 필요없는 식재료는 빼주고 필요한 식재료는 추가해줘"
            + "\\n 형식은 반드시 아래와 같아야해 다른말은 하지 말아줘"
            + "\\n 수정된 식재료: 식재료1, 식재료2, 식재료3....";
}

질문 형식은 위와 같다.

public String extractRealIngredients(String ingredientsResult) {
    int colonIndex = ingredientsResult.indexOf(":");

    if (colonIndex != -1 && colonIndex < ingredientsResult.length() - 1) {
        return ingredientsResult.substring(colonIndex + 2);
    } else {
        return ingredientsResult;
    }
}

응답을 처리할 때 ":" 뒤에 있는 문장만 잘라서 realIngredients에 저장할 것이다.

3. 음식 + 필요한 재료 -> 레시피: recipeQuestionBuilder(), parseRecipe(), extractItems()

이제 해당 음식에 대한 레시피를 알아낼 것이다.

private String recipeQuestionBuilder(String menu, String ingredients) {
    return ingredients + "를 가지고 " + menu + " 1인분을 만들꺼야\\n"
            + "구체적인 재료와 레시피를 추천해주는데 형식은 반드시 아래와 같아야해 다른말은 하지 말아줘"
            + "\\n메뉴: 음식이름"
            + "재료:\\n1.재료1\\n2.재료2\\n3.재료3\\n"
            + "레시피:\\n1.요리순서1\\n2.요리순서2\\n3.요리순서3";
}

질문 형식은 위와 같고, 만들 음식과, 있는 재료를 받아서 질문을 완성한다.

여기서 GPT-4의 성능을 확인할 수 있는데, 정말 정확하게 내가 주문한대로 응답 형식이 반환된다.

private static GptRecipeResponseDto parseRecipe(String recipe) {
    List<String> ingredients = new ArrayList<>();
    List<String> instructions = new ArrayList<>();

    int ingredientStartIndex = recipe.indexOf("재료:");
    if (ingredientStartIndex != -1) {
        int ingredientEndIndex = recipe.indexOf("레시피:", ingredientStartIndex);
        if (ingredientEndIndex != -1) {
            String ingredientsSection = recipe.substring(ingredientStartIndex + 4, ingredientEndIndex).trim();
            extractItems(ingredientsSection, ingredients);
        }
    }

    int instructionStartIndex = recipe.indexOf("레시피:");
    if (instructionStartIndex != -1) {
        String instructionsSection = recipe.substring(instructionStartIndex + 5).trim();
        extractItems(instructionsSection, instructions);
    }

    return new GptRecipeResponseDto(ingredients, instructions);
}

private static void extractItems(String section, List<String> items) {
    // Split the section into lines
    String[] lines = section.split("\\n");

    // Extract items from each line
    for (String line : lines) {
        // Remove the numbering (e.g., "1. ", "2. ") and trim the line
        String item = line.replaceAll("^\\d+\\.\\s*", "").trim();
        items.add(item);
    }
}

GptRecipeResponseDto 형식으로 반환형을 만들어주는 클래스들이다.
응답 형식에서 번호를 제거하고, 각각의 재료와 레시피를 따로따로 GptRecipeResponseDto에 담아서 반환해준다.

이렇게 귀찮은 작업을 하는 이유는 gpt는 번호를 붙히는 반환 형식을 매우 선호하기 때문이다.

또한 저장 기능에도 사용될 것이기 때문에, 프론트에서 저장을 위해 따로 형식을 손볼 필요 없이 그대로 전달해주면 된다.

public class GptRecipeResponseDto {
    private List<String> ingredients;
    private List<String> instructions;
}

Dto는 이렇게 간단하게 생겼다.

4. 음식 + 재료 -> 레시피 전체 flow: extractRecipePrompt()

public GptRecipeResponseDto extractRecipePrompt(GptRecipeRequestDto requestDto) {
    String ingredientsQuestion = realIngredientsQuestionBuilder(requestDto); // 질문 형성
    ChatCompletionDto ingredientsCompletionDto = chatCompletionDtoBuilder(ingredientsQuestion); // 요청 형식 형성
    Map<String, Object> ingredientsResultMap = prompt(ingredientsCompletionDto); // gpt api에 요청 및 반환
    String realIngredients = extractRealIngredients(extractContent(ingredientsResultMap)); // 반환형에서 응답만 추출

    String recipeQuestion = recipeQuestionBuilder(requestDto.getMenu(), realIngredients);
    ChatCompletionDto recipeChatCompletionDto = chatCompletionDtoBuilder(recipeQuestion);
    Map<String, Object> recipeResultMap = prompt(recipeChatCompletionDto);
    String realRecipe = extractContent(recipeResultMap);
    System.out.println(realRecipe);

    return parseRecipe(realRecipe);
}

먼저 음식 + 재료 -> 필요한 재료를 구해주고, 그렇게 얻은 필요한 재료를 이용해 레시피를 구해서 반환해준다.

API가 무려 2번이나 호출되어 프로젝트의 성능에 많은 영향을 주지만,
레시피를 따로 저장하지 않고도 GPT API를 이용해 사용자가 원하는 레시피를 얻을 수 있게 해준다.

 

다음 글에서는 저장된 레시피가 어떻게 저장되는지 설명하겠다.

'프로젝트 > AI명종원' 카테고리의 다른 글

채팅 CRUD 기능 구현  (0) 2024.05.18
레시피 CRUD 기능 구현  (0) 2024.05.17
GPT-3.5: 재료->음식->레시피 기능 구현  (0) 2024.05.16
Roboflow Java API  (0) 2024.05.10
Roboflow 이미지 전처리 + 모델 훈련  (0) 2024.05.10