Skip to content

Commit b541536

Browse files
committed
[Feat] 직무 분류 자동 기능 구현 (#27)
1 parent 7a68e39 commit b541536

7 files changed

Lines changed: 163 additions & 1 deletion

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com
2727
GOOGLE_CLIENT_SECRET=change-me
2828
APP_OAUTH2_REDIRECT_URI=http://localhost:3000/oauth2/redirect
2929
OPENAI_API_KEY=change-me
30+
JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65
3031

3132
MANAGEMENT_HEALTH_SHOW_DETAILS=always

.env.production.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com
3131
GOOGLE_CLIENT_SECRET=change-me
3232
APP_OAUTH2_REDIRECT_URI=https://your-frontend-domain/oauth2/redirect
3333
OPENAI_API_KEY=change-me
34+
JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65
3435

3536
MANAGEMENT_HEALTH_SHOW_DETAILS=never

src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingIngestService;
1010
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
1111
import io.swagger.v3.oas.annotations.Operation;
12+
import io.swagger.v3.oas.annotations.media.Content;
13+
import io.swagger.v3.oas.annotations.media.ExampleObject;
14+
import io.swagger.v3.oas.annotations.media.Schema;
15+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
1216
import io.swagger.v3.oas.annotations.tags.Tag;
1317
import jakarta.validation.Valid;
1418
import lombok.RequiredArgsConstructor;
@@ -60,6 +64,128 @@ public ApiResponse<JobPostingExtractResponse> extractJobPostingFromMultipart(
6064
summary = "채용 공고 추출부터 분류, 생성, 저장까지 일괄 처리",
6165
description = "이미지 또는 텍스트 공고를 추출하고, trigram 후보 검색과 AI 재분류를 거쳐 최종 소분류를 선택한 뒤 공고를 생성하고 저장합니다."
6266
)
67+
@ApiResponses(value = {
68+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
69+
responseCode = "200",
70+
description = "분류 confidence가 충분하여 저장까지 완료된 경우",
71+
content = @Content(
72+
mediaType = "application/json",
73+
schema = @Schema(implementation = ApiResponse.class),
74+
examples = @ExampleObject(value = """
75+
{
76+
"isSuccess": true,
77+
"code": "COMMON2000",
78+
"message": "채용 공고 추출 및 저장에 성공했습니다.",
79+
"result": {
80+
"savedToDatabase": true,
81+
"message": "채용 공고 추출 및 저장에 성공했습니다.",
82+
"extracted": {
83+
"companyName": "삼성전자",
84+
"jobTitle": "백엔드 개발자",
85+
"task": "백엔드 서비스 개발 및 운영",
86+
"requirements": "Java/Spring 기반 개발 경험",
87+
"preferredQualifications": "대용량 트래픽 처리 경험",
88+
"rawText": "채용 공고 원문 내용",
89+
"confidence": 0.92
90+
},
91+
"candidates": [
92+
{
93+
"detailClassificationId": 101,
94+
"detailClassificationName": "Java/Spring",
95+
"middleClassificationName": "백엔드",
96+
"bigClassificationName": "개발",
97+
"score": 0.91
98+
}
99+
],
100+
"classification": {
101+
"detailClassificationId": 101,
102+
"detailClassificationName": "Java/Spring",
103+
"middleClassificationName": "백엔드",
104+
"bigClassificationName": "개발",
105+
"reason": "Spring Boot, JPA, API 개발 맥락이 가장 강합니다.",
106+
"confidence": 0.87
107+
},
108+
"generated": {
109+
"companyName": "삼성전자",
110+
"jobTitle": "Java/Spring 백엔드 개발자",
111+
"task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선",
112+
"requirements": "Java/Spring 기반 개발 경험\\nRDB 사용 경험",
113+
"preferredQualifications": "대용량 트래픽 처리 경험\\nRedis 사용 경험",
114+
"summary": "서비스 백엔드 개발과 운영을 담당할 인재를 찾습니다."
115+
},
116+
"saved": {
117+
"jobPostingId": 10,
118+
"companyId": 3,
119+
"companyName": "삼성전자",
120+
"companySize": "ENTERPRISE",
121+
"detailClassificationId": 101,
122+
"detailClassificationName": "Java/Spring",
123+
"task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선",
124+
"requirement": "Java/Spring 기반 개발 경험\\nRDB 사용 경험",
125+
"preferred": "대용량 트래픽 처리 경험\\nRedis 사용 경험"
126+
}
127+
},
128+
"error": null
129+
}
130+
""")
131+
)
132+
),
133+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
134+
responseCode = "200",
135+
description = "분류 confidence가 낮아 저장을 보류한 경우",
136+
content = @Content(
137+
mediaType = "application/json",
138+
schema = @Schema(implementation = ApiResponse.class),
139+
examples = @ExampleObject(value = """
140+
{
141+
"isSuccess": true,
142+
"code": "COMMON2000",
143+
"message": "채용 공고 추출 및 저장에 성공했습니다.",
144+
"result": {
145+
"savedToDatabase": false,
146+
"message": "소분류 분류 confidence가 낮아 저장을 보류했습니다.",
147+
"extracted": {
148+
"companyName": "어떤회사",
149+
"jobTitle": "개발자",
150+
"task": "서비스 개발",
151+
"requirements": "개발 경험",
152+
"preferredQualifications": "우대 사항",
153+
"rawText": "채용 공고 원문 내용",
154+
"confidence": 0.79
155+
},
156+
"candidates": [
157+
{
158+
"detailClassificationId": 101,
159+
"detailClassificationName": "Java/Spring",
160+
"middleClassificationName": "백엔드",
161+
"bigClassificationName": "개발",
162+
"score": 0.62
163+
},
164+
{
165+
"detailClassificationId": 102,
166+
"detailClassificationName": "Node.js",
167+
"middleClassificationName": "백엔드",
168+
"bigClassificationName": "개발",
169+
"score": 0.58
170+
}
171+
],
172+
"classification": {
173+
"detailClassificationId": 101,
174+
"detailClassificationName": "Java/Spring",
175+
"middleClassificationName": "백엔드",
176+
"bigClassificationName": "개발",
177+
"reason": "후보 간 차이가 크지 않습니다.",
178+
"confidence": 0.49
179+
},
180+
"generated": null,
181+
"saved": null
182+
},
183+
"error": null
184+
}
185+
""")
186+
)
187+
)
188+
})
63189
@PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
64190
public ApiResponse<JobPostingIngestResponse> ingestJobPosting(
65191
@ModelAttribute JobPostingIngestMultipartRequest request

src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
@AllArgsConstructor
1010
public class JobPostingIngestResponse {
1111

12+
private boolean savedToDatabase;
13+
private String message;
1214
private JobPostingExtractResponse extracted;
1315
private List<JobPostingClassificationCandidateResponse> candidates;
1416
private JobPostingClassificationResultResponse classification;

src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
1313
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
1414
import lombok.RequiredArgsConstructor;
15+
import org.springframework.beans.factory.annotation.Value;
1516
import org.springframework.stereotype.Service;
1617
import org.springframework.transaction.annotation.Transactional;
1718

@@ -23,6 +24,9 @@ public class JobPostingIngestService {
2324

2425
private static final int DEFAULT_CANDIDATE_LIMIT = 10;
2526

27+
@Value("${job-posting.ingest.classification-confidence-threshold:0.65}")
28+
private double classificationConfidenceThreshold;
29+
2630
private final JobPostingAiService jobPostingAiService;
2731
private final JobPostingClassificationService jobPostingClassificationService;
2832
private final JobPostingService jobPostingService;
@@ -53,6 +57,18 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestMultipartRequest
5357
JobPostingClassificationResultResponse classification =
5458
jobPostingAiService.classifyDetailClassification(extracted, candidates);
5559

60+
if (classification.getConfidence() < classificationConfidenceThreshold) {
61+
return new JobPostingIngestResponse(
62+
false,
63+
"소분류 분류 confidence가 낮아 저장을 보류했습니다.",
64+
extracted,
65+
candidates,
66+
classification,
67+
null,
68+
null
69+
);
70+
}
71+
5672
JobPostingGenerateResponse generated = jobPostingAiService.generateJobPosting(
5773
new JobPostingGenerateRequest(
5874
extracted.getCompanyName(),
@@ -79,7 +95,15 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestMultipartRequest
7995
)
8096
);
8197

82-
return new JobPostingIngestResponse(extracted, candidates, classification, generated, saved);
98+
return new JobPostingIngestResponse(
99+
true,
100+
"채용 공고 추출 및 저장에 성공했습니다.",
101+
extracted,
102+
candidates,
103+
classification,
104+
generated,
105+
saved
106+
);
83107
}
84108

85109
private String fallbackCompanyName(String companyName) {

src/main/resources/application-prod.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,7 @@ openai:
8383
key: ${OPENAI_API_KEY}
8484
model:
8585
job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini}
86+
87+
job-posting:
88+
ingest:
89+
classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD}

src/main/resources/application.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,7 @@ openai:
7272
key: ${OPENAI_API_KEY:}
7373
model:
7474
job-posting-extractor: ${OPENAI_JOB_POSTING_MODEL:gpt-4o-mini}
75+
76+
job-posting:
77+
ingest:
78+
classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD}

0 commit comments

Comments
 (0)