diff --git a/src/main/java/com/ongil/backend/domain/product/service/AiMaterialService.java b/src/main/java/com/ongil/backend/domain/product/service/AiMaterialService.java index 34a3c1c..5841e7e 100644 --- a/src/main/java/com/ongil/backend/domain/product/service/AiMaterialService.java +++ b/src/main/java/com/ongil/backend/domain/product/service/AiMaterialService.java @@ -1,49 +1,37 @@ package com.ongil.backend.domain.product.service; -import java.time.Duration; -import java.util.List; - -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import com.ongil.backend.domain.product.dto.response.AiMaterialDescriptionResponse; -import com.theokanning.openai.completion.chat.ChatCompletionRequest; -import com.theokanning.openai.completion.chat.ChatMessage; -import com.theokanning.openai.service.OpenAiService; +import com.ongil.backend.global.openai.OpenAiClient; +import com.ongil.backend.global.openai.OpenAiException; +import com.ongil.backend.global.openai.OpenAiRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service +@RequiredArgsConstructor public class AiMaterialService { - private final OpenAiService openAiService; - - public AiMaterialService(@Value("${openai.api-key}") String apiKey) { - this.openAiService = new OpenAiService(apiKey, Duration.ofSeconds(60)); - } + private final OpenAiClient openAiClient; public AiMaterialDescriptionResponse generate(String material) { - try { - String prompt = createPrompt(material); - - ChatCompletionRequest request = ChatCompletionRequest.builder() - .model("gpt-3.5-turbo") - .messages(List.of( - new ChatMessage("system", "당신은 의류 소재 전문가입니다. 60대 이상 어르신이 이해하기 쉽게 설명해주세요."), - new ChatMessage("user", prompt) - )) - .temperature(0.3) - .maxTokens(1500) - .build(); - - String response = openAiService.createChatCompletion(request) - .getChoices() - .get(0) - .getMessage() - .getContent(); - + OpenAiRequest request = OpenAiRequest.of( + "gpt-3.5-turbo", + "당신은 의류 소재 전문가입니다. 60대 이상 어르신이 이해하기 쉽게 설명해주세요.", + createPrompt(material), + 0.3 + ); + String response = openAiClient.call(request); return parseResponse(response); - + } catch (OpenAiException e) { + log.warn("[AiMaterialService] AI 호출 실패, 기본값 반환. 소재: {}", material); + return AiMaterialDescriptionResponse.createDefault(); } catch (Exception e) { + log.warn("[AiMaterialService] 응답 파싱 실패, 기본값 반환. 소재: {}", material); return AiMaterialDescriptionResponse.createDefault(); } } @@ -86,22 +74,17 @@ private String createPrompt(String material) { } private AiMaterialDescriptionResponse parseResponse(String response) { - try { - String[] sections = response.split("\\[장점\\]|\\[단점\\]|\\[세탁방법\\]"); - - if (sections.length < 4) { - return AiMaterialDescriptionResponse.createDefault(); - } + String[] sections = response.split("\\[장점\\]|\\[단점\\]|\\[세탁방법\\]"); - return AiMaterialDescriptionResponse.builder() - .advantages(cleanSection(sections[1])) - .disadvantages(cleanSection(sections[2])) - .care(cleanSection(sections[3])) - .build(); - - } catch (Exception e) { + if (sections.length < 4) { return AiMaterialDescriptionResponse.createDefault(); } + + return AiMaterialDescriptionResponse.builder() + .advantages(cleanSection(sections[1])) + .disadvantages(cleanSection(sections[2])) + .care(cleanSection(sections[3])) + .build(); } private String cleanSection(String section) { diff --git a/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java b/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java index b85f096..1932b2b 100644 --- a/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java +++ b/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java @@ -1,5 +1,10 @@ package com.ongil.backend.domain.review.service; +import java.util.Arrays; +import java.util.List; + +import org.springframework.stereotype.Service; + import com.ongil.backend.domain.review.dto.request.AiReviewGenerateRequest; import com.ongil.backend.domain.review.dto.response.AiReviewResponse; import com.ongil.backend.domain.review.service.prompter.MaterialReviewPrompter; @@ -7,73 +12,59 @@ import com.ongil.backend.domain.review.validator.ReviewValidator; import com.ongil.backend.global.common.exception.AppException; import com.ongil.backend.global.common.exception.ErrorCode; -import com.theokanning.openai.completion.chat.ChatCompletionRequest; -import com.theokanning.openai.completion.chat.ChatMessage; -import com.theokanning.openai.completion.chat.ChatMessageRole; -import com.theokanning.openai.service.OpenAiService; +import com.ongil.backend.global.openai.OpenAiClient; +import com.ongil.backend.global.openai.OpenAiException; +import com.ongil.backend.global.openai.OpenAiRequest; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.Arrays; -import java.util.List; - @Slf4j @Service @RequiredArgsConstructor public class AiReviewGeneratorService { - private final OpenAiService openAiService; - private final SizeReviewPrompter sizePrompter; - private final MaterialReviewPrompter materialPrompter; - private final ReviewValidator reviewValidator; - - public AiReviewResponse generateSizeReview(AiReviewGenerateRequest request) { - reviewValidator.validateReviewStepCompletion(request.getSizeAnswer(), request.getFitIssueParts()); - - String systemPrompt = sizePrompter.getSystemPrompt(); - String userMessage = sizePrompter.buildUserMessage(request); - - String aiResponse = callOpenAi(systemPrompt, userMessage); - return AiReviewResponse.of(request.getReviewId(), parseAiResponse(aiResponse)); - } - - public AiReviewResponse generateMaterialReview(AiReviewGenerateRequest request) { - reviewValidator.validateReviewStepCompletion(request.getMaterialAnswer(), request.getMaterialFeatures()); - - String systemPrompt = materialPrompter.getSystemPrompt(); - String userMessage = materialPrompter.buildUserMessage(request); + private final OpenAiClient openAiClient; + private final SizeReviewPrompter sizePrompter; + private final MaterialReviewPrompter materialPrompter; + private final ReviewValidator reviewValidator; - String aiResponse = callOpenAi(systemPrompt, userMessage); - return AiReviewResponse.of(request.getReviewId(), parseAiResponse(aiResponse)); - } + public AiReviewResponse generateSizeReview(AiReviewGenerateRequest request) { + reviewValidator.validateReviewStepCompletion(request.getSizeAnswer(), request.getFitIssueParts()); - private String callOpenAi(String systemPrompt, String userMessage) { - List messages = List.of( - new ChatMessage(ChatMessageRole.SYSTEM.value(), systemPrompt), - new ChatMessage(ChatMessageRole.USER.value(), userMessage) - ); + try { + String aiResponse = openAiClient.call(OpenAiRequest.of( + "gpt-4o-mini", + sizePrompter.getSystemPrompt(), + sizePrompter.buildUserMessage(request), + 0.7 + )); + return AiReviewResponse.of(request.getReviewId(), parseAiResponse(aiResponse)); + } catch (OpenAiException e) { + throw new AppException(ErrorCode.AI_GENERATION_ERROR); + } + } - ChatCompletionRequest completionRequest = ChatCompletionRequest.builder() - .model("gpt-4o-mini") - .messages(messages) - .temperature(0.7) - .build(); + public AiReviewResponse generateMaterialReview(AiReviewGenerateRequest request) { + reviewValidator.validateReviewStepCompletion(request.getMaterialAnswer(), request.getMaterialFeatures()); - try { - return openAiService.createChatCompletion(completionRequest) - .getChoices().get(0).getMessage().getContent().trim(); - } catch (Exception e) { - log.error("AI 생성 실패: ", e); - throw new AppException(ErrorCode.AI_GENERATION_ERROR); - } - } + try { + String aiResponse = openAiClient.call(OpenAiRequest.of( + "gpt-4o-mini", + materialPrompter.getSystemPrompt(), + materialPrompter.buildUserMessage(request), + 0.7 + )); + return AiReviewResponse.of(request.getReviewId(), parseAiResponse(aiResponse)); + } catch (OpenAiException e) { + throw new AppException(ErrorCode.AI_GENERATION_ERROR); + } + } - private List parseAiResponse(String aiResponse) { - return Arrays.stream(aiResponse.split("\\|")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); - } + private List parseAiResponse(String aiResponse) { + return Arrays.stream(aiResponse.split("\\|")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } } diff --git a/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java b/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java index 85fcf39..2ecdfd8 100644 --- a/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java +++ b/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java @@ -8,90 +8,279 @@ public class SizeReviewPrompter implements ReviewPrompter { private static final String SYSTEM_PROMPT = """ - 너는 의류 착용 후기를 생성하는 AI임. - 사용자가 선택한 의류 종류, 부위, 강도를 기반으로 - 시니어도 이해하기 쉬운 표현으로 자연스러운 후기 문장을 생성해야 함. - - 아래 규칙을 반드시 모두 지켜야 함. - - ━━━━━━━━━━━━━━ - [1. 부위 범위 규칙] - - 사용자가 선택한 부위는 문장에서 언급 가능한 유일한 불편 범위임 - 선택된 부위 외의 신체 부위, 상황, 결과는 절대 언급 불가함 - 복합 부위(예: 엉덩이 & 가랑이)는 하나의 묶음이지만 - 문장 안에서 단일 부위만 언급했을 경우, - 불편한 상황·감정·원인은 해당 부위로만 한정해야 함 - - 예시로, - 가랑이가 답답해서 앉아 있을 때 불편함은 괜찮지만 - 가랑이가 답답해서 엉덩이 봉제선이 당겨지는 느낌은 안됨 - - 복합 부위를 함께 언급한 경우에만 - 두 부위 모두에 대한 불편 상황 설명 가능함 - 단, 부위 간 인과 관계(원인→결과) 표현은 금지함 - - ━━━━━━━━━━━━━━ - [2. 강도 표현 규칙] - - 강도에 따라 아래 표현 중에서만 선택하여 사용해야 하며, - 의미를 벗어나는 과장·완화 표현은 사용 불가함 - - 너무 답답: - 매우, 심하게, 숨쉬기 힘들 정도로 - - 조금 답답: - 살짝, 신경 쓰일 정도로 - (허리&복부 / 가슴&몸통의 경우만) - 밥 먹고 나면 답답해지는 느낌임 - - 약간 커서 거슬림: - 거슬릴 정도로 - - 너무 커서 불편함: - 많이, 지나치게 - - 수선이 필요할 정도로 큼: - 입고 다니기 힘들 정도로 큼 - - 편함: - 입는 내내 편함 - 신축성이 좋아 움직이기 편함 - 이 경우 특정 부위 언급 없이 - 전반적인 착용감만 작성해야 함 - - ━━━━━━━━━━━━━━ - [3. 표현 톤 규칙 (시니어 친화)] - - 전문 용어, 유행어, 신체 과장 표현 사용 금지 - 아래 표현은 사용하지 않음 - (핏, 옷매무새, Y존, 라인 부각, 실루엣 등) - - 대신 일상적이고 직관적인 표현 사용 - 예: - - 몸에 딱 붙는다 - - 움직일 때 불편하다 - - 앉거나 일어날 때 신경 쓰인다 - - 오래 입기엔 부담된다 - - ━━━━━━━━━━━━━━ - [4. 문장 구조 규칙] - - 하나의 문장에는 하나의 불편 경험만 포함함 - 평가 + 상황 + 느낌의 순서를 유지함 - 문장 끝은 반드시 ~임 으로 끝냄 - - ━━━━━━━━━━━━━━ - [출력 목표] - - 사용자가 선택한 - - 의류 종류 - - 부위 - - 강도 - 를 정확히 반영하여 - 부위 범위를 넘지 않는, - 강도에 맞는, - 시니어도 이해 가능한 한 문장 후기를 생성함. + ━━━━━━━━━━━━━━━━━━ + + 📌 사이즈 관련 후기 생성 프롬프트 (상황고정 · 강도분리 최종형) + 너는 의류 착용 후기를 생성하는 AI임. + 사용자가 선택한 + ① 의류 종류 + ② 1차 선택(입었을 때 느낌) + ③ 2차 선택(불편 부위) + ④ 불편 강도 + 를 기반으로 한 문장을 생성함. + ⚠️ 핵심 원칙 + + 부위별 상황 문장은 아래 정의된 문장을 그대로 사용함 + 강도 표현만 선택값에 따라 교체함 + 상황 내용은 절대 변경하지 않음 + 문장은 한 문장만 생성함 + ━━━━━━━━━━━━━━━━━━ + + [1. 문장 구조 규칙] + 문장 구조는 반드시 아래 순서를 따름: + 👉 평가(부위) → 강도 → 상황 → 마무리 + ✔ 강도 표현은 1회만 사용 + ✔ 인과 표현 금지 (그래서, 때문에 등 금지) + ✔ 문장 끝은 반드시 명사형으로 마무리 + → ~함 / ~임 / ~음 + ✔ 의미 중복 단어 사용 금지 + ━━━━━━━━━━━━━━━━━━ + + 📌 하의 – 숨막히게 답답, 살짝 답답 + ① 허리 & 복부 + 상황 고정 문장: + + 앉아 있으면 숨쉬기 불편함 + 식사 후 배가 답답해짐 + 출력 구조 예: + 허리와 복부가 매우 조여 앉아 있으면 숨쉬기 불편함 + 허리와 복부가 살짝 조여 식사 후 배가 답답해짐 + ② 엉덩이 & 가랑이 + 상황 고정 문장: + + 앉았다 일어날 때 끼는 느낌 있음 + 계단 오를 때 당김 있음 + 예: + 엉덩이와 가랑이가 심하게 끼어 앉았다 일어날 때 불편함 + 엉덩이와 가랑이가 매우 당겨 계단 오를 때 움직임 불편함 + ③ 허벅지 & 종아리 + 상황 고정 문장: + + 계단 오를 때 불편함 + 오래 걸으면 다리 저림 + 예: + 허벅지가 매우 끼어 계단 오를 때 불편함 + 종아리가 심하게 조여 오래 걸으면 다리 저림 있음 + ④ 전반적 + 상황 고정 문장: + + 움직일 때 불편함 + 입고 벗기 불편함 + 옷이 잘 안 늘어남 + 예: + 전반적으로 매우 조여 움직일 때 불편함 + 전반적으로 심하게 붙어 입고 벗기 불편함 + ━━━━━━━━━━━━━━━━━━ + + 📌 하의 – 헐렁함, 너무큼 + ① 허리 & 복부 + 걸을 때 바지가 흘러내림 + 예: + 허리가 많이 커 걸을 때 바지가 흘러내림 + ② 엉덩이 & 가랑이 + 걸을 때 천이 흔들림 + 옷 모양이 부해 보임 + 예: + 엉덩이와 가랑이가 지나치게 널널해 걸을 때 천이 흔들림 + ③ 허벅지 & 종아리 + 걸을 때 천이 쓸림 + 옷 모양이 부해 보임 + 예: + 허벅지와 종아리가 많이 남아 걸을 때 천이 쓸림 + ④ 전반적 + 바지선이 돌아감 + 주름이 많이 생김 + 옷 모양이 어색함 + 예: + 전반적으로 지나치게 커 바지선이 돌아감 + ━━━━━━━━━━━━━━━━━━ + + 📌 상의 – 숨 막히게 답답, 살짝 답답 + ① 목 & 어깨 + 팔을 들 때 당김 있음 + 하루 종일 신경 쓰임 + 예: + 어깨가 매우 좁아 팔을 들 때 당김 있음 + 목이 심하게 조여 하루 종일 신경 쓰임 + ② 겨드랑이 & 팔 + 팔을 들면 옷이 같이 올라감 + 팔 움직일 때 쓸림 있음 + 예: + 팔이 매우 타이트해 팔을 들면 옷이 같이 올라감 + ③ 가슴 & 몸통 + 깊게 숨 쉬면 답답함 + 몸이 부각됨 + 예: + 가슴과 몸통이 심하게 붙어 깊게 숨 쉬면 답답함 + ④ 전반적 + 움직일 때 답답함 + 입고 벗기 불편함 + 예: + 전반적으로 매우 붙어 움직일 때 답답함 + ━━━━━━━━━━━━━━━━━━ + + 📌 상의 – 헐렁함 / 너무 큼 + ① 목 & 어깨 + 고개 숙이면 신경 쓰임 + 옷 모양이 흐트러짐 + ② 겨드랑이 & 팔 + 팔 들면 속이 보일까 신경 쓰임 + ③ 가슴 & 몸통 + 앉으면 옷이 뜸 + 옷 모양이 안 예쁨 + ④ 전반적 + 옷이 따로 노는 느낌 있음 + 전체 모양이 어색함 + ━━━━━━━━━━━━━━━━━━ + + 📌 강도 적용 규칙 + 🔹 숨막히게 답답 선택 시 + → 매우 / 심하게 / 숨쉬기 힘들 정도로 중 1개 사용 + 🔹 살짝 답답 선택 시 + → 살짝 / 신경 쓰일 정도로 중 1개 사용 + 🔹 헐렁함 선택 시 + → 살짝 + 🔹 너무 큼 선택 시 + → 많이 / 지나치게 중 1개 사용 + 🔹 편함 선택 시 + → 부위 언급 금지 + → 전반적 착용감만 작성 + 예: 입는 내내 편함 / 신축성이 좋아 움직이기 편함 + ━━━━━━━━━━━━━━━━━━ + 이 버전은 + ✔ 네가 보낸 상황 전부 반영 + ✔ 강도만 교체 구조 + ✔ 문장 끝 통일 (~함/임/음) + ✔ 과장/인과 제거 + ✔ 중복 표현 방지 + + 📌 사이즈 후기 생성 프롬프트 (사용자 예시 반영 고정형) + 너는 의류 착용 후기를 생성하는 AI임. + 사용자가 선택한 + ① 의류 종류 + ② 1차 선택(입었을 때 느낌) + ③ 2차 선택(불편 부위) + ④ 강도 + 를 기반으로 한 문장만 생성함. + ⚠️ 핵심 원칙 + + 아래에 정의된 “상황 문장”을 최대한 그대로 활용함 + 상황은 바꾸지 말고 강도 표현만 조절함 + 문장 끝은 반드시 명사형으로 마무리 (~함 / ~임 / ~음) + 인과 표현 금지 (그래서, 때문에 등 금지) + 의미 중복 금지 + 강도 표현은 1회만 사용 + ━━━━━━━━━━━━━━━━━━ + + 👖 바지 + ① 너무 답답 / 살짝 답답 선택 시 + ▪ 허리 및 복부 + 기본 상황 문장: + + 배가 껴 숨쉬기 힘듦 + 식사 후 배가 답답함 + 출력 규칙: + 허리와 복부가 [강도] 조여 배가 껴 숨쉬기 힘듦 + 허리와 복부가 [강도] 조여 식사 후 배가 답답함 + ▪ 엉덩이 및 가랑이 + 기본 상황 문장: + + 앉을 때 가랑이와 엉덩이가 낌 + Y존 부각 있음 + 출력: + 엉덩이와 가랑이가 [강도] 끼어 앉을 때 낌 있음 + 엉덩이와 가랑이가 [강도] 붙어 Y존 부각 있음 + ▪ 허벅지 및 종아리 + 기본 상황 문장: + + 허벅지가 끼어 계단을 오르거나 앉을 때 불편함 + 종아리가 끼어 오래 입으면 다리 저림 있음 + 출력: + 허벅지가 [강도] 끼어 계단을 오르거나 앉을 때 불편함 + 종아리가 [강도] 조여 오래 입으면 다리 저림 있음 + ▪ 전반적 + 기본 상황 문장: + + 전체적으로 꽉 끼고 답답함 + 옷이 잘 안 늘어나 움직이기 불편함 + 출력: + 전반적으로 [강도] 붙어 움직이기 불편함 + 전반적으로 [강도] 조여 답답함 + ━━━━━━━━━━━━━━━━━━ + + ② 헐렁함 / 너무 큼 선택 시 + ▪ 허리 및 복부 + 허리가 커 바지가 흘러내림 + 출력: + 허리가 [강도] 커 걸을 때 바지가 흘러내림 + ▪ 엉덩이 및 가랑이 + 엉덩이와 가랑이가 널널해 핏이 부해 보임 + 출력: + 엉덩이와 가랑이 부분이 [강도] 널널해 전체 핏이 부해 보임 + ▪ 허벅지 및 종아리 + 허벅지와 종아리가 널널해 부해 보임 + 출력: + 허벅지와 종아리가 [강도] 널널해 옷 모양이 부해 보임 + ▪ 전반적 + 바지가 주름져 어색함 + 걸을 때 바지선이 돌아감 + 바지통이 넓어 옷 모양이 안 예쁨 + 출력: + 전반적으로 [강도] 커 걸을 때 바지선이 돌아감 + 전반적으로 [강도] 넓어 옷 모양이 안 예쁨 + ━━━━━━━━━━━━━━━━━━ + + 👕 상의 + ① 살짝 답답 / 너무 답답 + ▪ 목 & 어깨 + 목이 조여 답답함 + 어깨가 당김 + 팔을 들 때 어깨가 당김 + 출력: + 목이 [강도] 조여 답답함 + 어깨가 [강도] 좁아 팔을 들 때 당김 있음 + ▪ 겨드랑이 & 팔 + 팔을 들면 옷이 같이 올라감 + 팔 움직일 때 겨드랑이 쓸림 + 팔이 타이트해 부각됨 + 출력: + 팔이 [강도] 타이트해 움직일 때 겨드랑이 쓸림 있음 + 팔이 [강도] 조여 팔을 들면 옷이 같이 올라감 + ▪ 가슴 & 몸통 + 숨 쉬기 답답함 + 몸 부각 심함 + 출력: + 가슴과 몸통이 [강도] 붙어 숨 쉬기 답답함 + 가슴과 몸통이 [강도] 타이트해 몸 부각 있음 + ▪ 전반적 + 전반적으로 몸에 붙어 답답함 + 출력: + 전반적으로 [강도] 붙어 답답함 + ━━━━━━━━━━━━━━━━━━ + + 👗 원피스 + (동일 구조 유지, 네가 작성한 상황 그대로 반영) + 예: + 가슴과 몸통이 [강도] 조여 숨 쉬기 불편함 + 팔이 [강도] 붙어 움직일 때 원피스가 같이 올라감 + 엉덩이 부분이 [강도] 당겨 앉았다 일어날 때 불편함 + 치마 폭이 [강도] 좁아 보폭 제한 있음 + 전반적으로 [강도] 붙어 입고 벗기 불편함 + ━━━━━━━━━━━━━━━━━━ + + 📌 강도 매핑 규칙 + 🔹 너무 답답 / 숨막히게 답답 + → 매우 / 심하게 / 숨쉬기 힘들 정도로 중 1개 + 🔹 살짝 답답 + → 살짝 / 신경 쓰일 정도로 중 1개 + 🔹 너무 큼 + → 많이 / 지나치게 중 1개 + 🔹 헐렁함 + → 살짝 + 🔹 편함 + → 부위 언급 금지 + → 입는 내내 편함 / 신축성이 있어 움직이기 편함 + ━━━━━━━━━━━━━━━━━━\s """; @Override diff --git a/src/main/java/com/ongil/backend/domain/search/service/AiSearchService.java b/src/main/java/com/ongil/backend/domain/search/service/AiSearchService.java index 44f293c..e81be79 100644 --- a/src/main/java/com/ongil/backend/domain/search/service/AiSearchService.java +++ b/src/main/java/com/ongil/backend/domain/search/service/AiSearchService.java @@ -1,100 +1,58 @@ package com.ongil.backend.domain.search.service; -import com.ongil.backend.domain.search.dto.openai.Message; -import com.ongil.backend.domain.search.dto.openai.OpenAiRequest; -import com.ongil.backend.domain.search.dto.openai.OpenAiResponse; +import org.springframework.stereotype.Service; + +import com.ongil.backend.global.openai.OpenAiClient; +import com.ongil.backend.global.openai.OpenAiException; +import com.ongil.backend.global.openai.OpenAiRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.util.List; - @Service @RequiredArgsConstructor @Slf4j public class AiSearchService { - @Value("${openai.api-key}") - private String apiKey; - - private final RestTemplate restTemplate; - - private static final String OPENAI_URL = "https://api.openai.com/v1/chat/completions"; - private static final String MODEL = "gpt-4o-mini"; + private final OpenAiClient openAiClient; public String extractKeywords(String speechText) { - long startTime = System.currentTimeMillis(); try { - OpenAiRequest request = createRequest(speechText); - - OpenAiResponse response = restTemplate.postForObject( - OPENAI_URL, - createEntity(request), - OpenAiResponse.class - ); - - long endTime = System.currentTimeMillis(); - log.info("AI 모델: {}, 소요 시간: {}ms", MODEL, (endTime - startTime)); - - if (response == null || response.choices() == null || response.choices().isEmpty()) { - return fallbackExtract(speechText); - } - - OpenAiResponse.Choice firstChoice = response.choices().get(0); - if (firstChoice.message() == null || firstChoice.message().content() == null) { - log.warn("OpenAI 응답 본문이 비어있습니다. fallback을 실행합니다."); - return fallbackExtract(speechText); - } - - String result = normalize(firstChoice.message().content()); - log.info("추출 키워드: [{}] <- 원문: [{}]", result, speechText); - return result; - - } catch (Exception e) { - log.warn("OpenAI 호출 실패 ({}ms 소요). fallback 실행", (System.currentTimeMillis() - startTime), e); + String result = openAiClient.call(OpenAiRequest.of( + "gpt-4o-mini", + "입력된 문장에서 검색용 핵심 키워드(용도, 브랜드, 품목)를 공백 구분으로 추출합니다.", + buildUserMessage(speechText), + 0.1 + )); + String normalized = normalize(result); + log.info("[AiSearchService] 추출 키워드: [{}] <- 원문: [{}]", normalized, speechText); + return normalized; + } catch (OpenAiException e) { + log.warn("[AiSearchService] OpenAI 호출 실패, fallback 실행. 원문: {}", speechText); return fallbackExtract(speechText); } } - private OpenAiRequest createRequest(String speechText) { - String prompt = String.format(""" - [명령] - 다음 문장에서 쇼핑몰 상품 검색에 '직접적으로 필요한' 단어만 공백으로 구분해줘. - - [입력값] - "%s" - - [처리 가이드] - - '찾아줘', '보여줘'와 같은 요청어는 제외해. - - 상품 종류(신발, 바지)뿐만 아니라 '용도(러닝, 축구, 요가)'나 '브랜드'도 중요 키워드로 추출해. - - 추출된 단어들 사이에는 반드시 공백을 한 칸씩 넣어. - - 오직 키워드만 출력해. - - [출력 예시] - 입력: "러닝할 때 신기 좋은 신발 찾아줘" -> 응답: 러닝 신발 - 입력: "헬스장에서 입을 편한 바지" -> 응답: 헬스 바지 - 입력: "나이키 축구화 보여줘" -> 응답: 나이키 축구화 - 입력: "요즘 날씨에 신기 좋은 신발 찾아줘" -> 응답: 신발 - """, speechText); - - return new OpenAiRequest(MODEL, List.of( - new Message("system", "입력된 문장에서 검색용 핵심 키워드(용도, 브랜드, 품목)를 공백 구분으로 추출합니다."), - new Message("user", prompt) - ), 0.1); - } - - private HttpEntity createEntity(OpenAiRequest request) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setBearerAuth(apiKey); - return new HttpEntity<>(request, headers); + private String buildUserMessage(String speechText) { + return String.format(""" + [명령] + 다음 문장에서 쇼핑몰 상품 검색에 '직접적으로 필요한' 단어만 공백으로 구분해줘. + + [입력값] + "%s" + + [처리 가이드] + - '찾아줘', '보여줘'와 같은 요청어는 제외해. + - 상품 종류(신발, 바지)뿐만 아니라 '용도(러닝, 축구, 요가)'나 '브랜드'도 중요 키워드로 추출해. + - 추출된 단어들 사이에는 반드시 공백을 한 칸씩 넣어. + - 오직 키워드만 출력해. + + [출력 예시] + 입력: "러닝할 때 신기 좋은 신발 찾아줘" -> 응답: 러닝 신발 + 입력: "헬스장에서 입을 편한 바지" -> 응답: 헬스 바지 + 입력: "나이키 축구화 보여줘" -> 응답: 나이키 축구화 + 입력: "요즘 날씨에 신기 좋은 신발 찾아줘" -> 응답: 신발 + """, speechText); } private String normalize(String aiResult) { @@ -111,4 +69,4 @@ private String fallbackExtract(String text) { .replaceAll("\\s+", " ") .trim(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/ongil/backend/global/openai/OpenAiClient.java b/src/main/java/com/ongil/backend/global/openai/OpenAiClient.java new file mode 100644 index 0000000..c7dfa0a --- /dev/null +++ b/src/main/java/com/ongil/backend/global/openai/OpenAiClient.java @@ -0,0 +1,49 @@ +package com.ongil.backend.global.openai; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.theokanning.openai.completion.chat.ChatCompletionRequest; +import com.theokanning.openai.completion.chat.ChatMessage; +import com.theokanning.openai.completion.chat.ChatMessageRole; +import com.theokanning.openai.service.OpenAiService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// OpenAI API 공통 호출 컴포넌트 +@Slf4j +@Component +@RequiredArgsConstructor +public class OpenAiClient { + + private final OpenAiService openAiService; + + public String call(OpenAiRequest request) { + long startTime = System.currentTimeMillis(); + + List messages = List.of( + new ChatMessage(ChatMessageRole.SYSTEM.value(), request.systemPrompt()), + new ChatMessage(ChatMessageRole.USER.value(), request.userMessage()) + ); + + ChatCompletionRequest completionRequest = ChatCompletionRequest.builder() + .model(request.model()) + .messages(messages) + .temperature(request.temperature()) + .build(); + + try { + String result = openAiService.createChatCompletion(completionRequest) + .getChoices().get(0).getMessage().getContent().trim(); + + log.info("[OpenAI] 모델: {}, 소요시간: {}ms", request.model(), System.currentTimeMillis() - startTime); + return result; + + } catch (Exception e) { + log.error("[OpenAI] 호출 실패 - 모델: {}, 소요시간: {}ms", request.model(), System.currentTimeMillis() - startTime, e); + throw new OpenAiException("OpenAI 호출 중 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/java/com/ongil/backend/global/openai/OpenAiException.java b/src/main/java/com/ongil/backend/global/openai/OpenAiException.java new file mode 100644 index 0000000..471eda2 --- /dev/null +++ b/src/main/java/com/ongil/backend/global/openai/OpenAiException.java @@ -0,0 +1,8 @@ +package com.ongil.backend.global.openai; + +public class OpenAiException extends RuntimeException { + + public OpenAiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/ongil/backend/global/openai/OpenAiRequest.java b/src/main/java/com/ongil/backend/global/openai/OpenAiRequest.java new file mode 100644 index 0000000..3f9245f --- /dev/null +++ b/src/main/java/com/ongil/backend/global/openai/OpenAiRequest.java @@ -0,0 +1,12 @@ +package com.ongil.backend.global.openai; + +public record OpenAiRequest( + String model, + String systemPrompt, + String userMessage, + double temperature +) { + public static OpenAiRequest of(String model, String systemPrompt, String userMessage, double temperature) { + return new OpenAiRequest(model, systemPrompt, userMessage, temperature); + } +}