Skip to content

Commit 289d712

Browse files
authored
Merge pull request #30 from JobDri-Developer/feat/#27-job-posting
[Feat] 실제 공고 지원 API (#27)
2 parents 2d0d5b6 + 1582ae5 commit 289d712

9 files changed

Lines changed: 367 additions & 0 deletions

File tree

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ dependencies {
4343
implementation 'org.springframework.retry:spring-retry'
4444
implementation 'org.springframework.boot:spring-boot-starter-aop'
4545

46+
//openai
47+
implementation 'com.openai:openai-java:4.35.0'
48+
4649

4750
compileOnly 'org.projectlombok:lombok'
4851
developmentOnly 'org.springframework.boot:spring-boot-devtools'
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.jobdri.jobdri_api.domain.jobposting.controller;
2+
3+
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest;
4+
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest;
5+
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse;
6+
import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService;
7+
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import jakarta.validation.Valid;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.MediaType;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestBody;
15+
import org.springframework.web.bind.annotation.ModelAttribute;
16+
import org.springframework.web.bind.annotation.RequestMapping;
17+
import org.springframework.web.bind.annotation.RestController;
18+
19+
@RestController
20+
@RequiredArgsConstructor
21+
@RequestMapping("/api/job-postings")
22+
@Tag(name = "JobPosting AI", description = "채용 공고 추출 AI API")
23+
public class JobPostingAiController {
24+
25+
private final JobPostingAiService jobPostingAiService;
26+
27+
@Operation(
28+
summary = "채용 공고 정보 추출",
29+
description = "채용 공고 원문 텍스트를 기반으로 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 AI로 추출합니다."
30+
)
31+
@PostMapping(value = "/extract", consumes = MediaType.APPLICATION_JSON_VALUE)
32+
public ApiResponse<JobPostingExtractResponse> extractJobPostingFromText(
33+
@Valid @RequestBody JobPostingExtractRequest request
34+
) {
35+
return ApiResponse.onSuccess(
36+
"채용 공고 추출에 성공했습니다.",
37+
jobPostingAiService.extractJobPosting(request.rawText())
38+
);
39+
}
40+
41+
@Operation(
42+
summary = "채용 공고 정보 추출(이미지 또는 텍스트)",
43+
description = "프론트에서 캡처한 채용 공고 이미지 파일과 선택적 텍스트, 원본 URL을 함께 보내면 AI가 구조화된 채용 공고 정보를 추출합니다."
44+
)
45+
@PostMapping(value = "/extract", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
46+
public ApiResponse<JobPostingExtractResponse> extractJobPostingFromMultipart(
47+
@ModelAttribute JobPostingExtractMultipartRequest request
48+
) {
49+
return ApiResponse.onSuccess(
50+
"채용 공고 추출에 성공했습니다.",
51+
jobPostingAiService.extractJobPosting(request)
52+
);
53+
}
54+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.jobdri.jobdri_api.domain.jobposting.dto.request;
2+
3+
import lombok.Getter;
4+
import lombok.Setter;
5+
import org.springframework.web.multipart.MultipartFile;
6+
7+
@Getter
8+
@Setter
9+
public class JobPostingExtractMultipartRequest {
10+
11+
private String rawText;
12+
private String sourceUrl;
13+
private MultipartFile image;
14+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.jobdri.jobdri_api.domain.jobposting.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
public record JobPostingExtractRequest(
6+
@NotBlank(message = "채용 공고 원문은 필수입니다.")
7+
String rawText
8+
) {
9+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.jobdri.jobdri_api.domain.jobposting.dto.response;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
import lombok.NoArgsConstructor;
6+
import lombok.Setter;
7+
8+
@Getter
9+
@Setter
10+
@NoArgsConstructor
11+
@AllArgsConstructor
12+
public class JobPostingExtractResponse {
13+
14+
private String companyName;
15+
private String jobTitle;
16+
private String task;
17+
private String requirements;
18+
private String preferredQualifications;
19+
private String rawText;
20+
private double confidence;
21+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package com.jobdri.jobdri_api.domain.jobposting.service;
2+
3+
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest;
4+
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse;
5+
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
6+
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
7+
import com.openai.client.OpenAIClient;
8+
import com.openai.models.responses.ResponseCreateParams;
9+
import com.openai.models.responses.ResponseInputContent;
10+
import com.openai.models.responses.ResponseInputImage;
11+
import com.openai.models.responses.ResponseInputItem;
12+
import com.openai.models.responses.StructuredResponse;
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.beans.factory.annotation.Value;
16+
import org.springframework.stereotype.Service;
17+
import org.springframework.web.multipart.MultipartFile;
18+
19+
import java.io.IOException;
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import java.util.Set;
23+
import java.util.Base64;
24+
25+
@Service
26+
@Slf4j
27+
@RequiredArgsConstructor
28+
public class JobPostingAiService {
29+
30+
private final OpenAIClient openAIClient;
31+
32+
@Value("${openai.model.job-posting-extractor:gpt-4o-mini}")
33+
private String extractionModel;
34+
35+
private static final Set<String> SUPPORTED_IMAGE_TYPES = Set.of(
36+
"image/png",
37+
"image/jpeg",
38+
"image/jpg",
39+
"image/webp",
40+
"image/gif"
41+
);
42+
43+
public JobPostingExtractResponse extractJobPosting(String rawText) {
44+
return extractJobPosting(rawText, null, null);
45+
}
46+
47+
public JobPostingExtractResponse extractJobPosting(JobPostingExtractMultipartRequest request) {
48+
return extractJobPosting(request.getRawText(), request.getImage(), request.getSourceUrl());
49+
}
50+
51+
public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile, String sourceUrl) {
52+
validateInput(rawText, imageFile);
53+
54+
List<ResponseInputContent> contents = new ArrayList<>();
55+
contents.add(ResponseInputContent.ofInputText(
56+
com.openai.models.responses.ResponseInputText.builder()
57+
.text(buildPrompt(rawText, sourceUrl, imageFile != null))
58+
.build()
59+
));
60+
61+
if (imageFile != null && !imageFile.isEmpty()) {
62+
contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageFile)));
63+
}
64+
65+
var params = ResponseCreateParams.builder()
66+
.model(extractionModel)
67+
.inputOfResponse(List.of(
68+
ResponseInputItem.ofMessage(
69+
ResponseInputItem.Message.builder()
70+
.role(ResponseInputItem.Message.Role.USER)
71+
.content(contents)
72+
.build()
73+
)
74+
))
75+
.temperature(0.1)
76+
.text(JobPostingExtractResponse.class)
77+
.build();
78+
79+
try {
80+
StructuredResponse<JobPostingExtractResponse> response = openAIClient.responses().create(params);
81+
JobPostingExtractResponse extracted = extractStructuredContent(response);
82+
83+
normalizeResponse(extracted, rawText);
84+
return extracted;
85+
86+
} catch (Exception e) {
87+
log.error("채용 공고 추출 OpenAI API 호출 오류: {}", e.getMessage(), e);
88+
return createFallbackResponse(rawText);
89+
}
90+
}
91+
92+
private String buildPrompt(String rawText, String sourceUrl, boolean hasImage) {
93+
String normalizedRawText = rawText == null ? "" : rawText;
94+
String normalizedSourceUrl = sourceUrl == null ? "" : sourceUrl;
95+
96+
return """
97+
이 %s는 채용 공고입니다.
98+
회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 추출해주세요.
99+
100+
반드시 아래 JSON 형식으로만 응답해주세요.
101+
설명 문장, 마크다운, 코드블럭은 포함하지 마세요.
102+
103+
{
104+
"companyName": "string",
105+
"jobTitle": "string",
106+
"task": "string",
107+
"requirements": "string",
108+
"preferredQualifications": "string",
109+
"rawText": "string",
110+
"confidence": number
111+
}
112+
113+
규칙:
114+
1. 이미지가 있으면 이미지 안의 채용 공고 문구를 읽어 rawText에 정리해주세요.
115+
2. 텍스트가 있으면 rawText에는 입력 원문을 최대한 그대로 넣어주세요.
116+
3. 이미지와 텍스트가 둘 다 있으면 둘을 함께 참고해서 가장 정확한 값으로 채워주세요.
117+
4. 정보가 없거나 확실하지 않으면 해당 필드는 빈 문자열로 두세요.
118+
5. confidence는 추출 결과 전체에 대한 신뢰도를 0~1 사이 실수로 반환하세요.
119+
6. JSON 외의 다른 텍스트는 절대 출력하지 마세요.
120+
121+
[원본 URL]
122+
%s
123+
124+
[채용 공고 텍스트]
125+
%s
126+
""".formatted(hasImage ? "이미지 또는 텍스트" : "텍스트", normalizedSourceUrl, normalizedRawText);
127+
}
128+
129+
private ResponseInputImage buildImageContent(MultipartFile imageFile) {
130+
validateImage(imageFile);
131+
132+
try {
133+
String contentType = imageFile.getContentType();
134+
String base64 = Base64.getEncoder().encodeToString(imageFile.getBytes());
135+
String dataUrl = "data:%s;base64,%s".formatted(contentType, base64);
136+
137+
return ResponseInputImage.builder()
138+
.imageUrl(dataUrl)
139+
.detail(ResponseInputImage.Detail.HIGH)
140+
.build();
141+
} catch (IOException e) {
142+
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다.");
143+
}
144+
}
145+
146+
private JobPostingExtractResponse extractStructuredContent(StructuredResponse<JobPostingExtractResponse> response) {
147+
return response.output().stream()
148+
.filter(item -> item.message().isPresent())
149+
.flatMap(item -> item.asMessage().content().stream())
150+
.filter(content -> content.outputText().isPresent())
151+
.map(content -> content.asOutputText())
152+
.findFirst()
153+
.orElseThrow(() -> new GeneralException(
154+
GeneralErrorCode.INTERNAL_SERVER_ERROR,
155+
"AI 응답에서 채용 공고 추출 결과를 찾을 수 없습니다."
156+
));
157+
}
158+
159+
private void validateInput(String rawText, MultipartFile imageFile) {
160+
boolean hasRawText = rawText != null && !rawText.isBlank();
161+
boolean hasImage = imageFile != null && !imageFile.isEmpty();
162+
163+
if (!hasRawText && !hasImage) {
164+
throw new GeneralException(
165+
GeneralErrorCode.INVALID_PARAMETER,
166+
"rawText 또는 image 중 하나는 반드시 포함되어야 합니다."
167+
);
168+
}
169+
}
170+
171+
private void validateImage(MultipartFile imageFile) {
172+
String contentType = imageFile.getContentType();
173+
if (contentType == null || !SUPPORTED_IMAGE_TYPES.contains(contentType.toLowerCase())) {
174+
throw new GeneralException(
175+
GeneralErrorCode.INVALID_PARAMETER,
176+
"지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다."
177+
);
178+
}
179+
}
180+
181+
private void normalizeResponse(JobPostingExtractResponse response, String rawText) {
182+
if (response == null) {
183+
throw new GeneralException(
184+
GeneralErrorCode.INTERNAL_SERVER_ERROR,
185+
"AI 응답이 비어 있습니다."
186+
);
187+
}
188+
189+
if (response.getCompanyName() == null) {
190+
response.setCompanyName("");
191+
}
192+
if (response.getJobTitle() == null) {
193+
response.setJobTitle("");
194+
}
195+
if (response.getTask() == null) {
196+
response.setTask("");
197+
}
198+
if (response.getRequirements() == null) {
199+
response.setRequirements("");
200+
}
201+
if (response.getPreferredQualifications() == null) {
202+
response.setPreferredQualifications("");
203+
}
204+
if (response.getRawText() == null || response.getRawText().isBlank()) {
205+
response.setRawText(rawText == null ? "" : rawText);
206+
}
207+
208+
double confidence = response.getConfidence();
209+
if (Double.isNaN(confidence) || Double.isInfinite(confidence)) {
210+
response.setConfidence(0.0);
211+
} else if (confidence < 0.0) {
212+
response.setConfidence(0.0);
213+
} else if (confidence > 1.0) {
214+
response.setConfidence(1.0);
215+
}
216+
}
217+
218+
private JobPostingExtractResponse createFallbackResponse(String rawText) {
219+
return new JobPostingExtractResponse(
220+
"",
221+
"",
222+
"",
223+
"",
224+
"",
225+
rawText == null ? "" : rawText,
226+
0.0
227+
);
228+
}
229+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.jobdri.jobdri_api.global.config;
2+
3+
import com.openai.client.OpenAIClient;
4+
import com.openai.client.okhttp.OpenAIOkHttpClient;
5+
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
9+
import java.time.Duration;
10+
11+
@Configuration
12+
public class OpenAiConfig {
13+
14+
@Value("${openai.api.key}")
15+
private String openAiApiKey;
16+
17+
@Bean
18+
public OpenAIClient openAIClient() {
19+
return OpenAIOkHttpClient.builder()
20+
.apiKey(openAiApiKey)
21+
.timeout(Duration.ofSeconds(60))
22+
.maxRetries(2)
23+
.build();
24+
}
25+
}

src/main/resources/application-prod.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,9 @@ jwt:
7474
expiration:
7575
access-token: ${JWT_ACCESS_TOKEN_EXPIRATION}
7676
refresh-token: ${JWT_REFRESH_TOKEN_EXPIRATION}
77+
78+
openai:
79+
api:
80+
key: ${OPENAI_API_KEY}
81+
model:
82+
job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini}

src/main/resources/application.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,9 @@ jwt:
6363
expiration:
6464
access-token: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000}
6565
refresh-token: ${JWT_REFRESH_TOKEN_EXPIRATION:1209600000}
66+
67+
openai:
68+
api:
69+
key: ${OPENAI_API_KEY:}
70+
model:
71+
job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini}

0 commit comments

Comments
 (0)