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
49 changes: 48 additions & 1 deletion docs/fewshot-reviewed-loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- jobTitle을 보존하고, 없는 기존 CSV만 jobCategorySmall로 대체합니다.
- 우대사항을 예시 프롬프트에 포함합니다.
- approvedAnalysisJson은 단일 JSON 객체여야 합니다. 잘못된 행만 제외하고 다음 행을 읽습니다.
이 검사는 JSON 문법과 최상위 객체 검사이며 분석 내용의 의미 검증은 아닙니다.
검수 CSV와 JSON 리소스 모두 아래 부분 분석 계약을 검증합니다. 분석 의미의 최종 승인은 PM에게 있습니다.
- 활성·승인·caseId·문항·비식별 답변 검증을 유지합니다.

## 로컬 평가 설정
Expand Down Expand Up @@ -40,3 +40,50 @@ analysis.few-shot.reviewed-evaluation-csv-path=/absolute/path/fewshot_candidates
`./gradlew test --tests '*fewshot.*'`: 27개 성공.
선택 서비스의 기존 캐시·입력 제외 회귀 테스트와 CSV 로더의 선택 JD 필드,
직무명 호환, 우대사항 보존, 잘못된 JSON 행 이후 정상 행 적재를 확인했습니다.

## 2026-09-15 승인 데이터 연결 및 검증 보강

- 기존 변환본 `fewshot-pm-reviewed-20260914-v2`의 FS-02·03·05·08·09, 총 5개를
`analysis/fewshot/reviewed-fewshot-cases-pm-20260914-v2.json`에 반영했습니다.
새 승인을 만들어낸 것이 아니라 기존 승인·비식별 통과 데이터만 연결했습니다.
동일한 데이터이므로 버전은 v2를 유지합니다. 후속 데이터 변경 시 새 버전을 사용합니다.
- PM 우선순위 1이 최상위라는 확인은 변환본의 `10-rank` 값으로 이미 반영되어 있습니다.
검색기는 큰 priority를 우선하므로 추가로 역전하지 않습니다.
- 기존 점수는 예시에 넣지 않았으며 보류 사례·문장도 다시 활성화하지 않았습니다.
- 데이터 및 프롬프트의 이메일·국내 휴대전화·HTTP URL 패턴 검사에서 일치 항목이 없었습니다.
이는 이름·소속·경험 조합을 통한 재식별 위험까지 보장하는 개인정보 정책 검증은 아닙니다.
원본 승인 범위는 유지하며 5번 이슈에서 정책을 별도로 확정해야 합니다.

### 부분 분석 계약

- `keyStrengths`, `missingKeywords`, `questionAnalyses`: 필수 배열. 빈 배열 허용.
- 강점 항목: 비어 있지 않은 문자열 `title`, `quote`.
- 누락 항목: 비어 있지 않은 `keyword`, 출처는 `mainTask|qualification|preference`.
- 문장 항목: 양의 정수 `questionId`, 비어 있지 않은 `sentence`, `reason`,
`status=proven|mentioned|fabricated`, `improvement`는 null 또는 비어 있지 않은 문자열.
- 점수·feedback은 부분 Few-shot 계약의 필수 필드가 아닙니다.
- CSV의 필수 헤더, 중복 헤더, 열 개수, 닫히지 않은 따옴표도 검사합니다.
구조상 행 경계를 신뢰할 수 없는 CSV는 파일 단위로 제외합니다.
- JSON 리소스 내부 source로 승인 데이터 검증을 우회할 수 없도록 로딩 경로의 source를 적용합니다.
- 검증 통과 후 ID와 정규화 입력을 등록합니다. 같은 입력의 첫 유효 후보만 유지합니다.
기존 검색 단계와 동일한 NFKC·공백·대소문자 정규화 및 SHA-256을 재사용합니다.
입력 지문은 기존 호환성을 위해 주요 업무·자격요건·문항·답변을 기준으로 하며
우대사항만 다른 입력은 같은 입력으로 보수적으로 취급합니다.
- 파싱 오류 로그에는 답변이 포함될 수 있는 예외 메시지를 남기지 않습니다.

### 평가 프로필

`analysis-eval,fewshot-pm-eval`을 명시적으로 함께 선택하면 검수 후보 5개만 사용합니다.
이 전용 프로필은 `analysis.mode=single-pass`로 지정합니다. two-pass에는 Few-shot이 적용되지 않습니다.
운영 기본 프로필이나 기본 feature flag는 변경하지 않았습니다.

STATIC 비교 시 동일 프로필에서 `analysis.few-shot.dynamic-selection-enabled=false`만 덮어씁니다.
STATIC 또는 동적 실패 시 기존 정적 전체 블록이 사용되므로, 사용 후보 추적과 fallback 구분은
후속 2번 메타데이터 기록 작업이 완료된 뒤 품질 평가에서 확인해야 합니다.
holdout은 후보 원본 및 단순 비식별 변형과 분리하세요. 해시 검사는 유사문·비식별 전후 변형까지
탐지하지 못하므로 데이터 분할 검토를 대체하지 않습니다.

이번 작업에서는 외부 AI 호출, 운영 활성화, 실제 품질 비교 평가를 수행하지 않았습니다.

검증: `./gradlew test --tests '*fewshot.*' --tests '*AnalysisPromptBuilderTest' --tests '*FewShotPromptProviderTest' --tests '*Evaluation*'`
149개 통과(실패·건너뜀 0). `git diff --check` 통과.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.Normalizer;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
Expand All @@ -33,7 +32,6 @@ public class DefaultFewShotSearchService implements FewShotSearchService {
private static final long QUERY_EMBEDDING_CACHE_CLEANUP_INTERVAL_MILLIS = 60_000L;
private static final long DEFAULT_QUERY_EMBEDDING_IN_FLIGHT_WAIT_TIMEOUT_MILLIS = 20_000L;
private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^\\p{IsAlphabetic}\\p{IsDigit}가-힣]+");
private static final Pattern NORMALIZED_INPUT_WHITESPACE_PATTERN = Pattern.compile("[\\p{Z}\\s]+");

private final FewShotCaseStore caseStore;
private final FewShotSearchTextBuilder textBuilder;
Expand Down Expand Up @@ -383,7 +381,7 @@ private void initializeMissingDocumentEmbeddings(List<PendingDocumentEmbedding>

private List<FewShotCase> localPrefilter(List<FewShotCase> activeCases, FewShotSearchQuery query) {
int limit = Math.max(1, properties.getSearch().getCandidateLimit());
String queryInputHash = normalizedInputHash(
String queryInputHash = FewShotInputHash.of(
query.mainTasks(),
query.qualifications(),
query.question(),
Expand Down Expand Up @@ -509,7 +507,7 @@ private static boolean sameNormalizedInput(String queryInputHash, FewShotCase fe
if (!StringUtils.hasText(queryInputHash)) {
return false;
}
String candidateInputHash = normalizedInputHash(
String candidateInputHash = FewShotInputHash.of(
fewShotCase.mainTasks(),
fewShotCase.qualifications(),
fewShotCase.question(),
Expand All @@ -518,42 +516,6 @@ private static boolean sameNormalizedInput(String queryInputHash, FewShotCase fe
return queryInputHash.equals(candidateInputHash);
}

private static String normalizedInputHash(
List<String> mainTasks,
List<String> qualifications,
String question,
String answer
) {
String normalizedMainTasks = normalizeInputSection(mainTasks == null ? "" : String.join("\n", mainTasks));
String normalizedQualifications = normalizeInputSection(
qualifications == null ? "" : String.join("\n", qualifications)
);
String normalizedQuestion = normalizeInputSection(question);
String normalizedAnswer = normalizeInputSection(answer);
if (normalizedMainTasks.isEmpty()
&& normalizedQualifications.isEmpty()
&& normalizedQuestion.isEmpty()
&& normalizedAnswer.isEmpty()) {
return "";
}
return sha256(
normalizedMainTasks + '\u001f'
+ normalizedQualifications + '\u001f'
+ normalizedQuestion + '\u001f'
+ normalizedAnswer
);
}

private static String normalizeInputSection(String value) {
if (!StringUtils.hasText(value)) {
return "";
}
String unicodeNormalized = Normalizer.normalize(value, Normalizer.Form.NFKC);
return NORMALIZED_INPUT_WHITESPACE_PATTERN.matcher(unicodeNormalized)
.replaceAll(" ")
.trim()
.toLowerCase(Locale.ROOT);
}

private static String defaultString(String value) {
return value == null ? "" : value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,15 @@ private List<FewShotCase> loadJsonCases(String resourcePath, FewShotSource expec
List<FewShotCase> cases = objectMapper.readValue(json, new TypeReference<>() {
});
return cases.stream()
.filter(java.util.Objects::nonNull)
.map(fewShotCase -> normalizeSource(fewShotCase, expectedSource))
.toList();
} catch (IOException | RuntimeException e) {
log.warn(
"few-shot resource parse failed. source={}, resource={}, message={}",
"few-shot resource parse failed. source={}, resource={}, errorType={}",
expectedSource,
resourcePath,
e.getMessage()
e.getClass().getSimpleName()
);
return List.of();
}
Expand All @@ -119,14 +120,11 @@ private List<FewShotCase> loadJsonCases(String resourcePath, FewShotSource expec
private List<FewShotCase> filterSearchable(List<FewShotCase> cases) {
List<FewShotCase> result = new ArrayList<>();
Set<String> ids = new HashSet<>();
Set<String> inputHashes = new HashSet<>();
for (FewShotCase fewShotCase : cases) {
if (fewShotCase == null || !StringUtils.hasText(fewShotCase.id())) {
continue;
}
if (!ids.add(fewShotCase.id())) {
log.warn("few-shot case skipped. reason=duplicate_case_id, id={}, source={}", fewShotCase.id(), fewShotCase.source());
continue;
}
if (!fewShotCase.searchable()) {
log.debug("few-shot case skipped. reason=not_searchable, id={}, source={}", fewShotCase.id(), fewShotCase.source());
continue;
Expand All @@ -135,6 +133,25 @@ private List<FewShotCase> filterSearchable(List<FewShotCase> cases) {
log.debug("few-shot case skipped. reason=blank_prompt_block, id={}, source={}", fewShotCase.id(), fewShotCase.source());
continue;
}
if (fewShotCase.source() == FewShotSource.REVIEWED_EVALUATION
&& (!StringUtils.hasText(fewShotCase.question())
|| !StringUtils.hasText(fewShotCase.sanitizedAnswer())
|| !validReviewedAnalysis(fewShotCase.approvedAnalysisJson(), fewShotCase.id()))) {
log.warn("few-shot case skipped. reason=invalid_reviewed_case, id={}", fewShotCase.id());
continue;
}
if (ids.contains(fewShotCase.id())) {
log.warn("few-shot case skipped. reason=duplicate_case_id, id={}, source={}", fewShotCase.id(), fewShotCase.source());
continue;
}
String inputHash = FewShotInputHash.of(fewShotCase.mainTasks(), fewShotCase.qualifications(),
fewShotCase.question(), fewShotCase.sanitizedAnswer());
if (!inputHash.isEmpty() && !inputHashes.add(inputHash)) {
log.warn("few-shot case skipped. reason=duplicate_normalized_input, id={}, source={}",
fewShotCase.id(), fewShotCase.source());
continue;
}
ids.add(fewShotCase.id());
result.add(fewShotCase);
}
return List.copyOf(result);
Expand All @@ -152,7 +169,6 @@ private List<FewShotCase> loadReviewedEvaluationCsvCases(String csvPath) {
try {
List<Map<String, String>> rows = readCsv(path);
List<FewShotCase> result = new ArrayList<>();
Set<String> ids = new HashSet<>();
for (Map<String, String> row : rows) {
String id = value(row, "caseId");
if (!"true".equalsIgnoreCase(value(row, "fewShotEnabled"))) {
Expand Down Expand Up @@ -182,14 +198,14 @@ private List<FewShotCase> loadReviewedEvaluationCsvCases(String csvPath) {
log.warn("reviewed evaluation few-shot row skipped. reason=invalid_analysis_object, caseId={}", id);
continue;
}
if (!ReviewedFewShotAnalysisValidator.isValid(analysis)) {
log.warn("reviewed evaluation few-shot row skipped. reason=invalid_analysis_schema, caseId={}", id);
continue;
}
} catch (IOException e) {
log.warn("reviewed evaluation few-shot row skipped. reason=invalid_analysis_json, caseId={}", id);
continue;
}
if (!ids.add(id)) {
log.warn("reviewed evaluation few-shot row skipped. reason=duplicate_case_id, caseId={}", id);
continue;
}
result.add(new FewShotCase(
id,
FewShotSource.REVIEWED_EVALUATION,
Expand All @@ -209,18 +225,33 @@ private List<FewShotCase> loadReviewedEvaluationCsvCases(String csvPath) {
buildReviewedPromptBlock(id, row, sanitizedAnswer, approvedAnalysisJson)
));
}
log.info("reviewed evaluation few-shot CSV loaded. rows={}, accepted={}, path={}", rows.size(), result.size(), csvPath);
log.info("reviewed evaluation few-shot CSV validated before deduplication. rows={}, validRows={}, path={}", rows.size(), result.size(), csvPath);
return result;
} catch (IOException | RuntimeException e) {
log.warn("reviewed evaluation few-shot CSV parse failed. path={}, message={}", csvPath, e.getMessage());
log.warn("reviewed evaluation few-shot CSV parse failed. path={}, errorType={}", csvPath, e.getClass().getSimpleName());
return List.of();
}
}

private boolean validReviewedAnalysis(String json, String id) {
try {
var analysis = objectMapper.reader()
.with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(json);
if (ReviewedFewShotAnalysisValidator.isValid(analysis)) {
return true;
}
log.warn("reviewed few-shot case skipped. reason=invalid_analysis_schema, caseId={}", id);
} catch (IOException e) {
log.warn("reviewed few-shot case skipped. reason=invalid_analysis_json, caseId={}", id);
}
return false;
}

private FewShotCase normalizeSource(FewShotCase fewShotCase, FewShotSource expectedSource) {
return new FewShotCase(
fewShotCase.id(),
fewShotCase.source() == null ? expectedSource : fewShotCase.source(),
expectedSource,
fewShotCase.reviewStatus(),
fewShotCase.enabled(),
fewShotCase.priority(),
Expand Down Expand Up @@ -329,11 +360,23 @@ private static List<Map<String, String>> readCsv(Path path) throws IOException {
if (!headers.isEmpty() && !headers.getFirst().isEmpty() && headers.getFirst().charAt(0) == '\uFEFF') {
headers.set(0, headers.getFirst().substring(1));
}
if (new HashSet<>(headers).size() != headers.size()
|| !headers.containsAll(List.of("caseId", "question", "sanitizedAnswer",
"approvedAnalysisJson", "fewShotEnabled", "reviewStatus"))
|| headers.stream().noneMatch(Set.of("mainTasks", "qualifications", "preferences")::contains)) {
log.warn("reviewed evaluation few-shot CSV skipped. reason=invalid_csv_headers");
return List.of();
}
List<Map<String, String>> result = new ArrayList<>();
for (List<String> row : rows.subList(1, rows.size())) {
if (row.stream().allMatch(String::isBlank)) {
continue;
}
if (row.size() != headers.size()) {
log.warn("reviewed evaluation few-shot row skipped. reason=invalid_column_count, expected={}, actual={}",
headers.size(), row.size());
continue;
}
Map<String, String> values = new LinkedHashMap<>();
for (int i = 0; i < headers.size(); i++) {
values.put(headers.get(i), i < row.size() ? row.get(i) : "");
Expand Down Expand Up @@ -375,6 +418,9 @@ private static List<List<String>> parseRows(String content) {
field.append(current);
}
}
if (inQuotes) {
throw new IllegalArgumentException("Unclosed quoted CSV field");
}
row.add(field.toString());
if (!row.isEmpty() && row.stream().anyMatch(value -> !value.isBlank())) {
rows.add(row);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot;

import org.springframework.util.StringUtils;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.Normalizer;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;

final class FewShotInputHash {
private static final Pattern NORMALIZED_INPUT_WHITESPACE_PATTERN = Pattern.compile("[\\p{Z}\\s]+");

private FewShotInputHash() {
}

static String of(
List<String> mainTasks,
List<String> qualifications,
String question,
String answer
) {
String normalizedMainTasks = normalizeInputSection(mainTasks == null ? "" : String.join("\n", mainTasks));
String normalizedQualifications = normalizeInputSection(
qualifications == null ? "" : String.join("\n", qualifications)
);
String normalizedQuestion = normalizeInputSection(question);
String normalizedAnswer = normalizeInputSection(answer);
if (normalizedMainTasks.isEmpty()
&& normalizedQualifications.isEmpty()
&& normalizedQuestion.isEmpty()
&& normalizedAnswer.isEmpty()) {
return "";
}
return sha256(
normalizedMainTasks + '\u001f'
+ normalizedQualifications + '\u001f'
+ normalizedQuestion + '\u001f'
+ normalizedAnswer
);
}

private static String normalizeInputSection(String value) {
if (!StringUtils.hasText(value)) {
return "";
}
String unicodeNormalized = Normalizer.normalize(value, Normalizer.Form.NFKC);
return NORMALIZED_INPUT_WHITESPACE_PATTERN.matcher(unicodeNormalized)
.replaceAll(" ")
.trim()
.toLowerCase(Locale.ROOT);
}

private static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder();
for (byte b : hash) {
result.append("%02x".formatted(b));
}
return result.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 algorithm is not available", e);
}
}

}
Loading
Loading