From c3e7ae7119cea2395288ce59c3fe358cf8d78385 Mon Sep 17 00:00:00 2001 From: Woohyeok Choi Date: Mon, 14 Sep 2026 19:31:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[Fix]=20PM=20=EA=B2=80=EC=88=98=20Few-shot?= =?UTF-8?q?=20CSV=20=EC=A0=81=EC=9E=AC=20=EC=A1=B0=EA=B1=B4=20=EB=B0=8F=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=B3=B4=EC=A1=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주요 업무·자격요건·우대사항 중 하나 이상 존재하면 적재 허용 - 실제 직무명을 보존하고 기존 CSV의 소분류 대체 동작 유지 - 예시 프롬프트에 우대사항 포함 - 승인 분석 JSON의 단일 객체 형식 검사 및 오류 행 개별 제외 - CSV 호환성과 오류 처리 회귀 테스트 추가 - 검수 데이터 적재 설정 및 재확인 내용 문서화 --- docs/fewshot-reviewed-loader.md | 42 +++++++++++++++++++ .../service/ai/fewshot/FewShotCaseStore.java | 22 ++++++++-- .../ai/fewshot/FewShotCaseStoreTest.java | 30 +++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 docs/fewshot-reviewed-loader.md diff --git a/docs/fewshot-reviewed-loader.md b/docs/fewshot-reviewed-loader.md new file mode 100644 index 00000000..6b6d4b45 --- /dev/null +++ b/docs/fewshot-reviewed-loader.md @@ -0,0 +1,42 @@ +# PM 검수 Few-shot 적재 + +기존 `fix/fewshot-selection-safety-cache` 작업 브랜치에 로더 보강을 추가했습니다. +기존 dev에 후보 및 질의 임베딩 캐시가 있으므로 캐시는 중복 구현하지 않았습니다. + +## 변경 + +- CSV의 주요 업무·자격요건·우대사항 중 하나 이상 있으면 JD를 허용합니다. + 세 항목이 모두 빈 사례는 제외합니다. 누락된 원문 정보를 생성하지 않습니다. +- jobTitle을 보존하고, 없는 기존 CSV만 jobCategorySmall로 대체합니다. +- 우대사항을 예시 프롬프트에 포함합니다. +- approvedAnalysisJson은 단일 JSON 객체여야 합니다. 잘못된 행만 제외하고 다음 행을 읽습니다. + 이 검사는 JSON 문법과 최상위 객체 검사이며 분석 내용의 의미 검증은 아닙니다. +- 활성·승인·caseId·문항·비식별 답변 검증을 유지합니다. + +## 로컬 평가 설정 + +검수 후보 CSV는 평가 입력(holdout)과 분리해야 합니다. +후보 JSON과 CSV는 동일 데이터이므로 동시에 로딩하지 않습니다. + +```properties +analysis.few-shot.dynamic-selection-enabled=true +analysis.few-shot.dataset-version=fewshot-pm-reviewed-20260914-v2 +analysis.few-shot.source.reviewed-evaluation-enabled=true +analysis.few-shot.reviewed-evaluation-resource= +analysis.few-shot.reviewed-evaluation-csv-path=/absolute/path/fewshot_candidates_approved.csv +``` + +운영 기본 활성화 설정은 변경하지 않았습니다. Python 워커 연결과 실제 API 비용이 +발생하는 평가는 이 변경에 포함하지 않습니다. + +## 검수 확인 + +사용자가 PM에게 재확인한 내용: FS-02-S1의 fabricated 변경은 의도된 것이며, +우선순위 1이 최상위입니다. FS-02-S1은 상태 변경 의도 때문에 보류한 것이 아니라 +최종 판정 이유와 정책 정합성 보완이 남아 활성 예시에서 제외한 상태입니다. + +## 검증 + +`./gradlew test --tests '*fewshot.*'`: 27개 성공. +선택 서비스의 기존 캐시·입력 제외 회귀 테스트와 CSV 로더의 선택 JD 필드, +직무명 호환, 우대사항 보존, 잘못된 JSON 행 이후 정상 행 적재를 확인했습니다. diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java index 9a431816..f86d7e57 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java @@ -169,14 +169,27 @@ private List loadReviewedEvaluationCsvCases(String csvPath) { String sanitizedAnswer = value(row, "sanitizedAnswer"); String approvedAnalysisJson = value(row, "approvedAnalysisJson"); if (!StringUtils.hasText(id) - || !StringUtils.hasText(value(row, "mainTasks")) - || !StringUtils.hasText(value(row, "qualifications")) + || !(StringUtils.hasText(value(row, "mainTasks")) + || StringUtils.hasText(value(row, "qualifications")) + || StringUtils.hasText(value(row, "preferences"))) || !StringUtils.hasText(value(row, "question")) || !StringUtils.hasText(sanitizedAnswer) || !StringUtils.hasText(approvedAnalysisJson)) { log.warn("reviewed evaluation few-shot row skipped. reason=missing_required_field, caseId={}", id); continue; } + try { + var analysis = objectMapper.reader() + .with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(approvedAnalysisJson); + if (analysis == null || !analysis.isObject()) { + log.warn("reviewed evaluation few-shot row skipped. reason=invalid_analysis_object, caseId={}", id); + continue; + } + } catch (IOException e) { + log.warn("reviewed evaluation few-shot row skipped. reason=invalid_analysis_json, caseId={}", id); + continue; + } result.add(new FewShotCase( id, FewShotSource.REVIEWED_EVALUATION, @@ -184,7 +197,8 @@ private List loadReviewedEvaluationCsvCases(String csvPath) { true, parseInt(value(row, "fewShotPriority")), value(row, "jobCategorySmall"), - value(row, "jobCategorySmall"), + StringUtils.hasText(value(row, "jobTitle")) + ? value(row, "jobTitle") : value(row, "jobCategorySmall"), splitLines(value(row, "mainTasks")), splitLines(value(row, "qualifications")), value(row, "question"), @@ -250,6 +264,7 @@ private static String buildReviewedPromptBlock( 관련 JD 요구사항: - mainTask: %s - qualification: %s + - preference: %s 평가 대상 답변: - questionId: 1 @@ -262,6 +277,7 @@ private static String buildReviewedPromptBlock( id, value(row, "mainTasks"), value(row, "qualifications"), + value(row, "preferences"), value(row, "question"), sanitizedAnswer, approvedAnalysisJson diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java index 362f215a..301ac03b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java @@ -15,6 +15,36 @@ class FewShotCaseStoreTest { @TempDir Path tempDir; + @Test + void preservesOptionalJdSectionsAndSkipsMalformedJsonPerRow() throws Exception { + Path csv = tempDir.resolve("optional-jd.csv"); + Files.writeString(csv, """ + caseId,jobCategorySmall,jobTitle,mainTasks,qualifications,preferences,question,sanitizedAnswer,approvedAnalysisJson,fewShotEnabled,reviewStatus + FS-05,PA,Project Assistant,,협업 능력,Adobe,경험,답변,{},true,APPROVED + FS-09,BX,Brand Designer,IP 관리,,,경험,답변,{},true,APPROVED + PREF,디자인,,, ,Adobe,경험,답변,{},true,APPROVED + EMPTY,디자인,,,,,경험,답변,{},true,APPROVED + BROKEN,디자인,,IP 관리,,,경험,답변,{,true,APPROVED + ARRAY,디자인,,IP 관리,,,경험,답변,[],true,APPROVED + TRAILING,디자인,,IP 관리,,,경험,답변,{} garbage,true,APPROVED + LAST,개발,,API 개발,,,경험,답변,{},true,APPROVED + """); + FewShotProperties properties = new FewShotProperties(); + properties.getSource().setFixedEnabled(false); + properties.getSource().setCuratedEnabled(false); + properties.getSource().setReviewedEvaluationEnabled(true); + properties.setReviewedEvaluationResource(""); + properties.setReviewedEvaluationCsvPath(csv.toString()); + var loaded = new FewShotCaseStore(new FewShotPromptProvider(), properties, new ObjectMapper()) + .loadActiveCases(); + assertThat(loaded).extracting(FewShotCase::id).containsExactly("FS-05", "FS-09", "PREF", "LAST"); + assertThat(loaded.getFirst().jobTitle()).isEqualTo("Project Assistant"); + assertThat(loaded.getFirst().mainTasks()).isEmpty(); + assertThat(loaded.getFirst().promptBlock()).contains("- preference: Adobe"); + assertThat(loaded.get(1).qualifications()).isEmpty(); + assertThat(loaded.getLast().jobTitle()).isEqualTo("개발"); + } + @Test @DisplayName("reviewed evaluation CSV는 승인, 활성, 비식별 답변, 승인 분석이 있는 행만 후보로 적재한다") void loadsOnlyApprovedReviewedEvaluationRows() throws Exception { From ad7e96861c7459a11ac346446540673898cb8340 Mon Sep 17 00:00:00 2001 From: Woohyeok Choi Date: Tue, 15 Sep 2026 01:00:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[Fix]=20Few-shot=20=EB=AC=B4=ED=9A=A8=20?= =?UTF-8?q?=ED=96=89=EC=9D=98=20=EC=A4=91=EB=B3=B5=20ID=20=EC=84=A0?= =?UTF-8?q?=EC=A0=90=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 필수 필드 및 분석 JSON 검증 이후 caseId 등록 - 동일 ID의 첫 유효 행 유지 및 유효 행 중복 처리 보존 - 비활성·미승인·필수값 누락·잘못된 JSON 이후 정상 행 적재 회귀 테스트 추가 - Few-shot 관련 테스트 28개 통과 --- .../service/ai/fewshot/FewShotCaseStore.java | 8 +++--- .../ai/fewshot/FewShotCaseStoreTest.java | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java index f86d7e57..31d5f351 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStore.java @@ -155,10 +155,6 @@ private List loadReviewedEvaluationCsvCases(String csvPath) { Set ids = new HashSet<>(); for (Map row : rows) { String id = value(row, "caseId"); - if (!ids.add(id)) { - log.warn("reviewed evaluation few-shot row skipped. reason=duplicate_case_id, caseId={}", id); - continue; - } if (!"true".equalsIgnoreCase(value(row, "fewShotEnabled"))) { continue; } @@ -190,6 +186,10 @@ private List loadReviewedEvaluationCsvCases(String csvPath) { 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, diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java index 301ac03b..5ed96793 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotCaseStoreTest.java @@ -15,6 +15,34 @@ class FewShotCaseStoreTest { @TempDir Path tempDir; + @Test + @DisplayName("무효 행은 ID를 선점하지 않고 같은 ID의 첫 유효 행만 적재한다") + void retainsFirstValidRowAfterInvalidRowsWithSameCaseId() throws Exception { + Path csv = tempDir.resolve("duplicate-id.csv"); + Files.writeString(csv, """ + caseId,mainTasks,question,sanitizedAnswer,approvedAnalysisJson,fewShotEnabled,reviewStatus + EV-01,API 개발,경험,비활성 답변,{},false,APPROVED + EV-01,API 개발,경험,미승인 답변,{},true,IN_REVIEW + EV-01,API 개발,경험,,{},true,APPROVED + EV-01,API 개발,경험,잘못된 JSON 답변,{,true,APPROVED + EV-01,API 개발,경험,배열 JSON 답변,[],true,APPROVED + EV-01,API 개발,경험,첫 유효 답변,{},true,APPROVED + EV-01,API 개발,경험,중복 유효 답변,{},true,APPROVED + """); + FewShotProperties properties = new FewShotProperties(); + properties.getSource().setFixedEnabled(false); + properties.getSource().setCuratedEnabled(false); + properties.getSource().setReviewedEvaluationEnabled(true); + properties.setReviewedEvaluationResource(""); + properties.setReviewedEvaluationCsvPath(csv.toString()); + + var loaded = new FewShotCaseStore(new FewShotPromptProvider(), properties, new ObjectMapper()) + .loadActiveCases(); + + assertThat(loaded).extracting(FewShotCase::id).containsExactly("EV-01"); + assertThat(loaded.getFirst().sanitizedAnswer()).isEqualTo("첫 유효 답변"); + } + @Test void preservesOptionalJdSectionsAndSkipsMalformedJsonPerRow() throws Exception { Path csv = tempDir.resolve("optional-jd.csv");