Skip to content
Closed
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
Expand Up @@ -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;
Expand Down Expand Up @@ -216,6 +220,14 @@ public class AnalysisAiClient {
- missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다.
- missing은 questionAnalyses에 억지로 넣지 않는다.

[소제목 처리 규칙]
- 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다.
- 소제목 자체를 EXPERIENCE, PLAN, MOTIVATION, COMPETENCY 문장으로 분류하거나 questionAnalyses의 sentence로 반환하지 않는다.
- 소제목 자체를 keyStrengths의 quote 또는 keyWeaknesses의 근거로 반환하지 않는다.
- 소제목에 행동, 역할, 방법, 성과가 없다는 이유로 구체성이 부족하다고 판단하거나 점수를 감점하지 않는다.
- 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 맥락으로만 사용하고, 실제 평가는 본문 문장을 기준으로 한다.
- 소제목이 문단 내용을 요약하는 표현인지 여부는 문단 이해에만 참고하며, 소제목 문구 자체를 별도 첨삭 대상으로 만들지 않는다.

Comment on lines +223 to +230

Copy link
Copy Markdown

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는 모델 응답을 바로 반환합니다. sanitizeStrengthCandidatessanitizeAnalysisCandidates도 대괄호 소제목을 원문 부분 문자열로 인정합니다.

또한 2차 검토가 소제목 후보를 거절해도 recheckWhenAllCandidatesRejected가 이를 재검증 단계에서 다시 복원할 수 있습니다. buildRecheckPromptrecheckValidationFailure에는 소제목 차단 규칙이 없습니다.

한 줄 전체가 대괄호로 감싸진 항목을 식별하는 서버 측 판별을 추가하세요. 이 판별을 single-pass 결과, 1차 strength/analysis 후보, 재검증 복원 경로에 모두 적용하세요. 그렇지 않으면 모델 응답에 따라 PR 목표와 달리 소제목이 첨삭 또는 강점으로 노출됩니다.

Also applies to: 688-695, 867-867

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java`
around lines 223 - 230, 한 줄 전체가 대괄호로 감싸진 소제목을 서버에서 식별하고 모든 후보 경로에서 제외하도록 수정하세요.
analyzeSinglePass의 결과와 sanitizeStrengthCandidates, sanitizeAnalysisCandidates의
원문 부분 문자열 검증에 해당 판별을 적용하고, buildRecheckPrompt 및 recheckValidationFailure를 포함한
recheckWhenAllCandidatesRejected 복원 경로에서도 소제목이 다시 추가되지 않게 하세요.

[missingKeywords 규칙]
- 실제 입력 JD의 주요 업무, 자격 요건 원문에 존재하지만 자소서에 충분히 드러나지 않은 경험형 역량만 추출한다.
- 유사 JD 검색 결과, 직무별 보조 평가 기준, few-shot 예시, 모델의 일반 지식에서 키워드를 생성하지 않는다.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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가 될 수 있다.
Expand Down Expand Up @@ -842,6 +864,7 @@ String buildFinalPrompt(
- 단순 문체 선호 차이이거나 후보 분석이 자기소개서 질문 의도와 무관할 때 제거한다.
- 다짐이나 포부 문장을 성과 문장처럼 잘못 평가한 경우 제거한다.
- 자격증, 학력, 기술명 등 단순 정량 키워드가 없다는 이유만으로 문제 삼은 경우 제거한다.
- 한 줄 전체가 대괄호로 감싸진 소제목을 본문 문장처럼 평가한 후보는 NOT_ACTIONABLE로 거절한다.
- 수정안이 원문보다 실질적으로 개선되지 않으면 accepted=true로 두더라도 improvement는 null로 둔다.

[decision 정합성]
Expand Down Expand Up @@ -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 """
[시스템 지시]
Expand Down Expand Up @@ -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()),
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

FewShotPromptProvider에 없는 메서드를 호출합니다.

제공된 FewShotPromptProvider.java:13-43에는 getPrompt()만 있고 buildPromptBlock(List<SelectedFewShotCase>)가 없습니다. 이 호출은 컴파일되지 않습니다. FewShotPromptProvider에 해당 메서드를 구현하거나 기존 공개 API를 사용하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java`
at line 1209, Update the few-shot prompt construction in the relevant analysis
flow around selectedFewShots so it uses an API actually exposed by
FewShotPromptProvider. Prefer the existing getPrompt() method, or add
buildPromptBlock(List<SelectedFewShotCase>) to FewShotPromptProvider if that
behavior is required, ensuring the code compiles and preserves the intended
prompt output.

} 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
);
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

빈 결과와 검색 실패 fallback을 테스트하세요.

이 테스트는 선택 성공 경로만 검증합니다. resolveFewShotPromptBlock의 빈 결과 및 예외 fallback은 프롬프트 생성 가능성을 보장하는 동작입니다.

searchRelevantFewShots가 빈 목록을 반환하는 경우와 예외를 던지는 경우를 추가하세요. 두 경우 모두 고정 few-shot 블록이 포함되는지 검증하세요. 또한 호출 인자가 fewShotProperties.getSearch().getTopK()와 같은지 검증하세요.

As per path instructions: "해피패스만 있는 경우 경계 조건과 회귀 위험을 지적하세요."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java`
around lines 309 - 343, Expand
buildPromptUsesSelectedFewShotsWhenDynamicSelectionEnabled around
resolveFewShotPromptBlock to cover both an empty searchRelevantFewShots result
and an exception from that service, asserting each fallback prompt contains the
fixed few-shot block. Also verify searchRelevantFewShots is called with
fewShotProperties.getSearch().getTopK() as its top-k argument, while preserving
the existing successful-selection assertion.

Source: Path instructions


@Test
@DisplayName("1차 후보 프롬프트는 점수와 improvement 없이 후보 판정 규칙만 포함한다")
void buildCandidatePromptIncludesCandidateRulesOnly() {
Expand Down Expand Up @@ -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() {
Expand Down
Loading