From 30fe2f58f3dee8416b0c62f82a9d25c46e9ac986 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 12 May 2026 17:48:19 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[Feat]=20ai=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B3=B5=EA=B3=A0=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/JobPostingController.java | 75 ++++++++++ .../dto/request/JobPostingCreateRequest.java | 26 ++++ .../request/JobPostingGenerateRequest.java | 24 ++++ .../response/JobPostingGenerateResponse.java | 20 +++ .../dto/response/JobPostingResponse.java | 34 +++++ .../service/JobPostingAiService.java | 131 +++++++++++++++++- .../jobposting/service/JobPostingService.java | 73 ++++++++++ .../apiPayload/code/GeneralErrorCode.java | 3 + 8 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingCreateRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingGenerateRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java new file mode 100644 index 0000000..0f0114f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java @@ -0,0 +1,75 @@ +package com.jobdri.jobdri_api.domain.jobposting.controller; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; +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.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/job-postings") +@Tag(name = "JobPosting", description = "채용 공고 생성/저장/조회 API") +public class JobPostingController { + + private final JobPostingAiService jobPostingAiService; + private final JobPostingService jobPostingService; + + @Operation(summary = "채용 공고 초안 생성", description = "회사 정보와 직무 정보를 바탕으로 AI가 공고 본문 초안을 생성합니다.") + @PostMapping("/generate") + public ApiResponse generateJobPosting( + @Valid @RequestBody JobPostingGenerateRequest request + ) { + return ApiResponse.onSuccess( + "채용 공고 초안 생성에 성공했습니다.", + jobPostingAiService.generateJobPosting(request) + ); + } + + @Operation(summary = "채용 공고 저장", description = "생성되었거나 직접 작성한 채용 공고를 DB에 저장합니다.") + @PostMapping + public ApiResponse createJobPosting( + @Valid @RequestBody JobPostingCreateRequest request + ) { + return ApiResponse.onSuccess( + "채용 공고 저장에 성공했습니다.", + jobPostingService.createJobPosting(request) + ); + } + + @Operation(summary = "채용 공고 단건 조회", description = "채용 공고 ID로 단건 조회합니다.") + @GetMapping("/{jobPostingId}") + public ApiResponse getJobPosting(@PathVariable Long jobPostingId) { + return ApiResponse.onSuccess( + "채용 공고 조회에 성공했습니다.", + jobPostingService.getJobPosting(jobPostingId) + ); + } + + @Operation(summary = "채용 공고 목록 조회", description = "전체 공고 또는 회사별 공고 목록을 조회합니다.") + @GetMapping + public ApiResponse> getJobPostings( + @RequestParam(required = false) Long companyId + ) { + List result = companyId == null + ? jobPostingService.getAllJobPostings() + : jobPostingService.getJobPostingsByCompany(companyId); + + return ApiResponse.onSuccess("채용 공고 목록 조회에 성공했습니다.", result); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingCreateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingCreateRequest.java new file mode 100644 index 0000000..81dc67b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingCreateRequest.java @@ -0,0 +1,26 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record JobPostingCreateRequest( + @NotBlank(message = "회사명은 필수입니다.") + String companyName, + + @NotNull(message = "회사 규모는 필수입니다.") + CompanySize companySize, + + @NotNull(message = "소분류 ID는 필수입니다.") + Long detailClassificationId, + + @NotBlank(message = "주요 업무는 필수입니다.") + String task, + + @NotBlank(message = "자격 요건은 필수입니다.") + String requirement, + + @NotBlank(message = "우대 사항은 필수입니다.") + String preferred +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingGenerateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingGenerateRequest.java new file mode 100644 index 0000000..dddc162 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingGenerateRequest.java @@ -0,0 +1,24 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record JobPostingGenerateRequest( + @NotBlank(message = "회사명은 필수입니다.") + String companyName, + + @NotNull(message = "회사 규모는 필수입니다.") + CompanySize companySize, + + @NotBlank(message = "직무명은 필수입니다.") + String jobTitle, + + String hiringSummary, + String techStack, + String mainResponsibilities, + String requirements, + String preferredQualifications, + String tone +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java new file mode 100644 index 0000000..653e8e8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class JobPostingGenerateResponse { + + private String companyName; + private String jobTitle; + private String task; + private String requirements; + private String preferredQualifications; + private String summary; +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingResponse.java new file mode 100644 index 0000000..96f9d58 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingResponse.java @@ -0,0 +1,34 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class JobPostingResponse { + + private Long jobPostingId; + private Long companyId; + private String companyName; + private String companySize; + private Long detailClassificationId; + private String detailClassificationName; + private String task; + private String requirement; + private String preferred; + + public static JobPostingResponse from(JobPosting jobPosting) { + return JobPostingResponse.builder() + .jobPostingId(jobPosting.getId()) + .companyId(jobPosting.getCompany().getId()) + .companyName(jobPosting.getCompany().getName()) + .companySize(jobPosting.getCompany().getSize().name()) + .detailClassificationId(jobPosting.getDetailClassification().getId()) + .detailClassificationName(jobPosting.getDetailClassification().getDetailName()) + .task(jobPosting.getTask()) + .requirement(jobPosting.getRequirement()) + .preferred(jobPosting.getPreferred()) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index e65faf8..af54ec8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -1,7 +1,9 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; 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.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -44,6 +46,25 @@ public JobPostingExtractResponse extractJobPosting(String rawText) { return extractJobPosting(rawText, null, null); } + public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest request) { + var params = ResponseCreateParams.builder() + .model(extractionModel) + .input(buildGenerationPrompt(request)) + .temperature(0.7) + .text(JobPostingGenerateResponse.class) + .build(); + + try { + StructuredResponse response = openAIClient.responses().create(params); + JobPostingGenerateResponse generated = extractStructuredContent(response, JobPostingGenerateResponse.class); + normalizeGeneratedResponse(generated, request); + return generated; + } catch (Exception e) { + log.error("채용 공고 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); + return createFallbackGeneratedResponse(request); + } + } + public JobPostingExtractResponse extractJobPosting(JobPostingExtractMultipartRequest request) { return extractJobPosting(request.getRawText(), request.getImage(), request.getSourceUrl()); } @@ -78,7 +99,7 @@ public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile try { StructuredResponse response = openAIClient.responses().create(params); - JobPostingExtractResponse extracted = extractStructuredContent(response); + JobPostingExtractResponse extracted = extractStructuredContent(response, JobPostingExtractResponse.class); normalizeResponse(extracted, rawText); return extracted; @@ -143,7 +164,7 @@ private ResponseInputImage buildImageContent(MultipartFile imageFile) { } } - private JobPostingExtractResponse extractStructuredContent(StructuredResponse response) { + private T extractStructuredContent(StructuredResponse response, Class responseType) { return response.output().stream() .filter(item -> item.message().isPresent()) .flatMap(item -> item.asMessage().content().stream()) @@ -152,7 +173,7 @@ private JobPostingExtractResponse extractStructuredContent(StructuredResponse new GeneralException( GeneralErrorCode.INTERNAL_SERVER_ERROR, - "AI 응답에서 채용 공고 추출 결과를 찾을 수 없습니다." + "AI 응답에서 " + responseType.getSimpleName() + " 결과를 찾을 수 없습니다." )); } @@ -226,4 +247,108 @@ private JobPostingExtractResponse createFallbackResponse(String rawText) { 0.0 ); } + + private String buildGenerationPrompt(JobPostingGenerateRequest request) { + return """ + 아래 정보를 바탕으로 한국어 채용 공고 초안을 작성해주세요. + 출력은 반드시 JSON 객체 하나만 반환하세요. + 설명 문장, 마크다운, 코드블럭은 포함하지 마세요. + + { + "companyName": "string", + "jobTitle": "string", + "task": "string", + "requirements": "string", + "preferredQualifications": "string", + "summary": "string" + } + + 작성 규칙: + 1. task는 문장형 또는 불릿을 줄바꿈으로 구분한 자연스러운 본문으로 작성하세요. + 2. requirements는 필수 자격 요건만 정리하세요. + 3. preferredQualifications는 우대 사항만 정리하세요. + 4. summary는 2~3문장으로 포지션 소개를 작성하세요. + 5. 과장되거나 허위인 내용을 만들지 말고, 입력 정보 범위 안에서 실무적인 표현으로 작성하세요. + + [회사명] + %s + + [회사 규모] + %s + + [직무명] + %s + + [채용 배경 또는 포지션 소개] + %s + + [기술 스택] + %s + + [주요 업무 초안] + %s + + [자격 요건 초안] + %s + + [우대 사항 초안] + %s + + [원하는 톤] + %s + """.formatted( + request.companyName(), + request.companySize().name(), + request.jobTitle(), + defaultString(request.hiringSummary()), + defaultString(request.techStack()), + defaultString(request.mainResponsibilities()), + defaultString(request.requirements()), + defaultString(request.preferredQualifications()), + defaultString(request.tone()) + ); + } + + private void normalizeGeneratedResponse(JobPostingGenerateResponse response, JobPostingGenerateRequest request) { + if (response == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 생성 응답이 비어 있습니다." + ); + } + + if (response.getCompanyName() == null || response.getCompanyName().isBlank()) { + response.setCompanyName(request.companyName()); + } + if (response.getJobTitle() == null || response.getJobTitle().isBlank()) { + response.setJobTitle(request.jobTitle()); + } + if (response.getTask() == null) { + response.setTask(""); + } + if (response.getRequirements() == null) { + response.setRequirements(""); + } + if (response.getPreferredQualifications() == null) { + response.setPreferredQualifications(""); + } + if (response.getSummary() == null) { + response.setSummary(""); + } + } + + private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGenerateRequest request) { + return new JobPostingGenerateResponse( + request.companyName(), + request.jobTitle(), + defaultString(request.mainResponsibilities()), + defaultString(request.requirements()), + defaultString(request.preferredQualifications()), + defaultString(request.hiringSummary()) + ); + } + + private String defaultString(String value) { + return value == null ? "" : value; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java new file mode 100644 index 0000000..2ce5356 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java @@ -0,0 +1,73 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +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 JobPostingService { + + private final JobPostingRepository jobPostingRepository; + private final CompanyRepository companyRepository; + private final DetailClassificationRepository detailClassificationRepository; + + @Transactional + public JobPostingResponse createJobPosting(JobPostingCreateRequest request) { + Company company = companyRepository.findByName(request.companyName()) + .orElseGet(() -> companyRepository.save( + Company.create(request.companyName(), request.companySize()) + )); + + DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + request.detailClassificationId() + )); + + JobPosting jobPosting = JobPosting.create( + company, + detailClassification, + request.task(), + request.requirement(), + request.preferred() + ); + + return JobPostingResponse.from(jobPostingRepository.save(jobPosting)); + } + + public JobPostingResponse getJobPosting(Long jobPostingId) { + JobPosting jobPosting = jobPostingRepository.findById(jobPostingId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId + )); + + return JobPostingResponse.from(jobPosting); + } + + public List getAllJobPostings() { + return jobPostingRepository.findAll().stream() + .map(JobPostingResponse::from) + .toList(); + } + + public List getJobPostingsByCompany(Long companyId) { + return jobPostingRepository.findAllByCompanyId(companyId).stream() + .map(JobPostingResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java index 8bb0bad..9132649 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java @@ -32,6 +32,9 @@ public enum GeneralErrorCode implements BaseErrorCode { // 분류 에러 CLASSIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "CLASSIFICATION_4041", "분류를 찾을 수 없습니다."), + // 채용 공고 에러 + JOB_POSTING_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_POSTING_4041", "채용 공고를 찾을 수 없습니다."), + // 유저 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_4041", "유저를 찾을 수 없습니다."); From 7a68e3935cc6291dc8b9dc51ffe95c301b047dd9 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 12 May 2026 19:12:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[Feat]=20=EC=B1=84=EC=9A=A9=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EC=A7=81=EB=AC=B4=20=EB=B6=84=EB=A5=98=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20db=EB=82=B4=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=ED=95=9C=EC=A0=95=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailClassificationRepository.java | 25 +++ .../controller/JobPostingAiController.java | 18 ++ .../controller/JobPostingController.java | 14 ++ .../request/JobPostingGenerateRequest.java | 7 +- .../JobPostingIngestMultipartRequest.java | 18 ++ .../dto/request/JobPostingUpdateRequest.java | 26 +++ ...ostingClassificationCandidateResponse.java | 15 ++ ...obPostingClassificationResultResponse.java | 20 +++ .../response/JobPostingIngestResponse.java | 17 ++ .../domain/jobposting/entity/JobPosting.java | 14 ++ ...tingClassificationCandidateProjection.java | 14 ++ .../service/JobPostingAiService.java | 164 ++++++++++++++++-- .../JobPostingClassificationService.java | 51 ++++++ .../service/JobPostingIngestService.java | 91 ++++++++++ .../jobposting/service/JobPostingService.java | 46 ++++- src/main/resources/application-prod.yaml | 3 + src/main/resources/application.yaml | 3 + src/main/resources/schema.sql | 1 + 18 files changed, 525 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingUpdateRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingClassificationCandidateResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingClassificationResultResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingClassificationCandidateProjection.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingClassificationService.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java create mode 100644 src/main/resources/schema.sql diff --git a/src/main/java/com/jobdri/jobdri_api/domain/classification/repository/DetailClassificationRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/classification/repository/DetailClassificationRepository.java index 3a82499..674accf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/classification/repository/DetailClassificationRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/classification/repository/DetailClassificationRepository.java @@ -1,7 +1,10 @@ package com.jobdri.jobdri_api.domain.classification.repository; import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingClassificationCandidateProjection; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -9,4 +12,26 @@ public interface DetailClassificationRepository extends JpaRepository { List findAllByMiddleClassificationId(Long middleClassificationId); Optional findByDetailName(String detailName); + + @Query(value = """ + SELECT + dc.id AS detailClassificationId, + dc.detail_name AS detailClassificationName, + mc.middle_name AS middleClassificationName, + c.big_name AS bigClassificationName, + GREATEST( + word_similarity(lower(dc.detail_name), lower(:query)), + word_similarity(lower(mc.middle_name), lower(:query)), + word_similarity(lower(concat(c.big_name, ' ', mc.middle_name, ' ', dc.detail_name)), lower(:query)) + ) AS score + FROM detail_classifications dc + JOIN middle_classifications mc ON dc.middle_classification_id = mc.id + JOIN classifications c ON mc.classification_id = c.id + ORDER BY score DESC, dc.id ASC + LIMIT :limit + """, nativeQuery = true) + List findTopCandidatesByTrigram( + @Param("query") String query, + @Param("limit") int limit + ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index 8cb4165..50d7ae3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -1,9 +1,12 @@ package com.jobdri.jobdri_api.domain.jobposting.controller; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingIngestService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,6 +26,7 @@ public class JobPostingAiController { private final JobPostingAiService jobPostingAiService; + private final JobPostingIngestService jobPostingIngestService; @Operation( summary = "채용 공고 정보 추출", @@ -51,4 +55,18 @@ public ApiResponse extractJobPostingFromMultipart( jobPostingAiService.extractJobPosting(request) ); } + + @Operation( + summary = "채용 공고 추출부터 분류, 생성, 저장까지 일괄 처리", + description = "이미지 또는 텍스트 공고를 추출하고, trigram 후보 검색과 AI 재분류를 거쳐 최종 소분류를 선택한 뒤 공고를 생성하고 저장합니다." + ) + @PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse ingestJobPosting( + @ModelAttribute JobPostingIngestMultipartRequest request + ) { + return ApiResponse.onSuccess( + "채용 공고 추출 및 저장에 성공했습니다.", + jobPostingIngestService.ingestAndCreate(request) + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java index 0f0114f..b0b1fd7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingUpdateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService; @@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PutMapping; import java.util.List; @@ -52,6 +54,18 @@ public ApiResponse createJobPosting( ); } + @Operation(summary = "채용 공고 수정", description = "기존 채용 공고를 수정합니다. 회사명이 없으면 회사를 새로 생성합니다.") + @PutMapping("/{jobPostingId}") + public ApiResponse updateJobPosting( + @PathVariable Long jobPostingId, + @Valid @RequestBody JobPostingUpdateRequest request + ) { + return ApiResponse.onSuccess( + "채용 공고 수정에 성공했습니다.", + jobPostingService.updateJobPosting(jobPostingId, request) + ); + } + @Operation(summary = "채용 공고 단건 조회", description = "채용 공고 ID로 단건 조회합니다.") @GetMapping("/{jobPostingId}") public ApiResponse getJobPosting(@PathVariable Long jobPostingId) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingGenerateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingGenerateRequest.java index dddc162..50eba08 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingGenerateRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingGenerateRequest.java @@ -11,14 +11,15 @@ public record JobPostingGenerateRequest( @NotNull(message = "회사 규모는 필수입니다.") CompanySize companySize, - @NotBlank(message = "직무명은 필수입니다.") - String jobTitle, + @NotNull(message = "소분류 ID는 필수입니다.") + Long detailClassificationId, String hiringSummary, String techStack, String mainResponsibilities, String requirements, String preferredQualifications, - String tone + String tone, + String jobTitleHint ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java new file mode 100644 index 0000000..6fb81de --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java @@ -0,0 +1,18 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@Setter +public class JobPostingIngestMultipartRequest { + + private String rawText; + private String sourceUrl; + private MultipartFile image; + private CompanySize companySize; + private String tone; + private Integer candidateLimit; +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingUpdateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingUpdateRequest.java new file mode 100644 index 0000000..1402891 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingUpdateRequest.java @@ -0,0 +1,26 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record JobPostingUpdateRequest( + @NotBlank(message = "회사명은 필수입니다.") + String companyName, + + @NotNull(message = "회사 규모는 필수입니다.") + CompanySize companySize, + + @NotNull(message = "소분류 ID는 필수입니다.") + Long detailClassificationId, + + @NotBlank(message = "주요 업무는 필수입니다.") + String task, + + @NotBlank(message = "자격 요건은 필수입니다.") + String requirement, + + @NotBlank(message = "우대 사항은 필수입니다.") + String preferred +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingClassificationCandidateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingClassificationCandidateResponse.java new file mode 100644 index 0000000..e97d7fb --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingClassificationCandidateResponse.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class JobPostingClassificationCandidateResponse { + + private Long detailClassificationId; + private String detailClassificationName; + private String middleClassificationName; + private String bigClassificationName; + private double score; +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingClassificationResultResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingClassificationResultResponse.java new file mode 100644 index 0000000..b05c47e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingClassificationResultResponse.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class JobPostingClassificationResultResponse { + + private Long detailClassificationId; + private String detailClassificationName; + private String middleClassificationName; + private String bigClassificationName; + private String reason; + private double confidence; +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java new file mode 100644 index 0000000..4e6cbb5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java @@ -0,0 +1,17 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class JobPostingIngestResponse { + + private JobPostingExtractResponse extracted; + private List candidates; + private JobPostingClassificationResultResponse classification; + private JobPostingGenerateResponse generated; + private JobPostingResponse saved; +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPosting.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPosting.java index 9421fd8..aa9166c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPosting.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPosting.java @@ -57,4 +57,18 @@ public static JobPosting create( .preferred(preferred) .build(); } + + public void update( + Company company, + DetailClassification detailClassification, + String task, + String requirement, + String preferred + ) { + this.company = company; + this.detailClassification = detailClassification; + this.task = task; + this.requirement = requirement; + this.preferred = preferred; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingClassificationCandidateProjection.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingClassificationCandidateProjection.java new file mode 100644 index 0000000..9fa3676 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingClassificationCandidateProjection.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.jobposting.repository; + +public interface JobPostingClassificationCandidateProjection { + + Long getDetailClassificationId(); + + String getDetailClassificationName(); + + String getMiddleClassificationName(); + + String getBigClassificationName(); + + Double getScore(); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index af54ec8..b025c4e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -1,7 +1,11 @@ package com.jobdri.jobdri_api.domain.jobposting.service; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; 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.global.apiPayload.code.GeneralErrorCode; @@ -23,6 +27,7 @@ import java.util.List; import java.util.Set; import java.util.Base64; +import java.util.stream.Collectors; @Service @Slf4j @@ -30,6 +35,7 @@ public class JobPostingAiService { private final OpenAIClient openAIClient; + private final DetailClassificationRepository detailClassificationRepository; @Value("${openai.model.job-posting-extractor:gpt-4o-mini}") private String extractionModel; @@ -47,9 +53,15 @@ public JobPostingExtractResponse extractJobPosting(String rawText) { } public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest request) { + DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + request.detailClassificationId() + )); + var params = ResponseCreateParams.builder() .model(extractionModel) - .input(buildGenerationPrompt(request)) + .input(buildGenerationPrompt(request, detailClassification)) .temperature(0.7) .text(JobPostingGenerateResponse.class) .build(); @@ -65,6 +77,30 @@ public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest r } } + public JobPostingClassificationResultResponse classifyDetailClassification( + JobPostingExtractResponse extracted, + List candidates + ) { + var params = ResponseCreateParams.builder() + .model(extractionModel) + .input(buildClassificationPrompt(extracted, candidates)) + .temperature(0.1) + .text(JobPostingClassificationResultResponse.class) + .build(); + + try { + StructuredResponse response = + openAIClient.responses().create(params); + JobPostingClassificationResultResponse classification = + extractStructuredContent(response, JobPostingClassificationResultResponse.class); + normalizeClassificationResponse(classification, candidates); + return classification; + } catch (Exception e) { + log.error("채용 공고 소분류 분류 OpenAI API 호출 오류: {}", e.getMessage(), e); + return fallbackClassification(candidates); + } + } + public JobPostingExtractResponse extractJobPosting(JobPostingExtractMultipartRequest request) { return extractJobPosting(request.getRawText(), request.getImage(), request.getSourceUrl()); } @@ -147,6 +183,66 @@ private String buildPrompt(String rawText, String sourceUrl, boolean hasImage) { """.formatted(hasImage ? "이미지 또는 텍스트" : "텍스트", normalizedSourceUrl, normalizedRawText); } + private String buildClassificationPrompt( + JobPostingExtractResponse extracted, + List candidates + ) { + String candidateText = candidates.stream() + .map(candidate -> String.format( + "- id=%d | 대분류=%s | 중분류=%s | 소분류=%s | score=%.4f", + candidate.getDetailClassificationId(), + candidate.getBigClassificationName(), + candidate.getMiddleClassificationName(), + candidate.getDetailClassificationName(), + candidate.getScore() + )) + .collect(Collectors.joining("\n")); + + return """ + 다음 채용 공고 정보에 가장 적합한 소분류를 아래 후보 중 하나만 골라주세요. + 반드시 후보에 있는 id만 선택해야 하며, 새 값을 만들면 안 됩니다. + 출력은 반드시 JSON 객체 하나만 반환하세요. + + { + "detailClassificationId": number, + "detailClassificationName": "string", + "middleClassificationName": "string", + "bigClassificationName": "string", + "reason": "string", + "confidence": number + } + + [추출된 회사명] + %s + + [추출된 직무명] + %s + + [추출된 주요 업무] + %s + + [추출된 자격 요건] + %s + + [추출된 우대 사항] + %s + + [추출 원문] + %s + + [후보 목록] + %s + """.formatted( + defaultString(extracted.getCompanyName()), + defaultString(extracted.getJobTitle()), + defaultString(extracted.getTask()), + defaultString(extracted.getRequirements()), + defaultString(extracted.getPreferredQualifications()), + defaultString(extracted.getRawText()), + candidateText + ); + } + private ResponseInputImage buildImageContent(MultipartFile imageFile) { validateImage(imageFile); @@ -248,7 +344,7 @@ private JobPostingExtractResponse createFallbackResponse(String rawText) { ); } - private String buildGenerationPrompt(JobPostingGenerateRequest request) { + private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailClassification detailClassification) { return """ 아래 정보를 바탕으로 한국어 채용 공고 초안을 작성해주세요. 출력은 반드시 JSON 객체 하나만 반환하세요. @@ -276,7 +372,10 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request) { [회사 규모] %s - [직무명] + [소분류 직무] + %s + + [직무명 힌트] %s [채용 배경 또는 포지션 소개] @@ -299,7 +398,8 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request) { """.formatted( request.companyName(), request.companySize().name(), - request.jobTitle(), + detailClassification.getDetailName(), + defaultString(request.jobTitleHint()), defaultString(request.hiringSummary()), defaultString(request.techStack()), defaultString(request.mainResponsibilities()), @@ -312,17 +412,14 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request) { private void normalizeGeneratedResponse(JobPostingGenerateResponse response, JobPostingGenerateRequest request) { if (response == null) { throw new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "AI 생성 응답이 비어 있습니다." + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 생성 응답이 비어 있습니다." ); } if (response.getCompanyName() == null || response.getCompanyName().isBlank()) { response.setCompanyName(request.companyName()); } - if (response.getJobTitle() == null || response.getJobTitle().isBlank()) { - response.setJobTitle(request.jobTitle()); - } if (response.getTask() == null) { response.setTask(""); } @@ -337,10 +434,43 @@ private void normalizeGeneratedResponse(JobPostingGenerateResponse response, Job } } + private void normalizeClassificationResponse( + JobPostingClassificationResultResponse response, + List candidates + ) { + if (response == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 분류 응답이 비어 있습니다." + ); + } + + JobPostingClassificationCandidateResponse matched = candidates.stream() + .filter(candidate -> candidate.getDetailClassificationId().equals(response.getDetailClassificationId())) + .findFirst() + .orElseGet(() -> candidates.getFirst()); + + response.setDetailClassificationId(matched.getDetailClassificationId()); + response.setDetailClassificationName(matched.getDetailClassificationName()); + response.setMiddleClassificationName(matched.getMiddleClassificationName()); + response.setBigClassificationName(matched.getBigClassificationName()); + + if (response.getReason() == null) { + response.setReason(""); + } + + double confidence = response.getConfidence(); + if (Double.isNaN(confidence) || Double.isInfinite(confidence) || confidence < 0.0) { + response.setConfidence(0.0); + } else if (confidence > 1.0) { + response.setConfidence(1.0); + } + } + private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGenerateRequest request) { return new JobPostingGenerateResponse( request.companyName(), - request.jobTitle(), + defaultString(request.jobTitleHint()), defaultString(request.mainResponsibilities()), defaultString(request.requirements()), defaultString(request.preferredQualifications()), @@ -348,6 +478,20 @@ private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGen ); } + private JobPostingClassificationResultResponse fallbackClassification( + List candidates + ) { + JobPostingClassificationCandidateResponse first = candidates.getFirst(); + return new JobPostingClassificationResultResponse( + first.getDetailClassificationId(), + first.getDetailClassificationName(), + first.getMiddleClassificationName(), + first.getBigClassificationName(), + "후보 점수가 가장 높은 소분류를 기본값으로 선택했습니다.", + 0.0 + ); + } + private String defaultString(String value) { return value == null ? "" : value; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingClassificationService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingClassificationService.java new file mode 100644 index 0000000..db3fd2f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingClassificationService.java @@ -0,0 +1,51 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingClassificationCandidateProjection; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +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 JobPostingClassificationService { + + private final DetailClassificationRepository detailClassificationRepository; + + public List findCandidates(JobPostingExtractResponse extracted, int limit) { + String query = buildSearchQuery(extracted); + + return detailClassificationRepository.findTopCandidatesByTrigram(query, limit).stream() + .map(this::toResponse) + .toList(); + } + + private String buildSearchQuery(JobPostingExtractResponse extracted) { + return String.join(" ", + normalize(extracted.getJobTitle()), + normalize(extracted.getTask()), + normalize(extracted.getRequirements()), + normalize(extracted.getPreferredQualifications()), + normalize(extracted.getRawText()) + ).trim(); + } + + private String normalize(String value) { + return value == null ? "" : value; + } + + private JobPostingClassificationCandidateResponse toResponse(JobPostingClassificationCandidateProjection projection) { + return new JobPostingClassificationCandidateResponse( + projection.getDetailClassificationId(), + projection.getDetailClassificationName(), + projection.getMiddleClassificationName(), + projection.getBigClassificationName(), + projection.getScore() == null ? 0.0 : projection.getScore() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java new file mode 100644 index 0000000..9a9805a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java @@ -0,0 +1,91 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; +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.JobPostingIngestResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; +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 +public class JobPostingIngestService { + + private static final int DEFAULT_CANDIDATE_LIMIT = 10; + + private final JobPostingAiService jobPostingAiService; + private final JobPostingClassificationService jobPostingClassificationService; + private final JobPostingService jobPostingService; + + @Transactional + public JobPostingIngestResponse ingestAndCreate(JobPostingIngestMultipartRequest request) { + if (request.getCompanySize() == null) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "회사 규모는 필수입니다."); + } + + JobPostingExtractResponse extracted = jobPostingAiService.extractJobPosting( + request.getRawText(), + request.getImage(), + request.getSourceUrl() + ); + + int candidateLimit = request.getCandidateLimit() == null ? DEFAULT_CANDIDATE_LIMIT : request.getCandidateLimit(); + List candidates = + jobPostingClassificationService.findCandidates(extracted, candidateLimit); + + if (candidates.isEmpty()) { + throw new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "소분류 후보를 찾을 수 없습니다." + ); + } + + JobPostingClassificationResultResponse classification = + jobPostingAiService.classifyDetailClassification(extracted, candidates); + + JobPostingGenerateResponse generated = jobPostingAiService.generateJobPosting( + new JobPostingGenerateRequest( + extracted.getCompanyName(), + request.getCompanySize(), + classification.getDetailClassificationId(), + extracted.getRawText(), + "", + extracted.getTask(), + extracted.getRequirements(), + extracted.getPreferredQualifications(), + request.getTone(), + extracted.getJobTitle() + ) + ); + + JobPostingResponse saved = jobPostingService.createJobPosting( + new JobPostingCreateRequest( + fallbackCompanyName(extracted.getCompanyName()), + request.getCompanySize(), + classification.getDetailClassificationId(), + generated.getTask(), + generated.getRequirements(), + generated.getPreferredQualifications() + ) + ); + + return new JobPostingIngestResponse(extracted, candidates, classification, generated, saved); + } + + private String fallbackCompanyName(String companyName) { + if (companyName == null || companyName.isBlank()) { + return "미분류 회사"; + } + return companyName; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java index 2ce5356..65e35e2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java @@ -5,6 +5,7 @@ import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingUpdateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; @@ -27,18 +28,32 @@ public class JobPostingService { @Transactional public JobPostingResponse createJobPosting(JobPostingCreateRequest request) { - Company company = companyRepository.findByName(request.companyName()) - .orElseGet(() -> companyRepository.save( - Company.create(request.companyName(), request.companySize()) - )); + Company company = findOrCreateCompany(request.companyName(), request.companySize()); + DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); + + JobPosting jobPosting = JobPosting.create( + company, + detailClassification, + request.task(), + request.requirement(), + request.preferred() + ); - DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) + return JobPostingResponse.from(jobPostingRepository.save(jobPosting)); + } + + @Transactional + public JobPostingResponse updateJobPosting(Long jobPostingId, JobPostingUpdateRequest request) { + JobPosting jobPosting = jobPostingRepository.findById(jobPostingId) .orElseThrow(() -> new GeneralException( - GeneralErrorCode.CLASSIFICATION_NOT_FOUND, - "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + request.detailClassificationId() + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId )); - JobPosting jobPosting = JobPosting.create( + Company company = findOrCreateCompany(request.companyName(), request.companySize()); + DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); + + jobPosting.update( company, detailClassification, request.task(), @@ -46,7 +61,7 @@ public JobPostingResponse createJobPosting(JobPostingCreateRequest request) { request.preferred() ); - return JobPostingResponse.from(jobPostingRepository.save(jobPosting)); + return JobPostingResponse.from(jobPosting); } public JobPostingResponse getJobPosting(Long jobPostingId) { @@ -70,4 +85,17 @@ public List getJobPostingsByCompany(Long companyId) { .map(JobPostingResponse::from) .toList(); } + + private Company findOrCreateCompany(String companyName, com.jobdri.jobdri_api.domain.company.entity.CompanySize companySize) { + return companyRepository.findByName(companyName) + .orElseGet(() -> companyRepository.save(Company.create(companyName, companySize))); + } + + private DetailClassification findDetailClassification(Long detailClassificationId) { + return detailClassificationRepository.findById(detailClassificationId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + detailClassificationId + )); + } } diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 00bf63a..313bc3a 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -1,6 +1,9 @@ spring: application: name: jobdri-api + sql: + init: + mode: always datasource: url: ${DB_URL} username: ${DB_USERNAME} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 953538a..891f1c7 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,6 +1,9 @@ spring: application: name: jobdri-api + sql: + init: + mode: always datasource: url: ${DB_URL:jdbc:postgresql://localhost:5432/jobdri} username: ${DB_USERNAME:jobdri} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..588aec0 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; From b541536c1c131bf63adb9e82a38dc19f38546e44 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 12 May 2026 19:24:34 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[Feat]=20=EC=A7=81=EB=AC=B4=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20=EC=9E=90=EB=8F=99=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + .env.production.example | 1 + .../controller/JobPostingAiController.java | 126 ++++++++++++++++++ .../response/JobPostingIngestResponse.java | 2 + .../service/JobPostingIngestService.java | 26 +++- src/main/resources/application-prod.yaml | 4 + src/main/resources/application.yaml | 4 + 7 files changed, 163 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 12fbe59..4990b2a 100644 --- a/.env.example +++ b/.env.example @@ -27,5 +27,6 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=change-me APP_OAUTH2_REDIRECT_URI=http://localhost:3000/oauth2/redirect OPENAI_API_KEY=change-me +JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 MANAGEMENT_HEALTH_SHOW_DETAILS=always diff --git a/.env.production.example b/.env.production.example index 9b8bf92..eb0adfb 100644 --- a/.env.production.example +++ b/.env.production.example @@ -31,5 +31,6 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=change-me APP_OAUTH2_REDIRECT_URI=https://your-frontend-domain/oauth2/redirect OPENAI_API_KEY=change-me +JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 MANAGEMENT_HEALTH_SHOW_DETAILS=never diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index 50d7ae3..3a4b58c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -9,6 +9,10 @@ import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingIngestService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -60,6 +64,128 @@ public ApiResponse extractJobPostingFromMultipart( summary = "채용 공고 추출부터 분류, 생성, 저장까지 일괄 처리", description = "이미지 또는 텍스트 공고를 추출하고, trigram 후보 검색과 AI 재분류를 거쳐 최종 소분류를 선택한 뒤 공고를 생성하고 저장합니다." ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "분류 confidence가 충분하여 저장까지 완료된 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(value = """ + { + "isSuccess": true, + "code": "COMMON2000", + "message": "채용 공고 추출 및 저장에 성공했습니다.", + "result": { + "savedToDatabase": true, + "message": "채용 공고 추출 및 저장에 성공했습니다.", + "extracted": { + "companyName": "삼성전자", + "jobTitle": "백엔드 개발자", + "task": "백엔드 서비스 개발 및 운영", + "requirements": "Java/Spring 기반 개발 경험", + "preferredQualifications": "대용량 트래픽 처리 경험", + "rawText": "채용 공고 원문 내용", + "confidence": 0.92 + }, + "candidates": [ + { + "detailClassificationId": 101, + "detailClassificationName": "Java/Spring", + "middleClassificationName": "백엔드", + "bigClassificationName": "개발", + "score": 0.91 + } + ], + "classification": { + "detailClassificationId": 101, + "detailClassificationName": "Java/Spring", + "middleClassificationName": "백엔드", + "bigClassificationName": "개발", + "reason": "Spring Boot, JPA, API 개발 맥락이 가장 강합니다.", + "confidence": 0.87 + }, + "generated": { + "companyName": "삼성전자", + "jobTitle": "Java/Spring 백엔드 개발자", + "task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선", + "requirements": "Java/Spring 기반 개발 경험\\nRDB 사용 경험", + "preferredQualifications": "대용량 트래픽 처리 경험\\nRedis 사용 경험", + "summary": "서비스 백엔드 개발과 운영을 담당할 인재를 찾습니다." + }, + "saved": { + "jobPostingId": 10, + "companyId": 3, + "companyName": "삼성전자", + "companySize": "ENTERPRISE", + "detailClassificationId": 101, + "detailClassificationName": "Java/Spring", + "task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선", + "requirement": "Java/Spring 기반 개발 경험\\nRDB 사용 경험", + "preferred": "대용량 트래픽 처리 경험\\nRedis 사용 경험" + } + }, + "error": null + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "분류 confidence가 낮아 저장을 보류한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(value = """ + { + "isSuccess": true, + "code": "COMMON2000", + "message": "채용 공고 추출 및 저장에 성공했습니다.", + "result": { + "savedToDatabase": false, + "message": "소분류 분류 confidence가 낮아 저장을 보류했습니다.", + "extracted": { + "companyName": "어떤회사", + "jobTitle": "개발자", + "task": "서비스 개발", + "requirements": "개발 경험", + "preferredQualifications": "우대 사항", + "rawText": "채용 공고 원문 내용", + "confidence": 0.79 + }, + "candidates": [ + { + "detailClassificationId": 101, + "detailClassificationName": "Java/Spring", + "middleClassificationName": "백엔드", + "bigClassificationName": "개발", + "score": 0.62 + }, + { + "detailClassificationId": 102, + "detailClassificationName": "Node.js", + "middleClassificationName": "백엔드", + "bigClassificationName": "개발", + "score": 0.58 + } + ], + "classification": { + "detailClassificationId": 101, + "detailClassificationName": "Java/Spring", + "middleClassificationName": "백엔드", + "bigClassificationName": "개발", + "reason": "후보 간 차이가 크지 않습니다.", + "confidence": 0.49 + }, + "generated": null, + "saved": null + }, + "error": null + } + """) + ) + ) + }) @PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse ingestJobPosting( @ModelAttribute JobPostingIngestMultipartRequest request diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java index 4e6cbb5..fdab084 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java @@ -9,6 +9,8 @@ @AllArgsConstructor public class JobPostingIngestResponse { + private boolean savedToDatabase; + private String message; private JobPostingExtractResponse extracted; private List candidates; private JobPostingClassificationResultResponse classification; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java index 9a9805a..77e4729 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java @@ -12,6 +12,7 @@ import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,6 +24,9 @@ public class JobPostingIngestService { private static final int DEFAULT_CANDIDATE_LIMIT = 10; + @Value("${job-posting.ingest.classification-confidence-threshold:0.65}") + private double classificationConfidenceThreshold; + private final JobPostingAiService jobPostingAiService; private final JobPostingClassificationService jobPostingClassificationService; private final JobPostingService jobPostingService; @@ -53,6 +57,18 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestMultipartRequest JobPostingClassificationResultResponse classification = jobPostingAiService.classifyDetailClassification(extracted, candidates); + if (classification.getConfidence() < classificationConfidenceThreshold) { + return new JobPostingIngestResponse( + false, + "소분류 분류 confidence가 낮아 저장을 보류했습니다.", + extracted, + candidates, + classification, + null, + null + ); + } + JobPostingGenerateResponse generated = jobPostingAiService.generateJobPosting( new JobPostingGenerateRequest( extracted.getCompanyName(), @@ -79,7 +95,15 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestMultipartRequest ) ); - return new JobPostingIngestResponse(extracted, candidates, classification, generated, saved); + return new JobPostingIngestResponse( + true, + "채용 공고 추출 및 저장에 성공했습니다.", + extracted, + candidates, + classification, + generated, + saved + ); } private String fallbackCompanyName(String companyName) { diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 313bc3a..ab79a41 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -83,3 +83,7 @@ openai: key: ${OPENAI_API_KEY} model: job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini} + +job-posting: + ingest: + classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 891f1c7..d8474ea 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -72,3 +72,7 @@ openai: key: ${OPENAI_API_KEY:} model: job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini} + +job-posting: + ingest: + classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD} From 32cbefa2a91318a7263f31d91a441f5e349fa3a0 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 12 May 2026 19:29:25 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[Feat]=20=EC=A7=81=EB=AC=B4=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20=EC=9E=90=EB=8F=99=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 2 +- src/test/resources/application-test.yaml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d8474ea..4a41118 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -75,4 +75,4 @@ openai: job-posting: ingest: - classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD} + classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:0.65} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index a8d7044..999e156 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -1,4 +1,7 @@ spring: + sql: + init: + mode: never datasource: url: jdbc:h2:mem:jobdri-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa