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
Expand Up @@ -18,6 +18,33 @@ public record JobPostingGenerateRequest(
String requirements,
String preferredQualifications,
String tone,
String jobTitleHint
String jobTitleHint,
String postingNameHint
) {
public JobPostingGenerateRequest(
String companyName,
CompanySize companySize,
Long detailClassificationId,
String hiringSummary,
String techStack,
String mainResponsibilities,
String requirements,
String preferredQualifications,
String tone,
String jobTitleHint
) {
this(
companyName,
companySize,
detailClassificationId,
hiringSummary,
techStack,
mainResponsibilities,
requirements,
preferredQualifications,
tone,
jobTitleHint,
null
);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
package com.jobdri.jobdri_api.domain.jobposting.dto.response;

public record JobPostingExtractResponse(String companyName, String jobTitle, String task, String requirements,
String preferredQualifications, String rawText, double confidence) {
public record JobPostingExtractResponse(String postingName, String companyName, String jobTitle, String task,
String requirements, String preferredQualifications, String rawText,
double confidence) {

public JobPostingExtractResponse(
String companyName,
String jobTitle,
String task,
String requirements,
String preferredQualifications,
String rawText,
double confidence
) {
this("", companyName, jobTitle, task, requirements, preferredQualifications, rawText, confidence);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.text.Normalizer;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;

@Service
Expand Down Expand Up @@ -338,12 +340,13 @@ private String buildPrompt(String rawText, boolean hasImage) {

return """
이 %s는 채용 공고입니다.
회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 추출해주세요.
공고 제목, 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 추출해주세요.

반드시 아래 JSON 형식으로만 응답해주세요.
설명 문장, 마크다운, 코드블럭은 포함하지 마세요.

{
"postingName": "string",
"companyName": "string",
"jobTitle": "string",
"task": "string",
Expand All @@ -354,12 +357,15 @@ private String buildPrompt(String rawText, boolean hasImage) {
}

규칙:
1. 이미지가 있으면 이미지 안의 채용 공고 문구를 읽어 rawText에 정리해주세요.
2. 텍스트가 있으면 rawText에는 입력 원문을 최대한 그대로 넣어주세요.
3. 이미지와 텍스트가 둘 다 있으면 둘을 함께 참고해서 가장 정확한 값으로 채워주세요.
4. 정보가 없거나 확실하지 않으면 해당 필드는 빈 문자열로 두세요.
5. confidence는 추출 결과 전체에 대한 신뢰도를 0~1 사이 실수로 반환하세요.
6. JSON 외의 다른 텍스트는 절대 출력하지 마세요.
1. postingName은 원문에 명시된 채용 공고 제목을 그대로 추출하세요.
2. 원문에 공고 제목이 없거나 확실하지 않으면 postingName은 반드시 빈 문자열로 두세요.
3. postingName을 회사명과 직무명으로 새로 만들거나 요약하지 마세요.
4. 이미지가 있으면 이미지 안의 채용 공고 문구를 읽어 rawText에 정리해주세요.
5. 텍스트가 있으면 rawText에는 입력 원문을 최대한 그대로 넣어주세요.
6. 이미지와 텍스트가 둘 다 있으면 둘을 함께 참고해서 가장 정확한 값으로 채워주세요.
7. 정보가 없거나 확실하지 않으면 해당 필드는 빈 문자열로 두세요.
8. confidence는 추출 결과 전체에 대한 신뢰도를 0~1 사이 실수로 반환하세요.
9. JSON 외의 다른 텍스트는 절대 출력하지 마세요.

[채용 공고 텍스트]
%s
Expand Down Expand Up @@ -461,24 +467,46 @@ private JobPostingExtractResponse normalizeResponse(JobPostingExtractResponse re
confidence = 1.0;
}

String normalizedRawText = response.rawText() == null || response.rawText().isBlank()
? defaultString(rawText)
: response.rawText();

return new JobPostingExtractResponse(
resolveSupportedPostingName(response.postingName(), normalizedRawText),
defaultString(response.companyName()),
defaultString(response.jobTitle()),
defaultString(response.task()),
defaultString(response.requirements()),
defaultString(response.preferredQualifications()),
response.rawText() == null || response.rawText().isBlank() ? defaultString(rawText) : response.rawText(),
normalizedRawText,
confidence
);
}

private String resolveSupportedPostingName(String postingName, String sourceText) {
String normalizedPostingName = normalizeForSourceMatch(defaultString(postingName));
if (normalizedPostingName.isBlank()) {
return "";
}
return normalizeForSourceMatch(defaultString(sourceText)).contains(normalizedPostingName)
? defaultString(postingName)
: "";
}

private String normalizeForSourceMatch(String value) {
return Normalizer.normalize(value, Normalizer.Form.NFKC)
.replaceAll("[\\s\\p{Z}]+", "")
.toLowerCase(Locale.ROOT);
}

private JobPostingExtractResponse createFallbackResponse(String rawText) {
return new JobPostingExtractResponse(
"",
"",
"",
"",
"",
"",
rawText == null ? "" : rawText,
0.0
);
Expand All @@ -503,7 +531,7 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl
}

작성 규칙:
1. postingName은 공고 제목으로 사용할 수 있게 회사명과 직무명을 반영해 간결하게 작성하세요.
1. postingName은 공고명 힌트를 그대로 사용하세요. 빈 문자열이면 새로 만들지 말고 빈 문자열로 두세요.
2. jobTitle은 직무명만 작성하세요.
3. task는 문장형 또는 불릿을 줄바꿈으로 구분한 자연스러운 본문으로 작성하세요.
4. requirements는 필수 자격 요건만 정리하세요.
Expand All @@ -523,6 +551,9 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl
[직무명 힌트]
%s

[공고명 힌트]
%s

[채용 배경 또는 포지션 소개]
%s

Expand All @@ -545,6 +576,7 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl
companySize,
detailClassification.getDetailName(),
defaultString(request.jobTitleHint()),
defaultString(request.postingNameHint()),
defaultString(request.hiringSummary()),
defaultString(request.techStack()),
defaultString(request.mainResponsibilities()),
Expand Down Expand Up @@ -687,8 +719,10 @@ private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerate
companyName = request.companyName();
}

String postingName = request.postingNameHint() == null ? "" : request.postingNameHint();

return new JobPostingGenerateResponse(
defaultIfBlank(response.postingName(), request.jobTitleHint()),
postingName,
companyName,
defaultString(response.jobTitle()),
defaultString(response.task()),
Expand Down Expand Up @@ -814,8 +848,10 @@ private JobPostingClassificationResultResponse normalizeClassificationResponse(
}

private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGenerateRequest request) {
String postingName = request.postingNameHint() == null ? "" : request.postingNameHint();

return new JobPostingGenerateResponse(
defaultString(request.jobTitleHint()),
postingName,
request.companyName(),
defaultString(request.jobTitleHint()),
defaultString(request.mainResponsibilities()),
Expand Down Expand Up @@ -884,10 +920,6 @@ private String defaultString(String value) {
return value == null ? "" : value;
}

private String defaultIfBlank(String value, String fallback) {
return value == null || value.isBlank() ? defaultString(fallback) : value;
}

private RetrievalContext emptyRetrievalContext() {
return new RetrievalContext(List.of(), List.of());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command)
extracted.requirements(),
extracted.preferredQualifications(),
null,
extracted.jobTitle()
extracted.jobTitle(),
extracted.postingName()
)
);
JobPostingIngestQualityValidator.validateGenerated(generated);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference;
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest;
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse;
Expand Down Expand Up @@ -250,6 +251,8 @@ void generateMockJobPostingUsesTopScoredReferenceFirst() {
void generateJobPostingDoesNotThrowWhenCompanySizeIsNull() {
DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring");
when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification));
when(llmConcurrencyLimiter.execute(eq("job-posting-generate"), any()))
.thenThrow(new RuntimeException("AI 호출 실패"));
JobPostingGenerateRequest request = new JobPostingGenerateRequest(
"테스트 기업",
null,
Expand All @@ -266,11 +269,113 @@ void generateJobPostingDoesNotThrowWhenCompanySizeIsNull() {
JobPostingGenerateResponse response = jobPostingAiService.generateJobPosting(request);

assertThat(response.companyName()).isEqualTo("테스트 기업");
assertThat(response.postingName()).isEqualTo("백엔드 개발자");
assertThat(response.postingName()).isEmpty();
assertThat(response.jobTitle()).isEqualTo("백엔드 개발자");
verify(llmConcurrencyLimiter).execute(eq("job-posting-generate"), any());
}

@Test
@DisplayName("AI 호출 실패 시 fallback은 원문에서 추출한 공고명을 유지한다")
void generateJobPostingFallbackPreservesExtractedPostingName() {
DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring");
when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification));
when(llmConcurrencyLimiter.execute(eq("job-posting-generate"), any()))
.thenThrow(new RuntimeException("AI 호출 실패"));
JobPostingGenerateRequest request = new JobPostingGenerateRequest(
"테스트 기업",
null,
100L,
"채용 요약",
"",
"주요 업무",
"자격 요건",
"우대 사항",
null,
"백엔드 개발자",
"2026 백엔드 개발자 공개채용"
);

JobPostingGenerateResponse response = jobPostingAiService.generateJobPosting(request);

assertThat(response.postingName()).isEqualTo("2026 백엔드 개발자 공개채용");
}

@Test
@DisplayName("AI 호출 실패 시 fallback은 빈 공고명 힌트를 그대로 유지한다")
void generateJobPostingFallbackKeepsMissingPostingNameEmpty() {
DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring");
when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification));
when(llmConcurrencyLimiter.execute(eq("job-posting-generate"), any()))
.thenThrow(new RuntimeException("AI 호출 실패"));
JobPostingGenerateRequest request = new JobPostingGenerateRequest(
"테스트 기업",
null,
100L,
"채용 요약",
"",
"주요 업무",
"자격 요건",
"우대 사항",
null,
"백엔드 개발자",
""
);

JobPostingGenerateResponse response = jobPostingAiService.generateJobPosting(request);

assertThat(response.postingName()).isEmpty();
}

@Test
@DisplayName("추출된 공고명이 공백과 대소문자 차이만 있는 원문에 존재하면 유지한다")
void normalizeResponseKeepsPostingNameSupportedBySourceText() {
JobPostingExtractResponse extracted = new JobPostingExtractResponse(
"BACKEND Engineer",
"테스트 기업",
"백엔드 개발자",
"",
"",
"",
"Backend\nEngineer 공개채용",
0.9
);

JobPostingExtractResponse normalized = ReflectionTestUtils.invokeMethod(
jobPostingAiService,
"normalizeResponse",
extracted,
"Backend Engineer 공개채용"
);

assertThat(normalized).isNotNull();
assertThat(normalized.postingName()).isEqualTo("BACKEND Engineer");
}

@Test
@DisplayName("추출된 공고명이 원문에 없으면 빈 문자열로 제거한다")
void normalizeResponseRemovesPostingNameUnsupportedBySourceText() {
JobPostingExtractResponse extracted = new JobPostingExtractResponse(
"2026 백엔드 개발자 공개채용",
"테스트 기업",
"백엔드 개발자",
"",
"",
"",
"테스트 기업에서 개발자를 모집합니다.",
0.9
);

JobPostingExtractResponse normalized = ReflectionTestUtils.invokeMethod(
jobPostingAiService,
"normalizeResponse",
extracted,
"테스트 기업에서 개발자를 모집합니다."
);

assertThat(normalized).isNotNull();
assertThat(normalized.postingName()).isEmpty();
}

@Test
@DisplayName("limiter 예외는 fallback으로 삼키지 않고 전파한다")
void generateMockRecommendedQuestionsPropagatesLimiterFailure() {
Expand Down
Loading
Loading