diff --git a/README.md b/README.md index f49a882..57d032e 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,31 @@ Server: `https://api.jobdri.site` | **Mail / Realtime** | JavaMailSender(Gmail SMTP), SSE | | **Docs / Build** | SpringDoc Swagger, Gradle, GitHub Actions | +## Cohere Embed API 로컬 검증 + +Cohere 임베딩 클라이언트는 `COHERE_API_KEY` 환경변수를 통해 API key를 읽습니다. 값이 없어도 애플리케이션 기동은 실패하지 않지만, 실제 임베딩 호출 시 명확한 예외가 발생합니다. + +```bash +export COHERE_API_KEY='실제_API_키' +``` + +기본 설정: + +- endpoint: `POST https://api.cohere.com/v2/embed` +- model: `embed-v4.0` +- output dimension: `1024` +- document input type: `search_document` +- query input type: `search_query` +- embedding type: `float` + +초기 검증용 텍스트: + +```text +Spring Boot 기반 REST API 개발 및 PostgreSQL 성능 최적화 +``` + +기본 테스트 스위트는 실제 Cohere API를 호출하지 않습니다. 수동 검증이 필요하면 `COHERE_API_KEY`를 설정한 뒤 Spring 컨텍스트에서 `CohereEmbeddingClient.embedDocuments(...)` 또는 `embedQuery(...)`를 호출해 반환 벡터 차원이 1024인지 확인합니다. 전체 embedding 값이나 API key는 로그에 남기지 않습니다. + ## ✨ Key Features ### 1. 인증 및 사용자 관리 diff --git a/evaluation/evaluation_nlg_judge_missing_keyword_provenance.csv b/evaluation/evaluation_nlg_judge_missing_keyword_provenance.csv new file mode 100644 index 0000000..8f61bbd --- /dev/null +++ b/evaluation/evaluation_nlg_judge_missing_keyword_provenance.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,1,4.0,3.0,4.0,3.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험이 잘 드러나지만, 구체적인 절차와 결과 수치가 부족합니다.",6566,280,4806, +EV-02,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,5,5,5,5,5,4,"[""NONE""]","답변은 강점이 잘 드러나지만, 구체적인 행동과 방법론이 부족하여 일부 아쉬움이 있습니다.",6004,130,2873, +EV-03,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,3.0,3.0,3.0,3.0,4.0,4.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 분석 역량과 경험이 잘 드러나지만, 구체적인 행동과 결과를 더 강조할 필요가 있습니다.",7600,450,6170, +EV-04,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",명확한 문제 문장이 존재하나 분석이 없어서 중요한 첨삭 대상을 놓쳤습니다.,7672,133,2355, +EV-05,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,3.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","지원 동기와 포부가 구체적이지 않아 개선 여지가 있으며, 전반적으로 유용한 분석이 제공되었습니다.",6473,430,6139, +EV-06,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,1,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,4,5,5,5,5,5,"[""NONE""]","전반적으로 구체적인 성과와 실행 방법이 잘 드러나 있으며, 분석의 정확성이 높아 유용한 첨삭 자료로 활용될 수 있습니다.",9172,331,7780, +EV-07,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,4,5,5,5,5,3,"[""NONE""]","답변에 명확한 문제 문장이 없고, 강점 및 누락 키워드도 적절하여 분석 부재가 대체로 적합합니다.",5550,136,2995, +EV-08,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,3.0,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 자기소개서의 강점이 잘 드러나지만, 구체적인 실행 방안과 방법론이 부족하여 개선 여지가 있습니다.",7503,431,5298, +EV-09,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,5,5,5,5,5,4,"[""NONE""]","전반적으로 구체적인 행동과 방법론이 부족하지만, 경험을 잘 설명하고 있어 유용한 분석입니다.",6496,418,12493, +EV-10,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,4,5,5,5,5,3,"[""NONE""]","답변에 명확한 문제 문장이 없고, 강점 및 누락 키워드도 적절하여 분석 부재가 대체로 적합합니다.",6709,136,2661, +EV-11,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,3,3,2,2,2,[],답변에서 JD의 핵심 경험 요구사항인 복지 프로그램 기획과 행정 지원이 누락되었습니다.,6763,267,4588, +EV-12,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,5.0,4.0,5.0,4.0,4.0,5.0,5.0,4.0,5.0,5.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 직무 적합성을 잘 드러내고 있으나, 구체적인 행동이나 방법이 부족한 부분이 있습니다.",6990,424,6968, +EV-13,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험과 방법론이 잘 서술되었으나, 구체성이 부족한 부분이 있어 개선 여지가 있습니다.",6620,440,5346, +EV-14,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,4,5,5,5,5,3,"[""NONE""]",지원 동기에서 구체적인 행동이나 방법이 부족하여 명확한 메시지를 전달하지 못하고 있습니다.,6673,125,2601, +EV-15,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,3,3,2,2,2,"[""MISSED_ANALYSIS""]","지원 동기와 직무 적합성에 대한 구체적인 사례가 부족하며, 중요한 경험 키워드가 누락되었습니다.",5595,217,4432, +EV-16,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]","명확한 행동 계획과 방법론이 부족한 문장이 존재하나, 분석이 없어서 중요한 첨삭 대상을 놓쳤습니다.",6670,141,2356, +EV-17,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험이 잘 서술되었으나, 구체적인 행동이나 방법이 부족하여 개선 여지가 있습니다.",6002,408,4717, +EV-18,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,5,3,3,2,2,3,[],"답변에서 JD의 핵심 경험 요구사항인 사내 일반 행정 지원이 누락되어 있으며, 전반적으로 구체적인 경험이 부족합니다.",6023,170,8328, +EV-19,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,3.0,4.0,3.0,3.0,4.0,4.0,3.0,4.0,4.0,3,5,5,5,5,3,"[""NONE""]",지원 동기와 강점에 대한 구체성이 부족하여 개선 여지가 있습니다.,6176,396,6194, +EV-20,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",명확한 문제 문장이 존재하나 분석이 없어서 중요한 첨삭 대상을 놓쳤습니다.,6018,133,2254, diff --git a/evaluation/evaluation_nlg_judge_missing_keyword_provenance_comparison.csv b/evaluation/evaluation_nlg_judge_missing_keyword_provenance_comparison.csv new file mode 100644 index 0000000..578d22c --- /dev/null +++ b/evaluation/evaluation_nlg_judge_missing_keyword_provenance_comparison.csv @@ -0,0 +1,3 @@ +sourceResultFile,caseCount,successCount,judgeFailedCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,averageJudgeInputTokens,averageJudgeOutputTokens,averageJudgeLatencyMs,averageAnalysisCount,metaImprovementRate,unsupportedFactRate,falsePositiveAnalysisRate,fatalErrorRate,errorCodeCounts +evaluation/evaluation_nlg_judge_policy_alignment.csv,20,20,0,3.85,3.35,3.85,4.1,3.35,4.1,4.6,4.0,4.1,4.1,3.35,4.7,4.7,4.55,4.55,3.45,6412.75,258.95,7965.95,0.9,0.0,0.0,0.0,0.0,"{""MISSED_ANALYSIS"":5,""MISSED_MISSING_KEYWORD"":3,""NONE"":14}" +evaluation/evaluation_nlg_judge_missing_keyword_provenance.csv,20,20,0,4.0,3.3,3.9,3.8,3.3,4.2,4.7,3.9,4.2,4.2,3.4,4.7,4.7,4.55,4.55,3.3,6663.75,279.8,5067.7,0.9,0.0,0.0,0.0,0.0,"{""MISSED_ANALYSIS"":4,""NONE"":14}" diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereConfig.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereConfig.java new file mode 100644 index 0000000..0d464ed --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereConfig.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.global.cohere; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(CohereProperties.class) +public class CohereConfig { +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java new file mode 100644 index 0000000..6ea425d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClient.java @@ -0,0 +1,182 @@ +package com.jobdri.jobdri_api.global.cohere; + +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.dto.CohereEmbeddingRequest; +import com.jobdri.jobdri_api.global.cohere.dto.CohereEmbeddingResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.util.ArrayList; +import java.util.List; + +@Component +@Slf4j +public class CohereEmbeddingClient { + private static final int MAX_TEXTS_PER_REQUEST = 96; + private static final String INPUT_TYPE_SEARCH_DOCUMENT = "search_document"; + private static final String INPUT_TYPE_SEARCH_QUERY = "search_query"; + private static final List FLOAT_EMBEDDING_TYPE = List.of("float"); + + private final CohereProperties properties; + private final RestClient restClient; + + public CohereEmbeddingClient(CohereProperties properties, RestClient.Builder restClientBuilder) { + this.properties = properties; + this.restClient = restClientBuilder + .baseUrl(properties.baseUrl()) + .requestFactory(requestFactory(properties)) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + public List embedDocuments(List texts) { + return embed(texts, INPUT_TYPE_SEARCH_DOCUMENT); + } + + public float[] embedQuery(String text) { + if (!StringUtils.hasText(text)) { + throw invalidParameter("검색 질의 텍스트는 필수입니다."); + } + List embeddings = embed(List.of(text), INPUT_TYPE_SEARCH_QUERY); + if (embeddings.isEmpty()) { + throw unavailable("Cohere 검색 질의 임베딩 응답이 비어 있습니다."); + } + return embeddings.getFirst(); + } + + private List embed(List texts, String inputType) { + validateApiKey(); + List normalizedTexts = validateTexts(texts); + CohereEmbeddingRequest request = new CohereEmbeddingRequest( + properties.embedding().model(), + normalizedTexts, + inputType, + FLOAT_EMBEDDING_TYPE, + properties.embedding().dimension() + ); + + CohereEmbeddingResponse response = callCohere(request); + return validateResponse(response, normalizedTexts.size()); + } + + private CohereEmbeddingResponse callCohere(CohereEmbeddingRequest request) { + try { + return restClient.post() + .uri("/v2/embed") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.apiKey()) + .body(request) + .retrieve() + .onStatus( + status -> status.value() == 429 || status.is5xxServerError(), + (ignoredRequest, ignoredResponse) -> { + throw unavailable("Cohere Embed API가 일시적으로 응답할 수 없습니다."); + } + ) + .onStatus( + status -> status.value() == 400 + || status.value() == 401 + || status.value() == 403 + || status.is4xxClientError(), + (ignoredRequest, ignoredResponse) -> { + throw invalidParameter("Cohere Embed API 요청 또는 설정이 올바르지 않습니다."); + } + ) + .body(CohereEmbeddingResponse.class); + } catch (GeneralException e) { + throw e; + } catch (ResourceAccessException e) { + log.warn("Cohere Embed API access failed. reason=resource_access_failure, message={}", e.getMessage()); + throw new GeneralException( + GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, + "Cohere Embed API 응답이 지연되었거나 연결할 수 없습니다.", + e + ); + } catch (RestClientException e) { + log.warn("Cohere Embed API call failed. reason=rest_client_failure, message={}", e.getMessage()); + throw unavailable("Cohere Embed API 호출에 실패했습니다.", e); + } + } + + private List validateResponse(CohereEmbeddingResponse response, int expectedCount) { + if (response == null || response.embeddings() == null || response.embeddings().floatValues() == null + || response.embeddings().floatValues().isEmpty()) { + throw unavailable("Cohere 임베딩 응답이 비어 있습니다."); + } + + List> embeddings = response.embeddings().floatValues(); + if (embeddings.size() != expectedCount) { + throw unavailable("Cohere 임베딩 응답 개수가 요청 개수와 일치하지 않습니다."); + } + + List result = new ArrayList<>(); + for (List embedding : embeddings) { + if (embedding == null || embedding.size() != properties.embedding().dimension()) { + throw unavailable("Cohere 임베딩 차원이 설정값과 일치하지 않습니다."); + } + float[] vector = new float[embedding.size()]; + for (int i = 0; i < embedding.size(); i++) { + Double value = embedding.get(i); + if (value == null) { + throw unavailable("Cohere 임베딩 벡터에 비어 있는 값이 포함되어 있습니다."); + } + vector[i] = value.floatValue(); + } + result.add(vector); + } + return result; + } + + private void validateApiKey() { + if (!properties.hasApiKey()) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "Cohere API 키가 설정되지 않았습니다." + ); + } + } + + private List validateTexts(List texts) { + if (texts == null || texts.isEmpty()) { + throw invalidParameter("임베딩할 텍스트는 1개 이상이어야 합니다."); + } + if (texts.size() > MAX_TEXTS_PER_REQUEST) { + throw invalidParameter("Cohere 임베딩은 한 번에 최대 96개 텍스트만 요청할 수 있습니다."); + } + + List normalizedTexts = new ArrayList<>(); + for (String text : texts) { + if (!StringUtils.hasText(text)) { + throw invalidParameter("임베딩할 텍스트는 비어 있을 수 없습니다."); + } + normalizedTexts.add(text.trim()); + } + return List.copyOf(normalizedTexts); + } + + private static SimpleClientHttpRequestFactory requestFactory(CohereProperties properties) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.embedding().connectTimeout()); + requestFactory.setReadTimeout(properties.embedding().readTimeout()); + return requestFactory; + } + + private GeneralException invalidParameter(String message) { + return new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); + } + + private GeneralException unavailable(String message) { + return new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, message); + } + + private GeneralException unavailable(String message, Throwable cause) { + return new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, message, cause); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereProperties.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereProperties.java new file mode 100644 index 0000000..e1dca3c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/CohereProperties.java @@ -0,0 +1,46 @@ +package com.jobdri.jobdri_api.global.cohere; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "cohere") +public record CohereProperties( + String apiKey, + String baseUrl, + Embedding embedding +) { + private static final String DEFAULT_BASE_URL = "https://api.cohere.com"; + + public CohereProperties { + baseUrl = hasText(baseUrl) ? baseUrl : DEFAULT_BASE_URL; + embedding = embedding == null ? new Embedding(null, null, null, null) : embedding; + } + + boolean hasApiKey() { + return hasText(apiKey); + } + + public record Embedding( + String model, + Integer dimension, + Duration connectTimeout, + Duration readTimeout + ) { + private static final String DEFAULT_MODEL = "embed-v4.0"; + private static final int DEFAULT_DIMENSION = 1024; + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(15); + + public Embedding { + model = hasText(model) ? model : DEFAULT_MODEL; + dimension = dimension == null ? DEFAULT_DIMENSION : dimension; + connectTimeout = connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : connectTimeout; + readTimeout = readTimeout == null ? DEFAULT_READ_TIMEOUT : readTimeout; + } + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingRequest.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingRequest.java new file mode 100644 index 0000000..8fc6799 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingRequest.java @@ -0,0 +1,17 @@ +package com.jobdri.jobdri_api.global.cohere.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record CohereEmbeddingRequest( + String model, + List texts, + @JsonProperty("input_type") + String inputType, + @JsonProperty("embedding_types") + List embeddingTypes, + @JsonProperty("output_dimension") + Integer outputDimension +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java new file mode 100644 index 0000000..72aac3f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/cohere/dto/CohereEmbeddingResponse.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.global.cohere.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record CohereEmbeddingResponse( + Embeddings embeddings +) { + public record Embeddings( + @JsonProperty("float") + List> floatValues + ) { + } +} diff --git a/src/main/resources/application-analysis-eval.yaml b/src/main/resources/application-analysis-eval.yaml index d36333f..a23869e 100644 --- a/src/main/resources/application-analysis-eval.yaml +++ b/src/main/resources/application-analysis-eval.yaml @@ -85,6 +85,17 @@ evaluation: output: "" review-output: "" +cohere: + api-key: ${COHERE_API_KEY:} + base-url: ${COHERE_BASE_URL:https://api.cohere.com} + embedding: + model: ${COHERE_EMBEDDING_MODEL:embed-v4.0} + dimension: ${COHERE_EMBEDDING_DIMENSION:1024} + connect-timeout: ${COHERE_EMBEDDING_CONNECT_TIMEOUT:3s} + read-timeout: ${COHERE_EMBEDDING_READ_TIMEOUT:15s} + api: + key: ${COHERE_API_KEY:} + jwt: secret: key: ${JWT_SECRET_KEY:am9iZHJpLWFuYWx5c2lzLWV2YWwtbG9jYWwtc2VjcmV0LWtleQ==} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 0121e48..f025533 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -163,6 +163,13 @@ openai: job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini} cohere: + api-key: ${COHERE_API_KEY:} + base-url: ${COHERE_BASE_URL:https://api.cohere.com} + embedding: + model: ${COHERE_EMBEDDING_MODEL:embed-v4.0} + dimension: ${COHERE_EMBEDDING_DIMENSION:1024} + connect-timeout: ${COHERE_EMBEDDING_CONNECT_TIMEOUT:3s} + read-timeout: ${COHERE_EMBEDDING_READ_TIMEOUT:15s} api: key: ${COHERE_API_KEY:} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 65b8406..7bda61f 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -164,6 +164,13 @@ openai: job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini} cohere: + api-key: ${COHERE_API_KEY:} + base-url: ${COHERE_BASE_URL:https://api.cohere.com} + embedding: + model: ${COHERE_EMBEDDING_MODEL:embed-v4.0} + dimension: ${COHERE_EMBEDDING_DIMENSION:1024} + connect-timeout: ${COHERE_EMBEDDING_CONNECT_TIMEOUT:3s} + read-timeout: ${COHERE_EMBEDDING_READ_TIMEOUT:15s} api: key: ${COHERE_API_KEY:} diff --git a/src/test/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClientTest.java b/src/test/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClientTest.java new file mode 100644 index 0000000..6cc106c --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/global/cohere/CohereEmbeddingClientTest.java @@ -0,0 +1,256 @@ +package com.jobdri.jobdri_api.global.cohere; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CohereEmbeddingClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("embedDocuments는 search_document 요청을 보내고 float embeddings를 반환한다") + void embedDocuments() throws Exception { + AtomicReference requestJson = new AtomicReference<>(); + try (TestCohereServer server = startServer(200, responseJson(3, 2), requestJson)) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + List embeddings = client.embedDocuments(List.of( + "Spring Boot 기반 REST API 개발", + "PostgreSQL 성능 최적화" + )); + + assertThat(embeddings).hasSize(2); + assertThat(embeddings.getFirst()).hasSize(3); + assertThat(embeddings.getFirst()[0]).isEqualTo(0.1f); + assertThat(requestJson.get().get("model").asText()).isEqualTo("embed-v4.0"); + assertThat(requestJson.get().get("input_type").asText()).isEqualTo("search_document"); + assertThat(requestJson.get().get("output_dimension").asInt()).isEqualTo(3); + assertThat(requestJson.get().get("embedding_types").get(0).asText()).isEqualTo("float"); + assertThat(requestJson.get().get("texts")).hasSize(2); + assertThat(server.authorizationHeader()).isEqualTo("Bearer test-api-key"); + } + } + + @Test + @DisplayName("embedQuery는 search_query 요청을 보내고 단일 embedding을 반환한다") + void embedQuery() throws Exception { + AtomicReference requestJson = new AtomicReference<>(); + try (TestCohereServer server = startServer(200, responseJson(3, 1), requestJson)) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + float[] embedding = client.embedQuery("Spring Boot 기반 REST API 개발 및 PostgreSQL 성능 최적화"); + + assertThat(embedding).hasSize(3); + assertThat(requestJson.get().get("input_type").asText()).isEqualTo("search_query"); + assertThat(requestJson.get().get("texts")).hasSize(1); + } + } + + @Test + @DisplayName("API Key가 없으면 호출 시 명확한 예외를 발생시킨다") + void missingApiKey() { + CohereEmbeddingClient client = client("http://localhost:1", "", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of("text"))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)) + .hasMessageContaining("API 키"); + } + + @Test + @DisplayName("빈 입력과 blank query는 API 호출 전에 입력 오류로 처리한다") + void invalidInput() { + CohereEmbeddingClient client = client("http://localhost:1", "test-api-key", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of())) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)); + assertThatThrownBy(() -> client.embedDocuments(List.of("valid", " "))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)); + assertThatThrownBy(() -> client.embedQuery(" ")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)); + } + + @Test + @DisplayName("96개 초과 입력은 API 호출 전에 입력 오류로 처리한다") + void tooManyTexts() { + CohereEmbeddingClient client = client("http://localhost:1", "test-api-key", 3); + List texts = new ArrayList<>(); + for (int i = 0; i < 97; i++) { + texts.add("text-" + i); + } + + assertThatThrownBy(() -> client.embedDocuments(texts)) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)) + .hasMessageContaining("96"); + } + + @Test + @DisplayName("Cohere 429와 5xx는 서비스 일시 장애 예외로 변환한다") + void transientCohereErrors() throws Exception { + for (int status : List.of(429, 500)) { + try (TestCohereServer server = startServer(status, "{\"message\":\"temporary\"}", new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of("text"))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)); + } + } + } + + @Test + @DisplayName("Cohere 400, 401, 403은 요청 또는 설정 오류로 변환한다") + void requestOrConfigurationErrors() throws Exception { + for (int status : List.of(400, 401, 403)) { + try (TestCohereServer server = startServer(status, "{\"message\":\"bad request\"}", new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of("text"))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.INVALID_PARAMETER)); + } + } + } + + @Test + @DisplayName("응답 embedding 개수가 요청 texts 개수와 다르면 예외 처리한다") + void mismatchedEmbeddingCount() throws Exception { + try (TestCohereServer server = startServer(200, responseJson(3, 1), new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedDocuments(List.of("first", "second"))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)) + .hasMessageContaining("개수"); + } + } + + @Test + @DisplayName("응답 embedding 차원이 설정 차원과 다르면 예외 처리한다") + void mismatchedEmbeddingDimension() throws Exception { + try (TestCohereServer server = startServer(200, responseJson(2, 1), new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedQuery("query")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)) + .hasMessageContaining("차원"); + } + } + + @Test + @DisplayName("응답 body가 없거나 embeddings가 비어 있으면 예외 처리한다") + void emptyResponseBodyOrEmbeddings() throws Exception { + try (TestCohereServer server = startServer(200, "", new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedQuery("query")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)); + } + try (TestCohereServer server = startServer(200, "{\"embeddings\":{\"float\":[]}}", new AtomicReference<>())) { + CohereEmbeddingClient client = client(server.baseUrl(), "test-api-key", 3); + + assertThatThrownBy(() -> client.embedQuery("query")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE)); + } + } + + private CohereEmbeddingClient client(String baseUrl, String apiKey, int dimension) { + return new CohereEmbeddingClient( + new CohereProperties( + apiKey, + baseUrl, + new CohereProperties.Embedding( + "embed-v4.0", + dimension, + Duration.ofSeconds(1), + Duration.ofSeconds(2) + ) + ), + RestClient.builder() + ); + } + + private String responseJson(int dimension, int count) throws Exception { + List> embeddings = new ArrayList<>(); + for (int i = 0; i < count; i++) { + List vector = new ArrayList<>(); + for (int j = 0; j < dimension; j++) { + vector.add((i + 1) * 0.1 + j); + } + embeddings.add(vector); + } + return objectMapper.writeValueAsString(java.util.Map.of( + "embeddings", + java.util.Map.of("float", embeddings) + )); + } + + private TestCohereServer startServer( + int status, + String responseBody, + AtomicReference requestJson + ) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + AtomicReference authorizationHeader = new AtomicReference<>(); + server.createContext("/v2/embed", exchange -> { + authorizationHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + if (!requestBody.isBlank()) { + requestJson.set(objectMapper.readTree(requestBody)); + } + byte[] body = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + return new TestCohereServer(server, authorizationHeader); + } + + private static final class TestCohereServer implements AutoCloseable { + private final HttpServer server; + private final AtomicReference authorizationHeader; + + private TestCohereServer(HttpServer server, AtomicReference authorizationHeader) { + this.server = server; + this.authorizationHeader = authorizationHeader; + } + + String baseUrl() { + return "http://localhost:" + server.getAddress().getPort(); + } + + String authorizationHeader() { + return authorizationHeader.get(); + } + + @Override + public void close() { + server.stop(0); + } + } +}