diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 00000000..b4cd9154 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash '/Users/giminkim/IdeaProjects/backend/.codex/hooks/pre-bash.sh'" + } + ] + } + ] + } +} diff --git a/.codex/hooks/pre-bash.sh b/.codex/hooks/pre-bash.sh new file mode 100755 index 00000000..dcd4ca96 --- /dev/null +++ b/.codex/hooks/pre-bash.sh @@ -0,0 +1,17 @@ +#!/bin/bash +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('command',''))" 2>/dev/null || echo "") + +# git push --force ์ฐจ๋‹จ +if echo "$COMMAND" | grep -qE "git push.*(--force|-f\b)"; then + echo "๐Ÿšซ git push --force ๋Š” ๊ธˆ์ง€๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค." >&2 + exit 2 +fi + +# main ๋ธŒ๋žœ์น˜ ์ง์ ‘ push ์ฐจ๋‹จ +if echo "$COMMAND" | grep -qE "git push (origin )?main"; then + echo "๐Ÿšซ main ๋ธŒ๋žœ์น˜ ์ง์ ‘ push ๋Š” ๊ธˆ์ง€๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. PR์„ ํ†ตํ•ด mergeํ•˜์„ธ์š”." >&2 + exit 2 +fi + +exit 0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ccf7775e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# AGENTS.md โ€” DocGrid + +## Engineering Guidelines + +Don't assume. Don't hide confusion. Surface tradeoffs. + +**Think Before Coding** +- State assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them โ€” don't pick silently. +- For non-trivial tasks, start in Plan Mode and don't implement until approved. + +**Simplicity First** +- Minimum code that solves the problem. Nothing speculative. +- No features beyond what was asked. No abstractions for single-use code. + +**Surgical Changes** +- Touch only what you must. Don't "improve" adjacent code or formatting. +- Match existing style, even if you'd do it differently. +- Every changed line should trace directly to the user's request. + +**Goal-Driven Execution** +- Define success criteria before starting. +- For multi-step tasks, state a brief plan and verify each step. + +--- + +## ์–ด๋””์„œ ๋ฌด์—‡์„ ์ฝ์„์ง€ + +### ๐Ÿ”ต ์ž‘์—… ์ง์ „ ํ•ญ์ƒ +- ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ โ†’ ์ด ํŒŒ์ผ (AGENTS.md) +- ๋„๋ฉ”์ธ ๋ชฉ๋ก โ†’ `src/main/java/com/opensource/docgrid/domain/` + +### ๐ŸŸข ์ƒํ™ฉ๋ณ„ ๋ฃฐ (`.Codex/rules/`) โ€” ์ž๋™ ๋กœ๋“œ๋จ +- Java ์ฝ”๋“œ ์ž‘์„ฑ ์‹œ โ†’ `code_style.md` +- ํ…Œ์ŠคํŠธ ์ž‘์„ฑ/์ˆ˜์ • ์‹œ โ†’ `testing_guide.md` +- Security/Config ๋งŒ์งˆ ๋•Œ โ†’ `security.md` +- ๋ฐฐํฌ/Docker/GitHub Actions ๊ด€๋ จ โ†’ `deploy.md` + +### ๐ŸŸฃ AI ์ž‘์—… ํ”์  (`.dev/`) +- ์ƒˆ๋กœ ์•Œ๊ฒŒ ๋œ ํŒจํ„ดยท์ฃผ์˜์ ยท์˜ค๋ฅ˜ ๊ธฐ๋ก โ†’ `learnings/` +- ์ž‘์—… ์ค‘ ์ž„์‹œ ๋ฉ”๋ชจ (์ž‘์—… ์ข…๋ฃŒ ํ›„ ์‚ญ์ œ โ€” ๋น„์–ด์žˆ๋Š” ๊ฒŒ ์ •์ƒ) โ†’ `scratchpad/` + +--- + +## ํ”„๋กœ์ ํŠธ ๊ฐœ์š” + +- **Framework**: Spring Boot 3.5.16 +- **Language**: Java 17 +- **Build**: Gradle +- **DB**: PostgreSQL + Flyway +- **Package**: `com.opensource.docgrid` + +## ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ + +``` +src/main/java/com/opensource/docgrid/ +โ”œโ”€โ”€ global/ +โ”‚ โ”œโ”€โ”€ common/ # ๊ณตํ†ต ์‘๋‹ต (ApiResponse, ErrorResponse, BaseEntity) +โ”‚ โ”œโ”€โ”€ config/ # ์„ค์ • (SecurityConfig, CorsConfig, SwaggerConfig) +โ”‚ โ””โ”€โ”€ exception/ # ์ „์—ญ ์˜ˆ์™ธ (DocGridException, ErrorCode, GlobalExceptionHandler) +โ””โ”€โ”€ domain/ + โ””โ”€โ”€ {๋„๋ฉ”์ธ}/ + โ”œโ”€โ”€ entity/ + โ”œโ”€โ”€ repository/ + โ”œโ”€โ”€ service/ + โ”‚ โ”œโ”€โ”€ command/ # ์ƒํƒœ ๋ณ€๊ฒฝ + โ”‚ โ””โ”€โ”€ query/ # ์กฐํšŒ ์ „์šฉ + โ”œโ”€โ”€ controller/ + โ”œโ”€โ”€ dto/ + โ”‚ โ”œโ”€โ”€ request/ + โ”‚ โ””โ”€โ”€ response/ + โ”œโ”€โ”€ converter/ # Entity โ†” DTO ๋ณ€ํ™˜ + โ””โ”€โ”€ enums/ +``` + +## ์ฃผ์š” ๋ช…๋ น์–ด + +```bash +./gradlew build +./gradlew clean build +./gradlew build -x test +./gradlew test +``` + +--- + +## ์˜๊ตฌ ๊ธˆ์ง€ + +- `git add -A` / `git add .` (๋ฏผ๊ฐ ํŒŒ์ผ ์šฐํšŒ ์œ„ํ—˜) +- `git push --force` / `--no-verify` / `--amend` (์•ˆ์ „์žฅ์น˜ ์šฐํšŒ) +- `main` ๋ธŒ๋žœ์น˜ ์ง์ ‘ push โ€” PR + ๋ฆฌ๋ทฐ ํ›„ merge๋งŒ ํ—ˆ์šฉ +- ์‹œํฌ๋ฆฟ์„ `application.yml`์— ํ•˜๋“œ์ฝ”๋”ฉ +- Entity๋ฅผ Controller ๊ณ„์ธต์— ์ง์ ‘ ๋…ธ์ถœ diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/controller/EmbeddingModelController.java b/src/main/java/com/opensource/docgrid/domain/embedding/controller/EmbeddingModelController.java new file mode 100644 index 00000000..fbfba861 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/controller/EmbeddingModelController.java @@ -0,0 +1,74 @@ +package com.opensource.docgrid.domain.embedding.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.embedding.dto.response.EmbeddingModelResponse; +import com.opensource.docgrid.domain.embedding.service.query.EmbeddingModelQueryService; +import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.ErrorResponse; +import com.opensource.docgrid.global.common.response.ResponseUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag( + name = "Embedding Model", + description = "๋ฌธ์„œ ์ธ๋ฑ์‹ฑ๊ณผ ๊ฒ€์ƒ‰์—์„œ ์‚ฌ์šฉํ•  ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ์„ค์ • ์กฐํšŒ API" +) +@RestController +@RequestMapping("/api/embedding-models") +@RequiredArgsConstructor +public class EmbeddingModelController { + + private final EmbeddingModelQueryService embeddingModelQueryService; + + @Operation( + summary = "๊ธฐ๋ณธ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ์กฐํšŒ", + description = """ + is_active=true, is_searchable=true์ธ ๊ธฐ๋ณธ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + ์‹ ๊ทœ embedding_job ์ƒ์„ฑ ์‹œ ์‚ฌ์šฉํ•  ๋ชจ๋ธ ์„ค์ •์ด๋ฉฐ, ์ •์ƒ์ ์œผ๋กœ ํ•˜๋‚˜๋งŒ ์กด์žฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + ๋ชจ๋ธ์ด ์—†๊ฑฐ๋‚˜ ์—ฌ๋Ÿฌ ๊ฐœ ์กด์žฌํ•˜๋ฉด ์„œ๋ฒ„ ์„ค์ • ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. + ์‹ค์ œ Vector๋ฅผ ์ƒ์„ฑํ•˜๊ฑฐ๋‚˜ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์„ ์‹คํ–‰ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "๊ธฐ๋ณธ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ์กฐํšŒ ์„ฑ๊ณต" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "๊ธฐ๋ณธ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ์„ค์ • ์˜ค๋ฅ˜", + content = @Content( + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "๋ชจ๋ธ ๋ฏธ์„ค์ •", + value = """ + {"success":false,"status":500,"code":"EMBEDDING-MODEL-001","message":"์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.","method":"GET","path":"/api/embedding-models/active","timestamp":"2026-07-14 12:00:00"} + """ + ), + @ExampleObject( + name = "๋ชจ๋ธ ์ค‘๋ณต ์„ค์ •", + value = """ + {"success":false,"status":500,"code":"EMBEDDING-MODEL-002","message":"์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์ด ์—ฌ๋Ÿฌ ๊ฐœ ์„ค์ •๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.","method":"GET","path":"/api/embedding-models/active","timestamp":"2026-07-14 12:00:00"} + """ + ) + } + ) + ) + }) + @GetMapping(value = "/active", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getActiveModel() { + return ResponseUtils.ok(embeddingModelQueryService.getActiveModelResponse()); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingModelConverter.java b/src/main/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingModelConverter.java new file mode 100644 index 00000000..0f6f5075 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingModelConverter.java @@ -0,0 +1,21 @@ +package com.opensource.docgrid.domain.embedding.converter; + +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.embedding.dto.response.EmbeddingModelResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; + +@Component +public class EmbeddingModelConverter { + + public EmbeddingModelResponse toResponse(EmbeddingModel embeddingModel) { + return new EmbeddingModelResponse( + embeddingModel.getId(), + embeddingModel.getProvider(), + embeddingModel.getModelName(), + embeddingModel.getModelVersion(), + embeddingModel.getDimension(), + embeddingModel.getDistanceMetric() + ); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/EmbeddingModelResponse.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/EmbeddingModelResponse.java new file mode 100644 index 00000000..c0d51257 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/EmbeddingModelResponse.java @@ -0,0 +1,27 @@ +package com.opensource.docgrid.domain.embedding.dto.response; + +import com.opensource.docgrid.domain.embedding.enums.DistanceMetric; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record EmbeddingModelResponse( + @Schema(description = "์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ์‹๋ณ„์ž", example = "1") + Long id, + + @Schema(description = "๋ชจ๋ธ ์ œ๊ณต ๋˜๋Š” ์‹คํ–‰ ๋ฐฉ์‹", example = "MOCK") + EmbeddingProvider provider, + + @Schema(description = "๋ชจ๋ธ ์ด๋ฆ„", example = "mock-bge-m3") + String modelName, + + @Schema(description = "๋ชจ๋ธ ๋ฒ„์ „ ๋˜๋Š” Revision", example = "v1") + String modelVersion, + + @Schema(description = "๋ชจ๋ธ์ด ์ƒ์„ฑํ•˜๋Š” Vector ์ฐจ์›", example = "1024") + int dimension, + + @Schema(description = "Vector ์œ ์‚ฌ๋„ ๊ณ„์‚ฐ ๋ฐฉ์‹", example = "COSINE") + DistanceMetric distanceMetric +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingModel.java b/src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingModel.java index d873c5f1..df1b5594 100644 --- a/src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingModel.java +++ b/src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingModel.java @@ -1,5 +1,7 @@ package com.opensource.docgrid.domain.embedding.entity; +import java.util.Objects; + import com.opensource.docgrid.domain.embedding.enums.DistanceMetric; import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider; import com.opensource.docgrid.domain.embedding.enums.VectorStorageStrategy; @@ -31,8 +33,8 @@ * index: is_active, is_searchable. * *

์ฃผ์˜์‚ฌํ•ญ: 1๋‹จ๊ณ„ MVP๋Š” active์ด๋ฉด์„œ searchable์ธ ๋ชจ๋ธ์„ ๋‹จ 1๊ฐœ๋งŒ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ์ „์ œ๋กœ ํ•œ๋‹ค. - * TODO: ์ด๋ฅผ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ ˆ๋ฒจ ๊ฒ€์ฆ ๋˜๋Š” DB partial unique index๋กœ ๋ณด๊ฐ•ํ•ด "active+searchable ๋ชจ๋ธ์€ ํ•ญ์ƒ 1๊ฐœ"์ž„์„ - * ๊ฐ•์ œํ•  ํ•„์š”๊ฐ€ ์žˆ๋‹ค. dimension์€ ๋ชจ๋ธ๋ณ„๋กœ ๊ณ ์ •๋œ ๊ฐ’์ด๋ฉฐ embeddings.dimension๊ณผ ๋ฐ˜๋“œ์‹œ ์ผ์น˜ํ•ด์•ผ ํ•œ๋‹ค. + * active์ด๋ฉด์„œ searchable์ธ ๋ชจ๋ธ์€ DB partial unique index์™€ ์กฐํšŒ ์„œ๋น„์Šค์˜ ๊ฐœ์ˆ˜ ๊ฒ€์ฆ์œผ๋กœ ์ค‘๋ณต์„ ๋ฐฉ์ง€ํ•œ๋‹ค. + * dimension์€ ๋ชจ๋ธ๋ณ„๋กœ ๊ณ ์ •๋œ ๊ฐ’์ด๋ฉฐ embeddings.dimension๊ณผ ๋ฐ˜๋“œ์‹œ ์ผ์น˜ํ•ด์•ผ ํ•œ๋‹ค. * configJson์€ Hibernate JSON ํƒ€์ž… ๋งคํ•‘์ด ์—†์–ด TEXT๋กœ ์ž„์‹œ ๋งคํ•‘ํ–ˆ์œผ๋ฉฐ, ์ถ”ํ›„ OpenSQL JSON / Hibernate JSON * ๋งคํ•‘์œผ๋กœ ๊ต์ฒด๊ฐ€ ํ•„์š”ํ•˜๋‹ค. */ @@ -93,14 +95,28 @@ public class EmbeddingModel extends BaseEntity { public EmbeddingModel(EmbeddingProvider provider, String modelName, String modelVersion, int dimension, DistanceMetric distanceMetric, boolean isActive, boolean isSearchable, VectorStorageStrategy vectorStorageStrategy, String configJson) { - this.provider = provider; - this.modelName = modelName; - this.modelVersion = modelVersion; + if (dimension <= 0) { + throw new IllegalArgumentException("dimension์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + this.provider = Objects.requireNonNull(provider, "provider๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + this.modelName = requireText(modelName, "modelName"); + this.modelVersion = requireText(modelVersion, "modelVersion"); this.dimension = dimension; - this.distanceMetric = distanceMetric; + this.distanceMetric = Objects.requireNonNull(distanceMetric, "distanceMetric์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); this.isActive = isActive; this.isSearchable = isSearchable; - this.vectorStorageStrategy = vectorStorageStrategy; + this.vectorStorageStrategy = Objects.requireNonNull( + vectorStorageStrategy, + "vectorStorageStrategy๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค." + ); this.configJson = configJson; } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + "์€(๋Š”) ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return value; + } } diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingModelRepository.java b/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingModelRepository.java new file mode 100644 index 00000000..e347f1a5 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingModelRepository.java @@ -0,0 +1,20 @@ +package com.opensource.docgrid.domain.embedding.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider; + +public interface EmbeddingModelRepository extends JpaRepository { + + // ๋‹ค์ค‘ ๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ •์„ ์ˆจ๊ธฐ์ง€ ์•Š๊ณ  ์„œ๋น„์Šค์—์„œ ๊ฐœ์ˆ˜๋ฅผ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋„๋ก List๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + List findAllByIsActiveTrueAndIsSearchableTrue(); + + boolean existsByProviderAndModelNameAndModelVersion( + EmbeddingProvider provider, + String modelName, + String modelVersion + ); +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/service/query/EmbeddingModelQueryService.java b/src/main/java/com/opensource/docgrid/domain/embedding/service/query/EmbeddingModelQueryService.java new file mode 100644 index 00000000..a8b177fe --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/service/query/EmbeddingModelQueryService.java @@ -0,0 +1,48 @@ +package com.opensource.docgrid.domain.embedding.service.query; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.embedding.converter.EmbeddingModelConverter; +import com.opensource.docgrid.domain.embedding.dto.response.EmbeddingModelResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingModelRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EmbeddingModelQueryService { + + private final EmbeddingModelRepository embeddingModelRepository; + private final EmbeddingModelConverter embeddingModelConverter; + + // ํ›„์† embedding_jobs ์ƒ์„ฑ ์‹œ ๋ชจ๋ธ Entity๋ฅผ ์—ฐ๊ด€๊ด€๊ณ„์— ๊ณ ์ •ํ•˜๊ธฐ ์œ„ํ•œ ๋‚ด๋ถ€ ์กฐํšŒ ๋ฉ”์„œ๋“œ๋‹ค. + public EmbeddingModel getActiveModel() { + List activeModels = + embeddingModelRepository.findAllByIsActiveTrueAndIsSearchableTrue(); + + if (activeModels.isEmpty()) { + log.error("์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."); + throw new DocGridException(ErrorCode.EMBEDDING_MODEL_NOT_CONFIGURED); + } + + if (activeModels.size() > 1) { + log.error("์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์ด ์—ฌ๋Ÿฌ ๊ฐœ ์„ค์ •๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. count={}", activeModels.size()); + throw new DocGridException(ErrorCode.MULTIPLE_ACTIVE_EMBEDDING_MODELS); + } + + return activeModels.get(0); + } + + public EmbeddingModelResponse getActiveModelResponse() { + return embeddingModelConverter.toResponse(getActiveModel()); + } +} diff --git a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index 7d95091b..a9f8d369 100644 --- a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -20,7 +20,19 @@ public enum ErrorCode { DATA_CONFLICT(HttpStatus.CONFLICT, "COMMON-008", "๋ฐ์ดํ„ฐ ์ถฉ๋Œ์ด ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), // USER - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER-001", "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER-001", "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // EMBEDDING MODEL + EMBEDDING_MODEL_NOT_CONFIGURED( + HttpStatus.INTERNAL_SERVER_ERROR, + "EMBEDDING-MODEL-001", + "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค." + ), + MULTIPLE_ACTIVE_EMBEDDING_MODELS( + HttpStatus.INTERNAL_SERVER_ERROR, + "EMBEDDING-MODEL-002", + "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์ด ์—ฌ๋Ÿฌ ๊ฐœ ์„ค์ •๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค." + ); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index e0d36221..42167598 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -26,7 +26,7 @@ spring: flyway: enabled: true - locations: classpath:db/migration + locations: classpath:db/migration,classpath:db/seed minio: endpoint: ${MINIO_ENDPOINT} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 00000000..5b1d8f93 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,28 @@ +spring: + config: + activate: + on-profile: test + + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:app}?currentSchema=${TEST_DB_SCHEMA:docgrid_test}&sslmode=${DB_SSLMODE:require} + username: ${DB_USER:app} + password: ${DB_PASSWORD:local_password} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + + flyway: + enabled: true + locations: classpath:db/migration,classpath:db/seed + default-schema: ${TEST_DB_SCHEMA:docgrid_test} + schemas: ${TEST_DB_SCHEMA:docgrid_test} + create-schemas: true + +minio: + endpoint: http://localhost:9000 + access-key: test-access-key + secret-key: test-secret-key + bucket: test-bucket diff --git a/src/main/resources/db/migration/V27__add_embedding_model_constraints.sql b/src/main/resources/db/migration/V27__add_embedding_model_constraints.sql new file mode 100644 index 00000000..b24223ae --- /dev/null +++ b/src/main/resources/db/migration/V27__add_embedding_model_constraints.sql @@ -0,0 +1,9 @@ +ALTER TABLE embedding_models + ADD CONSTRAINT ck_embedding_models_dimension_positive + CHECK (dimension > 0); + +-- ์‹ ๊ทœ ์ž‘์—…๊ณผ ๊ฒ€์ƒ‰์— ๋™์‹œ์— ์‚ฌ์šฉํ•  ๋ชจ๋ธ์€ ์ตœ๋Œ€ ํ•˜๋‚˜๋งŒ ํ—ˆ์šฉํ•œ๋‹ค. +CREATE UNIQUE INDEX uk_embedding_models_one_active_searchable + ON embedding_models ((1)) + WHERE is_active = TRUE + AND is_searchable = TRUE; diff --git a/src/main/resources/db/seed/R__seed_mock_embedding_model.sql b/src/main/resources/db/seed/R__seed_mock_embedding_model.sql new file mode 100644 index 00000000..f443a2df --- /dev/null +++ b/src/main/resources/db/seed/R__seed_mock_embedding_model.sql @@ -0,0 +1,32 @@ +-- local/test์—์„œ ์‹ค์ œ BGE-M3์™€ ๊ฐ™์€ ์ฐจ์›์˜ ํ๋ฆ„์„ ๊ฒ€์ฆํ•˜๊ธฐ ์œ„ํ•œ ๊ธฐ๋ณธ Mock ๋ชจ๋ธ์ด๋‹ค. +INSERT INTO embedding_models ( + provider, + model_name, + model_version, + dimension, + distance_metric, + is_active, + is_searchable, + vector_storage_strategy, + config_json +) +VALUES ( + 'MOCK', + 'mock-bge-m3', + 'v1', + 1024, + 'COSINE', + TRUE, + TRUE, + 'SINGLE_DIMENSION', + NULL +) +ON CONFLICT (provider, model_name, model_version) +DO UPDATE SET + dimension = EXCLUDED.dimension, + distance_metric = EXCLUDED.distance_metric, + is_active = EXCLUDED.is_active, + is_searchable = EXCLUDED.is_searchable, + vector_storage_strategy = EXCLUDED.vector_storage_strategy, + config_json = EXCLUDED.config_json, + updated_at = CURRENT_TIMESTAMP; diff --git a/src/test/java/com/opensource/docgrid/DocgridApplicationTests.java b/src/test/java/com/opensource/docgrid/DocgridApplicationTests.java index 6823e26e..8f506eb4 100644 --- a/src/test/java/com/opensource/docgrid/DocgridApplicationTests.java +++ b/src/test/java/com/opensource/docgrid/DocgridApplicationTests.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class DocgridApplicationTests { diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/controller/EmbeddingModelControllerTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/controller/EmbeddingModelControllerTest.java new file mode 100644 index 00000000..895ab0b6 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/controller/EmbeddingModelControllerTest.java @@ -0,0 +1,92 @@ +package com.opensource.docgrid.domain.embedding.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.opensource.docgrid.domain.embedding.dto.response.EmbeddingModelResponse; +import com.opensource.docgrid.domain.embedding.enums.DistanceMetric; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider; +import com.opensource.docgrid.domain.embedding.service.query.EmbeddingModelQueryService; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@WebMvcTest(EmbeddingModelController.class) +@DisplayName("EmbeddingModelController ํ…Œ์ŠคํŠธ") +class EmbeddingModelControllerTest { + + private static final String ACTIVE_MODEL_URL = "/api/embedding-models/active"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private EmbeddingModelQueryService embeddingModelQueryService; + + // WebMvc slice์—๋Š” Entity metamodel์ด ์—†์œผ๋ฏ€๋กœ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ JPA Auditing ์˜์กด์„ฑ๋งŒ ๋Œ€์ฒดํ•œ๋‹ค. + @MockitoBean + private JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @Test + @DisplayName("์ธ์ฆ๋œ ์‚ฌ์šฉ์ž๊ฐ€ ๊ธฐ๋ณธ ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์„ ์กฐํšŒํ•œ๋‹ค") + void getActiveModel_returnsApiResponse() throws Exception { + EmbeddingModelResponse response = new EmbeddingModelResponse( + 42L, + EmbeddingProvider.MOCK, + "mock-bge-m3", + "v1", + 1024, + DistanceMetric.COSINE + ); + given(embeddingModelQueryService.getActiveModelResponse()).willReturn(response); + + mockMvc.perform(get(ACTIVE_MODEL_URL).with(user("docgrid-user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.id").value(42)) + .andExpect(jsonPath("$.data.provider").value("MOCK")) + .andExpect(jsonPath("$.data.modelName").value("mock-bge-m3")) + .andExpect(jsonPath("$.data.modelVersion").value("v1")) + .andExpect(jsonPath("$.data.dimension").value(1024)) + .andExpect(jsonPath("$.data.distanceMetric").value("COSINE")) + .andExpect(jsonPath("$.data.configJson").doesNotExist()) + .andExpect(jsonPath("$.data.vectorStorageStrategy").doesNotExist()); + } + + @Test + @DisplayName("๊ธฐ๋ณธ ๋ชจ๋ธ์ด ์—†์œผ๋ฉด ์ „์šฉ ์„œ๋ฒ„ ์„ค์ • ์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getActiveModel_returnsNotConfiguredError() throws Exception { + given(embeddingModelQueryService.getActiveModelResponse()) + .willThrow(new DocGridException(ErrorCode.EMBEDDING_MODEL_NOT_CONFIGURED)); + + mockMvc.perform(get(ACTIVE_MODEL_URL).with(user("docgrid-user"))) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(500)) + .andExpect(jsonPath("$.code").value("EMBEDDING-MODEL-001")); + } + + @Test + @DisplayName("๊ธฐ๋ณธ ๋ชจ๋ธ์ด ์—ฌ๋Ÿฌ ๊ฐœ๋ฉด ์ „์šฉ ์„œ๋ฒ„ ์„ค์ • ์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getActiveModel_returnsMultipleModelsError() throws Exception { + given(embeddingModelQueryService.getActiveModelResponse()) + .willThrow(new DocGridException(ErrorCode.MULTIPLE_ACTIVE_EMBEDDING_MODELS)); + + mockMvc.perform(get(ACTIVE_MODEL_URL).with(user("docgrid-user"))) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(500)) + .andExpect(jsonPath("$.code").value("EMBEDDING-MODEL-002")); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingModelTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingModelTest.java new file mode 100644 index 00000000..70674d3d --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingModelTest.java @@ -0,0 +1,55 @@ +package com.opensource.docgrid.domain.embedding.entity; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.opensource.docgrid.domain.embedding.enums.DistanceMetric; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider; +import com.opensource.docgrid.domain.embedding.enums.VectorStorageStrategy; + +@DisplayName("EmbeddingModel ์ƒ์„ฑ ๊ฒ€์ฆ ํ…Œ์ŠคํŠธ") +class EmbeddingModelTest { + + @Test + @DisplayName("๋ชจ๋ธ ์ด๋ฆ„์ด ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + void create_throws_when_modelNameIsBlank() { + assertThatThrownBy(() -> createModel(" ", "v1", 1024)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("๋ชจ๋ธ ๋ฒ„์ „์ด ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + void create_throws_when_modelVersionIsBlank() { + assertThatThrownBy(() -> createModel("mock-bge-m3", " ", 1024)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("์ฐจ์›์ด 0์ด๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + void create_throws_when_dimensionIsZero() { + assertThatThrownBy(() -> createModel("mock-bge-m3", "v1", 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("์ฐจ์›์ด ์Œ์ˆ˜์ด๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + void create_throws_when_dimensionIsNegative() { + assertThatThrownBy(() -> createModel("mock-bge-m3", "v1", -1)) + .isInstanceOf(IllegalArgumentException.class); + } + + private EmbeddingModel createModel(String modelName, String modelVersion, int dimension) { + return EmbeddingModel.builder() + .provider(EmbeddingProvider.MOCK) + .modelName(modelName) + .modelVersion(modelVersion) + .dimension(dimension) + .distanceMetric(DistanceMetric.COSINE) + .isActive(true) + .isSearchable(true) + .vectorStorageStrategy(VectorStorageStrategy.SINGLE_DIMENSION) + .build(); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/fixture/EmbeddingModelFixture.java b/src/test/java/com/opensource/docgrid/domain/embedding/fixture/EmbeddingModelFixture.java new file mode 100644 index 00000000..5fb6fc13 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/fixture/EmbeddingModelFixture.java @@ -0,0 +1,33 @@ +package com.opensource.docgrid.domain.embedding.fixture; + +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.enums.DistanceMetric; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider; +import com.opensource.docgrid.domain.embedding.enums.VectorStorageStrategy; + +public class EmbeddingModelFixture { + + public static final String MODEL_NAME = "mock-bge-m3"; + public static final String MODEL_VERSION = "v1"; + public static final int DIMENSION = 1024; + + private EmbeddingModelFixture() { + } + + public static EmbeddingModel createDefaultModel() { + return createModel(MODEL_NAME, true, true); + } + + public static EmbeddingModel createModel(String modelName, boolean isActive, boolean isSearchable) { + return EmbeddingModel.builder() + .provider(EmbeddingProvider.MOCK) + .modelName(modelName) + .modelVersion(MODEL_VERSION) + .dimension(DIMENSION) + .distanceMetric(DistanceMetric.COSINE) + .isActive(isActive) + .isSearchable(isSearchable) + .vectorStorageStrategy(VectorStorageStrategy.SINGLE_DIMENSION) + .build(); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingModelRepositoryTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingModelRepositoryTest.java new file mode 100644 index 00000000..15eb20d4 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingModelRepositoryTest.java @@ -0,0 +1,86 @@ +package com.opensource.docgrid.domain.embedding.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider; +import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DisplayName("EmbeddingModelRepository ํ…Œ์ŠคํŠธ") +class EmbeddingModelRepositoryTest { + + @Autowired + private EmbeddingModelRepository embeddingModelRepository; + + @Test + @DisplayName("active์ด๋ฉด์„œ searchable์ธ ๊ธฐ๋ณธ Mock ๋ชจ๋ธ์„ ์กฐํšŒํ•œ๋‹ค") + void findAllByIsActiveTrueAndIsSearchableTrue_returnsSeedModel() { + List result = embeddingModelRepository.findAllByIsActiveTrueAndIsSearchableTrue(); + + assertThat(result) + .extracting(EmbeddingModel::getModelName) + .containsExactly(EmbeddingModelFixture.MODEL_NAME); + } + + @Test + @DisplayName("active ๋˜๋Š” searchable์ด false์ธ ๋ชจ๋ธ์€ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค") + void findAllByIsActiveTrueAndIsSearchableTrue_excludesUnavailableModels() { + EmbeddingModel activeOnly = EmbeddingModelFixture.createModel("active-only", true, false); + EmbeddingModel searchableOnly = EmbeddingModelFixture.createModel("searchable-only", false, true); + EmbeddingModel unavailable = EmbeddingModelFixture.createModel("unavailable", false, false); + embeddingModelRepository.saveAllAndFlush(List.of(activeOnly, searchableOnly, unavailable)); + + List result = embeddingModelRepository.findAllByIsActiveTrueAndIsSearchableTrue(); + + assertThat(result) + .extracting(EmbeddingModel::getModelName) + .doesNotContain("active-only", "searchable-only", "unavailable"); + } + + @Test + @DisplayName("provider, ๋ชจ๋ธ ์ด๋ฆ„, ๋ฒ„์ „ ์กฐํ•ฉ์œผ๋กœ ์กด์žฌ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•œ๋‹ค") + void existsByProviderAndModelNameAndModelVersion_returnsTrue() { + boolean exists = embeddingModelRepository.existsByProviderAndModelNameAndModelVersion( + EmbeddingProvider.MOCK, + EmbeddingModelFixture.MODEL_NAME, + EmbeddingModelFixture.MODEL_VERSION + ); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("provider, ๋ชจ๋ธ ์ด๋ฆ„, ๋ฒ„์ „์ด ์ค‘๋ณต๋˜๋ฉด ์ €์žฅํ•  ์ˆ˜ ์—†๋‹ค") + void saveAndFlush_throws_when_identityIsDuplicated() { + EmbeddingModel first = EmbeddingModelFixture.createModel("duplicate-model", false, false); + EmbeddingModel duplicate = EmbeddingModelFixture.createModel("duplicate-model", false, false); + embeddingModelRepository.saveAndFlush(first); + + assertThatThrownBy(() -> embeddingModelRepository.saveAndFlush(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("active์ด๋ฉด์„œ searchable์ธ ๋ชจ๋ธ์„ ๋‘ ๊ฐœ ์ €์žฅํ•  ์ˆ˜ ์—†๋‹ค") + void saveAndFlush_throws_when_multipleModelsAreActiveAndSearchable() { + EmbeddingModel duplicateActiveModel = + EmbeddingModelFixture.createModel("second-active-searchable", true, true); + + assertThatThrownBy(() -> embeddingModelRepository.saveAndFlush(duplicateActiveModel)) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/service/query/EmbeddingModelQueryServiceTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/service/query/EmbeddingModelQueryServiceTest.java new file mode 100644 index 00000000..a8cb8d93 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/service/query/EmbeddingModelQueryServiceTest.java @@ -0,0 +1,97 @@ +package com.opensource.docgrid.domain.embedding.service.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.opensource.docgrid.domain.embedding.converter.EmbeddingModelConverter; +import com.opensource.docgrid.domain.embedding.dto.response.EmbeddingModelResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.enums.DistanceMetric; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider; +import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingModelRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EmbeddingModelQueryService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class EmbeddingModelQueryServiceTest { + + @InjectMocks + private EmbeddingModelQueryService embeddingModelQueryService; + + @Mock + private EmbeddingModelRepository embeddingModelRepository; + + @Mock + private EmbeddingModelConverter embeddingModelConverter; + + @Test + @DisplayName("์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋ธ์ด ํ•˜๋‚˜๋ฉด ํ•ด๋‹น Entity๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getActiveModel_returnsModel_when_exactlyOneExists() { + EmbeddingModel expected = EmbeddingModelFixture.createDefaultModel(); + given(embeddingModelRepository.findAllByIsActiveTrueAndIsSearchableTrue()) + .willReturn(List.of(expected)); + + EmbeddingModel result = embeddingModelQueryService.getActiveModel(); + + assertThat(result).isSameAs(expected); + } + + @Test + @DisplayName("์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋ธ์ด ์—†์œผ๋ฉด ์„ค์ • ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void getActiveModel_throws_when_noModelExists() { + given(embeddingModelRepository.findAllByIsActiveTrueAndIsSearchableTrue()) + .willReturn(List.of()); + + assertThatThrownBy(() -> embeddingModelQueryService.getActiveModel()) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.EMBEDDING_MODEL_NOT_CONFIGURED); + } + + @Test + @DisplayName("์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋ธ์ด ์—ฌ๋Ÿฌ ๊ฐœ๋ฉด ์„ค์ • ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void getActiveModel_throws_when_multipleModelsExist() { + EmbeddingModel first = EmbeddingModelFixture.createDefaultModel(); + EmbeddingModel second = EmbeddingModelFixture.createModel("another-model", true, true); + given(embeddingModelRepository.findAllByIsActiveTrueAndIsSearchableTrue()) + .willReturn(List.of(first, second)); + + assertThatThrownBy(() -> embeddingModelQueryService.getActiveModel()) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.MULTIPLE_ACTIVE_EMBEDDING_MODELS); + } + + @Test + @DisplayName("์™ธ๋ถ€ ์กฐํšŒ๋Š” Entity๋ฅผ Converter๋กœ ๋ณ€ํ™˜ํ•ด ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getActiveModelResponse_convertsEntityToResponse() { + EmbeddingModel model = EmbeddingModelFixture.createDefaultModel(); + EmbeddingModelResponse expected = new EmbeddingModelResponse( + null, + EmbeddingProvider.MOCK, + EmbeddingModelFixture.MODEL_NAME, + EmbeddingModelFixture.MODEL_VERSION, + EmbeddingModelFixture.DIMENSION, + DistanceMetric.COSINE + ); + given(embeddingModelRepository.findAllByIsActiveTrueAndIsSearchableTrue()) + .willReturn(List.of(model)); + given(embeddingModelConverter.toResponse(model)).willReturn(expected); + + EmbeddingModelResponse result = embeddingModelQueryService.getActiveModelResponse(); + + assertThat(result).isEqualTo(expected); + then(embeddingModelConverter).should().toResponse(model); + } +}