Skip to content
Open
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
36 changes: 36 additions & 0 deletions ops/db/migrations/20260615_mock_embedding_vector_1024.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- Manual migration for environments that already have embedding tables created
-- with an unbounded vector column. Run after backing up the database.

CREATE EXTENSION IF NOT EXISTS vector;

DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'mock_job_posting_embeddings'
AND column_name = 'embedding'
) THEN
ALTER TABLE mock_job_posting_embeddings
ALTER COLUMN embedding TYPE vector(1024);
END IF;
END $$;

DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'mock_question_embeddings'
AND column_name = 'embedding'
) THEN
ALTER TABLE mock_question_embeddings
ALTER COLUMN embedding TYPE vector(1024);
END IF;
END $$;

CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_hnsw
ON mock_job_posting_embeddings USING hnsw (embedding vector_cosine_ops);

CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw
ON mock_question_embeddings USING hnsw (embedding vector_cosine_ops);
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.jobdri.jobdri_api.domain.analysis.controller;

import com.jobdri.jobdri_api.domain.analysis.dto.request.AnalysisRetrievalPreviewRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisRetrievalPreviewResponse;
import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAdminDebugService;
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/analysis")
@Tag(name = "AnalysisAdmin", description = "관리자용 자소서 분석 디버그 API")
public class AnalysisAdminController {

private final AnalysisAdminDebugService analysisAdminDebugService;

@Operation(
summary = "분석 retrieval 미리보기",
description = "mockApplyId를 기준으로 실제 분석 전에 조회되는 유사 JD/문항 검색 결과를 반환합니다."
)
@PostMapping("/retrieval-preview")
public ApiResponse<AnalysisRetrievalPreviewResponse> previewRetrieval(
@Valid @RequestBody AnalysisRetrievalPreviewRequest request
) {
return ApiResponse.onSuccess(
"분석 retrieval 미리보기에 성공했습니다.",
analysisAdminDebugService.previewRetrieval(request.mockApplyId())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.jobdri.jobdri_api.domain.analysis.dto.request;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

public record AnalysisRetrievalPreviewRequest(
@NotNull(message = "mockApplyId는 필수입니다.")
@Positive(message = "mockApplyId는 1 이상이어야 합니다.")
Long mockApplyId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.jobdri.jobdri_api.domain.analysis.dto.response;

import java.util.List;

public record AnalysisRetrievalPreviewResponse(
Long mockApplyId,
JobPostingSnapshot jobPosting,
List<QuestionSnapshot> questions,
List<JobPostingReference> similarJobPostings,
List<QuestionReference> similarQuestions
) {
public record JobPostingSnapshot(
Long jobPostingId,
String companyName,
String detailClassificationName,
String task,
String requirement,
String preferred
) {
}

public record QuestionSnapshot(
Long questionId,
String content,
String answer
) {
}

public record JobPostingReference(
Long corpusId,
String companyName,
String roleName,
String responsibilities,
String requirements,
String preferred,
double distance
) {
}

public record QuestionReference(
Long corpusId,
String companyName,
String roleName,
String questionType,
Integer charLimit,
String questionText,
double distance
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.jobdri.jobdri_api.domain.analysis.service;

import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisRetrievalPreviewResponse;
import com.jobdri.jobdri_api.domain.analysis.entity.Question;
import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext;
import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting;
import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply;
import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AnalysisAdminDebugService {

private final MockApplyRepository mockApplyRepository;
private final QuestionRepository questionRepository;
private final CorpusRetrievalService corpusRetrievalService;

public AnalysisRetrievalPreviewResponse previewRetrieval(Long mockApplyId) {
MockApply mockApply = mockApplyRepository.findByIdWithJobPosting(mockApplyId)
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.MOCK_APPLY_NOT_FOUND,
"해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId
));
List<Question> questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApplyId);
JobPosting jobPosting = mockApply.getJobPosting();

RetrievalContext referenceContext = corpusRetrievalService.retrieveForAnalysis(jobPosting, questions);

return new AnalysisRetrievalPreviewResponse(
mockApply.getId(),
new AnalysisRetrievalPreviewResponse.JobPostingSnapshot(
jobPosting.getId(),
jobPosting.getCompany().getName(),
jobPosting.getDetailClassification().getDetailName(),
jobPosting.getTask(),
jobPosting.getRequirement(),
jobPosting.getPreferred()
),
questions.stream()
.map(question -> new AnalysisRetrievalPreviewResponse.QuestionSnapshot(
question.getId(),
question.getContent(),
question.getAnswer()
))
.toList(),
referenceContext.jobPostingReferences().stream()
.map(reference -> new AnalysisRetrievalPreviewResponse.JobPostingReference(
reference.corpusId(),
reference.companyName(),
reference.roleName(),
reference.responsibilities(),
reference.requirements(),
reference.preferred(),
reference.distance()
))
.toList(),
referenceContext.questionReferences().stream()
.map(reference -> new AnalysisRetrievalPreviewResponse.QuestionReference(
reference.corpusId(),
reference.companyName(),
reference.roleName(),
reference.questionType(),
reference.charLimit(),
reference.questionText(),
reference.distance()
))
.toList()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse;
import com.jobdri.jobdri_api.domain.analysis.entity.Question;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference;
import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
Expand All @@ -20,16 +24,26 @@
@Slf4j
@RequiredArgsConstructor
public class AnalysisAiClient {
private static final int MAX_REFERENCE_SECTION_LENGTH = 3000;
private static final int MAX_REFERENCE_FIELD_LENGTH = 300;

private final OpenAIClient openAIClient;
private final CorpusRetrievalService corpusRetrievalService;

@Value("${openai.model.cover-letter-analysis:gpt-4o-mini}")
private String analysisModel;

public AnalysisLlmResponse analyze(JobPosting jobPosting, List<Question> questions) {
RetrievalContext referenceContext = emptyContext();
try {
referenceContext = corpusRetrievalService.retrieveForAnalysis(jobPosting, questions);
} catch (Exception e) {
log.warn("자소서 분석 retrieval 실패. mock analysis will continue without references. message={}", e.getMessage());
log.debug("analysis retrieval exception", e);
}
var params = ResponseCreateParams.builder()
.model(analysisModel)
.input(buildPrompt(jobPosting, questions))
.input(buildPrompt(jobPosting, questions, referenceContext))
.temperature(0.2)
.text(AnalysisLlmResponse.class)
.build();
Expand All @@ -48,7 +62,11 @@ public AnalysisLlmResponse analyze(JobPosting jobPosting, List<Question> questio
}
}

private String buildPrompt(JobPosting jobPosting, List<Question> questions) {
private String buildPrompt(
JobPosting jobPosting,
List<Question> questions,
RetrievalContext referenceContext
) {
String questionText = questions.stream()
.map(question -> """
- questionId: %d
Expand All @@ -61,6 +79,9 @@ private String buildPrompt(JobPosting jobPosting, List<Question> questions) {
))
.reduce("", (left, right) -> left + "\n" + right);

String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences());
String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences());
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return """
[시스템 지시]
너는 한국 채용 담당자이자 자기소개서 평가 전문가다.
Expand Down Expand Up @@ -126,6 +147,12 @@ private String buildPrompt(JobPosting jobPosting, List<Question> questions) {
우대 사항:
%s

[유사 JD 검색 결과]
%s

[유사 자소서 문항 검색 결과]
%s

[자소서 문항과 답변]
%s

Expand All @@ -148,10 +175,62 @@ private String buildPrompt(JobPosting jobPosting, List<Question> questions) {
defaultString(jobPosting.getTask()),
defaultString(jobPosting.getRequirement()),
defaultString(jobPosting.getPreferred()),
similarJobPostingText,
similarQuestionText,
questionText
);
}

private String formatJobPostingReferences(List<RetrievedJobPostingReference> references) {
if (references == null || references.isEmpty()) {
return "없음";
}
String formatted = references.stream()
.map(reference -> """
- 회사명: %s
직무명: %s
주요 업무: %s
자격 요건: %s
우대 사항: %s
거리: %.4f
""".formatted(
truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH),
truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH),
truncate(defaultString(reference.responsibilities()), MAX_REFERENCE_FIELD_LENGTH),
truncate(defaultString(reference.requirements()), MAX_REFERENCE_FIELD_LENGTH),
truncate(defaultString(reference.preferred()), MAX_REFERENCE_FIELD_LENGTH),
reference.distance()
))
.reduce("", (left, right) -> left + "\n" + right)
.trim();
return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH);
}

private String formatQuestionReferences(List<RetrievedQuestionReference> references) {
if (references == null || references.isEmpty()) {
return "없음";
}
String formatted = references.stream()
.map(reference -> """
- 회사명: %s
직무명: %s
문항 유형: %s
글자 수 제한: %s
문항: %s
거리: %.4f
""".formatted(
truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH),
truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH),
truncate(defaultString(reference.questionType()), MAX_REFERENCE_FIELD_LENGTH),
reference.charLimit() == null ? "" : reference.charLimit(),
truncate(defaultString(reference.questionText()), MAX_REFERENCE_FIELD_LENGTH),
reference.distance()
))
.reduce("", (left, right) -> left + "\n" + right)
.trim();
return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH);
}

private AnalysisLlmResponse extractStructuredContent(StructuredResponse<AnalysisLlmResponse> response) {
return response.output().stream()
.filter(item -> item.message().isPresent())
Expand All @@ -168,4 +247,15 @@ private AnalysisLlmResponse extractStructuredContent(StructuredResponse<Analysis
private String defaultString(String value) {
return value == null ? "" : value;
}

private String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength) + "...";
}

private RetrievalContext emptyContext() {
return new RetrievalContext(List.of(), List.of());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@

public interface DetailClassificationRepository extends JpaRepository<DetailClassification, Long> {
List<DetailClassification> findAllByMiddleClassificationId(Long middleClassificationId);
Optional<DetailClassification> findByDetailName(String detailName);
long countByDetailName(String detailName);
Optional<DetailClassification> findByDetailNameIgnoreCase(String detailName);
long countByDetailNameIgnoreCase(String detailName);

@Query("""
SELECT dc
Expand Down
Loading
Loading