diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index 654ce205..fe1a7fb5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -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 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); + } 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 diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index f9cf203f..4f1ca8c9 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -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개일 수 있음"); + } + @Test @DisplayName("1차 후보 프롬프트는 점수와 improvement 없이 후보 판정 규칙만 포함한다") void buildCandidatePromptIncludesCandidateRulesOnly() { @@ -334,6 +380,10 @@ void buildCandidatePromptIncludesCandidateRulesOnly() { .contains("실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다.") .contains("수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다.") .contains("자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다.") + .contains("[소제목 처리 규칙]") + .contains("한 줄 전체가 대괄호로 감싸진 형식") + .contains("소제목은 sentenceType을 분류하지 않고 strengthCandidates 또는 analysisCandidates에 넣지 않는다.") + .contains("소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 문맥으로만 사용") .contains("[strengthCandidates Positive Examples]") .contains("포토샵과 일러스트로 상세페이지를 제작") .contains("라이노와 Fusion360으로 제품을 설계") @@ -341,6 +391,40 @@ void buildCandidatePromptIncludesCandidateRulesOnly() { .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() {