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
76 changes: 76 additions & 0 deletions docs/fewshot-evaluation-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Few-shot 평가 메타데이터

후속 이슈 2번의 선택 이력 기록입니다. 운영 API·DB·기존 평가 CSV 컬럼은 변경하지 않습니다.
`EvaluationAnalysisRunner`가 사용하는 배치 서비스에서 실행마다 별도 JSONL 파일을 생성합니다.

## 출력 및 연결

평가 출력이 `evaluation_ai_results.csv`라면 같은 폴더에
`evaluation_ai_results.csv.fewshot.<runId>.jsonl`이 생성됩니다.
정확한 경로는 배치 시작 로그의 `Few-shot evaluation metadata output`에 표시됩니다.
실행별 새 파일이므로 이전 메타데이터를 덮어쓰지 않습니다.

- 한 줄은 입력 CSV의 데이터 행 하나에 대응합니다.
- `rowIndex`: 헤더를 제외한 1부터 시작하는 행 번호.
- `caseId`: 평가 입력의 ID. 중복 ID는 `rowIndex`로 구분합니다.
- `runId`: 한 실행 내에서 동일한 UUID.
- `outcome`: SUCCESS 또는 FAILED.
- `metadataStatus`: RECORDED 또는 UNAVAILABLE.
- `captureStage`: SELECTION_OBSERVED 또는 UNAVAILABLE.
- `selections`: 이 행에서 관측한 선택 스냅샷 배열.
- `schemaVersion=1`, `recordedAt`: sidecar 형식 버전 및 UTC 기록 시각.

행 처리 직후 flush하므로 뒤 행에서 실패해도 앞 행의 메타데이터는 남습니다.
비정상 종료 시 sidecar는 부분 결과일 수 있으며, CSV 생성까지 완료되었다는 뜻은 아닙니다.
sidecar 저장 실패는 평가 실행 실패로 전파됩니다.

## 선택 스냅샷

| selectionMode | 의미 | scoreType |
| --- | --- | --- |
| STATIC | 동적 선택 비활성, 기존 정적 전체 예시 | NONE |
| EMBEDDING | 임베딩 검색 후보 사용(캐시 반환 포함) | COSINE_SIMILARITY |
| LOCAL_FALLBACK | 로컬 기준으로 선택한 후보 사용 | LOCAL_HEURISTIC |
| STATIC_FALLBACK | 빈 결과·검색 예외 등으로 정적 전체 예시 복귀 | NONE |
| NOT_APPLIED | two-pass 경로에는 Few-shot 미적용 | NONE |

- `selectedCases`: 실제 조립한 예시의 ID, source, score, 후보 datasetVersion.
- `topScore/bottomScore/avgScore`: 최종 선택 후보의 최대·최소·평균 점수.
프롬프트 순서와 최대·최소 점수 순서는 다를 수 있습니다.
- `datasetVersion/minSimilarity/topK/minimumSelectedCount`: 실행의 선택 설정.
topK는 설정한 요청 개수이며 실제 개수는 selectedCases 배열 길이입니다.
- `reason`: 정적 선택·fallback·미적용 사유 코드. 예외 메시지 원문은 넣지 않습니다.

정적 예시 ID는 기존 로더와 같은 `FS-FIXED-1..N`입니다.
정적 후보의 datasetVersion은 `static-resource`로 표시하며, 검색 설정의 datasetVersion과 구분합니다.
정적 선택은 유사도 계산이 없으므로 후보 점수와 점수 통계는 **0이 아니라 null**입니다.
LOCAL_HEURISTIC 점수는 코사인 유사도와 섞어 분포를 비교하면 안 됩니다.

## 실제 호출과의 관계

프롬프트 조립 시 검색을 한 번 수행하고 그 선택 결과를 기록합니다.
메타데이터 수집을 위한 추가 Cohere/OpenAI 호출은 없습니다.
기록은 외부 AI 호출 전에 전달되므로, 시간 초과나 응답 검증 실패 행에도 선택 이력이 남습니다.
따라서 RECORDED는 **프롬프트 선택을 관측했다는 뜻**이며, API 호출 성공이나 토큰 소비를 보장하지 않습니다.

single-pass는 한 선택을, hybrid-exact는 single-pass 하위 호출의 선택을 기록합니다.
two-pass는 NOT_APPLIED를 기록합니다.
선택 전에 실패했거나 메타데이터를 지원하지 않는 다른 generator는 UNAVAILABLE로 기록하며
STATIC으로 추정하지 않습니다.

선택 스냅샷에는 자소서 원문, JD, 프롬프트 본문, 임베딩 벡터를 넣지 않습니다.
기존 평가 CSV의 원문 보존 동작은 바꾸지 않았으므로 CSV 접근 권한은 기존대로 관리해야 합니다.

## 다음 작업

3번 STATIC/DYNAMIC 비교 평가에서는 동일 모델·temperature·holdout·Judge 조건을 유지하고
각 실행의 CSV와 sidecar를 함께 보관합니다.
토큰 사용량·Cohere 호출 수·전체 비용 수집 및 실제 품질 비교는 이번 변경에 포함하지 않습니다.

## 검증

`./gradlew test --tests '*AnalysisAiClientTest' --tests '*Evaluation*' --tests '*fewshot.*' --tests '*FewShotMetadataPromptTest' --tests '*FewShotPromptProviderTest'`

192개 통과(실패·건너뜀 0). 선택 이력과 프롬프트의 일치, 로컬 점수 구분,
정적 fallback, 모드별 실패 전 기록, 실패 행 보존, 중복 caseId 행 구분,
기존 CSV 컬럼 유지 및 실행별 파일 분리를 확인했습니다. 외부 AI 호출은 수행하지 않았습니다.
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.jobdri.jobdri_api.domain.analysis.service.ai;

import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSelectionMetadata;
import java.util.function.Consumer;

import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse;
Expand Down Expand Up @@ -168,8 +171,20 @@ public AnalysisAiCallResult analyzeForEvaluationResult(
AnalysisPromptInput promptInput,
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria,
Instant deadline
) {
return analyzeForEvaluationResult(promptInput, jobCategoryEvaluationCriteria, deadline, ignored -> {});
}

public AnalysisAiCallResult analyzeForEvaluationResult(
AnalysisPromptInput promptInput,
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria,
Instant deadline,
Consumer<FewShotSelectionMetadata> recorder
) {
try {
if (resolveAnalysisMode() == AnalysisMode.TWO_PASS) {
recorder.accept(analysisPromptBuilder.fewShotNotApplied());
}
return switch (resolveAnalysisMode()) {
case TWO_PASS -> analyzeTwoPass(
promptInput,
Expand All @@ -183,14 +198,16 @@ public AnalysisAiCallResult analyzeForEvaluationResult(
emptyContext(),
jobCategoryEvaluationCriteria,
"cover-letter-analysis-evaluation",
deadline
deadline,
recorder
);
case SINGLE_PASS -> analyzeSinglePass(
promptInput,
emptyContext(),
jobCategoryEvaluationCriteria,
"cover-letter-analysis-evaluation",
deadline
deadline,
recorder
);
};
} catch (GeneralException e) {
Expand All @@ -210,11 +227,22 @@ private AnalysisAiCallResult analyzeSinglePass(
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria,
String operationName,
Instant deadline
) {
return analyzeSinglePass(promptInput, referenceContext, jobCategoryEvaluationCriteria, operationName, deadline, ignored -> {});
}

private AnalysisAiCallResult analyzeSinglePass(
AnalysisPromptInput promptInput,
RetrievalContext referenceContext,
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria,
String operationName,
Instant deadline,
Consumer<FewShotSelectionMetadata> recorder
) {
long startedAt = System.nanoTime();
AnalysisLlmResponse response = createStructuredResponse(
operationName,
buildPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria),
analysisPromptBuilder.buildSinglePassPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, recorder),
AnalysisLlmResponse.class,
deadline
);
Expand Down Expand Up @@ -318,13 +346,25 @@ private AnalysisAiCallResult analyzeHybridExact(
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria,
String operationName,
Instant deadline
) {
return analyzeHybridExact(promptInput, referenceContext, jobCategoryEvaluationCriteria, operationName, deadline, ignored -> {});
}

private AnalysisAiCallResult analyzeHybridExact(
AnalysisPromptInput promptInput,
RetrievalContext referenceContext,
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria,
String operationName,
Instant deadline,
Consumer<FewShotSelectionMetadata> recorder
) {
AnalysisAiCallResult singlePassResult = analyzeSinglePass(
promptInput,
referenceContext,
jobCategoryEvaluationCriteria,
operationName + "-single-pass",
deadline
deadline,
recorder
);
AnalysisAiCallResult twoPassResult = analyzeTwoPass(
promptInput,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchService;
import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSelectionMode;
import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.SelectedFewShotCase;
import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSelectionMetadata;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference;
Expand Down Expand Up @@ -516,12 +517,23 @@ String buildSinglePassPrompt(
AnalysisPromptInput promptInput,
RetrievalContext referenceContext,
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria
) {
return buildSinglePassPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, ignored -> {});
}

String buildSinglePassPrompt(
AnalysisPromptInput promptInput,
RetrievalContext referenceContext,
JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria,
java.util.function.Consumer<FewShotSelectionMetadata> recorder
) {
String questionText = formatQuestions(promptInput);
String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences());
String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences());
String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria);
String fewShotPromptBlock = resolveFewShotPromptBlock(promptInput);
FewShotPromptSelection selection = resolveFewShotPromptBlock(promptInput);
String fewShotPromptBlock = selection.prompt();
recorder.accept(selection.metadata());

return """
[시스템 지시]
Expand Down Expand Up @@ -605,9 +617,9 @@ String buildSinglePassPrompt(
);
}

private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) {
private FewShotPromptSelection resolveFewShotPromptBlock(AnalysisPromptInput promptInput) {
if (fewShotSearchService == null || fewShotProperties == null || !fewShotProperties.isDynamicSelectionEnabled()) {
return fewShotPromptProvider.getPrompt();
return staticSelection("STATIC", "dynamic_disabled");
}
try {
List<SelectedFewShotCase> selectedFewShots = fewShotSearchService.searchRelevantFewShots(
Expand All @@ -621,7 +633,7 @@ private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) {
promptInput.caseId(),
fewShotProperties.getDatasetVersion()
);
return fewShotPromptProvider.getPrompt();
return staticSelection("STATIC_FALLBACK", "empty_selection");
}
log.debug(
"dynamic few-shot prompt selected. caseId={}, selectedIds={}, sources={}, scores={}, datasetVersion={}",
Expand All @@ -631,7 +643,13 @@ private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) {
selectedFewShots.stream().map(item -> "%.4f".formatted(item.score())).toList(),
fewShotProperties.getDatasetVersion()
);
return fewShotPromptProvider.buildPromptBlock(selectedFewShots);
if (selectedFewShots.stream().anyMatch(item -> item == null || item.fewShotCase() == null
|| !org.springframework.util.StringUtils.hasText(item.fewShotCase().promptBlock())
|| !Double.isFinite(item.score()))) {
return staticSelection("STATIC_FALLBACK", "invalid_selection");
}
return new FewShotPromptSelection(fewShotPromptProvider.buildPromptBlock(selectedFewShots),
FewShotSelectionMetadata.selected(selectedFewShots, fewShotProperties));
} catch (Exception e) {
log.warn(
"dynamic few-shot selection failed. selectionMode={}, caseId={}, datasetVersion={}, reason={}, message={}",
Expand All @@ -642,10 +660,23 @@ private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) {
e.getMessage()
);
log.debug("dynamic few-shot selection exception", e);
return fewShotPromptProvider.getPrompt();
return staticSelection("STATIC_FALLBACK", "selection_exception");
}
}

private FewShotPromptSelection staticSelection(String mode, String reason) {
return new FewShotPromptSelection(fewShotPromptProvider.getPrompt(),
FewShotSelectionMetadata.staticSelection(mode, reason,
fewShotPromptProvider.getFixedExampleBlocks().size(), fewShotProperties));
}

FewShotSelectionMetadata fewShotNotApplied() {
return FewShotSelectionMetadata.staticSelection("NOT_APPLIED", "two_pass", 0, fewShotProperties);
}

private record FewShotPromptSelection(String prompt, FewShotSelectionMetadata metadata) {
}

private String formatJobCategoryEvaluationCriteriaSection(JobCategoryEvaluationCriteria criteria) {
if (criteria == null) {
return "";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot;

import java.util.List;
import java.util.stream.IntStream;

/** Prompt-selection snapshot only: never contains answers, JD text or embeddings. */
public record FewShotSelectionMetadata(
String selectionMode,
String reason,
String datasetVersion,
double minSimilarity,
int topK,
int minimumSelectedCount,
String scoreType,
List<Candidate> selectedCases,
Double topScore,
Double bottomScore,
Double avgScore
) {
public FewShotSelectionMetadata {
selectedCases = List.copyOf(selectedCases);
}

public record Candidate(String id, String source, Double score, String datasetVersion) {
}

public static FewShotSelectionMetadata selected(List<SelectedFewShotCase> selected, FewShotProperties properties) {
boolean embedding = selected.stream().allMatch(item -> "cohere-embedding".equals(item.selectionMethod()));
var scores = selected.stream().mapToDouble(SelectedFewShotCase::score).summaryStatistics();
return new FewShotSelectionMetadata(
embedding ? "EMBEDDING" : "LOCAL_FALLBACK", "", properties.getDatasetVersion(),
properties.getSearch().getMinSimilarity(), properties.getSearch().getTopK(),
properties.getSearch().getMinimumSelectedCount(), embedding ? "COSINE_SIMILARITY" : "LOCAL_HEURISTIC",
selected.stream().map(item -> new Candidate(item.fewShotCase().id(),
item.fewShotCase().source().name(), item.score(), item.fewShotCase().datasetVersion())).toList(),
scores.getMax(), scores.getMin(), scores.getAverage());
}

public static FewShotSelectionMetadata staticSelection(String mode, String reason, int exampleCount,
FewShotProperties configured) {
var properties = configured == null ? new FewShotProperties() : configured;
return new FewShotSelectionMetadata(mode, reason, properties.getDatasetVersion(),
properties.getSearch().getMinSimilarity(), properties.getSearch().getTopK(),
properties.getSearch().getMinimumSelectedCount(), "NONE",
IntStream.rangeClosed(1, exampleCount)
.mapToObj(i -> new Candidate("FS-FIXED-" + i, "FIXED", null, "static-resource")).toList(),
null, null, null);
}
}
Loading
Loading