diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties index b12c514..c9f936c 100644 --- a/.gradle/buildOutputCleanup/cache.properties +++ b/.gradle/buildOutputCleanup/cache.properties @@ -1,2 +1,2 @@ -#Sat Feb 15 02:43:43 KST 2025 +#Sat Feb 15 09:08:54 KST 2025 gradle.version=8.10 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin index ed5e392..8744ab6 100644 Binary files a/.gradle/buildOutputCleanup/outputFiles.bin and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/src/main/java/com/team4/giftidea/controller/GptController.java b/src/main/java/com/team4/giftidea/controller/GptController.java index b2a0bf7..a91d151 100644 --- a/src/main/java/com/team4/giftidea/controller/GptController.java +++ b/src/main/java/com/team4/giftidea/controller/GptController.java @@ -1,11 +1,17 @@ package com.team4.giftidea.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.team4.giftidea.configuration.GptConfig; +import com.team4.giftidea.dto.GptRequestDTO; +import com.team4.giftidea.dto.GptResponseDTO; +import com.team4.giftidea.entity.Product; +import com.team4.giftidea.service.ProductService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.RestTemplate; @@ -13,19 +19,11 @@ import java.io.*; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.team4.giftidea.configuration.GptConfig; -import com.team4.giftidea.dto.GptRequestDTO; -import com.team4.giftidea.dto.GptResponseDTO; -import com.team4.giftidea.entity.Product; -import com.team4.giftidea.service.ProductService; +import java.util.*; +import java.util.stream.Collectors; @Slf4j +@Tag(name = "🎁 GPT 추천 API", description = "카카오톡 대화를 분석하여 GPT를 통해 추천 선물을 제공하는 API") @RestController @RequestMapping("/api/gpt") public class GptController { @@ -41,17 +39,19 @@ public GptController(RestTemplate restTemplate, GptConfig gptConfig, ProductServ this.productService = productService; } - - // GPT 모델의 입력 토큰 제한 (예: 출력 토큰 고려 후 설정, 여기서는 예시로 25000) + // GPT 모델의 입력 토큰 제한 (예: 출력 토큰 고려 후 설정, 여기서는 예시로 11000) private static final int GPT_INPUT_LIMIT = 11000; /** - * 파일의 아랫부분부터 토큰을 센 후, 총 토큰 수가 GPT_INPUT_LIMIT 이하인 내용만 - * 선택하여 로컬에 저장하고, 그 청크를 반환합니다. + * 파일의 아랫부분부터 토큰을 누적하여, GPT 입력 제한 이하인 내용만 선택한 후, + * 해당 청크를 GPT API로 보내 키워드를 추출하고, 최종적으로 관련 상품과 Reasons를 반환합니다. * * @param file 업로드된 카카오톡 대화 파일 (.txt) * @param targetName 대상 이름 (예: "여자친구") - * @return 전처리된 청크 (아랫부분부터 토큰 누적하여 GPT_INPUT_LIMIT 이하) + * @param relation 대상과의 관계 (예: "couple", "friend", "parent") + * @param sex 대상 성별 ("male" 또는 "female") + * @param theme 선물 주제 (예: "birthday", "valentine") + * @return 상품 목록과 Reasons 리스트를 포함한 JSON 배열 */ @Operation( summary = "카톡 대화 분석 후 선물 추천", @@ -69,14 +69,11 @@ public List processFileAndRecommend( @RequestParam("targetName") @Parameter(description = "분석 대상 이름 (예: '여자친구')", required = true) String targetName, @RequestParam("relation") @Parameter(description = "대상과의 관계 (couple, friend, parent 등)", required = true) String relation, @RequestParam("sex") @Parameter(description = "대상 성별 (male 또는 female)", required = true) String sex, - @RequestParam("theme") @Parameter(description = "선물 주제 (birthday, valentine 등)", required = true) String theme - ) { + @RequestParam("theme") @Parameter(description = "선물 주제 (birthday, valentine 등)", required = true) String theme) { - List processedMessages = new ArrayList<>(); - int formatType = detectFormatType(file); - - // 1. 파일의 모든 줄을 읽고, targetName이 포함된 줄만 필터링하여 리스트에 저장 + // 1. 파일의 모든 줄 중, targetName이 포함된 줄만 필터링 List allTargetLines = new ArrayList<>(); + int formatType = detectFormatType(file); try (BufferedReader reader = new BufferedReader( new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { String line; @@ -90,49 +87,61 @@ public List processFileAndRecommend( log.error("파일 읽기 오류: ", e); } - // 2. 파일의 아랫부분부터 토큰을 누적 (역순으로 처리) + // 2. 파일의 아랫부분부터 토큰을 역순으로 누적하여 GPT_INPUT_LIMIT 이하인 내용만 선택 int currentTokenCount = 0; List selectedLines = new ArrayList<>(); - // reverse 순회 for (int i = allTargetLines.size() - 1; i >= 0; i--) { String currentLine = allTargetLines.get(i); int tokenCount = countTokens(currentLine); if (currentTokenCount + tokenCount > GPT_INPUT_LIMIT) { - // 토큰 제한을 초과하면 중단 break; } - // 아랫부분부터 선택하므로, 먼저 선택된 줄이 마지막에 온다. selectedLines.add(currentLine); currentTokenCount += tokenCount; } - // 원래 순서대로 복원 (파일에서 아랫부분이 우선이므로, 리스트를 reverse) + // 원래 순서대로 복원 Collections.reverse(selectedLines); - - // 3. 선택된 줄들을 하나의 청크로 합침 StringBuilder finalChunk = new StringBuilder(); for (String s : selectedLines) { finalChunk.append(s).append("\n"); } + List processedMessages = new ArrayList<>(); processedMessages.add(finalChunk.toString()); - // 2. GPT API 호출: 전처리된 메시지로 키워드 반환 + // (선택 사항) - 로컬에 저장 (여기서는 임시 파일로 저장 후 삭제하지 않음) + try { + File outputFile = new File(System.getProperty("user.home"), "processed_kakaochat.txt"); + try (BufferedWriter writer = new BufferedWriter(new FileWriter(outputFile, false))) { + writer.write(finalChunk.toString()); + writer.flush(); + } + log.info("전처리 완료. 결과 파일 저장 위치: " + outputFile.getAbsolutePath()); + } catch (IOException e) { + log.error("파일 저장 오류: ", e); + } + + // 3. GPT API 호출: 전처리된 메시지(청크)를 기반으로 키워드 및 근거 추출 String gptResponse = generatePrompt(processedMessages, relation, sex, theme); - // 3. 키워드, 근거 리스트 변환 및 상품 검색 + // 4. GPT 응답 파싱 + // 예를 들어, GPT 응답이 아래와 같은 형식이라 가정: + // "Categories: 향수, 무선이어폰, 목걸이\n- 향수: [근거 내용...]\n- 무선이어폰: [근거 내용...]\n- 목걸이: [근거 내용...]" String[] responseLines = gptResponse.split("\n"); String categories = responseLines[0].replace("Categories: ", "").trim(); - String reasons = responseLines.length > 1 ? responseLines[1].trim() : ""; - - List keywords = Arrays.asList(categories.split(", ")); - keywords.replaceAll(String::trim); + String reasons = responseLines.length > 1 ? gptResponse.substring(gptResponse.indexOf("\n") + 1).trim() : ""; + List keywords = Arrays.stream(categories.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); List reasonList = Arrays.asList(reasons.split("\n")); + // 5. 데이터베이스에서 검색 (키워드를 기반으로 상품 조회) List products_No_reason = productService.searchByKeywords(keywords); - List products = new ArrayList<>(products_No_reason); - products.add(reasonList); + List finalResponse = new ArrayList<>(products_No_reason); + finalResponse.add(reasonList); - return products; + return finalResponse; } private int detectFormatType(MultipartFile file) { @@ -169,8 +178,7 @@ private int countTokens(String text) { } private String generatePrompt(List processedMessages, String relation, String sex, String theme) { - String combinedMessages = String.join("\n", processedMessages); // List을 하나의 String으로 합침 - + String combinedMessages = String.join("\n", processedMessages); if ("couple".equals(relation)) { if ("male".equals(sex)) { return extractKeywordsAndReasonsCoupleMan(theme, combinedMessages); @@ -194,63 +202,46 @@ private String generatePrompt(List processedMessages, String relation, S return extractKeywordsAndReasonsSeasonalWoman(theme, combinedMessages); } } - return "조건에 맞는 선물 추천 기능이 없습니다."; } private String generateText(String prompt) { GptRequestDTO request = new GptRequestDTO(gptConfig.getModel(), prompt); try { - // HTTP 요청 전에 request 객체 로깅 ObjectMapper mapper = new ObjectMapper(); - GptResponseDTO response = restTemplate.postForObject(gptConfig.getApiUrl(), request, GptResponseDTO.class); - // 응답 검증 - if (response != null) { - log.debug("GPT 응답 수신: {}", mapper.writeValueAsString(response)); - - if (response.getChoices() != null && !response.getChoices().isEmpty()) { - String content = response.getChoices().get(0).getMessage().getContent(); - - if (content.contains("1.")) { - // 첫 번째 줄: 카테고리 리스트 추출 - String categories = content.split("1.")[1].split("\n")[0]; - - // 카테고리 리스트 (괄호 안의 항목들) - String[] categoryArray = categories.split("\\[|\\]")[1].split(","); - - List keywords = new ArrayList<>(); - for (String category : categoryArray) { - keywords.add(category.trim()); - } - - // 두 번째 줄 이후: 카테고리별 설명(reason) 추출 - List reasons = new ArrayList<>(); - String[] lines = content.split("\n"); - - for (String line : lines) { - line = line.trim(); - if (line.startsWith("- ")) { // 설명 부분인지 확인 - int startIndex = line.indexOf(": ["); - if (startIndex != -1) { - String reason = line.substring(startIndex + 3, line.length() - 1).trim(); - reasons.add(reason); - } - } - } - - // 카테고리와 설명을 조합하여 반환 - return "Categories: " + String.join(", ", keywords) + "\n" + - "Reasons: " + String.join("\n", reasons); + if (response != null && response.getChoices() != null && !response.getChoices().isEmpty()) { + String content = response.getChoices().get(0).getMessage().getContent(); + log.debug("GPT 전체 응답: {}", content); + + // "1."과 "2."를 기준으로 파싱 (응답 포맷이 아래와 같다고 가정) + // 예: "Categories: 향수, 무선이어폰, 목걸이\n- 향수: [...]\n- 무선이어폰: [...]\n- 목걸이: [...]" + if (content.contains("1.") && content.contains("2.")) { + String[] parts = content.split("2\\."); + String part1 = parts[0].trim(); + String reasonsPart = parts[1].trim(); + + if (part1.startsWith("1.")) { + part1 = part1.substring(2).trim(); + } + int startIdx = part1.indexOf("["); + int endIdx = part1.indexOf("]"); + String categories = ""; + if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) { + categories = part1.substring(startIdx + 1, endIdx).trim(); } else { - log.warn("GPT 응답에서 카테고리 정보가 올바르지 않습니다."); + log.warn("카테고리 부분 추출 실패, 전체 내용: {}", part1); } + log.debug("추출된 카테고리: {}", categories); + log.debug("추출된 Reasons: {}", reasonsPart); + + return "Categories: " + categories + "\n" + reasonsPart; } else { - log.warn("GPT 응답에 'choices'가 없거나 빈 리스트입니다."); + log.warn("응답 포맷이 예상과 다릅니다: {}", content); } } else { - log.warn("GPT 응답이 null입니다."); + log.warn("GPT 응답이 null이거나 choices가 비어 있습니다."); } return "GPT 응답 오류 발생"; } catch (Exception e) { @@ -262,149 +253,140 @@ private String generateText(String prompt) { } } - private String extractKeywordsAndReasonsCoupleMan(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 남자 애인이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 남성 지갑, 남성 스니커즈, 백팩, 토트백, 크로스백, 벨트, 선글라스, 향수, 헬스가방, 무선이어폰, 스마트워치, 맨투맨, 마우스, 키보드, 전기면도기, 게임기 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 남자 애인이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. + 카테고리: 남성 지갑, 남성 스니커즈, 백팩, 토트백, 크로스백, 벨트, 선글라스, 향수, 헬스가방, 무선이어폰, 스마트워치, 맨투맨, 마우스, 키보드, 전기면도기, 게임기 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsCoupleWoman(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 여자 애인이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 여성 지갑, 여성 스니커즈, 숄더백, 토트백, 크로스백, 향수, 목걸이, 무선이어폰, 스마트워치, 에어랩 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - - return generateText(prompt); // GPT 모델 호출 + 다음 텍스트를 참고하여 여자 애인이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. + 카테고리: 여성 지갑, 여성 스니커즈, 숄더백, 토트백, 크로스백, 향수, 목걸이, 무선이어폰, 스마트워치, 에어랩 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); + return generateText(prompt); } private String extractKeywordsAndReasonsDad(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 부모님이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 현금 박스, 안마기기, 아버지 신발, 시계 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 부모님이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. + 카테고리: 현금 박스, 안마기기, 아버지 신발, 시계 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsMom(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 부모님이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 현금 박스, 안마기기, 어머니 신발, 건강식품, 스카프 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 부모님이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. + 카테고리: 현금 박스, 안마기기, 어머니 신발, 건강식품, 스카프 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsFriend(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 친구가 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 제시된 카테고리에 없는 추천 선물이 있다면 3개에 포함해주세요. - 카테고리: 핸드크림, 텀블러, 립밤, 머플러, 비타민, 입욕제, 블루투스 스피커 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 친구가 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. + 제시된 카테고리에 없는 추천 선물이 있다면 3개에 포함해주세요. + 카테고리: 핸드크림, 텀블러, 립밤, 머플러, 비타민, 입욕제, 블루투스 스피커 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsHousewarming(String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 집들이에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 조명, 핸드워시, 식기, 디퓨저, 오설록 티세트, 휴지, 파자마세트, 무드등, 디퓨저, 수건, 전기포트, 에어프라이기 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, message); - + 다음 텍스트를 참고하여 집들이에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. + 카테고리: 조명, 핸드워시, 식기, 디퓨저, 오설록 티세트, 휴지, 파자마세트, 무드등, 디퓨저, 수건, 전기포트, 에어프라이기 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, message); return generateText(prompt); } private String extractKeywordsAndReasonsSeasonalMan(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 초콜릿, 수제 초콜릿 키트, 파자마세트, 남자 화장품 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. + 카테고리: 초콜릿, 수제 초콜릿 키트, 파자마세트, 남자 화장품 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsSeasonalWoman(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 초콜릿, 수제 초콜릿 키트, 립밤, 파자마세트, 립스틱 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. + 카테고리: 초콜릿, 수제 초콜릿 키트, 립밤, 파자마세트, 립스틱 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } -} +} \ No newline at end of file