Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.jobdri.jobdri_api.domain.analysis.controller;

import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse;
import com.jobdri.jobdri_api.domain.analysis.service.AnalysisService;
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import com.jobdri.jobdri_api.global.security.UserDetailsImpl;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/mock-applies/{mockApplyId}/analysis")
@Tag(name = "Analysis", description = "자소서 분석 API")
public class AnalysisController {

private final AnalysisService analysisService;

@Operation(summary = "자소서 분석 실행", description = "저장된 문항 답변과 공고 정보를 기반으로 자소서를 분석하고 결과를 저장합니다.")
@PostMapping
public ApiResponse<AnalysisResponse> analyze(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long mockApplyId
) {
return ApiResponse.onSuccess(
"자소서 분석이 완료되었습니다.",
analysisService.analyze(getAuthenticatedUser(userDetails), mockApplyId)
);
}

@Operation(summary = "자소서 분석 결과 조회", description = "저장된 자소서 분석 결과를 조회합니다.")
@GetMapping
public ApiResponse<AnalysisResponse> getAnalysis(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long mockApplyId
) {
return ApiResponse.onSuccess(
"자소서 분석 결과 조회에 성공했습니다.",
analysisService.getAnalysis(getAuthenticatedUser(userDetails), mockApplyId)
);
}

private com.jobdri.jobdri_api.domain.user.entity.User getAuthenticatedUser(UserDetailsImpl userDetails) {
if (userDetails == null || userDetails.getUser() == null) {
throw new GeneralException(GeneralErrorCode.MISSING_AUTH_INFO, "인증 정보가 누락되었습니다.");
}
return userDetails.getUser();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.jobdri.jobdri_api.domain.analysis.dto.llm;

import java.util.List;

public record AnalysisLlmResponse(
Integer score,
Integer jobFit,
Integer impact,
Integer completeness,
String feedback,
List<QuestionAnalysisItem> questionAnalyses
) {
public record QuestionAnalysisItem(
Long questionId,
String sentence,
String reason,
String improvement
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.jobdri.jobdri_api.domain.analysis.dto.response;

import com.jobdri.jobdri_api.domain.analysis.entity.Question;

import java.util.List;

public record AnalysisQuestionResponse(
Long questionId,
String questionContent,
String answer,
List<QuestionAnalysisResponse> analyses
) {
public static AnalysisQuestionResponse of(
Question question,
List<QuestionAnalysisResponse> analyses
) {
return new AnalysisQuestionResponse(
question.getId(),
question.getContent(),
question.getAnswer(),
analyses
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.jobdri.jobdri_api.domain.analysis.dto.response;

import com.jobdri.jobdri_api.domain.analysis.entity.Analysis;
import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus;

import java.util.List;

public record AnalysisResponse(
Long mockApplyId,
Long analysisId,
MockApplyStatus status,
int score,
int jobFit,
int impact,
int completeness,
String feedback,
List<AnalysisQuestionResponse> questions
) {
public static AnalysisResponse of(
Analysis analysis,
MockApplyStatus status,
List<AnalysisQuestionResponse> questions
) {
return new AnalysisResponse(
analysis.getMockApply().getId(),
analysis.getId(),
status,
analysis.getScore(),
analysis.getJobFit(),
analysis.getImpact(),
analysis.getCompleteness(),
analysis.getFeedback(),
questions
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.jobdri.jobdri_api.domain.analysis.dto.response;

import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis;

public record QuestionAnalysisResponse(
Long questionAnalysisId,
String sentence,
String reason,
String improvement,
int start,
int end
) {
public static QuestionAnalysisResponse from(QuestionAnalysis questionAnalysis) {
return new QuestionAnalysisResponse(
questionAnalysis.getId(),
questionAnalysis.getSentence(),
questionAnalysis.getReason(),
questionAnalysis.getImprovement(),
questionAnalysis.getStart(),
questionAnalysis.getEnd()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,13 @@ public static Analysis create(
int completeness,
String feedback
) {
Analysis analysis = Analysis.builder()
return Analysis.builder()
.mockApply(mockApply)
.score(score)
.jobFit(jobFit)
.impact(impact)
.completeness(completeness)
.feedback(feedback)
.build();
mockApply.assignAnalysis(analysis);
return analysis;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@
public interface QuestionAnalysisRepository extends JpaRepository<QuestionAnalysis, Long> {
List<QuestionAnalysis> findAllByQuestionId(Long questionId);
List<QuestionAnalysis> findAllByAnalysisId(Long analysisId);
List<QuestionAnalysis> findAllByAnalysisIdOrderByQuestionIdAscIdAsc(Long analysisId);
void deleteAllByAnalysisId(Long analysisId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package com.jobdri.jobdri_api.domain.analysis.service;

import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse;
import com.jobdri.jobdri_api.domain.analysis.entity.Question;
import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting;
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.StructuredResponse;
import com.openai.models.responses.StructuredResponseOutputMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@Slf4j
@RequiredArgsConstructor
public class AnalysisAiClient {

private final OpenAIClient openAIClient;

@Value("${openai.model.cover-letter-analysis:gpt-4o-mini}")
private String analysisModel;

public AnalysisLlmResponse analyze(JobPosting jobPosting, List<Question> questions) {
var params = ResponseCreateParams.builder()
.model(analysisModel)
.input(buildPrompt(jobPosting, questions))
.temperature(0.2)
.text(AnalysisLlmResponse.class)
.build();

try {
StructuredResponse<AnalysisLlmResponse> response = openAIClient.responses().create(params);
return extractStructuredContent(response);
} catch (GeneralException e) {
throw e;
} catch (Exception e) {
log.error("자소서 분석 OpenAI API 호출 오류: {}", e.getMessage(), e);
throw new GeneralException(
GeneralErrorCode.SERVICE_UNAVAILABLE,
"자소서 분석 AI 호출에 실패했습니다."
);
}
}

private String buildPrompt(JobPosting jobPosting, List<Question> questions) {
String questionText = questions.stream()
.map(question -> """
- questionId: %d
question: %s
answer: %s
""".formatted(
question.getId(),
defaultString(question.getContent()),
defaultString(question.getAnswer())
))
.reduce("", (left, right) -> left + "\n" + right);

return """
[시스템 지시]
너는 한국 채용 담당자이자 자기소개서 평가 전문가다.
반드시 JSON만 출력한다.
자소서 원문에 없는 sentence를 만들지 않는다.
sentence는 반드시 해당 question의 answer에 포함된 정확한 부분 문자열이어야 한다.

[출력 형식]
{
"score": 64,
"jobFit": 70,
"impact": 55,
"completeness": 67,
"feedback": "한 줄 피드백",
"questionAnalyses": [
{
"questionId": 1,
"sentence": "자소서 답변 안에 실제 존재하는 정확한 부분 문자열",
"reason": "문제 이유",
"improvement": "개선 예시 문장"
}
]
}

[평가 절차]
1. JD의 주요 업무, 자격 요건, 우대 사항을 읽고 핵심 역량을 정리한다.
2. 각 문항 답변이 JD와 얼마나 연결되는지 평가한다.
3. 주장, 경험, 성과가 구체적 근거로 입증되는지 평가한다.
4. 질문에 맞게 답했는지, 문장 흐름과 완성도가 충분한지 평가한다.
5. 보완이 필요한 원문 문장을 최대 2~3개만 추출한다.

[점수 기준]
- 85~100: 매우 우수
- 70~84: 양호
- 55~69: 개선 필요
- 40~54: 대폭 수정 필요
- 40 미만: 직무/JD와 거의 무관

[세부 기준]
- jobFit: JD와 직무 역량 매칭
- impact: 성과 구체성, 수치, 결과
- completeness: 문장 완성도, 논리 흐름, 질문 적합성

[상태 라벨 참고]
- proven: 구체적 경험/수치로 충분히 입증됨
- mentioned: 관련 내용을 언급은 했지만 구체 근거가 부족함
- missing: 자소서에서 아예 다루지 않음
- fabricated: 주장은 하지만 신뢰할 수 있는 근거가 부족함

[약점 유형 참고]
unsupported_claim, vague_evidence, exaggeration, missing_outcome

[채용 공고]
회사명: %s
직무명: %s
주요 업무:
%s

자격 요건:
%s

우대 사항:
%s

[자소서 문항과 답변]
%s

[중요 규칙]
- JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다.
- questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다.
- sentence는 answer에 포함된 정확한 substring만 사용한다.
- start/end index는 출력하지 않는다. 서버가 Java에서 계산한다.
- 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다.
""".formatted(
defaultString(jobPosting.getCompany().getName()),
defaultString(jobPosting.getDetailClassification().getDetailName()),
defaultString(jobPosting.getTask()),
defaultString(jobPosting.getRequirement()),
defaultString(jobPosting.getPreferred()),
questionText
);
}

private AnalysisLlmResponse extractStructuredContent(StructuredResponse<AnalysisLlmResponse> response) {
return response.output().stream()
.filter(item -> item.message().isPresent())
.flatMap(item -> item.asMessage().content().stream())
.filter(content -> content.outputText().isPresent())
.map(StructuredResponseOutputMessage.Content::asOutputText)
.findFirst()
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.INTERNAL_SERVER_ERROR,
"AI 응답에서 자소서 분석 결과를 찾을 수 없습니다."
));
}

private String defaultString(String value) {
return value == null ? "" : value;
}
}
Loading
Loading