Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash '/Users/giminkim/IdeaProjects/backend/.codex/hooks/pre-bash.sh'"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

절대 경로 사용 금지 및 상대 경로로 변경

환경에 종속적인 절대 경로가 하드코딩되어 있어, 다른 개발자의 로컬 환경이나 CI 파이프라인에서 훅이 정상적으로 실행되지 않고 실패합니다. 프로젝트 루트 기준의 상대 경로로 수정해야 합니다.

🛠 제안하는 수정안
-            "command": "bash '/Users/giminkim/IdeaProjects/backend/.codex/hooks/pre-bash.sh'"
+            "command": "bash .codex/hooks/pre-bash.sh"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"command": "bash '/Users/giminkim/IdeaProjects/backend/.codex/hooks/pre-bash.sh'"
"command": "bash .codex/hooks/pre-bash.sh"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.codex/hooks.json at line 9, Update the hook command in the hooks
configuration to remove the hardcoded user-specific absolute path and invoke
pre-bash.sh using a project-root-relative path, preserving the existing hook
script and bash execution behavior.

}
]
}
]
}
}
17 changes: 17 additions & 0 deletions .codex/hooks/pre-bash.sh
Original file line number Diff line number Diff line change
@@ -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
93 changes: 93 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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/`) — 자동 로드됨

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check the actual casing of the codex directory in the root.
ls -ld .[cC]odex 2>/dev/null

Repository: DocGrid/backend

Length of output: 209


🏁 Script executed:

sed -n '1,120p' AGENTS.md | cat -n

Repository: DocGrid/backend

Length of output: 3196


🏁 Script executed:

find .codex -maxdepth 3 -type d -o -type f | sort

Repository: DocGrid/backend

Length of output: 216


경로 표기와 코드 블록 형식을 정리하세요

  • AGENTS.md:33.Codex/rules/는 실제 경로인 .codex/hooks/와 이름/대소문자가 다릅니다. 경로를 실제 구조에 맞게 통일해야 혼동을 줄일 수 있습니다.
  • AGENTS.md:55의 코드 블록에는 언어 지정이 없어 lint 경고가 납니다. bash 같은 식으로 붙여 주세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@AGENTS.md` at line 33, AGENTS.md의 상황별 룰 안내에서 경로 표기를 실제 구조인 .codex/hooks/로
통일하고, 문서 내 언어 미지정 코드 블록에는 bash 등 적절한 언어 식별자를 추가하세요.

- 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 계층에 직접 노출
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<EmbeddingModelResponse>> getActiveModel() {
return ResponseUtils.ok(embeddingModelQueryService.getActiveModelResponse());
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,8 +33,8 @@
* index: is_active, is_searchable.
*
* <p>주의사항: 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
* 매핑으로 교체가 필요하다.
*/
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<EmbeddingModel, Long> {

// 다중 기본 모델 설정을 숨기지 않고 서비스에서 개수를 검증할 수 있도록 List로 반환한다.
List<EmbeddingModel> findAllByIsActiveTrueAndIsSearchableTrue();

boolean existsByProviderAndModelNameAndModelVersion(
EmbeddingProvider provider,
String modelName,
String modelVersion
);
}
Original file line number Diff line number Diff line change
@@ -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<EmbeddingModel> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading