diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/qnaset/service/StarAnalysisAsyncService.java b/backend/src/main/java/com/shyashyashya/refit/domain/qnaset/service/StarAnalysisAsyncService.java index 62dda0778..77bb971d6 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/qnaset/service/StarAnalysisAsyncService.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/qnaset/service/StarAnalysisAsyncService.java @@ -14,6 +14,7 @@ import com.shyashyashya.refit.global.gemini.GeminiClient; import com.shyashyashya.refit.global.gemini.GeminiGenerateRequest; import com.shyashyashya.refit.global.gemini.GeminiGenerateResponse; +import com.shyashyashya.refit.global.gemini.GenerateModel; import com.shyashyashya.refit.global.gemini.StarAnalysisGeminiResponse; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; @@ -43,7 +44,10 @@ public CompletableFuture createStarAnalysis(Long qnaSetId) { log.info("Send star analysis generate request to gemini. qnaSetId: {}", qnaSetId); CompletableFuture reqFuture = - geminiClient.sendAsyncRequest(requestBody, STAR_ANALYSIS_CREATE_REQUEST_TIMEOUT_SEC); + // geminiClient.sendAsyncRequest(requestBody, GenerateModel.GEMINI_2_5_FLASH_LITE, + // STAR_ANALYSIS_CREATE_REQUEST_TIMEOUT_SEC); + geminiClient.sendAsyncTextGenerateRequest( + requestBody, GenerateModel.GEMMA_3_27B_IT, STAR_ANALYSIS_CREATE_REQUEST_TIMEOUT_SEC); return reqFuture .thenApplyAsync( @@ -72,6 +76,12 @@ private StarAnalysisDto processSuccessRequest( private StarAnalysisGeminiResponse parseStarAnalysisGeminiResponse(String text) { try { + if (text.startsWith("```json\n")) { + text = text.substring("```json\n".length()); + } + if (text.endsWith("```")) { + text = text.substring(0, text.length() - "```".length()); + } return objectMapper.readValue(text, StarAnalysisGeminiResponse.class); } catch (JsonProcessingException e) { throw new CustomException(STAR_ANALYSIS_PARSING_FAILED); diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/user/api/TestUserController.java b/backend/src/main/java/com/shyashyashya/refit/domain/user/api/TestUserController.java index 379165532..75f27c301 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/user/api/TestUserController.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/user/api/TestUserController.java @@ -1,32 +1,47 @@ package com.shyashyashya.refit.domain.user.api; +import static com.shyashyashya.refit.domain.qnaset.constant.StarAnalysisConstant.STAR_ANALYSIS_CREATE_REQUEST_TIMEOUT_SEC; +import static com.shyashyashya.refit.global.exception.ErrorCode.TEXT_EMBEDDING_CREATE_FAILED; import static com.shyashyashya.refit.global.exception.ErrorCode.USER_NOT_FOUND; +import static com.shyashyashya.refit.global.model.ResponseCode.COMMON200; import static com.shyashyashya.refit.global.model.ResponseCode.COMMON204; import com.shyashyashya.refit.domain.user.repository.UserRepository; import com.shyashyashya.refit.global.auth.repository.RefreshTokenRepository; import com.shyashyashya.refit.global.dto.ApiResponse; import com.shyashyashya.refit.global.exception.CustomException; +import com.shyashyashya.refit.global.gemini.GeminiClient; +import com.shyashyashya.refit.global.gemini.GeminiEmbeddingRequest; +import com.shyashyashya.refit.global.gemini.GeminiEmbeddingResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "Test Auth/User API", description = "개발용 테스트 인증/인가 API입니다.") +@Tag(name = "Test API", description = "개발용 테스트 API입니다.") @RestController @RequestMapping("/test/user") @RequiredArgsConstructor +@Slf4j public class TestUserController { private final UserRepository userRepository; private final RefreshTokenRepository refreshTokenRepository; + private final GeminiClient geminiClient; + private final Executor geminiPostProcessExecutor; @Operation(summary = "(테스트용) 유저를 이메일로 찾아 삭제합니다.") @DeleteMapping @@ -61,4 +76,26 @@ public ResponseEntity> deleteUserById(@PathVariable Long userI var body = ApiResponse.success(COMMON204); return ResponseEntity.ok(body); } + + // TODO API 삭제: Gemini Embedding 생성 테스트용 임시 메소드 + @Operation(summary = "(테스트) 요청 텍스트의 임베딩값을 생성합니다.") + @PostMapping("/test-embedding") + public CompletableFuture>> getGeminiEmbedding( + @RequestBody @NotBlank String text) { + + GeminiEmbeddingRequest requestBody = GeminiEmbeddingRequest.of( + text, GeminiEmbeddingRequest.TaskType.CLUSTERING, GeminiEmbeddingRequest.OutputDimensionality.D128); + + CompletableFuture reqFuture = + geminiClient.sendAsyncEmbeddingRequest(requestBody, STAR_ANALYSIS_CREATE_REQUEST_TIMEOUT_SEC); + + CompletableFuture result = reqFuture + .thenApplyAsync(response -> response, geminiPostProcessExecutor) + .exceptionally(e -> { + log.error(e.getMessage(), e); + throw new CustomException(TEXT_EMBEDDING_CREATE_FAILED); + }); + + return result.thenApply(rsp -> ResponseEntity.ok(ApiResponse.success(COMMON200, rsp))); + } } diff --git a/backend/src/main/java/com/shyashyashya/refit/global/exception/ErrorCode.java b/backend/src/main/java/com/shyashyashya/refit/global/exception/ErrorCode.java index 2eedc3525..e2d2dba74 100644 --- a/backend/src/main/java/com/shyashyashya/refit/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/shyashyashya/refit/global/exception/ErrorCode.java @@ -57,7 +57,8 @@ public enum ErrorCode { STAR_ANALYSIS_CREATE_FAILED(INTERNAL_SERVER_ERROR, "스타 분석 생성 중 오류가 발생하였습니다."), STAR_ANALYSIS_COMPLETE_FAILED(INTERNAL_SERVER_ERROR, "스타 분석 업데이트 중 오류가 발생하였습니다."), STAR_ANALYSIS_DELETE_NOT_ALLOWED_STATUS(BAD_REQUEST, "진행 중(IN_PROGRESS)인 스타 분석만 삭제할 수 있습니다."), - ; + + TEXT_EMBEDDING_CREATE_FAILED(INTERNAL_SERVER_ERROR, "임베딩 생성에 실패하였습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiClient.java b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiClient.java index 82d2b9b81..8e2d057f1 100644 --- a/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiClient.java +++ b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiClient.java @@ -15,17 +15,11 @@ public class GeminiClient { private final GeminiProperty geminiProperty; private final WebClient webClient; - // TODO 요청 URL 상수로 분리, 임베딩 요청 고려 - private static final String GEMINI_API_URL = - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"; - // "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"; - // "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent"; - - public CompletableFuture sendAsyncRequest( - GeminiGenerateRequest requestBody, Long timeoutSec) { + public CompletableFuture sendAsyncTextGenerateRequest( + GeminiGenerateRequest requestBody, GenerateModel model, Long timeoutSec) { return webClient .post() - .uri(GEMINI_API_URL) + .uri(model.endpoint()) .header("x-goog-api-key", geminiProperty.apiKey()) .accept(MediaType.APPLICATION_JSON) .bodyValue(requestBody) @@ -34,4 +28,21 @@ public CompletableFuture sendAsyncRequest( .timeout(Duration.ofSeconds(timeoutSec)) .toFuture(); } + + private static final String EMBEDDING_ENDPOINT = + "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent"; + + public CompletableFuture sendAsyncEmbeddingRequest( + GeminiEmbeddingRequest requestBody, Long timeoutSec) { + return webClient + .post() + .uri(EMBEDDING_ENDPOINT) + .header("x-goog-api-key", geminiProperty.apiKey()) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(GeminiEmbeddingResponse.class) + .timeout(Duration.ofSeconds(timeoutSec)) + .toFuture(); + } } diff --git a/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiEmbeddingRequest.java b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiEmbeddingRequest.java new file mode 100644 index 000000000..07769fc84 --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiEmbeddingRequest.java @@ -0,0 +1,46 @@ +package com.shyashyashya.refit.global.gemini; + +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.List; + +public record GeminiEmbeddingRequest(TaskType taskType, Content content, OutputDimensionality outputDimensionality) { + + public enum TaskType { + SEMANTIC_SIMILARITY, + CLASSIFICATION, + CLUSTERING + } + + public enum OutputDimensionality { + D2048(2048), + D1536(1536), + D768(768), + D512(512), + D256(256), + D128(128); + + private final int value; + + OutputDimensionality(int value) { + this.value = value; + } + + @JsonValue + public int value() { + return value; + } + } + + public record Content(List parts) {} + + public record Part(String text) {} + + public static GeminiEmbeddingRequest of(String text, TaskType taskType, OutputDimensionality outputDimensionality) { + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("text must not be blank"); + } + + Content content = new Content(List.of(new Part(text))); + return new GeminiEmbeddingRequest(taskType, content, outputDimensionality); + } +} diff --git a/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiEmbeddingResponse.java b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiEmbeddingResponse.java new file mode 100644 index 000000000..3bf4ca87a --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiEmbeddingResponse.java @@ -0,0 +1,8 @@ +package com.shyashyashya.refit.global.gemini; + +import java.util.List; + +public record GeminiEmbeddingResponse(Embedding embedding) { + + public record Embedding(List values) {} +} diff --git a/backend/src/main/java/com/shyashyashya/refit/global/gemini/GenerateModel.java b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GenerateModel.java new file mode 100644 index 000000000..5170e8be9 --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GenerateModel.java @@ -0,0 +1,33 @@ +package com.shyashyashya.refit.global.gemini; + +public enum GenerateModel { + GEMINI_2_5_PRO("gemini-2.5-pro"), + GEMINI_2_5_FLASH_LITE("gemini-2.5-flash-lite"), + GEMINI_2_5_FLASH("gemini-2.5-flash"), + GEMINI_3_FLASH("gemini-3-flash-preview"), + GEMINI_3_PRO("gemini-3-pro-preview"), + + GEMMA_3_1B_IT("gemma-3-1b-it"), + GEMMA_3_4B_IT("gemma-3-4b-it"), + GEMMA_3_12B_IT("gemma-3-12b-it"), + GEMMA_3_27B_IT("gemma-3-27b-it"); + + private static final String PREFIX = "https://generativelanguage.googleapis.com/v1beta/models/"; + private static final String SUFFIX = ":generateContent"; + + private final String name; + private final String endpoint; + + GenerateModel(String name) { + this.name = name; + this.endpoint = PREFIX + name + SUFFIX; + } + + public String id() { + return name; + } + + public String endpoint() { + return endpoint; + } +}