diff --git a/build.gradle b/build.gradle index 27afc18..cb36d73 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,9 @@ dependencies { implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework.boot:spring-boot-starter-aop' + //openai + implementation 'com.openai:openai-java:4.35.0' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' 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 new file mode 100644 index 0000000..8cb4165 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -0,0 +1,54 @@ +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.JobPostingExtractMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService; +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.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/job-postings") +@Tag(name = "JobPosting AI", description = "채용 공고 추출 AI API") +public class JobPostingAiController { + + private final JobPostingAiService jobPostingAiService; + + @Operation( + summary = "채용 공고 정보 추출", + description = "채용 공고 원문 텍스트를 기반으로 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 AI로 추출합니다." + ) + @PostMapping(value = "/extract", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse extractJobPostingFromText( + @Valid @RequestBody JobPostingExtractRequest request + ) { + return ApiResponse.onSuccess( + "채용 공고 추출에 성공했습니다.", + jobPostingAiService.extractJobPosting(request.rawText()) + ); + } + + @Operation( + summary = "채용 공고 정보 추출(이미지 또는 텍스트)", + description = "프론트에서 캡처한 채용 공고 이미지 파일과 선택적 텍스트, 원본 URL을 함께 보내면 AI가 구조화된 채용 공고 정보를 추출합니다." + ) + @PostMapping(value = "/extract", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse extractJobPostingFromMultipart( + @ModelAttribute JobPostingExtractMultipartRequest request + ) { + return ApiResponse.onSuccess( + "채용 공고 추출에 성공했습니다.", + jobPostingAiService.extractJobPosting(request) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java new file mode 100644 index 0000000..82b04ce --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@Setter +public class JobPostingExtractMultipartRequest { + + private String rawText; + private String sourceUrl; + private MultipartFile image; +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java new file mode 100644 index 0000000..1bbe645 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record JobPostingExtractRequest( + @NotBlank(message = "채용 공고 원문은 필수입니다.") + String rawText +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingExtractResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingExtractResponse.java new file mode 100644 index 0000000..fe3e132 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingExtractResponse.java @@ -0,0 +1,21 @@ +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 JobPostingExtractResponse { + + private String companyName; + private String jobTitle; + private String task; + private String requirements; + private String preferredQualifications; + private String rawText; + private double confidence; +} 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 new file mode 100644 index 0000000..e65faf8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -0,0 +1,229 @@ +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.response.JobPostingExtractResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.openai.client.OpenAIClient; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputContent; +import com.openai.models.responses.ResponseInputImage; +import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.StructuredResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.Base64; + +@Service +@Slf4j +@RequiredArgsConstructor +public class JobPostingAiService { + + private final OpenAIClient openAIClient; + + @Value("${openai.model.job-posting-extractor:gpt-4o-mini}") + private String extractionModel; + + private static final Set SUPPORTED_IMAGE_TYPES = Set.of( + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/gif" + ); + + public JobPostingExtractResponse extractJobPosting(String rawText) { + return extractJobPosting(rawText, null, null); + } + + public JobPostingExtractResponse extractJobPosting(JobPostingExtractMultipartRequest request) { + return extractJobPosting(request.getRawText(), request.getImage(), request.getSourceUrl()); + } + + public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile, String sourceUrl) { + validateInput(rawText, imageFile); + + List contents = new ArrayList<>(); + contents.add(ResponseInputContent.ofInputText( + com.openai.models.responses.ResponseInputText.builder() + .text(buildPrompt(rawText, sourceUrl, imageFile != null)) + .build() + )); + + if (imageFile != null && !imageFile.isEmpty()) { + contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageFile))); + } + + var params = ResponseCreateParams.builder() + .model(extractionModel) + .inputOfResponse(List.of( + ResponseInputItem.ofMessage( + ResponseInputItem.Message.builder() + .role(ResponseInputItem.Message.Role.USER) + .content(contents) + .build() + ) + )) + .temperature(0.1) + .text(JobPostingExtractResponse.class) + .build(); + + try { + StructuredResponse response = openAIClient.responses().create(params); + JobPostingExtractResponse extracted = extractStructuredContent(response); + + normalizeResponse(extracted, rawText); + return extracted; + + } catch (Exception e) { + log.error("채용 공고 추출 OpenAI API 호출 오류: {}", e.getMessage(), e); + return createFallbackResponse(rawText); + } + } + + private String buildPrompt(String rawText, String sourceUrl, boolean hasImage) { + String normalizedRawText = rawText == null ? "" : rawText; + String normalizedSourceUrl = sourceUrl == null ? "" : sourceUrl; + + return """ + 이 %s는 채용 공고입니다. + 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 추출해주세요. + + 반드시 아래 JSON 형식으로만 응답해주세요. + 설명 문장, 마크다운, 코드블럭은 포함하지 마세요. + + { + "companyName": "string", + "jobTitle": "string", + "task": "string", + "requirements": "string", + "preferredQualifications": "string", + "rawText": "string", + "confidence": number + } + + 규칙: + 1. 이미지가 있으면 이미지 안의 채용 공고 문구를 읽어 rawText에 정리해주세요. + 2. 텍스트가 있으면 rawText에는 입력 원문을 최대한 그대로 넣어주세요. + 3. 이미지와 텍스트가 둘 다 있으면 둘을 함께 참고해서 가장 정확한 값으로 채워주세요. + 4. 정보가 없거나 확실하지 않으면 해당 필드는 빈 문자열로 두세요. + 5. confidence는 추출 결과 전체에 대한 신뢰도를 0~1 사이 실수로 반환하세요. + 6. JSON 외의 다른 텍스트는 절대 출력하지 마세요. + + [원본 URL] + %s + + [채용 공고 텍스트] + %s + """.formatted(hasImage ? "이미지 또는 텍스트" : "텍스트", normalizedSourceUrl, normalizedRawText); + } + + private ResponseInputImage buildImageContent(MultipartFile imageFile) { + validateImage(imageFile); + + try { + String contentType = imageFile.getContentType(); + String base64 = Base64.getEncoder().encodeToString(imageFile.getBytes()); + String dataUrl = "data:%s;base64,%s".formatted(contentType, base64); + + return ResponseInputImage.builder() + .imageUrl(dataUrl) + .detail(ResponseInputImage.Detail.HIGH) + .build(); + } catch (IOException e) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); + } + } + + private JobPostingExtractResponse extractStructuredContent(StructuredResponse response) { + return response.output().stream() + .filter(item -> item.message().isPresent()) + .flatMap(item -> item.asMessage().content().stream()) + .filter(content -> content.outputText().isPresent()) + .map(content -> content.asOutputText()) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 응답에서 채용 공고 추출 결과를 찾을 수 없습니다." + )); + } + + private void validateInput(String rawText, MultipartFile imageFile) { + boolean hasRawText = rawText != null && !rawText.isBlank(); + boolean hasImage = imageFile != null && !imageFile.isEmpty(); + + if (!hasRawText && !hasImage) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "rawText 또는 image 중 하나는 반드시 포함되어야 합니다." + ); + } + } + + private void validateImage(MultipartFile imageFile) { + String contentType = imageFile.getContentType(); + if (contentType == null || !SUPPORTED_IMAGE_TYPES.contains(contentType.toLowerCase())) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다." + ); + } + } + + private void normalizeResponse(JobPostingExtractResponse response, String rawText) { + if (response == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 응답이 비어 있습니다." + ); + } + + if (response.getCompanyName() == null) { + response.setCompanyName(""); + } + if (response.getJobTitle() == null) { + response.setJobTitle(""); + } + if (response.getTask() == null) { + response.setTask(""); + } + if (response.getRequirements() == null) { + response.setRequirements(""); + } + if (response.getPreferredQualifications() == null) { + response.setPreferredQualifications(""); + } + if (response.getRawText() == null || response.getRawText().isBlank()) { + response.setRawText(rawText == null ? "" : rawText); + } + + double confidence = response.getConfidence(); + if (Double.isNaN(confidence) || Double.isInfinite(confidence)) { + response.setConfidence(0.0); + } else if (confidence < 0.0) { + response.setConfidence(0.0); + } else if (confidence > 1.0) { + response.setConfidence(1.0); + } + } + + private JobPostingExtractResponse createFallbackResponse(String rawText) { + return new JobPostingExtractResponse( + "", + "", + "", + "", + "", + rawText == null ? "" : rawText, + 0.0 + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/OpenAiConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/OpenAiConfig.java new file mode 100644 index 0000000..1d3e359 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/OpenAiConfig.java @@ -0,0 +1,25 @@ +package com.jobdri.jobdri_api.global.config; + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +public class OpenAiConfig { + + @Value("${openai.api.key}") + private String openAiApiKey; + + @Bean + public OpenAIClient openAIClient() { + return OpenAIOkHttpClient.builder() + .apiKey(openAiApiKey) + .timeout(Duration.ofSeconds(60)) + .maxRetries(2) + .build(); + } +} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index a80ba1c..00bf63a 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -74,3 +74,9 @@ jwt: expiration: access-token: ${JWT_ACCESS_TOKEN_EXPIRATION} refresh-token: ${JWT_REFRESH_TOKEN_EXPIRATION} + +openai: + api: + key: ${OPENAI_API_KEY} + model: + job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7d54f32..953538a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -63,3 +63,9 @@ jwt: expiration: access-token: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} refresh-token: ${JWT_REFRESH_TOKEN_EXPIRATION:1209600000} + +openai: + api: + key: ${OPENAI_API_KEY:} + model: + job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini}