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
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.jobdri.jobdri_api.domain.analysis.dto.criteria;

import java.util.List;

public record JobCategoryEvaluationCriteria(
String jobCategoryMiddle,
List<String> includedJobs,
List<String> competencyTags,
List<String> coreCompetencies,
List<String> relatedActions,
List<String> relatedExperiences,
List<String> relatedKeywords,
String goodEvidenceExample,
List<String> missingKeywordExamples
) {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.jobdri.jobdri_api.domain.analysis.service;

import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria;
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;
Expand Down Expand Up @@ -28,6 +29,7 @@
public class AnalysisAiClient {
private static final int MAX_REFERENCE_SECTION_LENGTH = 3000;
private static final int MAX_REFERENCE_FIELD_LENGTH = 300;
private static final int MAX_CRITERIA_ITEMS = 5;
private static final String OUTPUT_SCHEMA = """
[출력 형식]
{
Expand Down Expand Up @@ -177,10 +179,18 @@ public class AnalysisAiClient {
private String analysisModel;

public AnalysisLlmResponse analyze(AnalysisExecutionPayload payload) {
return analyze(payload.jobPosting(), payload.answeredQuestions());
return analyze(payload.jobPosting(), payload.answeredQuestions(), payload.jobCategoryEvaluationCriteria());
}

public AnalysisLlmResponse analyze(JobPosting jobPosting, List<Question> questions) {
return analyze(jobPosting, questions, null);
}

public AnalysisLlmResponse analyze(
JobPosting jobPosting,
List<Question> questions,
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria
) {
RetrievalContext referenceContext = emptyContext();
try {
referenceContext = corpusRetrievalService.retrieveForAnalysis(jobPosting, questions);
Expand All @@ -190,7 +200,7 @@ public AnalysisLlmResponse analyze(JobPosting jobPosting, List<Question> questio
}
var params = ResponseCreateParams.builder()
.model(analysisModel)
.input(buildPrompt(jobPosting, questions, referenceContext))
.input(buildPrompt(jobPosting, questions, referenceContext, jobCategoryEvaluationCriteria))
.temperature(0.2)
.text(AnalysisLlmResponse.class)
.build();
Expand All @@ -212,10 +222,11 @@ public AnalysisLlmResponse analyze(JobPosting jobPosting, List<Question> questio
}
}

private String buildPrompt(
String buildPrompt(
JobPosting jobPosting,
List<Question> questions,
RetrievalContext referenceContext
RetrievalContext referenceContext,
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria
) {
String questionText = questions.stream()
.map(question -> """
Expand All @@ -231,6 +242,7 @@ private String buildPrompt(

String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences());
String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences());
String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria);

return """
[시스템 지시]
Expand Down Expand Up @@ -264,6 +276,8 @@ private String buildPrompt(
[유사 자소서 문항 검색 결과]
%s

%s

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

Expand Down Expand Up @@ -291,10 +305,54 @@ private String buildPrompt(
defaultString(jobPosting.getPreferred()),
similarJobPostingText,
similarQuestionText,
jobCategoryCriteriaSection,
questionText
);
}

private String formatJobCategoryEvaluationCriteriaSection(JobCategoryEvaluationCriteria criteria) {
if (criteria == null) {
return "";
}

return """
[직무별 보조 평가 기준]
중분류: %s
주의:
- 이 직무별 기준은 실제 JD를 대체하지 않는다.
- 실제 JD의 자격요건, 우대사항, 주요업무를 우선한다.
- 직무별 기준은 JD가 모호하거나 암묵 역량 판단이 필요할 때만 보조적으로 참고한다.
- missingKeywords는 실제 JD 표현을 우선 사용하고, 직무별 missingKeywordExamples는 문구 정리와 유사 키워드 묶기에만 참고한다.
- 직무별 기준에 있는 키워드가 자소서에 없다는 이유만으로 무조건 missing 처리하지 않는다.
- 원문에 없는 수치, 도구, 경험을 만들어내지 않는다.
핵심 역량: %s
관련 행동: %s
관련 키워드: %s
좋은 근거 예시: %s
누락 키워드 문구 예시: %s
""".formatted(
defaultString(criteria.jobCategoryMiddle()),
formatCriteriaList(criteria.coreCompetencies()),
formatCriteriaList(criteria.relatedActions()),
formatCriteriaList(criteria.relatedKeywords()),
truncate(defaultString(criteria.goodEvidenceExample()), MAX_REFERENCE_FIELD_LENGTH),
formatCriteriaList(criteria.missingKeywordExamples())
).trim();
}

private String formatCriteriaList(List<String> values) {
if (values == null || values.isEmpty()) {
return "없음";
}
String formatted = values.stream()
.filter(value -> value != null && !value.isBlank())
.limit(MAX_CRITERIA_ITEMS)
.map(value -> "- " + truncate(value.trim(), MAX_REFERENCE_FIELD_LENGTH))
.reduce("", (left, right) -> left + "\n" + right)
.trim();
return formatted.isBlank() ? "없음" : "\n" + formatted;
}

private String formatJobPostingReferences(List<RetrievedJobPostingReference> references) {
if (references == null || references.isEmpty()) {
return "없음";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.jobdri.jobdri_api.domain.analysis.service;

import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria;
import com.jobdri.jobdri_api.domain.analysis.entity.Question;
import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting;

Expand All @@ -11,6 +12,16 @@ public record AnalysisExecutionPayload(
Long mockApplyId,
JobPosting jobPosting,
List<Question> questions,
List<Question> answeredQuestions
List<Question> answeredQuestions,
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria
) {
public AnalysisExecutionPayload(
Long userId,
Long mockApplyId,
JobPosting jobPosting,
List<Question> questions,
List<Question> answeredQuestions
) {
this(userId, mockApplyId, jobPosting, questions, answeredQuestions, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria;
import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse;
Expand Down Expand Up @@ -68,6 +69,7 @@ public class AnalysisService {
private final AnalysisAiClient analysisAiClient;
private final CreditService creditService;
private final ObjectMapper objectMapper;
private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider;

@Transactional
@AuditLogEvent(action = "ANALYSIS_RUN", targetType = "MOCK_APPLY", targetId = "#arg1")
Expand Down Expand Up @@ -130,18 +132,29 @@ public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApp
// Initialize hierarchy before leaving the read transaction so detached payload can be used safely.
mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName();
mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName();
JobCategoryEvaluationCriteria evaluationCriteria = jobCategoryEvaluationCriteriaProvider
.findByMiddleName(mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName())
.orElse(null);

return new AnalysisExecutionPayload(
user.getId(),
mockApplyId,
mockApply.getJobPosting(),
List.copyOf(questions),
List.copyOf(answeredQuestions)
List.copyOf(answeredQuestions),
evaluationCriteria
);
}

public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) {
return analysisAiClient.analyze(payload.jobPosting(), payload.answeredQuestions());
if (payload.jobCategoryEvaluationCriteria() == null) {
return analysisAiClient.analyze(payload.jobPosting(), payload.answeredQuestions());
}
return analysisAiClient.analyze(
payload.jobPosting(),
payload.answeredQuestions(),
payload.jobCategoryEvaluationCriteria()
);
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.jobdri.jobdri_api.domain.analysis.service;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Component
@Slf4j
public class JobCategoryEvaluationCriteriaProvider {
private static final String RESOURCE_PATH = "analysis/job-category-evaluation-criteria.json";
private static final TypeReference<List<JobCategoryEvaluationCriteria>> CRITERIA_TYPE = new TypeReference<>() {
};

private final Map<String, JobCategoryEvaluationCriteria> criteriaByMiddleName;

public JobCategoryEvaluationCriteriaProvider(ObjectMapper objectMapper) {
this.criteriaByMiddleName = loadCriteria(objectMapper);
log.info("Loaded job category evaluation criteria. count={}", criteriaByMiddleName.size());
}

public Optional<JobCategoryEvaluationCriteria> findByMiddleName(String middleName) {
if (!StringUtils.hasText(middleName)) {
return Optional.empty();
}
return Optional.ofNullable(criteriaByMiddleName.get(normalizeKey(middleName)));
}

private Map<String, JobCategoryEvaluationCriteria> loadCriteria(ObjectMapper objectMapper) {
ClassPathResource resource = new ClassPathResource(RESOURCE_PATH);
try (InputStream inputStream = resource.getInputStream()) {
List<JobCategoryEvaluationCriteria> criteria = objectMapper.readValue(inputStream, CRITERIA_TYPE);
Map<String, JobCategoryEvaluationCriteria> result = new LinkedHashMap<>();

for (JobCategoryEvaluationCriteria item : criteria) {
if (item == null || !StringUtils.hasText(item.jobCategoryMiddle())) {
log.warn("Skipped invalid job category evaluation criteria row. reason=blank_middle_name");
continue;
}

String key = normalizeKey(item.jobCategoryMiddle());
if (result.containsKey(key)) {
throw new IllegalStateException("중복된 직무 중분류 평가 기준입니다. middleName=" + item.jobCategoryMiddle());
}
result.put(key, item);
}

return Map.copyOf(result);
} catch (IOException e) {
throw new IllegalStateException("직무 중분류 평가 기준 resource 로딩에 실패했습니다. path=" + RESOURCE_PATH, e);
}
}

private String normalizeKey(String value) {
return value.trim()
.replace('ㆍ', '·')
.replace('・', '·')
.replace('/', '·')
.replaceAll("\\s+", "");
}
}
Loading
Loading