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/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..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 @@ -1,11 +1,18 @@ 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.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; @@ -23,6 +30,7 @@ public class JobPostingAiController { private final JobPostingAiService jobPostingAiService; + private final JobPostingIngestService jobPostingIngestService; @Operation( summary = "채용 공고 정보 추출", @@ -51,4 +59,140 @@ public ApiResponse extractJobPostingFromMultipart( jobPostingAiService.extractJobPosting(request) ); } + + @Operation( + 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 + ) { + 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 new file mode 100644 index 0000000..b0b1fd7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java @@ -0,0 +1,89 @@ +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.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; +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 org.springframework.web.bind.annotation.PutMapping; + +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 = "기존 채용 공고를 수정합니다. 회사명이 없으면 회사를 새로 생성합니다.") + @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) { + 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..50eba08 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingGenerateRequest.java @@ -0,0 +1,25 @@ +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, + + @NotNull(message = "소분류 ID는 필수입니다.") + Long detailClassificationId, + + String hiringSummary, + String techStack, + String mainResponsibilities, + String requirements, + String preferredQualifications, + 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/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/JobPostingIngestResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java new file mode 100644 index 0000000..fdab084 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java @@ -0,0 +1,19 @@ +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 boolean savedToDatabase; + private String message; + 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/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/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 e65faf8..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,13 @@ 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; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -21,6 +27,7 @@ import java.util.List; import java.util.Set; import java.util.Base64; +import java.util.stream.Collectors; @Service @Slf4j @@ -28,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; @@ -44,6 +52,55 @@ public JobPostingExtractResponse extractJobPosting(String rawText) { return extractJobPosting(rawText, null, null); } + 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, detailClassification)) + .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 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()); } @@ -78,7 +135,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; @@ -126,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); @@ -143,7 +260,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 +269,7 @@ private JobPostingExtractResponse extractStructuredContent(StructuredResponse new GeneralException( GeneralErrorCode.INTERNAL_SERVER_ERROR, - "AI 응답에서 채용 공고 추출 결과를 찾을 수 없습니다." + "AI 응답에서 " + responseType.getSimpleName() + " 결과를 찾을 수 없습니다." )); } @@ -226,4 +343,156 @@ private JobPostingExtractResponse createFallbackResponse(String rawText) { 0.0 ); } + + private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailClassification detailClassification) { + 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 + + [원하는 톤] + %s + """.formatted( + request.companyName(), + request.companySize().name(), + detailClassification.getDetailName(), + defaultString(request.jobTitleHint()), + 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.getTask() == null) { + response.setTask(""); + } + if (response.getRequirements() == null) { + response.setRequirements(""); + } + if (response.getPreferredQualifications() == null) { + response.setPreferredQualifications(""); + } + if (response.getSummary() == null) { + response.setSummary(""); + } + } + + 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(), + defaultString(request.jobTitleHint()), + defaultString(request.mainResponsibilities()), + defaultString(request.requirements()), + defaultString(request.preferredQualifications()), + defaultString(request.hiringSummary()) + ); + } + + 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..77e4729 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java @@ -0,0 +1,115 @@ +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.beans.factory.annotation.Value; +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; + + @Value("${job-posting.ingest.classification-confidence-threshold:0.65}") + private double classificationConfidenceThreshold; + + 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); + + if (classification.getConfidence() < classificationConfidenceThreshold) { + return new JobPostingIngestResponse( + false, + "소분류 분류 confidence가 낮아 저장을 보류했습니다.", + extracted, + candidates, + classification, + null, + null + ); + } + + 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( + true, + "채용 공고 추출 및 저장에 성공했습니다.", + 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 new file mode 100644 index 0000000..65e35e2 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingService.java @@ -0,0 +1,101 @@ +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.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; +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 = findOrCreateCompany(request.companyName(), request.companySize()); + DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); + + JobPosting jobPosting = JobPosting.create( + company, + detailClassification, + request.task(), + request.requirement(), + request.preferred() + ); + + return JobPostingResponse.from(jobPostingRepository.save(jobPosting)); + } + + @Transactional + public JobPostingResponse updateJobPosting(Long jobPostingId, JobPostingUpdateRequest request) { + JobPosting jobPosting = jobPostingRepository.findById(jobPostingId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId + )); + + Company company = findOrCreateCompany(request.companyName(), request.companySize()); + DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); + + jobPosting.update( + company, + detailClassification, + request.task(), + request.requirement(), + request.preferred() + ); + + return JobPostingResponse.from(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(); + } + + 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/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", "유저를 찾을 수 없습니다."); diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 00bf63a..ab79a41 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} @@ -80,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 953538a..4a41118 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} @@ -69,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:0.65} 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; 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