-
Notifications
You must be signed in to change notification settings - Fork 0
[Fix] 자기소개서 소제목을 첨삭 평가 대상에서 제외 #241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,10 @@ | |
| import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; | ||
| import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; | ||
| import com.jobdri.jobdri_api.domain.analysis.entity.Question; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchQuery; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchService; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.SelectedFewShotCase; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizer; | ||
|
|
@@ -216,6 +220,14 @@ public class AnalysisAiClient { | |
| - missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다. | ||
| - missing은 questionAnalyses에 억지로 넣지 않는다. | ||
|
|
||
| [소제목 처리 규칙] | ||
| - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. | ||
| - 소제목 자체를 EXPERIENCE, PLAN, MOTIVATION, COMPETENCY 문장으로 분류하거나 questionAnalyses의 sentence로 반환하지 않는다. | ||
| - 소제목 자체를 keyStrengths의 quote 또는 keyWeaknesses의 근거로 반환하지 않는다. | ||
| - 소제목에 행동, 역할, 방법, 성과가 없다는 이유로 구체성이 부족하다고 판단하거나 점수를 감점하지 않는다. | ||
| - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 맥락으로만 사용하고, 실제 평가는 본문 문장을 기준으로 한다. | ||
| - 소제목이 문단 내용을 요약하는 표현인지 여부는 문단 이해에만 참고하며, 소제목 문구 자체를 별도 첨삭 대상으로 만들지 않는다. | ||
|
|
||
| [missingKeywords 규칙] | ||
| - 실제 입력 JD의 주요 업무, 자격 요건 원문에 존재하지만 자소서에 충분히 드러나지 않은 경험형 역량만 추출한다. | ||
| - 유사 JD 검색 결과, 직무별 보조 평가 기준, few-shot 예시, 모델의 일반 지식에서 키워드를 생성하지 않는다. | ||
|
|
@@ -283,6 +295,8 @@ public class AnalysisAiClient { | |
| private final CorpusRetrievalService corpusRetrievalService; | ||
| private final LlmConcurrencyLimiter llmConcurrencyLimiter; | ||
| private final FewShotPromptProvider fewShotPromptProvider; | ||
| private final FewShotSearchService fewShotSearchService; | ||
| private final FewShotProperties fewShotProperties; | ||
| private final AsyncMetricsRecorder asyncMetricsRecorder; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
|
|
@@ -671,6 +685,14 @@ String buildCandidatePrompt( | |
| - status 다양성이나 개수를 채우기 위해 후보를 만들지 않는다. | ||
| - improvement를 생성하지 않는다. | ||
|
|
||
| [소제목 처리 규칙] | ||
| - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. | ||
| - 소제목은 sentenceType을 분류하지 않고 strengthCandidates 또는 analysisCandidates에 넣지 않는다. | ||
| - 소제목에 행동, 역할, 방법, 결과가 없다는 이유로 LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE 후보를 만들지 않는다. | ||
| - 소제목 자체의 구체성을 평가하거나 점수 근거로 사용하지 않는다. | ||
| - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 문맥으로만 사용하고, 후보 판정은 본문 문장을 기준으로 한다. | ||
| - 소제목이 문단을 요약하는지는 문단 이해에만 참고하며, 소제목 문구 자체를 첨삭 후보로 만들지 않는다. | ||
|
|
||
| [strengthCandidates 생성 규칙] | ||
| - 반드시 수치 성과가 있어야만 strengthCandidates가 되는 것은 아니다. | ||
| - JD와 직접 연결된다면 구체적인 도구·기술 사용 경험은 strengthCandidates가 될 수 있다. | ||
|
|
@@ -842,6 +864,7 @@ String buildFinalPrompt( | |
| - 단순 문체 선호 차이이거나 후보 분석이 자기소개서 질문 의도와 무관할 때 제거한다. | ||
| - 다짐이나 포부 문장을 성과 문장처럼 잘못 평가한 경우 제거한다. | ||
| - 자격증, 학력, 기술명 등 단순 정량 키워드가 없다는 이유만으로 문제 삼은 경우 제거한다. | ||
| - 한 줄 전체가 대괄호로 감싸진 소제목을 본문 문장처럼 평가한 후보는 NOT_ACTIONABLE로 거절한다. | ||
| - 수정안이 원문보다 실질적으로 개선되지 않으면 accepted=true로 두더라도 improvement는 null로 둔다. | ||
|
|
||
| [decision 정합성] | ||
|
|
@@ -1074,6 +1097,7 @@ String buildSinglePassPrompt( | |
| String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences()); | ||
| String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences()); | ||
| String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); | ||
| String fewShotPromptBlock = resolveFewShotPromptBlock(promptInput); | ||
|
|
||
| return """ | ||
| [시스템 지시] | ||
|
|
@@ -1137,13 +1161,14 @@ String buildSinglePassPrompt( | |
| - improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다. | ||
| - 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다. | ||
| - fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다. | ||
| - 한 줄 전체가 대괄호로 감싸진 소제목을 questionAnalyses 또는 keyStrengths에 포함하지 않았는지 확인한다. | ||
| - jobFit, impact, completeness는 0~100 정수로 출력한다. | ||
| - 총점 score는 서버가 jobFit 50%%, impact 30%%, completeness 20%%로 계산하므로 출력하지 않는다. | ||
| """.formatted( | ||
| OUTPUT_SCHEMA, | ||
| EVALUATION_CRITERIA, | ||
| STATUS_AND_WRITING_RULES.formatted(AnalysisImprovementRules.bannedPhrasesText()), | ||
| fewShotPromptProvider.getPrompt(), | ||
| fewShotPromptBlock, | ||
| defaultString(promptInput.companyName()), | ||
| defaultString(promptInput.jobName()), | ||
| defaultString(promptInput.mainTasks()), | ||
|
|
@@ -1156,6 +1181,45 @@ String buildSinglePassPrompt( | |
| ); | ||
| } | ||
|
|
||
| private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) { | ||
| if (fewShotSearchService == null || fewShotProperties == null || !fewShotProperties.isDynamicSelectionEnabled()) { | ||
| return fewShotPromptProvider.getPrompt(); | ||
| } | ||
| try { | ||
| List<SelectedFewShotCase> selectedFewShots = fewShotSearchService.searchRelevantFewShots( | ||
| FewShotSearchQuery.from(promptInput), | ||
| fewShotProperties.getSearch().getTopK() | ||
| ); | ||
| if (selectedFewShots.isEmpty()) { | ||
| log.warn( | ||
| "dynamic few-shot selection returned empty result. fallback=fixed, caseId={}, datasetVersion={}", | ||
| promptInput.caseId(), | ||
| fewShotProperties.getDatasetVersion() | ||
| ); | ||
| return fewShotPromptProvider.getPrompt(); | ||
| } | ||
| log.debug( | ||
| "dynamic few-shot prompt selected. caseId={}, selectedIds={}, sources={}, scores={}, datasetVersion={}", | ||
| promptInput.caseId(), | ||
| selectedFewShots.stream().map(item -> item.fewShotCase().id()).toList(), | ||
| selectedFewShots.stream().map(item -> item.fewShotCase().source()).toList(), | ||
| selectedFewShots.stream().map(item -> "%.4f".formatted(item.score())).toList(), | ||
| fewShotProperties.getDatasetVersion() | ||
| ); | ||
| return fewShotPromptProvider.buildPromptBlock(selectedFewShots); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
제공된 🤖 Prompt for AI Agents |
||
| } catch (Exception e) { | ||
| log.warn( | ||
| "dynamic few-shot selection failed. fallback=fixed, caseId={}, datasetVersion={}, reason={}, message={}", | ||
| promptInput.caseId(), | ||
| fewShotProperties.getDatasetVersion(), | ||
| e.getClass().getSimpleName(), | ||
| e.getMessage() | ||
| ); | ||
| log.debug("dynamic few-shot selection exception", e); | ||
| return fewShotPromptProvider.getPrompt(); | ||
| } | ||
| } | ||
|
|
||
| AnalysisCandidateResponse sanitizeCandidates( | ||
| AnalysisPromptInput promptInput, | ||
| AnalysisCandidateResponse candidates | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,12 @@ | |
| import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse.RejectionCode; | ||
| 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.analysis.service.ai.fewshot.FewShotProperties; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotCase; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotReviewStatus; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchService; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSource; | ||
| import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.SelectedFewShotCase; | ||
| import com.jobdri.jobdri_api.domain.company.entity.Company; | ||
| import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; | ||
| import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; | ||
|
|
@@ -30,11 +36,15 @@ | |
|
|
||
| class AnalysisAiClientTest { | ||
|
|
||
| private final FewShotSearchService fewShotSearchService = mock(FewShotSearchService.class); | ||
| private final FewShotProperties fewShotProperties = new FewShotProperties(); | ||
| private final AnalysisAiClient analysisAiClient = new AnalysisAiClient( | ||
| mock(OpenAIClient.class), | ||
| mock(CorpusRetrievalService.class), | ||
| mock(LlmConcurrencyLimiter.class), | ||
| new FewShotPromptProvider(), | ||
| fewShotSearchService, | ||
| fewShotProperties, | ||
| mock(AsyncMetricsRecorder.class), | ||
| new ObjectMapper() | ||
| ); | ||
|
|
@@ -296,6 +306,42 @@ void buildPromptIncludesFewShotV4Rules() { | |
| .isLessThan(prompt.indexOf("[자소서 문항과 답변]")); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("dynamic few-shot이 활성화되면 선택된 예시만 single-pass 프롬프트에 포함한다") | ||
| void buildPromptUsesSelectedFewShotsWhenDynamicSelectionEnabled() { | ||
| fewShotProperties.setDynamicSelectionEnabled(true); | ||
| FewShotCase selectedCase = new FewShotCase( | ||
| "FS-DYNAMIC-1", | ||
| FewShotSource.CURATED, | ||
| FewShotReviewStatus.APPROVED, | ||
| true, | ||
| 0, | ||
| "백엔드 개발", | ||
| "Backend Engineer", | ||
| List.of("API 개발"), | ||
| List.of("Spring Boot"), | ||
| "직무 경험", | ||
| "API를 개발했습니다.", | ||
| "{\"questionAnalyses\":[]}", | ||
| List.of("api"), | ||
| "fewshot-test-v1", | ||
| "## 예시 Z: 동적 선택 예시\n출력 중 문장/누락 관련 필드:\n{}" | ||
| ); | ||
| when(fewShotSearchService.searchRelevantFewShots(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt())) | ||
| .thenReturn(List.of(new SelectedFewShotCase(selectedCase, 0.91, "test"))); | ||
|
|
||
| String prompt = analysisAiClient.buildPrompt( | ||
| mockJobPosting(), | ||
| List.of(mockQuestion()), | ||
| new RetrievalContext(List.of(), List.of()), | ||
| null | ||
| ); | ||
|
|
||
| assertThat(prompt) | ||
| .contains("## 예시 Z: 동적 선택 예시") | ||
| .doesNotContain("## 예시 A: 좋은 근거가 있으면 보완 문장이 0개일 수 있음"); | ||
| } | ||
|
Comment on lines
+309
to
+343
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 빈 결과와 검색 실패 fallback을 테스트하세요. 이 테스트는 선택 성공 경로만 검증합니다.
As per path instructions: "해피패스만 있는 경우 경계 조건과 회귀 위험을 지적하세요." 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| @Test | ||
| @DisplayName("1차 후보 프롬프트는 점수와 improvement 없이 후보 판정 규칙만 포함한다") | ||
| void buildCandidatePromptIncludesCandidateRulesOnly() { | ||
|
|
@@ -334,13 +380,51 @@ void buildCandidatePromptIncludesCandidateRulesOnly() { | |
| .contains("실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다.") | ||
| .contains("수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다.") | ||
| .contains("자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다.") | ||
| .contains("[소제목 처리 규칙]") | ||
| .contains("한 줄 전체가 대괄호로 감싸진 형식") | ||
| .contains("소제목은 sentenceType을 분류하지 않고 strengthCandidates 또는 analysisCandidates에 넣지 않는다.") | ||
| .contains("소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 문맥으로만 사용") | ||
| .contains("[strengthCandidates Positive Examples]") | ||
| .contains("포토샵과 일러스트로 상세페이지를 제작") | ||
| .contains("라이노와 Fusion360으로 제품을 설계") | ||
| .contains("급여 정산표를 만들어 함수로 자동 비교") | ||
| .contains("5만 원 시재 차이 원인을 거래 자료 대조로 추적"); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("단일 패스 프롬프트는 대괄호 소제목을 평가와 첨삭 대상에서 제외한다") | ||
| void buildSinglePassPromptExcludesBracketedSubheadingsFromEvaluation() { | ||
| String prompt = analysisAiClient.buildPrompt( | ||
| mockJobPosting(), | ||
| List.of(mockQuestion()), | ||
| new RetrievalContext(List.of(), List.of()), | ||
| null | ||
| ); | ||
|
|
||
| assertThat(prompt) | ||
| .contains("[소제목 처리 규칙]") | ||
| .contains("본문 문장이 아니라 소제목이다.") | ||
| .contains("questionAnalyses의 sentence로 반환하지 않는다.") | ||
| .contains("keyStrengths의 quote 또는 keyWeaknesses의 근거로 반환하지 않는다.") | ||
| .contains("실제 평가는 본문 문장을 기준으로 한다.") | ||
| .contains("소제목 문구 자체를 별도 첨삭 대상으로 만들지 않는다."); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("2차 검토 프롬프트는 잘못 유입된 대괄호 소제목 후보를 거절한다") | ||
| void buildFinalPromptRejectsBracketedSubheadingCandidate() { | ||
| String prompt = analysisAiClient.buildFinalPrompt( | ||
| promptInput(), | ||
| new RetrievalContext(List.of(), List.of()), | ||
| null, | ||
| new AnalysisCandidateResponse(List.of(), List.of(), List.of()) | ||
| ); | ||
|
|
||
| assertThat(prompt) | ||
| .contains("한 줄 전체가 대괄호로 감싸진 소제목") | ||
| .contains("NOT_ACTIONABLE로 거절한다."); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("후보 sanitizer는 1차 후보의 원문, source, status, 개수를 검증한다") | ||
| void sanitizeCandidatesFiltersInvalidCandidateItems() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
소제목 제외를 서버 검증으로 강제하세요.
현재 규칙은 프롬프트에만 있습니다.
analyzeSinglePass는 모델 응답을 바로 반환합니다.sanitizeStrengthCandidates와sanitizeAnalysisCandidates도 대괄호 소제목을 원문 부분 문자열로 인정합니다.또한 2차 검토가 소제목 후보를 거절해도
recheckWhenAllCandidatesRejected가 이를 재검증 단계에서 다시 복원할 수 있습니다.buildRecheckPrompt와recheckValidationFailure에는 소제목 차단 규칙이 없습니다.한 줄 전체가 대괄호로 감싸진 항목을 식별하는 서버 측 판별을 추가하세요. 이 판별을 single-pass 결과, 1차 strength/analysis 후보, 재검증 복원 경로에 모두 적용하세요. 그렇지 않으면 모델 응답에 따라 PR 목표와 달리 소제목이 첨삭 또는 강점으로 노출됩니다.
Also applies to: 688-695, 867-867
🤖 Prompt for AI Agents