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
@@ -1,6 +1,12 @@
package com.jobdri.jobdri_api.domain.jobposting.dto.response;

public record JobPostingGenerateResponse(String companyName, String jobTitle, String task, String requirements,
String preferredQualifications, String summary) {

public record JobPostingGenerateResponse(
String postingName,
String companyName,
String jobTitle,
String task,
String requirements,
String preferredQualifications,
String summary
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.jobdri.jobdri_api.domain.jobposting.dto.response;

import java.util.List;

public record JobPostingIngestValidationErrorResponse(
String reason,
String message,
List<InvalidField> invalidFields
) {

public record InvalidField(
String field,
String label,
String message
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl
설명 문장, 마크다운, 코드블럭은 포함하지 마세요.

{
"postingName": "string",
"companyName": "string",
"jobTitle": "string",
"task": "string",
Expand All @@ -433,11 +434,13 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl
}

작성 규칙:
1. task는 문장형 또는 불릿을 줄바꿈으로 구분한 자연스러운 본문으로 작성하세요.
2. requirements는 필수 자격 요건만 정리하세요.
3. preferredQualifications는 우대 사항만 정리하세요.
4. summary는 2~3문장으로 포지션 소개를 작성하세요.
5. 과장되거나 허위인 내용을 만들지 말고, 입력 정보 범위 안에서 실무적인 표현으로 작성하세요.
1. postingName은 공고 제목으로 사용할 수 있게 회사명과 직무명을 반영해 간결하게 작성하세요.
2. jobTitle은 직무명만 작성하세요.
3. task는 문장형 또는 불릿을 줄바꿈으로 구분한 자연스러운 본문으로 작성하세요.
4. requirements는 필수 자격 요건만 정리하세요.
5. preferredQualifications는 우대 사항만 정리하세요.
6. summary는 2~3문장으로 포지션 소개를 작성하세요.
7. 과장되거나 허위인 내용을 만들지 말고, 입력 정보 범위 안에서 실무적인 표현으로 작성하세요.

[회사명]
%s
Expand Down Expand Up @@ -678,6 +681,7 @@ private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerate
}

return new JobPostingGenerateResponse(
defaultIfBlank(response.postingName(), request.jobTitleHint()),
companyName,
defaultString(response.jobTitle()),
defaultString(response.task()),
Expand Down Expand Up @@ -804,6 +808,7 @@ private JobPostingClassificationResultResponse normalizeClassificationResponse(

private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGenerateRequest request) {
return new JobPostingGenerateResponse(
defaultString(request.jobTitleHint()),
request.companyName(),
defaultString(request.jobTitleHint()),
defaultString(request.mainResponsibilities()),
Expand Down Expand Up @@ -872,6 +877,10 @@ 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 @@ -2,17 +2,19 @@

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.JobPostingIngestValidationErrorResponse;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestValidationErrorResponse.InvalidField;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;

final class JobPostingIngestQualityValidator {

private static final double MIN_EXTRACT_CONFIDENCE = 0.3;
private static final int MIN_SHORT_FIELD_LENGTH = 2;
private static final int MIN_DESCRIPTION_LENGTH = 5;
private static final Set<String> PLACEHOLDER_VALUES = Set.of(
"string",
"null",
Expand All @@ -34,41 +36,59 @@ private JobPostingIngestQualityValidator() {

static void validateExtracted(JobPostingExtractResponse extracted) {
if (extracted == null) {
throwInvalidJobPosting();
throwInvalidJobPostingWithFields(List.of(
invalidField("companyName", "회사명"),
invalidField("jobTitle", "직무")
));
}
if (Double.isNaN(extracted.confidence())
|| Double.isInfinite(extracted.confidence())
|| extracted.confidence() < MIN_EXTRACT_CONFIDENCE
|| extracted.confidence() < 0.0
|| extracted.confidence() > 1.0) {
throwInvalidJobPosting();
}
validateField(extracted.companyName(), MIN_SHORT_FIELD_LENGTH);
validateField(extracted.jobTitle(), MIN_SHORT_FIELD_LENGTH);
validateField(extracted.task(), MIN_DESCRIPTION_LENGTH);
validateField(extracted.requirements(), MIN_DESCRIPTION_LENGTH);
validateRequiredFields(List.of(
field("companyName", "회사명", extracted.companyName()),
field("jobTitle", "직무", extracted.jobTitle())
));
}

static void validateGenerated(JobPostingGenerateResponse generated) {
if (generated == null) {
throwInvalidJobPosting();
throwInvalidJobPostingWithFields(List.of(
invalidField("postingName", "공고명"),
invalidField("companyName", "회사명"),
invalidField("jobTitle", "직무")
));
}
validateField(generated.companyName(), MIN_SHORT_FIELD_LENGTH);
validateField(generated.jobTitle(), MIN_SHORT_FIELD_LENGTH);
validateField(generated.task(), MIN_DESCRIPTION_LENGTH);
validateField(generated.requirements(), MIN_DESCRIPTION_LENGTH);
validateRequiredFields(List.of(
field("postingName", "공고명", generated.postingName()),
field("companyName", "회사명", generated.companyName()),
field("jobTitle", "직무", generated.jobTitle())
));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private static void validateField(String value, int minLength) {
if (value == null) {
throwInvalidJobPosting();
private static void validateRequiredFields(List<FieldCandidate> candidates) {
List<InvalidField> invalidFields = new ArrayList<>();
for (FieldCandidate candidate : candidates) {
if (isInvalidRequiredField(candidate.value())) {
invalidFields.add(invalidField(candidate.field(), candidate.label()));
}
}

String normalized = value.trim();
if (normalized.length() < minLength || isPlaceholder(normalized)) {
throwInvalidJobPosting();
if (!invalidFields.isEmpty()) {
throwInvalidJobPostingWithFields(invalidFields);
}
}

private static boolean isInvalidRequiredField(String value) {
if (value == null) {
return true;
}
String normalized = value.trim();
return normalized.length() < MIN_SHORT_FIELD_LENGTH || isPlaceholder(normalized);
}

private static boolean isPlaceholder(String value) {
String normalized = value.trim().toLowerCase(Locale.ROOT);
return PLACEHOLDER_VALUES.contains(normalized) || normalized.matches("string\\d*");
Expand All @@ -80,4 +100,31 @@ private static void throwInvalidJobPosting() {
"채용 공고로 인식할 수 없는 입력입니다. 공고 내용을 확인해주세요."
);
}

private static void throwInvalidJobPostingWithFields(List<InvalidField> invalidFields) {
throw new GeneralException(
GeneralErrorCode.INVALID_PARAMETER,
"채용 공고 필수 정보를 인식하지 못했습니다. 공고 내용을 확인해주세요.",
new JobPostingIngestValidationErrorResponse(
"INVALID_JOB_POSTING_FIELDS",
"공고명, 회사명, 직무 정보를 확인해주세요.",
invalidFields
)
);
}

private static FieldCandidate field(String field, String label, String value) {
return new FieldCandidate(field, label, value);
}

private static InvalidField invalidField(String field, String label) {
return new InvalidField(field, label, label + "을(를) 인식하지 못했습니다.");
}

private record FieldCandidate(
String field,
String label,
String value
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command)
resolveUser(command),
new JobPostingCreateRequest(
JobPostingProfileColor.DEFAULT,
generated.jobTitle(),
generated.postingName(),
fallbackCompanyName(extracted.companyName()),
null,
generated.jobTitle(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public JobPostingIngestResponse finalizeAndComplete(
userService.getUser(userId),
new JobPostingCreateRequest(
JobPostingProfileColor.DEFAULT,
generated.jobTitle(),
generated.postingName(),
fallbackCompanyName(extracted.companyName()),
null,
generated.jobTitle(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,28 @@
public class GeneralException extends RuntimeException {

private final BaseErrorCode code;
private final Object error;

public GeneralException(BaseErrorCode code) {
this.code = code;
this.error = null;
}

public GeneralException(BaseErrorCode code, String message) {
super(message);
this.code = code;
this.error = null;
}

public GeneralException(BaseErrorCode code, String message, Object error) {
super(message);
this.code = code;
this.error = error;
}

public GeneralException(BaseErrorCode code, String message, Throwable cause) {
super(message, cause);
this.code = code;
this.error = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public ResponseEntity<ApiResponse<Object>> handleCustomException(GeneralExceptio
}
return ResponseEntity
.status(code.getHttpStatus())
.body(ApiResponse.onFailure(code, e.getMessage()));
.body(ApiResponse.onFailure(code, e.getError() != null ? e.getError() : e.getMessage()));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ void generateJobPostingDoesNotThrowWhenCompanySizeIsNull() {
JobPostingGenerateResponse response = jobPostingAiService.generateJobPosting(request);

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