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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 인증 및 사용자 관리
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.apache.httpcomponents.client5:httpclient5'
implementation 'org.apache.poi:poi-ooxml:5.4.1'
implementation 'com.pgvector:pgvector:0.1.6'

Expand Down
21 changes: 21 additions & 0 deletions evaluation/evaluation_nlg_judge_missing_keyword_provenance.csv
Original file line number Diff line number Diff line change
@@ -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,
Original file line number Diff line number Diff line change
@@ -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}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE analysis_async_tasks
ADD COLUMN IF NOT EXISTS execution_context_snapshot TEXT;

ALTER TABLE analysis_async_tasks
ADD COLUMN IF NOT EXISTS input_fingerprint_snapshot VARCHAR(64);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS idx_analysis_async_tasks_user_mock_apply_status
ON analysis_async_tasks (user_id, mock_apply_id, status);
16 changes: 16 additions & 0 deletions ops/db/migrations/20260730_job_posting_embeddings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS job_posting_embeddings (
id BIGSERIAL PRIMARY KEY,
job_posting_id BIGINT NOT NULL UNIQUE REFERENCES job_postings(id) ON DELETE CASCADE,
embedding_model VARCHAR(100) NOT NULL,
embedding vector(1024) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_job_posting
ON job_posting_embeddings (job_posting_id);

CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw
ON job_posting_embeddings USING hnsw (embedding vector_cosine_ops);
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,43 @@ public record AnalysisWorkerContextResponse(
String bigClassificationName,
String middleClassificationName,
String detailClassificationName,
List<AnalysisWorkerQuestionItem> questions
List<AnalysisWorkerQuestionItem> questions,
List<SimilarJobPostingContext> similarJobPostings
) {
public AnalysisWorkerContextResponse(
Long userId,
Long mockApplyId,
String companyName,
String jobTitle,
String task,
String requirements,
String preferredQualifications,
String bigClassificationName,
String middleClassificationName,
String detailClassificationName,
List<AnalysisWorkerQuestionItem> questions
) {
this(
userId,
mockApplyId,
companyName,
jobTitle,
task,
requirements,
preferredQualifications,
bigClassificationName,
middleClassificationName,
detailClassificationName,
questions,
List.of()
);
}

public AnalysisWorkerContextResponse {
questions = questions == null ? List.of() : List.copyOf(questions);
similarJobPostings = similarJobPostings == null ? List.of() : List.copyOf(similarJobPostings);
}

public record AnalysisWorkerQuestionItem(
Long questionId,
String question,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.jobdri.jobdri_api.domain.analysis.dto.worker;

public record SimilarJobPostingContext(
Long jobPostingId,
String companyName,
String postingName,
String jobTitle,
String task,
String requirements,
String preferredQualifications,
int similarityRank,
double similarityScore
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ public class AnalysisAsyncTask extends CreatedAtEntity {
@Column(name = "estimated_remaining_seconds")
private Integer estimatedRemainingSeconds;

@Column(name = "execution_context_snapshot", columnDefinition = "TEXT")
private String executionContextSnapshot;

@Column(name = "input_fingerprint_snapshot", length = 64)
private String inputFingerprintSnapshot;

public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRetryCount) {
AnalysisAsyncTask task = new AnalysisAsyncTask();
task.taskId = UUID.randomUUID().toString();
Expand Down Expand Up @@ -217,6 +223,14 @@ public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) {
}
}

public void captureExecutionSnapshot(String executionContextSnapshot, String inputFingerprintSnapshot) {
if (this.executionContextSnapshot != null || this.inputFingerprintSnapshot != null) {
return;
}
this.executionContextSnapshot = executionContextSnapshot;
this.inputFingerprintSnapshot = inputFingerprintSnapshot;
}

private boolean isTerminal() {
return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@ Optional<AnalysisAsyncTask> findFirstByUserIdAndMockApplyIdAndStatusInOrderByCre
Collection<TaskStatus> statuses
);

List<AnalysisAsyncTask> findByUserIdAndMockApplyIdInAndStatusIn(
Long userId,
Collection<Long> mockApplyIds,
Collection<TaskStatus> statuses
);

List<AnalysisAsyncTask> findByStatusIn(Collection<TaskStatus> statuses);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.jobdri.jobdri_api.domain.analysis.service.async;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest;
Expand All @@ -12,6 +14,7 @@
import com.jobdri.jobdri_api.domain.analysis.entity.Question;
import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository;
import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload;
import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider;
import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService;
import com.jobdri.jobdri_api.domain.user.entity.User;
import com.jobdri.jobdri_api.domain.user.service.UserService;
Expand Down Expand Up @@ -43,6 +46,8 @@ public class AnalysisWorkerBridgeService {
private final AnalysisService analysisService;
private final UserService userService;
private final WorkerTaskResultService workerTaskResultService;
private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider;
private final ObjectMapper objectMapper;

@Transactional
public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) {
Expand Down Expand Up @@ -109,10 +114,13 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long
);
}
reserveCreditIfNeeded(task);
if (task.getExecutionContextSnapshot() != null) {
return readContextSnapshot(task);
}

User user = userService.getUser(userId);
AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId);

return new AnalysisWorkerContextResponse(
AnalysisWorkerContextResponse context = new AnalysisWorkerContextResponse(
userId,
mockApplyId,
payload.jobPosting().getCompany().getName(),
Expand All @@ -123,8 +131,14 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long
payload.jobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(),
payload.jobPosting().getDetailClassification().getMiddleClassification().getMiddleName(),
payload.jobPosting().getDetailClassification().getDetailName(),
toQuestionItems(payload.questions())
toQuestionItems(payload.questions()),
payload.similarJobPostings()
);
task.captureExecutionSnapshot(
writeContextSnapshot(context),
analysisInputFingerprintProvider.create(payload)
);
return context;
}

@Transactional
Expand Down Expand Up @@ -169,9 +183,20 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques
}

User user = userService.getUser(request.userId());
AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, request.mockApplyId());
AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task);
AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(
user,
request.mockApplyId(),
contextSnapshot.similarJobPostings()
);
AnalysisLlmResponse llmResponse = request.llmResponse();
AnalysisResponse response = analysisService.finalizeAnalysis(user, request.mockApplyId(), payload, llmResponse);
AnalysisResponse response = analysisService.finalizeAnalysis(
user,
request.mockApplyId(),
payload,
llmResponse,
task.getInputFingerprintSnapshot()
);
analysisAsyncTaskService.updateWorkerMetadata(taskId, request.workerId(), request.queueLatencyMillis());
confirmCreditIfNeeded(task);
analysisAsyncTaskService.markSuccess(taskId, response);
Expand Down Expand Up @@ -219,6 +244,34 @@ private List<AnalysisWorkerContextResponse.AnalysisWorkerQuestionItem> toQuestio
.toList();
}

private String writeContextSnapshot(AnalysisWorkerContextResponse context) {
try {
return objectMapper.writeValueAsString(context);
} catch (JsonProcessingException exception) {
throw new GeneralException(
GeneralErrorCode.INTERNAL_SERVER_ERROR,
"자소서 분석 worker 컨텍스트 snapshot 저장에 실패했습니다."
);
}
}

private AnalysisWorkerContextResponse readContextSnapshot(AnalysisAsyncTask task) {
if (task.getExecutionContextSnapshot() == null || task.getInputFingerprintSnapshot() == null) {
throw new GeneralException(
GeneralErrorCode.INTERNAL_SERVER_ERROR,
"자소서 분석 worker 실행 snapshot이 존재하지 않습니다. taskId=" + task.getTaskId()
);
}
try {
return objectMapper.readValue(task.getExecutionContextSnapshot(), AnalysisWorkerContextResponse.class);
} catch (JsonProcessingException exception) {
throw new GeneralException(
GeneralErrorCode.INTERNAL_SERVER_ERROR,
"자소서 분석 worker 컨텍스트 snapshot을 읽을 수 없습니다. taskId=" + task.getTaskId()
);
}
}

private AnalysisAsyncTask getTask(String taskId) {
return analysisAsyncTaskRepository.findById(taskId)
.orElseThrow(() -> new GeneralException(
Expand Down
Loading
Loading