From 9cc95517f859419f801434f642a209143b2cad37 Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 29 Jul 2026 18:43:12 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[Fix]=20missing=20keyword=20replay=20?= =?UTF-8?q?=EA=B2=80=EC=88=98=20CSV=EC=97=90=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EB=AC=B8=EB=A7=A5=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - review CSV에 question, answer, mainTasks, qualifications 컬럼 추가 - 입력 CSV 값을 변환 없이 검수용 review row에 복사 - 기존 replay 판정 및 summary 생성 로직은 유지 - review CSV 문맥 컬럼 복사 테스트 추가 --- .../MissingKeywordSanitizerReplayService.java | 16 ++++++++++++++-- ...MissingKeywordSanitizerReplayServiceTest.java | 10 +++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java index 357cdd5..41c1d56 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java @@ -53,6 +53,10 @@ class MissingKeywordSanitizerReplayService { private static final List REVIEW_HEADERS = List.of( "caseId", "candidateIndex", + "question", + "answer", + "mainTasks", + "qualifications", "keyword", "relatedRequirement", "accepted", @@ -129,7 +133,7 @@ ReplaySummary replay(Path input, Path output, Path reviewOutput) throws IOExcept reasonCounts.merge(decision.rejectionReason().name(), 1L, Long::sum); replayRows.add(toReplayRow(caseId, rawCandidateCount, acceptedCount, rejectedCount, decision)); if (rawCandidateCount > 0) { - reviewRows.add(toReviewRow(caseId, decision)); + reviewRows.add(toReviewRow(caseId, row, decision)); } } } @@ -237,11 +241,19 @@ private Map toReplayRow( return row; } - private Map toReviewRow(String caseId, MissingKeywordSanitizationDecision decision) { + private Map toReviewRow( + String caseId, + Map inputRow, + MissingKeywordSanitizationDecision decision + ) { AnalysisCandidateResponse.MissingKeywordCandidate candidate = decision.originalCandidate(); Map row = new LinkedHashMap<>(); row.put("caseId", caseId); row.put("candidateIndex", String.valueOf(decision.candidateIndex())); + row.put("question", value(inputRow, "question")); + row.put("answer", value(inputRow, "answer")); + row.put("mainTasks", value(inputRow, "mainTasks")); + row.put("qualifications", value(inputRow, "qualifications")); row.put("keyword", candidate == null ? "" : value(candidate.keyword())); row.put("relatedRequirement", candidate == null ? "" : value(candidate.relatedRequirement())); row.put("accepted", String.valueOf(decision.accepted())); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java index 184090a..408099c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java @@ -43,7 +43,13 @@ void replayWritesDecisionAndReviewCsv() throws Exception { assertThat(replayRows.getFirst().get("rejectionReason")).isEqualTo("ACCEPTED"); assertThat(replayRows.get(1).get("accepted")).isEqualTo("false"); assertThat(replayRows.get(1).get("rejectionReason")).isEqualTo("NOT_RELATED_TO_JD"); - assertThat(EvaluationCsvSupport.read(reviewOutput)).hasSize(2); + List> reviewRows = EvaluationCsvSupport.read(reviewOutput); + assertThat(reviewRows).hasSize(2); + assertThat(reviewRows.getFirst()) + .containsEntry("question", "지원 동기") + .containsEntry("answer", "재고관리및분석경험을 쌓았습니다.") + .containsEntry("mainTasks", "재고 현황을 분석하고 적정 재고를 관리합니다.") + .containsEntry("qualifications", "영어 고객 커뮤니케이션 경험"); assertThat(Files.isRegularFile(summary.summaryOutput())).isTrue(); assertThat(summary.rawCandidateCount()).isEqualTo(2); assertThat(summary.acceptedCandidateCount()).isEqualTo(1); @@ -150,6 +156,7 @@ private List headers() { "caseId", "mainTasks", "qualifications", + "question", "answer", "rawCandidateResponseJson", "sanitizedCandidateResponseJson" @@ -165,6 +172,7 @@ private Map row( row.put("caseId", caseId); row.put("mainTasks", "재고 현황을 분석하고 적정 재고를 관리합니다."); row.put("qualifications", "영어 고객 커뮤니케이션 경험"); + row.put("question", "지원 동기"); row.put("answer", "재고관리및분석경험을 쌓았습니다."); row.put("rawCandidateResponseJson", objectMapper.writeValueAsString(raw)); row.put("sanitizedCandidateResponseJson", objectMapper.writeValueAsString(sanitized)); From 7fb4ba60d268d1f618261822fbd1fdd7788bf811 Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 29 Jul 2026 20:12:48 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[Fix]=20missing=20keyword=20candidate=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EB=B0=80=EB=8F=84=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1차 후보 프롬프트에 missingKeywordCandidates 전용 생성 규칙 추가 - 누락 키워드는 mainTasks 또는 qualifications의 명시 항목만 사용하도록 제한 - JD 밖 개념 확장 및 일반화 금지 규칙 추가 - relatedRequirement는 JD 원문 기반으로만 작성하도록 제한 - 답변에 이미 존재하는 역량은 누락 후보로 생성하지 않도록 명시 - precision 우선 및 애매한 경우 빈 배열 반환 규칙 추가 - 상세페이지/브랜드 운영/행정지원 negative few-shot 추가 - 후보 프롬프트 회귀 테스트 보강 --- ...ation_missing_keyword_sanitizer_review.csv | 132 +++++++++++++++++- .../analysis/service/ai/AnalysisAiClient.java | 33 +++++ .../service/ai/AnalysisAiClientTest.java | 12 +- 3 files changed, 169 insertions(+), 8 deletions(-) diff --git a/evaluation/evaluation_missing_keyword_sanitizer_review.csv b/evaluation/evaluation_missing_keyword_sanitizer_review.csv index a95d859..067bff9 100644 --- a/evaluation/evaluation_missing_keyword_sanitizer_review.csv +++ b/evaluation/evaluation_missing_keyword_sanitizer_review.csv @@ -1,7 +1,125 @@ -caseId,candidateIndex,keyword,relatedRequirement,accepted,rejectionReason,manualVerdict,manualNote -EV-02,0,브랜드 BI/CI 수립 경험,"포토샵, 일러스트를 활용한 실무 디자인 작업이 능숙한 분",false,INVALID_FORMAT,, -EV-02,1,UX/UI 인터페이스 설계,"포토샵, 일러스트를 활용한 실무 디자인 작업이 능숙한 분",false,INVALID_FORMAT,, -EV-05,0,브랜드 BI/CI 수립 경험,"창의적 공간 상상력을 즐기며, 이를 바탕으로 새로운 컨셉을 제안할 수 있는 분",false,NOT_RELATED_TO_JD,, -EV-05,1,UX/UI 인터페이스 설계,"패션, 건축, 패션 등 통찰을 바탕으로 트렌디한 디자인에 이해도가 높은 분",false,NOT_RELATED_TO_JD,, -EV-11,0,행정 지원,사업 및 행정지원,false,NOT_RELATED_TO_JD,, -EV-11,1,복지 프로그램 기획,사업 및 행정지원,false,NOT_RELATED_TO_JD,, +caseId,candidateIndex,question,answer,mainTasks,qualifications,keyword,relatedRequirement,accepted,rejectionReason,manualVerdict,manualNote +EV-02,0,지원 동기와 입사 후 포부를 기술하시오,"기획부터 판매까지] +온라인 스토어의 상세페이지는 소비자의 구매를 결정짓는 최종 관문이자, 브랜드의 신뢰도를 결정합니다. 저는 제품이 만들어지는 과정만큼이나 `소비자에게 어떻게 전달되고 판매되는지`의 가치를 중요하게 생각합니다. 이를 경험해 보고자 1인 제품 브랜드인 `○○○`를 직접 기획하여 운영했습니다. 포토샵과 일러스트를 활용해 고객 니즈에 맞춘 상세페이지를 제작하였고, 브랜드 인스타그램을 동시에 운영하며 제품 업로드 후 1개월 만에 실질적인 구매 전환을 이끌어 냈습니다. 또한 `○○○` 스토어의 분기별 배너 제작을 진행하며 실무 감각을 이어가고 있습니다. 이러한 경험을 통해 유입 경로에 따른 이미지 디자인의 중요성을 배웠으며, 브랜드 고유의 분위기를 유지하면서도 약속된 일정을 준수하며 기획의 의도를 구현해 내는 실무 프로세스를 체득했습니다. + +[라이노와 키샷] +저의 강점은 포토샵, 일러스트를 활용한 작업뿐만 아니라 제품을 직접 설계하고 디자인할 수 있다는 점입니다. 제품 디자인을 공부하며 라이노를 활용해 기초를 단단히 하였고, 졸업 후에는 Fusion360, 키샷, 지브러시까지 스스로 공부하며 다룰 수 있는 툴의 범위를 넓혔습니다. 대표적으로 `○○○` 조명 프로젝트를 진행하며 실제 조명 부품들이 내부에 결합되는 디자인을 라이노와 Fusion360을 이용하여 설계하고 디자인했습니다. 또한, 키샷을 활용해 제품의 구조와 특성이 잘 보일 수 있는 렌더링을 진행해 상세페이지까지 작업하고, 실제 제품 제작까지 완료해 보았습니다. 단순히 기획된 촬영 이미지와 소스를 편집하는 것을 넘어, 제품의 구조를 이해하고 필요한 각도와 연출의 렌더링 컷을 기획하고 작업할 수 있습니다. 이는 촬영이 어려운 조건 속에서도 제품의 기능과 디테일을 소비자에게 시각적으로 정확하게 전달하는 데에 큰 장점이 됩니다. + +[제품의 가치를 온전히 전달하는 디자이너] +제품 디자인부터 판매까지 전 과정을 경험하며 느낀 것은, 아무리 좋은 제품도 온라인상에서 소비자에게 제대로 전달되지 않으면 가치를 잃는다는 점입니다. `○○○`이 선보이는 감각적인 제품들이 스토어 화면 너머의 고객들에게도 생생하게 전달될 수 있도록, 저의 포토샵·일러스트 실무 능력과 3D 설계 역량을 쏟고 싶어 지원하게 되었습니다. 입사 후에는 우선 `○○○`의 업무 프로세스를 빠르게 익혀, 배너 및 상세페이지 제작 업무에 즉각 투입되겠습니다. 나아가 촬영본만으로 표현하기 힘든 제품의 디테일을 3D 렌더링으로 보완하고, 만드는 사람의 마음과 사는 사람의 시각을 모두 아는 디자이너로서 `○○○`의 온라인 매출 성장에 기여하겠습니다.","- 오픈마켓 운영 및 관리 +- 상세페이지 제작(포토샵)","- 포토샵, 일러스트 을 활용한 실무 디자인 작업이 능숙한 분",브랜드 BI/CI 수립 경험,"포토샵, 일러스트를 활용한 실무 디자인 작업이 능숙한 분",false,INVALID_FORMAT, NOT_JD_RELATED, "JD는 상세페이지 제작 및 포토샵/일러스트 활용을 요구하며 BI/CI 구축 경험은 요구하지 않음" +EV-02,1,지원 동기와 입사 후 포부를 기술하시오,"기획부터 판매까지] +온라인 스토어의 상세페이지는 소비자의 구매를 결정짓는 최종 관문이자, 브랜드의 신뢰도를 결정합니다. 저는 제품이 만들어지는 과정만큼이나 `소비자에게 어떻게 전달되고 판매되는지`의 가치를 중요하게 생각합니다. 이를 경험해 보고자 1인 제품 브랜드인 `○○○`를 직접 기획하여 운영했습니다. 포토샵과 일러스트를 활용해 고객 니즈에 맞춘 상세페이지를 제작하였고, 브랜드 인스타그램을 동시에 운영하며 제품 업로드 후 1개월 만에 실질적인 구매 전환을 이끌어 냈습니다. 또한 `○○○` 스토어의 분기별 배너 제작을 진행하며 실무 감각을 이어가고 있습니다. 이러한 경험을 통해 유입 경로에 따른 이미지 디자인의 중요성을 배웠으며, 브랜드 고유의 분위기를 유지하면서도 약속된 일정을 준수하며 기획의 의도를 구현해 내는 실무 프로세스를 체득했습니다. + +[라이노와 키샷] +저의 강점은 포토샵, 일러스트를 활용한 작업뿐만 아니라 제품을 직접 설계하고 디자인할 수 있다는 점입니다. 제품 디자인을 공부하며 라이노를 활용해 기초를 단단히 하였고, 졸업 후에는 Fusion360, 키샷, 지브러시까지 스스로 공부하며 다룰 수 있는 툴의 범위를 넓혔습니다. 대표적으로 `○○○` 조명 프로젝트를 진행하며 실제 조명 부품들이 내부에 결합되는 디자인을 라이노와 Fusion360을 이용하여 설계하고 디자인했습니다. 또한, 키샷을 활용해 제품의 구조와 특성이 잘 보일 수 있는 렌더링을 진행해 상세페이지까지 작업하고, 실제 제품 제작까지 완료해 보았습니다. 단순히 기획된 촬영 이미지와 소스를 편집하는 것을 넘어, 제품의 구조를 이해하고 필요한 각도와 연출의 렌더링 컷을 기획하고 작업할 수 있습니다. 이는 촬영이 어려운 조건 속에서도 제품의 기능과 디테일을 소비자에게 시각적으로 정확하게 전달하는 데에 큰 장점이 됩니다. + +[제품의 가치를 온전히 전달하는 디자이너] +제품 디자인부터 판매까지 전 과정을 경험하며 느낀 것은, 아무리 좋은 제품도 온라인상에서 소비자에게 제대로 전달되지 않으면 가치를 잃는다는 점입니다. `○○○`이 선보이는 감각적인 제품들이 스토어 화면 너머의 고객들에게도 생생하게 전달될 수 있도록, 저의 포토샵·일러스트 실무 능력과 3D 설계 역량을 쏟고 싶어 지원하게 되었습니다. 입사 후에는 우선 `○○○`의 업무 프로세스를 빠르게 익혀, 배너 및 상세페이지 제작 업무에 즉각 투입되겠습니다. 나아가 촬영본만으로 표현하기 힘든 제품의 디테일을 3D 렌더링으로 보완하고, 만드는 사람의 마음과 사는 사람의 시각을 모두 아는 디자이너로서 `○○○`의 온라인 매출 성장에 기여하겠습니다.","- 오픈마켓 운영 및 관리 +- 상세페이지 제작(포토샵)","- 포토샵, 일러스트 을 활용한 실무 디자인 작업이 능숙한 분",UX/UI 인터페이스 설계,"포토샵, 일러스트를 활용한 실무 디자인 작업이 능숙한 분",false,INVALID_FORMAT, NOT_JD_RELATED, "JD는 UX/UI 설계를 요구하지 않으며 상세페이지 제작과 동일한 의미가 아님" +EV-05,0,지원 동기와 입사 후 포부를 기술하시오,"LAB 기획 및 디자인 직무를 통해 추상적인 컨셉이 공간과 오브제, 시각 언어로 완성되는 전 과정에 기여하고 싶어 지원했습니다. +학교 수업에서 패션 브랜드를 리서치할 때 ○○○를 선택했습니다. 리서치를 하면서 직접 확인하고 싶어 ○○대학교 ○○○, 신사 매장을 방문했습니다. 매장마다 컨셉은 완전히 달랐지만, 오브제의 스케일감과 비현실적인 공간 연출 방식에서 ○○○ 특유의 브랜드 정체성이 분명하게 느껴졌습니다. 오프라인 매장과 캠페인 비주얼, SNS를 비교해보니 금속성 오브제, 낯선 동선 설계, 비현실적 스케일감이 채널이 달라져도 동일하게 유지된다는 것을 확인했습니다. 저는 이 일관성이 브랜드를 강하게 기억하게 만드는 핵심이라고 판단했습니다. +LAB 직무에서 가장 중요한 것은 파격적인 컨셉이 실제 공간과 오브제, 협업, 품질로 연결될 때까지 브랜드 언어를 일관되게 유지하는 것이라고 생각합니다. 최근 생성형 AI와 실시간 비주얼 기술이 빠르게 발전하면서 컨셉 시각화 속도는 빨라지고 있지만, 기술이 빠를수록 브랜드 고유의 감각이 흔들리는 문제가 생깁니다. 기술을 다루는 속도보다 그것을 브랜드 언어로 번역하는 능력이 더 중요해지는 이유입니다. +저는 생성형 AI를 활용한 빠른 시각화와, 브랜드 언어의 일관성을 끝까지 유지하는 협업 운영, 이 두 가지를 동시에 수행해왔습니다. '○○○' 프로젝트에서는 Midjourney, Kling, 11Labs를 활용해 무드보드부터 캐릭터 감정선에 맞는 영상과 나레이션까지 구성해 10분 분량의 풀 AI 브랜드 필름을 완성했습니다. 초기 컨셉 정의 문서를 기반으로 수정 라운드를 4회에서 2회로 줄이며 내러티브 완성도와 시각적 일관성 부문에서 최우수 평가를 받았습니다. '○○○' 프로젝트에서는 VD팀 3명을 리딩하며 그래픽, UI, 브랜딩 담당자와 주 2회 리뷰 체계를 운영했습니다. 기존 AR 글래스 솔루션 대비 비주얼 컨셉을 차별화하고 사용자 니즈를 구체적으로 반영한 결과, 함께한 현직 디자이너로부터 ""기대 이상의 완성도""라는 피드백을 받았습니다. 또한 한국디자인진흥원 글로벌 역량강화 프로그램에 선발되어 ○○대학교에서 'Future of Design & Technology' 워크숍에 참여했습니다. AI as a Co-Creator, UX/UI for Emerging Technologies 등 전 과정을 영어로 수행하며 글로벌 디자인 기업 ○○○을 방문해 브랜드 경험 산업의 해외 흐름을 직접 확인했습니다. +입사 초기에는 LAB의 기획 프로세스와 브랜드 언어 체계를 빠르게 체득해 기획 단계부터 실행 가능한 아이디어를 제안하는 것을 목표로 삼겠습니다. 3년차에는 세 가지를 완성하고 싶습니다. 생성형 AI를 활용한 컨셉 시각화 프로세스를 팀 내에 정착시켜 아이디어 제안 속도를 높이는 것, 공간과 오브제를 넘어 디지털 경험으로 확장되는 새로운 내러티브 포맷을 제안하는 것, 하나의 컨셉 기획을 처음부터 끝까지 리드해 결과물로 완성하는 것입니다. 초기에는 컨셉 기획과 시각화의 완성도를 높여 브랜드 경험의 밀도를 강화하는 데 기여하고, 이후 하나의 컨셉이 실제 공간 경험과 고객 반응으로 이어지는 과정을 축적해 장기적으로는 브랜드 인지도와 비즈니스 성과에도 기여하는 기획자로 성장하겠습니다.","- 내러티브 기획을 담당합니다. +- 공간, 오브제 등 모든 영역에 걸쳐 독창적인 컨셉과 내러티브를 주도적으로 기획하고 디자인 비전 제시 +- 컨셉을 시각화합니다. +- 3D 등, 시 모델 등 최적의 방법을 활용하여, 구상한 컨셉을 높은 수준의 비주얼 이미지로 완성 +- 디자인을 완결합니다. +- 전문적인 제작 팀과의 협업을 통해 상상하던 것을 현실화","- 창의적 공간 상상력을 즐기며, 이를 바탕으로 새로운 컨셉을 제안할 수 있는 분 +- 패션, 건축, 패션 등 통찰을 바탕으로 트렌디한 디자인에 이해도가 높은 분 +- 3D, 시 모델 등 활용하여 시각화 작업이 가능한 분 +- 기획한 비전이 새로운 환경 및 상황에서도 대중들과 소통할 수 있는 분",브랜드 BI/CI 수립 경험,"창의적 공간 상상력을 즐기며, 이를 바탕으로 새로운 컨셉을 제안할 수 있는 분",false,NOT_RELATED_TO_JD, NOT_JD_RELATED, JD와 자소서 모두 BI/CI 구축 경험을 요구하거나 기술하지 않음. +EV-05,1,지원 동기와 입사 후 포부를 기술하시오,"LAB 기획 및 디자인 직무를 통해 추상적인 컨셉이 공간과 오브제, 시각 언어로 완성되는 전 과정에 기여하고 싶어 지원했습니다. +학교 수업에서 패션 브랜드를 리서치할 때 ○○○를 선택했습니다. 리서치를 하면서 직접 확인하고 싶어 ○○대학교 ○○○, 신사 매장을 방문했습니다. 매장마다 컨셉은 완전히 달랐지만, 오브제의 스케일감과 비현실적인 공간 연출 방식에서 ○○○ 특유의 브랜드 정체성이 분명하게 느껴졌습니다. 오프라인 매장과 캠페인 비주얼, SNS를 비교해보니 금속성 오브제, 낯선 동선 설계, 비현실적 스케일감이 채널이 달라져도 동일하게 유지된다는 것을 확인했습니다. 저는 이 일관성이 브랜드를 강하게 기억하게 만드는 핵심이라고 판단했습니다. +LAB 직무에서 가장 중요한 것은 파격적인 컨셉이 실제 공간과 오브제, 협업, 품질로 연결될 때까지 브랜드 언어를 일관되게 유지하는 것이라고 생각합니다. 최근 생성형 AI와 실시간 비주얼 기술이 빠르게 발전하면서 컨셉 시각화 속도는 빨라지고 있지만, 기술이 빠를수록 브랜드 고유의 감각이 흔들리는 문제가 생깁니다. 기술을 다루는 속도보다 그것을 브랜드 언어로 번역하는 능력이 더 중요해지는 이유입니다. +저는 생성형 AI를 활용한 빠른 시각화와, 브랜드 언어의 일관성을 끝까지 유지하는 협업 운영, 이 두 가지를 동시에 수행해왔습니다. '○○○' 프로젝트에서는 Midjourney, Kling, 11Labs를 활용해 무드보드부터 캐릭터 감정선에 맞는 영상과 나레이션까지 구성해 10분 분량의 풀 AI 브랜드 필름을 완성했습니다. 초기 컨셉 정의 문서를 기반으로 수정 라운드를 4회에서 2회로 줄이며 내러티브 완성도와 시각적 일관성 부문에서 최우수 평가를 받았습니다. '○○○' 프로젝트에서는 VD팀 3명을 리딩하며 그래픽, UI, 브랜딩 담당자와 주 2회 리뷰 체계를 운영했습니다. 기존 AR 글래스 솔루션 대비 비주얼 컨셉을 차별화하고 사용자 니즈를 구체적으로 반영한 결과, 함께한 현직 디자이너로부터 ""기대 이상의 완성도""라는 피드백을 받았습니다. 또한 한국디자인진흥원 글로벌 역량강화 프로그램에 선발되어 ○○대학교에서 'Future of Design & Technology' 워크숍에 참여했습니다. AI as a Co-Creator, UX/UI for Emerging Technologies 등 전 과정을 영어로 수행하며 글로벌 디자인 기업 ○○○을 방문해 브랜드 경험 산업의 해외 흐름을 직접 확인했습니다. +입사 초기에는 LAB의 기획 프로세스와 브랜드 언어 체계를 빠르게 체득해 기획 단계부터 실행 가능한 아이디어를 제안하는 것을 목표로 삼겠습니다. 3년차에는 세 가지를 완성하고 싶습니다. 생성형 AI를 활용한 컨셉 시각화 프로세스를 팀 내에 정착시켜 아이디어 제안 속도를 높이는 것, 공간과 오브제를 넘어 디지털 경험으로 확장되는 새로운 내러티브 포맷을 제안하는 것, 하나의 컨셉 기획을 처음부터 끝까지 리드해 결과물로 완성하는 것입니다. 초기에는 컨셉 기획과 시각화의 완성도를 높여 브랜드 경험의 밀도를 강화하는 데 기여하고, 이후 하나의 컨셉이 실제 공간 경험과 고객 반응으로 이어지는 과정을 축적해 장기적으로는 브랜드 인지도와 비즈니스 성과에도 기여하는 기획자로 성장하겠습니다.","- 내러티브 기획을 담당합니다. +- 공간, 오브제 등 모든 영역에 걸쳐 독창적인 컨셉과 내러티브를 주도적으로 기획하고 디자인 비전 제시 +- 컨셉을 시각화합니다. +- 3D 등, 시 모델 등 최적의 방법을 활용하여, 구상한 컨셉을 높은 수준의 비주얼 이미지로 완성 +- 디자인을 완결합니다. +- 전문적인 제작 팀과의 협업을 통해 상상하던 것을 현실화","- 창의적 공간 상상력을 즐기며, 이를 바탕으로 새로운 컨셉을 제안할 수 있는 분 +- 패션, 건축, 패션 등 통찰을 바탕으로 트렌디한 디자인에 이해도가 높은 분 +- 3D, 시 모델 등 활용하여 시각화 작업이 가능한 분 +- 기획한 비전이 새로운 환경 및 상황에서도 대중들과 소통할 수 있는 분",UX/UI 인터페이스 설계,"패션, 건축, 패션 등 통찰을 바탕으로 트렌디한 디자인에 이해도가 높은 분",false,NOT_RELATED_TO_JD, NOT_JD_RELATED,JD는 공간·컨셉·3D 시각화를 요구하며 UX/UI 인터페이스 설계는 직접 요구사항이 아님. +EV-11,0,지원 동기와 입사 후 포부를 기술하시오,"사회적/경제적 어려움을 겪는 청년들이 스스로 잠재력을 발견하고 건강한 사회 구성원으로 성장하고 나아갈 수 있도록 도움을 주고자 지원하게 되었습니다. +과거 소심한 성격으로 친구 관계에 어려움을 겪었을 때 상담 선생님을 통해 진정서 있는 지지를 얻어 극복했던 경험은 제가 타인에게 정서적 안정망이 되어주겠다는 확고한 비전으로 이어졌습니다. +대상을 선입견 없이 바라보고 있는 그대로 이해하는 것은 저의 오랜 가치관입니다. +이러한 편견 없는 존중을 현장에서 활용하며 청년들과 깊은 라포를 형성할 수 있습니다. +나아가 청년들이 단순 참여자에 머무르지 않고 사업단 운영의 주체가 되어 스스로 미래를 설계할 수 있도록 지지하는 든든한 조력자가 되겠습니다. + +청년자립도전사업의 핵심은 심리 정서 안정과 실질적 자립 지원 연계의 균형이라고 생각합니다. +저는 상담학을 전공하며 청소년 상담사 3급 자격을 취득했고 진로 및 구직을 전문적으로 지원하고자 직업상담사 2급 필기 합격 후 오는 7월 실기 시험을 앞두고 있습니다. +대학 시절에는 상담학을 전공하며 여러 과목에서 프로그램을 기획하고 운영한 경험이 있습니다. +콜라주를 활용한 감정 표현 프로그램을 진행하며 잡지와 신문지를 활용해서 자신의 감정을 대변하는 이미지를 찾아 찢고 붙이는 활동을 기획했습니다. +그 과정 속에서 참여자들의 부정적 감정을 건강하게 해소할 수 있도록 도움을 주었고 자신에 대해 알아갈 수 있도록 했습니다. +그 결과 참여자들이 자신의 내면을 자유롭게 표헌하고 스스로에 대해 알아갈 수 있는 시간이었다고 이야기해주며 정서적 정화와 자존감 회복의 효과를 확인했습니다. +무엇보다 운영 보조자로서 참여자들이 프로그램에 참여하는 방식을 확인하며 개인의 성향과 특성에 대해 입체적으로 파악할 수 있었고 관찰력을 길렀습니다. +이런 기획 및 운영 능력을 바탕으로 금천자립센터에서도 청년들의 욕구에 맞춘 자존감 향상, 취업 역량 강화 등 다양한 프로그램을 운영하고 지원하겠습니다. + +저는 일시 보호 쉼터의 거리 상담 봉사자로 활동하며 위기 청소년들의 현실을 현장에서 직접 마주하고 대처하는 능력을 키웠습니다. +실제로 진로를 정하지 못한 것으로 고민을 하던 고3 청소년과 대화를 통해서 진로를 정하지 못했다는 것에 위축되지 않을 수 있게 도움을 주었습니다. +어떤 대학, 학과에 가고 싶냐고 먼저 질문하기 보단 어떤 활동을 가장 좋아하고 잘하는지 또는 가장 행복하고 나다울 때는 언제인지를 질문하며 청소년이 자신감을 회복할 수 있게 도움을 주었습니다. +그 후에 어렸을 때 하고 싶었었던 배우에 대해서 한 번 더 도전하게 되며 연기학원에 등록했고 부모님을 잘 설득할 수 있도록 같이 대화 연습을 해왔습니다. +활동 후 매번 상담일지를 작성하며 청소년들과 나눴던 대화 주제, 정서 상태를 간단히 기록하고 청소년들이 깊게 이야기 했던 부분, 꺼려했던 부분을 잘 정리할 수 있었습니다. +무엇보다 저의 대화태도와 언어습관에 대해 끊임없이 성찰했고 그 과정은 대상에게 진솔하게 다가가 마음을 여는 밑거름이 되었습니다. + +또한 ○○대학교 총동아리 연합회 총무부에서 1년간 활동하며 전반적인 행정 업무와 예산 관리를 전담했습니다. +원래도 꼼꼼한 성격인데 문서 작성에서 누락된 부분,오류를 사전에 한번 더 확인하는 습관까지 길렀습니다. +이러한 행정 역량을 바탕으로 공공 사업 지침에 따른 철저한 예산 관리와 행정 업무를 수행하며 센터의 운영 효율성을 높이겠습니다. +이러한 과정에서 다양한 배경을 가진 구성원 간의 갈등을 중재하며 행동 너머의 감정적 맥락을 헤아려 현실적인 타협점을 제안하는 리더십도 배웠습니다. + +상대방의 이면에 숨겨진 감정을 깊이 이해하고 공감을 바탕으로 합리적인 소통을 이끌어 내는 것은 저의 가장 큰 장점입니다. +봉사 경험에서도 저의 장점을 활용하며 청소년의 반항 너머의 알아달라는 외침을 세심하게 파악할 수 있었습니다. +이 장점은 자립센터안에서도 활용하며 자립 과정에서 불안을 겪는 청년들에게 정서적 안정감을 제공하고 욕구 파악을 통해 개인별 자립 계획을 수립하는 데 도움을 줄 수 있습니다. +반면 저의 단점은 신중함이며 새로운 환경에 적응할 때 실수를 줄이고자 하는 행동이지만 업무 초기 속도가 조금 느리다고 느낄 수 있습니다, +이를 보완하고자 입사 후 센터의 운영 매뉴얼과 피드백을 빠르게 습득하고 신중함에 속도감을 더해서 빈틈없이 업무를 수행하겠습니다. + +입사 후 사회복지사 자격증을 취득항 예정이며 자격증만이 전부는 아니지만 청년들에게 더 실질적인 도움을 제공하기 위해 끊임없이 노력하는 자세로 임하겠습니다. +지금 2종 운전면허를 준비중이며 이 또한 7월 내에 취득하도록 노력하겠습니다. + +입사 후 청년들과 초기 라포 형성에 집중하고 신뢰를 바탕으로 개인별 욕구를 명확히 파악하고 참여도를 극대화하겠습니다. +현장의 목소리에 귀 기울이고 필요한 역량을 적극적으로 습득하여 청년들이 사회적 고립을 깨고 당당한 자립의 변화를 맞이할 수 있도록 하겠습니다. +매일 처음의 약속과 초심을 잃지 않고 기관에는 성실함으로 신뢰를 주고 청년들에게는 따뜻함을 주는 정직안 전담관리자가 되겠습니다.","- 청년자립도전사업단 운영 및 관리 +- 사업 및 행정지원","- 사회복지사, 청소년지도사, 청소년상담사, 직업상담사, 임상심리사 중 1개의 이상 자격증 소지자",행정 지원,사업 및 행정지원,false,NOT_RELATED_TO_JD, ALREADY_MENTIONED, "답변에서 행정 업무와 예산 관리 경험을 이미 구체적으로 기술하고 있음." +EV-11,1,지원 동기와 입사 후 포부를 기술하시오,"사회적/경제적 어려움을 겪는 청년들이 스스로 잠재력을 발견하고 건강한 사회 구성원으로 성장하고 나아갈 수 있도록 도움을 주고자 지원하게 되었습니다. +과거 소심한 성격으로 친구 관계에 어려움을 겪었을 때 상담 선생님을 통해 진정서 있는 지지를 얻어 극복했던 경험은 제가 타인에게 정서적 안정망이 되어주겠다는 확고한 비전으로 이어졌습니다. +대상을 선입견 없이 바라보고 있는 그대로 이해하는 것은 저의 오랜 가치관입니다. +이러한 편견 없는 존중을 현장에서 활용하며 청년들과 깊은 라포를 형성할 수 있습니다. +나아가 청년들이 단순 참여자에 머무르지 않고 사업단 운영의 주체가 되어 스스로 미래를 설계할 수 있도록 지지하는 든든한 조력자가 되겠습니다. + +청년자립도전사업의 핵심은 심리 정서 안정과 실질적 자립 지원 연계의 균형이라고 생각합니다. +저는 상담학을 전공하며 청소년 상담사 3급 자격을 취득했고 진로 및 구직을 전문적으로 지원하고자 직업상담사 2급 필기 합격 후 오는 7월 실기 시험을 앞두고 있습니다. +대학 시절에는 상담학을 전공하며 여러 과목에서 프로그램을 기획하고 운영한 경험이 있습니다. +콜라주를 활용한 감정 표현 프로그램을 진행하며 잡지와 신문지를 활용해서 자신의 감정을 대변하는 이미지를 찾아 찢고 붙이는 활동을 기획했습니다. +그 과정 속에서 참여자들의 부정적 감정을 건강하게 해소할 수 있도록 도움을 주었고 자신에 대해 알아갈 수 있도록 했습니다. +그 결과 참여자들이 자신의 내면을 자유롭게 표헌하고 스스로에 대해 알아갈 수 있는 시간이었다고 이야기해주며 정서적 정화와 자존감 회복의 효과를 확인했습니다. +무엇보다 운영 보조자로서 참여자들이 프로그램에 참여하는 방식을 확인하며 개인의 성향과 특성에 대해 입체적으로 파악할 수 있었고 관찰력을 길렀습니다. +이런 기획 및 운영 능력을 바탕으로 금천자립센터에서도 청년들의 욕구에 맞춘 자존감 향상, 취업 역량 강화 등 다양한 프로그램을 운영하고 지원하겠습니다. + +저는 일시 보호 쉼터의 거리 상담 봉사자로 활동하며 위기 청소년들의 현실을 현장에서 직접 마주하고 대처하는 능력을 키웠습니다. +실제로 진로를 정하지 못한 것으로 고민을 하던 고3 청소년과 대화를 통해서 진로를 정하지 못했다는 것에 위축되지 않을 수 있게 도움을 주었습니다. +어떤 대학, 학과에 가고 싶냐고 먼저 질문하기 보단 어떤 활동을 가장 좋아하고 잘하는지 또는 가장 행복하고 나다울 때는 언제인지를 질문하며 청소년이 자신감을 회복할 수 있게 도움을 주었습니다. +그 후에 어렸을 때 하고 싶었었던 배우에 대해서 한 번 더 도전하게 되며 연기학원에 등록했고 부모님을 잘 설득할 수 있도록 같이 대화 연습을 해왔습니다. +활동 후 매번 상담일지를 작성하며 청소년들과 나눴던 대화 주제, 정서 상태를 간단히 기록하고 청소년들이 깊게 이야기 했던 부분, 꺼려했던 부분을 잘 정리할 수 있었습니다. +무엇보다 저의 대화태도와 언어습관에 대해 끊임없이 성찰했고 그 과정은 대상에게 진솔하게 다가가 마음을 여는 밑거름이 되었습니다. + +또한 ○○대학교 총동아리 연합회 총무부에서 1년간 활동하며 전반적인 행정 업무와 예산 관리를 전담했습니다. +원래도 꼼꼼한 성격인데 문서 작성에서 누락된 부분,오류를 사전에 한번 더 확인하는 습관까지 길렀습니다. +이러한 행정 역량을 바탕으로 공공 사업 지침에 따른 철저한 예산 관리와 행정 업무를 수행하며 센터의 운영 효율성을 높이겠습니다. +이러한 과정에서 다양한 배경을 가진 구성원 간의 갈등을 중재하며 행동 너머의 감정적 맥락을 헤아려 현실적인 타협점을 제안하는 리더십도 배웠습니다. + +상대방의 이면에 숨겨진 감정을 깊이 이해하고 공감을 바탕으로 합리적인 소통을 이끌어 내는 것은 저의 가장 큰 장점입니다. +봉사 경험에서도 저의 장점을 활용하며 청소년의 반항 너머의 알아달라는 외침을 세심하게 파악할 수 있었습니다. +이 장점은 자립센터안에서도 활용하며 자립 과정에서 불안을 겪는 청년들에게 정서적 안정감을 제공하고 욕구 파악을 통해 개인별 자립 계획을 수립하는 데 도움을 줄 수 있습니다. +반면 저의 단점은 신중함이며 새로운 환경에 적응할 때 실수를 줄이고자 하는 행동이지만 업무 초기 속도가 조금 느리다고 느낄 수 있습니다, +이를 보완하고자 입사 후 센터의 운영 매뉴얼과 피드백을 빠르게 습득하고 신중함에 속도감을 더해서 빈틈없이 업무를 수행하겠습니다. + +입사 후 사회복지사 자격증을 취득항 예정이며 자격증만이 전부는 아니지만 청년들에게 더 실질적인 도움을 제공하기 위해 끊임없이 노력하는 자세로 임하겠습니다. +지금 2종 운전면허를 준비중이며 이 또한 7월 내에 취득하도록 노력하겠습니다. + +입사 후 청년들과 초기 라포 형성에 집중하고 신뢰를 바탕으로 개인별 욕구를 명확히 파악하고 참여도를 극대화하겠습니다. +현장의 목소리에 귀 기울이고 필요한 역량을 적극적으로 습득하여 청년들이 사회적 고립을 깨고 당당한 자립의 변화를 맞이할 수 있도록 하겠습니다. +매일 처음의 약속과 초심을 잃지 않고 기관에는 성실함으로 신뢰를 주고 청년들에게는 따뜻함을 주는 정직안 전담관리자가 되겠습니다.","- 청년자립도전사업단 운영 및 관리 +- 사업 및 행정지원","- 사회복지사, 청소년지도사, 청소년상담사, 직업상담사, 임상심리사 중 1개의 이상 자격증 소지자",복지 프로그램 기획,사업 및 행정지원,false,NOT_RELATED_TO_JD, NOT_JD_RELATED, "답변에는 프로그램 기획 경험이 있으나 JD는 복지 프로그램 기획을 직접 요구하지 않음." 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 7f6d70c..7044dce 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 @@ -644,6 +644,39 @@ String buildCandidatePrompt( - status 다양성이나 개수를 채우기 위해 후보를 만들지 않는다. - improvement를 생성하지 않는다. + [missingKeywordCandidates 생성 규칙] + - missingKeywordCandidates는 recall보다 precision을 우선한다. 확실한 누락 역량이 없으면 []를 반환한다. + - 개수를 채우기 위해 missingKeywordCandidates를 생성하지 않는다. 애매하면 생성하지 않는다. + - keyword는 main_tasks 또는 qualifications에 명시적으로 존재하거나 직접적으로 동일한 의미인 항목만 사용한다. + - 유사 JD, preferences, 유사 자소서 문항, 직무 일반 지식, 모델의 추론에서 keyword를 만들지 않는다. + - JD에 없는 개념으로 확장하거나 일반화하지 않는다. + - "상세페이지 제작"을 "UX/UI 인터페이스 설계"로 바꾸지 않는다. + - "브랜드 운영"을 "브랜드 BI/CI 수립 경험"으로 바꾸지 않는다. + - "사업 및 행정지원"을 "복지 프로그램 기획"으로 바꾸지 않는다. + - keyword는 가능하면 JD 원문 표현을 유지한다. 추상화하거나 더 넓은 상위 개념으로 바꾸지 않는다. + - relatedRequirement는 main_tasks 또는 qualifications의 실제 문장을 그대로 복사하거나 조사/공백 정도만 최소 수정한다. + - relatedRequirement에 JD 원문에 없는 새 표현을 만들지 않는다. + - 답변 전체에 keyword와 직접 동일한 역량, 수행 경험, 도구 사용, 업무 수행이 이미 충분히 있으면 missingKeywordCandidates에 넣지 않는다. + - "행정 업무", "행정 지원", "사업 및 행정지원"처럼 같은 역량이 답변에 입증되어 있으면 누락으로 만들지 않는다. + + [missingKeywordCandidates Negative Examples] + - Example 1 + JD main_tasks: 상세페이지 제작 + JD qualifications: 포토샵 + Answer: 포토샵으로 상세페이지를 제작했습니다. + missingKeywordCandidates: [] + 이유: 이미 답변에 있으며, 상세페이지 제작을 UX/UI 인터페이스 설계 같은 JD 밖 개념으로 확장하지 않는다. + - Example 2 + JD main_tasks: 브랜드 운영 + Answer: 브랜드를 운영했습니다. + missingKeywordCandidates: [] + 이유: 브랜드 운영은 이미 답변에 있으며, BI/CI 구축 경험으로 일반화하지 않는다. + - Example 3 + JD main_tasks: 사업 및 행정지원 + Answer: 총무부에서 행정 업무와 예산 관리를 담당했습니다. + missingKeywordCandidates: [] + 이유: 행정지원 역량이 이미 답변에 충분히 존재한다. + [문장 유형별 후보 기준] - EXPERIENCE: 역할, 행동, 방법, 결과, 직무 연결성 중 실제로 부족한 요소가 있어야 한다. - EXPERIENCE: 주변 문장에 역할, 방법, 성과가 이어지면 부족하다고 판단하지 않는다. 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 353542d..f97aab3 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 @@ -317,7 +317,17 @@ void buildCandidatePromptIncludesCandidateRulesOnly() { .contains("MISSING은 analysisCandidates에 넣지 않고 missingKeywordCandidates로만 분리한다.") .contains("독립적인 문제가 있으면 최대 3개까지 반환한다.") .contains("내부 판단 과정이나 chain-of-thought를 출력하지 않는다.") - .contains("점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다."); + .contains("점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다.") + .contains("[missingKeywordCandidates 생성 규칙]") + .contains("missingKeywordCandidates는 recall보다 precision을 우선한다.") + .contains("확실한 누락 역량이 없으면 []를 반환한다.") + .contains("keyword는 main_tasks 또는 qualifications에 명시적으로 존재하거나 직접적으로 동일한 의미인 항목만 사용한다.") + .contains("relatedRequirement는 main_tasks 또는 qualifications의 실제 문장을 그대로 복사하거나 조사/공백 정도만 최소 수정한다.") + .contains("답변 전체에 keyword와 직접 동일한 역량, 수행 경험, 도구 사용, 업무 수행이 이미 충분히 있으면 missingKeywordCandidates에 넣지 않는다.") + .contains("[missingKeywordCandidates Negative Examples]") + .contains("상세페이지 제작을 UX/UI 인터페이스 설계 같은 JD 밖 개념으로 확장하지 않는다.") + .contains("브랜드 운영은 이미 답변에 있으며, BI/CI 구축 경험으로 일반화하지 않는다.") + .contains("행정지원 역량이 이미 답변에 충분히 존재한다."); } @Test From 1c3292f65391fc2e0c041df2fe6dd68e41055600 Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 29 Jul 2026 21:50:11 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[Fix]=20NLG=20judge=20validation=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EB=A1=9C=EA=B7=B8=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - caseId mismatch 발생 시 expected/actual caseId와 response null 여부 로깅 - 빈 questionAnalyses에 대한 judge 평가 개수 불일치 로깅 - NLG judge 평가 예외 발생 시 stacktrace 포함 ERROR 로그 출력 - JsonProcessingException originalMessage 로깅 - IllegalArgumentException validationMessage 로깅 - 기존 judge 결과, 평가 기준, CSV 포맷은 유지 --- evaluation/evaluation_nlg_judge_current.csv | 21 +++++++++ ...valuation_nlg_judge_current_comparison.csv | 4 ++ ...lg_judge_two_pass_candidate_prompt_fix.csv | 21 +++++++++ ...o_pass_candidate_prompt_fix_comparison.csv | 3 ++ .../evaluation/NlgEvaluationBatchService.java | 44 ++++++++++++++++++- 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 evaluation/evaluation_nlg_judge_current.csv create mode 100644 evaluation/evaluation_nlg_judge_current_comparison.csv create mode 100644 evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix.csv create mode 100644 evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_comparison.csv diff --git a/evaluation/evaluation_nlg_judge_current.csv b/evaluation/evaluation_nlg_judge_current.csv new file mode 100644 index 0000000..df55087 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_current.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results.csv,0,,,,,,,,,,,1,5,5,1,1,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","답변에 대한 분석이 전혀 이루어지지 않았으며, 중요한 누락 키워드가 발견되었습니다. 전반적으로 개선이 필요합니다.",3631,142,3720, +EV-02,evaluation/evaluation_ai_results.csv,1,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,5,5,5,5,5,5,"[""NONE""]","지원자의 경험과 강점이 잘 드러나 있으며, JD의 요구사항을 충족하고 있습니다. 전반적으로 높은 품질의 자기소개서입니다.",2811,274,3745, +EV-03,evaluation/evaluation_ai_results.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]","자기소개서에 명백한 첨삭 대상이 없으나, 분석이 비어 있어 중요한 문제를 놓쳤습니다. 전반적으로 분석의 깊이가 부족합니다.",4131,139,5664, +EV-04,evaluation/evaluation_ai_results.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,4.0,3.0,4.0,3.0,2,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 부족하고, 주요 키워드가 누락되어 있어 전반적인 유용성이 낮습니다. 또한, 중요한 분석 대상을 놓쳤습니다.",4998,294,6407, +EV-05,evaluation/evaluation_ai_results.csv,1,4.0,3.0,4.0,4.0,3.0,5.0,5.0,4.0,5.0,5.0,3,5,5,5,5,3,"[""NONE""]","지원자의 포부는 명확하나 구체적인 실행 방법이 부족하여 다소 아쉬운 점이 있습니다. 전반적으로 강점이 잘 드러나지만, 분석의 깊이가 부족합니다.",3185,288,3982, +EV-06,evaluation/evaluation_ai_results.csv,0,,,,,,,,,,,1,5,5,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","자기소개서에서 명백한 문제 문장이 없으나, 분석이 전혀 이루어지지 않아 중요한 첨삭 대상을 놓쳤습니다. 또한, JD의 핵심 경험 요구사항이 누락되었습니다.",5786,159,3268, +EV-07,evaluation/evaluation_ai_results.csv,1,4.0,3.0,4.0,3.0,2.0,5.0,5.0,5.0,5.0,5.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 중요한 첨삭 대상을 놓쳤고, 문맥을 무시한 부분이 있어 전반적인 유용성이 낮게 평가되었습니다.",2708,283,4626, +EV-08,evaluation/evaluation_ai_results.csv,0,,,,,,,,,,,1,5,5,1,1,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 JD의 주요 요구사항과 잘 연결되지 않았으며, 분석이 전혀 이루어지지 않아 중요한 문제를 놓쳤습니다. 누락된 키워드도 다수 존재합니다.",4255,153,3799, +EV-09,evaluation/evaluation_ai_results.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,1,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험은 유용하지만, 세무 신고 실무 경험이 누락되어 있으며, 분석이 부족한 부분이 있습니다.",3577,299,4855, +EV-10,evaluation/evaluation_ai_results.csv,1,4.0,3.0,5.0,4.0,2.0,5.0,5.0,5.0,5.0,5.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","지원자의 교육 철학과 실습 경험이 잘 드러나지만, 포부의 구체적인 실행 방법이 부족하여 문맥을 간과한 분석이 있었습니다.",3484,285,5276, +EV-11,evaluation/evaluation_ai_results.csv,1,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","지원 동기와 포부가 잘 드러나 있으며, 자격증 관련 경험도 명확하게 표현되었습니다. 다만, 일부 문장에서 구체성이 부족하여 개선 여지가 있습니다.",3584,277,5816, +EV-12,evaluation/evaluation_ai_results.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,2,2,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]",지원 동기와 직무 적합성에 대한 설명이 구체적이지 않아 중요한 첨삭 대상을 놓쳤습니다. 또한 JD의 핵심 경험 요구사항이 누락되어 평가가 낮아졌습니다.,3852,306,6174, +EV-13,evaluation/evaluation_ai_results.csv,1,3.0,3.0,4.0,4.0,2.0,4.0,5.0,4.0,5.0,4.0,2,4,4,2,2,2,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석이 부족하여 주요 경험과 관련된 키워드가 누락되었고, 문맥을 충분히 반영하지 못한 점이 문제로 지적되었습니다.",3573,285,4428, +EV-14,evaluation/evaluation_ai_results.csv,0,,,,,,,,,,,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 JD의 주요 요구사항을 충분히 반영하지 못하고 있으며, 분석이 전혀 이루어지지 않아 중요한 첨삭 대상을 놓쳤습니다.",3704,147,3526, +EV-15,evaluation/evaluation_ai_results.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기는 잘 설명되었으나, 회사에 대한 구체적인 기여 방안이 부족하고, 주요 경험 키워드가 누락되었습니다.",3137,289,5168, +EV-16,evaluation/evaluation_ai_results.csv,1,,,,,,,,,,,3,4,4,1,1,3,"[""MISSED_MISSING_KEYWORD""]","지원자의 경험과 역량 설명이 구체적이나, JD의 핵심 경험 요구사항인 재고 관리 및 분석 경험이 누락되었습니다.",3996,176,7417, +EV-17,evaluation/evaluation_ai_results.csv,1,3.0,3.0,4.0,4.0,2.0,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 중요한 문제를 놓쳤고, 문맥을 무시한 부분이 있어 전반적인 유용성이 낮게 평가되었습니다.",3107,292,4754, +EV-18,evaluation/evaluation_ai_results.csv,1,3.0,3.0,4.0,4.0,2.0,5.0,5.0,4.0,5.0,5.0,2,4,4,2,2,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석이 부족하여 중요한 첨삭 대상을 놓쳤고, JD의 핵심 경험 요구사항이 누락되었습니다.",3156,270,5383, +EV-19,evaluation/evaluation_ai_results.csv,1,3.0,3.0,5.0,3.0,2.0,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 QA 직무에 대한 이해는 좋지만, 분석이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 전반적으로 유용성은 중간 수준입니다.",3102,277,5843, +EV-20,evaluation/evaluation_ai_results.csv,1,3.0,3.0,4.0,3.0,2.0,5.0,5.0,4.0,5.0,5.0,2,4,4,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 포부는 구체적이지 않으며, JD의 핵심 경험 요구사항이 누락되었습니다. 분석이 부족하여 중요한 문제를 놓쳤습니다.",3695,287,5350, diff --git a/evaluation/evaluation_nlg_judge_current_comparison.csv b/evaluation/evaluation_nlg_judge_current_comparison.csv new file mode 100644 index 0000000..51e10a6 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_current_comparison.csv @@ -0,0 +1,4 @@ +sourceResultFile,caseCount,successCount,judgeFailedCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,averageJudgeInputTokens,averageJudgeOutputTokens,averageJudgeLatencyMs,averageAnalysisCount,metaImprovementRate,unsupportedFactRate,falsePositiveAnalysisRate,fatalErrorRate,errorCodeCounts +evaluation/evaluation_nlg_judge_v5a_v2.csv,20,20,0,3.06,2.75,3.75,3.19,2.06,4.0,4.13,3.81,3.69,4.06,2.05,4.45,4.45,3.45,3.45,2.75,,,4214.25,0.8,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":4,""MISSED_ANALYSIS"":18,""MISSED_MISSING_KEYWORD"":10,""NONE"":1}" +evaluation/evaluation_nlg_judge_two_pass_v2_1_v2.csv,20,20,0,3.33,2.5,3.5,3.33,2.17,4.0,4.17,3.5,3.33,4.0,1.85,4.14,4.14,4.07,4.07,2.6,,,3821.65,0.45,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":2,""MISSED_ANALYSIS"":19,""MISSED_MISSING_KEYWORD"":2,""MISSED_STRENGTH"":2,""NONE"":1}" +evaluation/evaluation_nlg_judge_current.csv,20,20,0,3.36,2.93,4.0,3.5,2.21,4.43,4.64,4.07,4.57,4.43,2.15,4.55,4.55,3.0,3.05,2.8,3673.6,246.1,4960.05,0.75,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":6,""MISSED_ANALYSIS"":16,""MISSED_MISSING_KEYWORD"":12,""NONE"":3}" diff --git a/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix.csv b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix.csv new file mode 100644 index 0000000..faa827f --- /dev/null +++ b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-02,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-03,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,5.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","자기소개서에서 구체적인 성과와 행동이 부족하여 개선이 필요합니다. 분석 대상이 명확히 존재하지만, 이에 대한 적절한 평가가 이루어지지 않았습니다.",5192,445,6034, +EV-04,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,4.0,3.0,3.0,3.0,3.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","전반적으로 직무 적합성을 잘 나타냈으나, 구체적인 방법이나 절차에 대한 설명이 부족하여 중요한 첨삭 대상을 놓쳤습니다.",5613,201,5419, +EV-05,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,4.0,3.0,4.0,3.0,3.0,4.0,5.0,3.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원자의 경험이 구체적으로 부족하여 중요한 첨삭 대상을 놓쳤습니다. 전반적으로 개선이 필요합니다.,3684,399,6796, +EV-06,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,2.0,3.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",분석이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 구체적인 방법론과 성과를 강조할 필요가 있습니다.,6578,252,4392, +EV-07,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-08,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","전반적으로 경험이 잘 드러나지만, 구체적인 실행 방법이 부족하여 중요한 첨삭 대상을 놓쳤습니다.",5063,96,5016, +EV-09,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_STRENGTH"",""MISSED_ANALYSIS""]",경험을 잘 서술하였으나 성과 수치가 부족합니다.,4556,434,6756, +EV-10,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-11,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,1,1,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]",지원 동기와 프로그램 운영에 대한 구체적인 사례나 성과가 부족하여 전반적인 평가가 낮았습니다. 누락된 자격증 관련 키워드도 확인되었습니다.,4403,149,3799, +EV-12,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,3.0,4.0,2.0,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",구체적인 행동과 방법이 부족하여 분석이 미흡했습니다. 주요 강점이 잘 드러나지 않았습니다.,4698,421,5270, +EV-13,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-14,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,5.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",경험의 구체성과 성과 수치가 부족하여 개선이 필요합니다. 중요한 첨삭 대상을 놓쳤습니다.,4687,423,7082, +EV-15,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험을 잘 연결했으나, 구체적인 성과와 JD의 핵심 요구사항이 부족하여 평가가 낮아졌습니다.",3527,106,2021, +EV-16,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,4.0,3.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",경험 서술이 구체적이지 않아 강점이 잘 드러나지 않았습니다. 중요한 첨삭 대상을 놓쳤습니다.,4896,309,4756, +EV-17,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,1,1,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]",분석이 비어있고 구체적인 행동 및 계획이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 누락된 키워드도 확인되어 전반적인 유용성이 낮습니다.,3685,153,2422, +EV-18,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",분석이 비어있어 명백한 문제 문장이 존재함에도 불구하고 적절한 분석이 이루어지지 않았습니다. 이로 인해 전반적인 유용성이 낮게 평가되었습니다.,3841,112,2854, +EV-19,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","지원자의 QA 직무에 대한 이해와 경험이 잘 드러났으나, 구체적인 행동과 방법론이 부족하여 개선이 필요합니다.",4005,313,6245, +EV-20,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed diff --git a/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_comparison.csv b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_comparison.csv new file mode 100644 index 0000000..fedb553 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_comparison.csv @@ -0,0 +1,3 @@ +sourceResultFile,caseCount,successCount,judgeFailedCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,averageJudgeInputTokens,averageJudgeOutputTokens,averageJudgeLatencyMs,averageAnalysisCount,metaImprovementRate,unsupportedFactRate,falsePositiveAnalysisRate,fatalErrorRate,errorCodeCounts +evaluation/evaluation_nlg_judge_two_pass_v2.csv,20,20,0,3.92,3.54,3.92,3.38,2.85,3.92,4.85,3.92,4.31,3.92,,3.3,,3.0,,4.0,,,3805.0,0.65,0.0,0.0,0.0,0.0,"{""NONE"":20}" +evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix.csv,20,14,6,3.78,2.89,3.67,3.56,2.78,4.11,4.44,3.78,4.22,4.11,2.07,4.29,4.29,4.36,4.36,2.93,4602.0,272.36,4918.71,1.07,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":1,""MISSED_ANALYSIS"":13,""MISSED_MISSING_KEYWORD"":3,""MISSED_STRENGTH"":1,""NONE"":1}" diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java index b5eaa05..acdc7a4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java @@ -83,7 +83,7 @@ NlgEvaluationSummary run(Path inputPath, Path outputPath) throws IOException { successCount++; } } catch (Exception e) { - log.warn("NLG judge failed. caseId={}, message={}", caseId, e.getMessage()); + logEvaluationFailure(caseId, inputPath.toString(), e); results.add(NlgEvaluationResult.failed(caseId, inputPath.toString(), failureStage(e))); } } @@ -152,12 +152,23 @@ private NlgEvaluationResult validateAndBuildResult( ) { NlgEvaluationResponse response = callResult.response(); if (response == null || !Objects.equals(input.caseId(), response.caseId())) { + log.warn( + "Judge validation failed. expectedCaseId={}, actualCaseId={}, responseNull={}", + input.caseId(), + response == null ? null : response.caseId(), + response == null + ); return NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"); } if (input.questionAnalyses().isEmpty() && response.questionAnalysisEvaluations() != null && !response.questionAnalysisEvaluations().isEmpty()) { + log.warn( + "Judge validation failed. inputQuestionAnalyses={}, responseQuestionAnalysisEvaluations={}", + input.questionAnalyses().size(), + response.questionAnalysisEvaluations().size() + ); return NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"); } @@ -785,6 +796,37 @@ private String failureStage(Exception e) { return "judge_call_failed"; } + private void logEvaluationFailure(String caseId, String sourceResultFile, Exception e) { + if (e instanceof JsonProcessingException jsonProcessingException) { + log.error( + "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, exceptionType={}, originalMessage={}", + caseId, + sourceResultFile, + jsonProcessingException.getClass().getSimpleName(), + jsonProcessingException.getOriginalMessage(), + e + ); + return; + } + if (e instanceof IllegalArgumentException illegalArgumentException) { + log.error( + "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, exceptionType={}, validationMessage={}", + caseId, + sourceResultFile, + illegalArgumentException.getClass().getSimpleName(), + illegalArgumentException.getMessage(), + e + ); + return; + } + log.error( + "NLG Judge evaluation failed. caseId={}, sourceResultFile={}", + caseId, + sourceResultFile, + e + ); + } + record NlgEvaluationSummary( int totalCount, int successCount, From 92b92a6c342832230d153c11ae9db0929b3826e9 Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 29 Jul 2026 21:56:25 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[Fix]=20NLG=20judge=20validation=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=9B=90=EC=9D=B8=20=EC=A7=84=EB=8B=A8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Judge 응답 null, caseId mismatch, questionAnalysis 개수 불일치 원인 구분 로그 추가 - Judge 호출 및 Structured Output 역직렬화 실패 시 stacktrace 포함 ERROR 로그 출력 - Jackson path/reference chain과 실패 필드명 로깅 - NlgEvaluationErrorCode enum 역직렬화 실패 시 unknownValue와 targetEnum 로깅 - root cause 타입과 메시지 로깅 - 기존 Judge 프롬프트, DTO, enum, validation 기준, CSV failureStage 호환성 유지 --- .../evaluation/NlgEvaluationAiClient.java | 47 ++++-- .../evaluation/NlgEvaluationBatchService.java | 148 +++++++++++++++++- 2 files changed, 178 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java index 5c9cace..b13eb19 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java @@ -7,6 +7,7 @@ import com.openai.models.responses.StructuredResponse; import com.openai.models.responses.StructuredResponseOutputMessage; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -14,6 +15,7 @@ @Component @RequiredArgsConstructor +@Slf4j class NlgEvaluationAiClient { private final OpenAIClient openAIClient; private final LlmConcurrencyLimiter llmConcurrencyLimiter; @@ -29,17 +31,32 @@ JudgeCallResult evaluate(NlgJudgeInput input) { .temperature(0.0) .text(NlgEvaluationResponse.class) .build(); - StructuredResponse response = llmConcurrencyLimiter.execute( - "analysis-nlg-judge", - () -> openAIClient.responses().create(params) - ); - ResponseUsage usage = response.usage().orElse(null); - return new JudgeCallResult( - extractStructuredContent(response), - elapsedMillis(startedAt), - toIntegerTokenCount(usage == null ? null : usage.inputTokens()), - toIntegerTokenCount(usage == null ? null : usage.outputTokens()) - ); + try { + StructuredResponse response = llmConcurrencyLimiter.execute( + "analysis-nlg-judge", + () -> openAIClient.responses().create(params) + ); + ResponseUsage usage = response.usage().orElse(null); + return new JudgeCallResult( + extractStructuredContent(response), + elapsedMillis(startedAt), + toIntegerTokenCount(usage == null ? null : usage.inputTokens()), + toIntegerTokenCount(usage == null ? null : usage.outputTokens()) + ); + } catch (RuntimeException e) { + Throwable rootCause = rootCause(e); + log.error( + "NLG Judge call failed. caseId={}, sourceResultFile={}, exceptionType={}, message={}, rootCauseType={}, rootCauseMessage={}, rawJudgeResponseAvailable=false", + input.caseId(), + input.sourceResultFile(), + e.getClass().getName(), + e.getMessage(), + rootCause.getClass().getName(), + rootCause.getMessage(), + e + ); + throw e; + } } String buildPrompt(NlgJudgeInput input) { @@ -160,6 +177,14 @@ private Integer toIntegerTokenCount(Long tokens) { return tokens > Integer.MAX_VALUE ? Integer.MAX_VALUE : tokens.intValue(); } + private Throwable rootCause(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + record JudgeCallResult( NlgEvaluationResponse response, Long latencyMs, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java index acdc7a4..d867ca8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -151,12 +153,32 @@ private NlgEvaluationResult validateAndBuildResult( NlgEvaluationAiClient.JudgeCallResult callResult ) { NlgEvaluationResponse response = callResult.response(); - if (response == null || !Objects.equals(input.caseId(), response.caseId())) { + if (response == null) { + log.warn( + "Judge validation failed. reason=response_null, caseId={}, sourceResultFile={}", + input.caseId(), + input.sourceResultFile() + ); + log.warn( + "Judge validation failed. expectedCaseId={}, actualCaseId={}, responseNull={}", + input.caseId(), + null, + true + ); + return NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"); + } + if (!Objects.equals(input.caseId(), response.caseId())) { + log.warn( + "Judge validation failed. reason=case_id_mismatch, expectedCaseId={}, actualCaseId={}, sourceResultFile={}", + input.caseId(), + response.caseId(), + input.sourceResultFile() + ); log.warn( "Judge validation failed. expectedCaseId={}, actualCaseId={}, responseNull={}", input.caseId(), - response == null ? null : response.caseId(), - response == null + response.caseId(), + false ); return NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"); } @@ -164,6 +186,11 @@ private NlgEvaluationResult validateAndBuildResult( if (input.questionAnalyses().isEmpty() && response.questionAnalysisEvaluations() != null && !response.questionAnalysisEvaluations().isEmpty()) { + log.warn( + "Judge validation failed. reason=question_analysis_count_mismatch, caseId={}, sourceResultFile={}", + input.caseId(), + input.sourceResultFile() + ); log.warn( "Judge validation failed. inputQuestionAnalyses={}, responseQuestionAnalysisEvaluations={}", input.questionAnalyses().size(), @@ -797,12 +824,30 @@ private String failureStage(Exception e) { } private void logEvaluationFailure(String caseId, String sourceResultFile, Exception e) { + Throwable rootCause = rootCause(e); + String internalReason = failureReason(e); + String jacksonPath = jacksonPath(e); + String failedField = failedFieldName(e); + Object unknownEnumValue = unknownEnumValue(e); if (e instanceof JsonProcessingException jsonProcessingException) { log.error( - "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, exceptionType={}, originalMessage={}", + "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, reason={}, exceptionType={}, message={}, rootCauseType={}, rootCauseMessage={}, jacksonPath={}, failedField={}, targetEnum={}, unknownValue={}, rawJudgeResponseAvailable=false", caseId, sourceResultFile, + internalReason, jsonProcessingException.getClass().getSimpleName(), + jsonProcessingException.getMessage(), + rootCause.getClass().getName(), + rootCause.getMessage(), + jacksonPath, + failedField, + targetEnumName(e), + unknownEnumValue, + e + ); + log.error( + "NLG Judge JsonProcessingException detail. caseId={}, originalMessage={}", + caseId, jsonProcessingException.getOriginalMessage(), e ); @@ -810,23 +855,114 @@ private void logEvaluationFailure(String caseId, String sourceResultFile, Except } if (e instanceof IllegalArgumentException illegalArgumentException) { log.error( - "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, exceptionType={}, validationMessage={}", + "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, reason={}, exceptionType={}, message={}, rootCauseType={}, rootCauseMessage={}, jacksonPath={}, failedField={}, targetEnum={}, unknownValue={}, rawJudgeResponseAvailable=false", caseId, sourceResultFile, + internalReason, illegalArgumentException.getClass().getSimpleName(), illegalArgumentException.getMessage(), + rootCause.getClass().getName(), + rootCause.getMessage(), + jacksonPath, + failedField, + targetEnumName(e), + unknownEnumValue, e ); return; } log.error( - "NLG Judge evaluation failed. caseId={}, sourceResultFile={}", + "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, reason={}, exceptionType={}, message={}, rootCauseType={}, rootCauseMessage={}, jacksonPath={}, failedField={}, targetEnum={}, unknownValue={}, rawJudgeResponseAvailable=false", caseId, sourceResultFile, + internalReason, + e.getClass().getName(), + e.getMessage(), + rootCause.getClass().getName(), + rootCause.getMessage(), + jacksonPath, + failedField, + targetEnumName(e), + unknownEnumValue, e ); } + private String failureReason(Exception e) { + if (findCause(e, InvalidFormatException.class) + .filter(this::isNlgEvaluationErrorCodeFailure) + .isPresent()) { + return "unknown_error_code"; + } + if (findCause(e, JsonProcessingException.class).isPresent()) { + return "json_deserialization_failed"; + } + if (e instanceof IllegalArgumentException) { + return "illegal_argument"; + } + if (findCause(e, IllegalStateException.class) + .map(Throwable::getMessage) + .filter(message -> message != null && message.contains("구조화된 결과를 찾을 수 없습니다")) + .isPresent()) { + return "structured_output_missing"; + } + return "judge_call_failed"; + } + + private boolean isNlgEvaluationErrorCodeFailure(InvalidFormatException e) { + Class targetType = e.getTargetType(); + return targetType != null && NlgEvaluationErrorCode.class.equals(targetType); + } + + private String jacksonPath(Exception e) { + return findCause(e, JsonMappingException.class) + .map(JsonMappingException::getPathReference) + .orElse(""); + } + + private String failedFieldName(Exception e) { + return findCause(e, JsonMappingException.class) + .flatMap(mappingException -> mappingException.getPath().stream() + .map(JsonMappingException.Reference::getFieldName) + .filter(StringUtils::hasText) + .reduce((first, second) -> second)) + .orElse(""); + } + + private Object unknownEnumValue(Exception e) { + return findCause(e, InvalidFormatException.class) + .filter(this::isNlgEvaluationErrorCodeFailure) + .map(InvalidFormatException::getValue) + .orElse(null); + } + + private String targetEnumName(Exception e) { + return findCause(e, InvalidFormatException.class) + .filter(this::isNlgEvaluationErrorCodeFailure) + .map(InvalidFormatException::getTargetType) + .map(Class::getSimpleName) + .orElse(""); + } + + private Throwable rootCause(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + + private Optional findCause(Throwable throwable, Class type) { + Throwable current = throwable; + while (current != null) { + if (type.isInstance(current)) { + return Optional.of(type.cast(current)); + } + current = current.getCause(); + } + return Optional.empty(); + } + record NlgEvaluationSummary( int totalCount, int successCount, From 4031e3d2ff57a22ebbcf83991d94435fe0600ade Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 29 Jul 2026 22:19:53 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[Fix]=20NLG=20judge=20=EB=B9=88=20questio?= =?UTF-8?q?nAnalyses=20=ED=8F=89=EA=B0=80=20=EA=B3=84=EC=95=BD=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - questionAnalysesJson에 명시된 분석 항목만 questionAnalysisEvaluations로 평가하도록 프롬프트 보강 - questionAnalysesJson이 빈 배열이면 questionAnalysisEvaluations도 반드시 빈 배열로 반환하도록 명시 - raw/sanitized/review candidate에서 분석을 추론하거나 복원하지 않도록 금지 - rejected 또는 removed candidate를 questionAnalysisEvaluation으로 변환하지 않도록 명시 - 빈 분석은 validation error가 아니며 noAnalysisAppropriateness로 평가하도록 정리 - 분석 N개 입력 시 입력 분석 N개에만 대응 평가하도록 출력 계약 추가 - NLG judge 프롬프트 회귀 테스트 추가 - DTO, enum, validation, CSV 포맷은 변경하지 않음 --- ..._pass_candidate_prompt_fix_diagnostics.csv | 21 +++++ .../evaluation/NlgEvaluationAiClient.java | 16 ++++ .../evaluation/NlgEvaluationAiClientTest.java | 94 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_diagnostics.csv diff --git a/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_diagnostics.csv b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_diagnostics.csv new file mode 100644 index 0000000..647ced4 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_diagnostics.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-02,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-03,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","자기소개서에서 구체적인 성과와 행동이 부족하여 개선이 필요합니다. 분석 대상이 명확히 존재하지만, 이에 대한 적절한 평가가 이루어지지 않았습니다.",5192,445,6976, +EV-04,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,4.0,3.0,3.0,3.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 경험이 직무와 관련이 있지만, 구체적인 방법이나 절차가 부족하여 분석이 미흡했습니다.",5613,192,4088, +EV-05,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,4.0,3.0,4.0,3.0,3.0,4.0,4.0,3.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원자의 경험이 구체적으로 부족하여 분석이 미흡했습니다. 전반적으로 개선이 필요합니다.,3684,395,6332, +EV-06,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,4.0,3.0,3.0,4.0,2,1,1,1,1,3,"[""CONTEXT_IGNORED"",""MISSED_MISSING_KEYWORD""]",분석에서 구체적인 방법론과 성과가 부족하여 평가가 낮아졌습니다. JD의 핵심 경험 요구사항이 누락되어 전체 유용성 점수가 낮았습니다.,6578,266,6125, +EV-07,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-08,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-09,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",경험을 잘 서술하였으나 성과 수치가 부족합니다.,4556,425,5820, +EV-10,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-11,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,1,1,3,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기와 관련된 구체적인 사례가 부족하며, 자격증 관련 누락이 확인되었습니다. 분석이 비어 있어 중요한 문제를 놓쳤습니다.",4403,145,2845, +EV-12,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,3.0,4.0,2.0,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",분석이 부족하여 구체적인 행동과 방법이 결여된 점이 아쉽습니다. 주요 문장에 대한 개선이 필요합니다.,4698,425,6049, +EV-13,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-14,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",경험의 구체성과 성과 수치가 부족하여 개선이 필요합니다. 분석이 부족한 부분이 있어 전반적인 유용성이 낮게 평가되었습니다.,4687,431,6506, +EV-15,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]",지원자의 경험을 잘 연결했으나 구체적인 성과가 부족하여 주요 첨삭 대상을 놓쳤습니다. JD의 핵심 경험 요구사항도 누락되었습니다.,3527,113,2844, +EV-16,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,3.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","전반적으로 경험을 잘 서술했으나, 구체적인 행동과 결과에 대한 설명이 부족하여 분석이 미흡하게 평가되었습니다.",4896,429,6576, +EV-17,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-18,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 비어있어 중요한 첨삭 대상을 놓쳤으며, 경험과 방법론이 부족하다는 점이 명확히 드러났습니다.",3841,135,4081, +EV-19,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 QA 직무에 대한 이해와 경험이 잘 드러났으나, 구체적인 행동과 방법론이 부족하여 개선이 필요합니다.",4005,435,6981, +EV-20,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java index b13eb19..fa7a9e1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java @@ -94,6 +94,22 @@ String buildPrompt(NlgJudgeInput input) { - questionAnalyses가 없으면 questionAnalysisEvaluations는 []로 둔다. 가짜 평가를 생성하지 않는다. - questionAnalyses가 비어 있어도 중요한 첨삭 대상을 놓쳤다면 MISSED_ANALYSIS를 부여하고 overallUsefulness를 낮게 평가한다. + [questionAnalysisEvaluations 출력 계약] + - questionAnalysisEvaluations는 questionAnalysesJson에 명시적으로 포함된 분석 항목만 평가한다. + - The number of questionAnalysisEvaluations must equal the number of entries in questionAnalysesJson. + - Each evaluation must correspond to exactly one input question analysis. + - Do not add evaluations for analyses that are not present in questionAnalysesJson. + - questionAnalysesJson이 빈 배열인 경우 questionAnalysisEvaluations MUST be an empty array. + - 이 경우에도 noAnalysisAppropriateness, strengthsPrecision, strengthsCoverage, missingKeywordsPrecision, missingKeywordsCoverage, overallUsefulness, caseErrorCodes, shortRationale은 계속 평가한다. + - questionAnalysesJson이 빈 배열이어도 question, answer, keyStrengthsJson, missingKeywordsJson, rawCandidateResponseJson, sanitizedCandidateResponseJson, candidateReviewResponseJson을 보고 새로운 question analysis를 추론하거나 생성하지 않는다. + - Do not infer, reconstruct, create, or restore question analyses from question, answer, keyStrengthsJson, missingKeywordsJson, rawCandidateResponseJson, sanitizedCandidateResponseJson, or candidateReviewResponseJson. + - Rejected or removed candidates must not be converted into questionAnalysisEvaluations. + - 삭제되었거나 거절된 candidate를 questionAnalysisEvaluation으로 복원하지 않는다. + - raw/sanitized/review candidate는 참고 자료일 뿐 평가 대상 분석 목록이 아니다. + - 빈 questionAnalysesJson은 그 자체로 validation error가 아니며, 분석 부재의 적절성은 noAnalysisAppropriateness로 평가한다. + - 출력 예: questionAnalysesJson=[]이면 반드시 "questionAnalysisEvaluations": [] 이어야 한다. + - analysisIndex는 questionAnalysesJson 입력 배열의 index만 사용한다. + [errorCode와 점수 정합성] - problemValidity <= 2이면 FALSE_POSITIVE_ANALYSIS 후보로 검토한다. - contextAwareness <= 2이면 CONTEXT_IGNORED 후보로 검토한다. diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java index 16b9c6c..e8d3f87 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java @@ -60,4 +60,98 @@ void buildPromptContainsJudgeRulesWithoutChainOfThoughtOutput() { assertThat(prompt).contains("기본값을 4로 두지 말고"); assertThat(prompt).contains("1,2,3,4,5를 실제 오류 심각도에 따라 분산"); } + + @Test + @DisplayName("questionAnalysesJson이 빈 배열이면 평가 배열도 반드시 빈 배열이어야 한다") + void buildPromptRequiresEmptyQuestionAnalysisEvaluationsForEmptyInputAnalyses() { + String prompt = buildPrompt( + "[]", + "{\"questionAnalysisCandidates\":[{\"sentence\":\"복원 후보\"}]}", + "{\"reviews\":[{\"accepted\":false}]}" + ); + + assertThat(prompt) + .contains("questionAnalysisEvaluations MUST be an empty array") + .contains("Do not infer, reconstruct, create, or restore question analyses") + .contains("Rejected or removed candidates must not be converted into questionAnalysisEvaluations") + .contains("삭제되었거나 거절된 candidate를 questionAnalysisEvaluation으로 복원하지 않는다") + .contains("빈 questionAnalysesJson은 그 자체로 validation error가 아니며") + .contains("noAnalysisAppropriateness로 평가한다") + .contains("\"questionAnalysisEvaluations\": []"); + } + + @Test + @DisplayName("questionAnalysesJson에 2개 분석이 있으면 해당 입력 분석만 평가하도록 지시한다") + void buildPromptRequiresOneEvaluationPerInputAnalysisOnly() { + String prompt = buildPrompt( + """ + [ + {"sentence":"첫 번째 분석 문장","status":"MENTIONED"}, + {"sentence":"두 번째 분석 문장","status":"FABRICATED"} + ] + """, + "{}", + "{}" + ); + + assertThat(prompt) + .contains("The number of questionAnalysisEvaluations must equal the number of entries in questionAnalysesJson") + .contains("Each evaluation must correspond to exactly one input question analysis") + .contains("Do not add evaluations for analyses that are not present in questionAnalysesJson") + .contains("analysisIndex는 questionAnalysesJson 입력 배열의 index만 사용한다") + .contains("raw/sanitized/review candidate는 참고 자료일 뿐 평가 대상 분석 목록이 아니다"); + } + + @Test + @DisplayName("questionAnalysisEvaluations 계약을 추가해도 기존 점수, 오류, case-level 평가 기준은 유지한다") + void buildPromptKeepsExistingJudgeCriteria() { + String prompt = buildPrompt("[]", "{}", "{}"); + + assertThat(prompt) + .contains("relevance, problemValidity, sentenceTypeConsistency, reasonCorrectness, contextAwareness") + .contains("faithfulness, tenseConsistency, usability, nonMeta, meaningPreservation") + .contains("problemValidity <= 2이면 FALSE_POSITIVE_ANALYSIS") + .contains("contextAwareness <= 2이면 CONTEXT_IGNORED") + .contains("nonMeta <= 2이면 META_IMPROVEMENT") + .contains("다른 오류 코드가 하나라도 있으면 NONE을 함께 반환하지 않는다") + .contains("noAnalysisAppropriateness") + .contains("strengthsPrecision") + .contains("strengthsCoverage") + .contains("missingKeywordsPrecision") + .contains("missingKeywordsCoverage") + .contains("overallUsefulness") + .contains("caseErrorCodes") + .contains("shortRationale") + .contains("MISSED_ANALYSIS") + .contains("MISSED_MISSING_KEYWORD"); + } + + private String buildPrompt( + String questionAnalysesJson, + String rawCandidateResponseJson, + String candidateReviewResponseJson + ) { + NlgEvaluationAiClient client = new NlgEvaluationAiClient( + mock(OpenAIClient.class), + mock(LlmConcurrencyLimiter.class) + ); + return client.buildPrompt(new NlgEvaluationAiClient.NlgJudgeInput( + "EV-01", + "evaluation/result.csv", + "재고 분석", + "장애 대응 경험", + "SQL 우대", + "지원 동기", + "답변", + questionAnalysesJson, + "[]", + "[]", + rawCandidateResponseJson, + "", + candidateReviewResponseJson, + 0, + 0, + List.of() + )); + } } From 9dcd29bb5ebdaac471aa787f40139a0c8e03ee0b Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 29 Jul 2026 22:48:49 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[Fix]=20NLG=20judge=20=EB=B9=88=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=8F=89=EA=B0=80=20=EA=B0=9C=EC=88=98=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - questionAnalyses가 비어 있는데 judge가 questionAnalysisEvaluations를 생성한 경우만 1회 재시도 - 내부 JudgeValidationReason과 JudgeValidationResult로 validation 실패 사유 구분 - response null, caseId mismatch, 역직렬화 오류, judge 호출 실패는 재시도하지 않도록 유지 - 재시도 시작, 성공, 재실패 로그 추가 - 재시도 성공 시 두 번째 응답만 최종 결과로 사용 - 재시도 실패 시 기존 judge_validation_failed 결과 유지 - NLG judge 재시도 조건 및 비대상 실패 유형 테스트 추가 - DTO, enum, 프롬프트, CSV 포맷은 변경하지 않음 --- ...ss_candidate_prompt_fix_empty_contract.csv | 21 +++ .../evaluation/NlgEvaluationBatchService.java | 134 ++++++++++--- .../NlgEvaluationBatchServiceTest.java | 176 ++++++++++++++---- 3 files changed, 260 insertions(+), 71 deletions(-) create mode 100644 evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_empty_contract.csv diff --git a/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_empty_contract.csv b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_empty_contract.csv new file mode 100644 index 0000000..6a002f6 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_empty_contract.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","답변에서 명확한 문제 문장이 없으나, 분석이 부재하여 중요한 첨삭 대상을 놓쳤습니다.",4739,128,3830, +EV-02,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,1,1,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]",지원자의 경험이 구체적이지 않아 주요 요구사항을 충족하지 못하고 있습니다. 분석이 부재하여 중요한 첨삭 대상을 놓쳤습니다.,3770,144,4522, +EV-03,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,3.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",자기소개서에서 구체적인 성과와 행동이 부족하여 개선이 필요합니다. 분석 대상이 명확히 드러나지 않아 평가가 낮아졌습니다.,5551,440,9878, +EV-04,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 문맥을 무시한 부분이 있으며, 구체적인 방법이 결여되어 있습니다. 전반적으로 직무 적합성을 나타내지만, 개선이 필요합니다.",5972,293,6452, +EV-05,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,3.0,4.0,3.0,3.0,4.0,4.0,3.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원자의 경험이 구체적으로 서술되지 않아 분석이 부족하다고 판단됩니다. 전반적으로 유용성은 중간 수준입니다.,4043,283,6193, +EV-06,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,2.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 문맥을 무시한 부분이 있으며, 구체적인 방법론이 결여되어 있습니다. 전반적으로 개선이 필요합니다.",6937,338,5702, +EV-07,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 전혀 없으며, 명백한 문제 문장이 존재합니다. 구체적인 성과와 방법에 대한 설명이 부족합니다.",3737,131,2693, +EV-08,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 경험이 잘 드러나지만, 구체적인 실행 방법이 부족하여 중요한 첨삭 대상을 놓쳤습니다.",5422,96,3062, +EV-09,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",경험을 잘 서술하였으나 성과 수치가 부족하여 분석이 미흡하게 평가되었습니다.,4915,432,6761, +EV-10,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-11,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,1,1,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기와 포부에 대한 구체적인 사례와 성과가 부족하여 전반적인 평가가 낮았습니다. 또한, 필요한 자격증이 누락되어 있습니다.",4762,147,4483, +EV-12,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,2.0,3.0,3.0,2.0,5.0,5.0,3.0,3.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 부족하여 구체적인 행동과 방법이 명확하지 않으며, 중요한 첨삭 대상을 놓쳤습니다.",5057,422,6963, +EV-13,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 전혀 없으며, 명백한 문제 문장이 존재합니다. 구체적인 행동과 방법이 부족하여 개선이 필요합니다.",4603,131,3074, +EV-14,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,2.0,3.0,3.0,2.0,5.0,5.0,3.0,3.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원자의 경험이 구체적이지 않고 성과 수치가 부족하여 개선이 필요합니다. 중요한 첨삭 대상을 놓쳤습니다.,5046,426,6273, +EV-15,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,3,3,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험은 잘 연결되었으나, 구체적인 성과와 JD의 핵심 요구사항이 부족하여 전반적인 유용성이 낮습니다.",3886,144,3244, +EV-16,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,4.0,3.0,3.0,4.0,4.0,4.0,4.0,4.0,3,5,5,5,5,3,"[""NONE""]",전반적으로 경험이 구체적으로 서술되지 않아 강점이 잘 드러나지 않았습니다. 개선이 필요합니다.,5255,414,7782, +EV-17,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","답변에 명백한 첨삭 대상이 없으나, 구체적인 행동과 계획이 부족하여 평가가 낮아졌습니다.",4044,131,2971, +EV-18,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 전혀 없으며, 명백한 문제 문장이 존재하여 적절한 평가가 이루어지지 않았습니다.",4200,128,1775, +EV-19,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","지원자의 QA 직무에 대한 이해는 좋지만, 구체적인 행동과 방법론이 부족하여 개선이 필요합니다.",4364,449,10275, +EV-20,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원 동기와 입사 후 포부에서 구체성이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 분석이 없어서 전반적인 유용성이 낮게 평가되었습니다.,4317,140,2459, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java index d867ca8..e9a7956 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java @@ -79,7 +79,7 @@ NlgEvaluationSummary run(Path inputPath, Path outputPath) throws IOException { try { NlgEvaluationAiClient.NlgJudgeInput input = buildJudgeInput(inputPath, row, sourceCaseRows, resolvedHeaders); NlgEvaluationAiClient.JudgeCallResult callResult = nlgEvaluationAiClient.evaluate(input); - NlgEvaluationResult result = validateAndBuildResult(input, callResult); + NlgEvaluationResult result = validateAndBuildResultWithRetry(input, callResult); results.add(result); if (!StringUtils.hasText(result.failureStage())) { successCount++; @@ -148,7 +148,36 @@ private NlgEvaluationAiClient.NlgJudgeInput buildJudgeInput( ); } - private NlgEvaluationResult validateAndBuildResult( + private NlgEvaluationResult validateAndBuildResultWithRetry( + NlgEvaluationAiClient.NlgJudgeInput input, + NlgEvaluationAiClient.JudgeCallResult callResult + ) { + JudgeValidationResult firstValidation = validateAndBuildResult(input, callResult); + if (firstValidation.reason() != JudgeValidationReason.QUESTION_ANALYSIS_COUNT_MISMATCH) { + return firstValidation.result(); + } + + log.warn( + "Retrying NLG Judge after validation failure. caseId={}, sourceResultFile={}, retryAttempt=1, reason=question_analysis_count_mismatch, inputQuestionAnalyses={}, responseQuestionAnalysisEvaluations={}", + input.caseId(), + input.sourceResultFile(), + input.questionAnalyses().size(), + firstValidation.responseQuestionAnalysisEvaluations() + ); + NlgEvaluationAiClient.JudgeCallResult retryCallResult = nlgEvaluationAiClient.evaluate(input); + JudgeValidationResult retryValidation = validateAndBuildResult(input, retryCallResult); + if (retryValidation.reason() == JudgeValidationReason.NONE) { + log.info("NLG Judge retry succeeded. caseId={}, retryAttempt=1", input.caseId()); + } else if (retryValidation.reason() == JudgeValidationReason.QUESTION_ANALYSIS_COUNT_MISMATCH) { + log.warn( + "NLG Judge retry failed. caseId={}, retryAttempt=1, reason=question_analysis_count_mismatch", + input.caseId() + ); + } + return retryValidation.result(); + } + + private JudgeValidationResult validateAndBuildResult( NlgEvaluationAiClient.NlgJudgeInput input, NlgEvaluationAiClient.JudgeCallResult callResult ) { @@ -165,7 +194,11 @@ private NlgEvaluationResult validateAndBuildResult( null, true ); - return NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"); + return JudgeValidationResult.failed( + input, + JudgeValidationReason.RESPONSE_NULL, + null + ); } if (!Objects.equals(input.caseId(), response.caseId())) { log.warn( @@ -180,7 +213,11 @@ private NlgEvaluationResult validateAndBuildResult( response.caseId(), false ); - return NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"); + return JudgeValidationResult.failed( + input, + JudgeValidationReason.CASE_ID_MISMATCH, + response.questionAnalysisEvaluations() + ); } if (input.questionAnalyses().isEmpty() @@ -196,7 +233,11 @@ private NlgEvaluationResult validateAndBuildResult( input.questionAnalyses().size(), response.questionAnalysisEvaluations().size() ); - return NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"); + return JudgeValidationResult.failed( + input, + JudgeValidationReason.QUESTION_ANALYSIS_COUNT_MISMATCH, + response.questionAnalysisEvaluations() + ); } List evaluations = @@ -209,32 +250,34 @@ private NlgEvaluationResult validateAndBuildResult( if (missedValidatedMissingKeywords) { missingKeywordsCoverage = missingKeywordsCoverage == null ? 1 : Math.min(missingKeywordsCoverage, 1); } - return new NlgEvaluationResult( - input.caseId(), - input.sourceResultFile(), - input.questionAnalyses().size(), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::relevance), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::problemValidity), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::sentenceTypeConsistency), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::reasonCorrectness), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::contextAwareness), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::faithfulness), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::tenseConsistency), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::usability), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::nonMeta), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::meaningPreservation), - validCaseScore(response.noAnalysisAppropriateness()), - validCaseScore(response.strengthsPrecision()), - validCaseScore(response.strengthsCoverage()), - validCaseScore(response.missingKeywordsPrecision()), - missingKeywordsCoverage, - validOverallUsefulness(response.overallUsefulness(), hasFatalError), - writeJson(errorCodes.stream().map(Enum::name).toList()), - truncateShortRationale(response.shortRationale()), - callResult.inputTokens(), - callResult.outputTokens(), - callResult.latencyMs(), - "" + return JudgeValidationResult.success( + new NlgEvaluationResult( + input.caseId(), + input.sourceResultFile(), + input.questionAnalyses().size(), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::relevance), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::problemValidity), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::sentenceTypeConsistency), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::reasonCorrectness), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::contextAwareness), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::faithfulness), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::tenseConsistency), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::usability), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::nonMeta), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::meaningPreservation), + validCaseScore(response.noAnalysisAppropriateness()), + validCaseScore(response.strengthsPrecision()), + validCaseScore(response.strengthsCoverage()), + validCaseScore(response.missingKeywordsPrecision()), + missingKeywordsCoverage, + validOverallUsefulness(response.overallUsefulness(), hasFatalError), + writeJson(errorCodes.stream().map(Enum::name).toList()), + truncateShortRationale(response.shortRationale()), + callResult.inputTokens(), + callResult.outputTokens(), + callResult.latencyMs(), + "" + ) ); } @@ -979,6 +1022,35 @@ record NlgEvaluationComparisonSummary( ) { } + private enum JudgeValidationReason { + NONE, + RESPONSE_NULL, + CASE_ID_MISMATCH, + QUESTION_ANALYSIS_COUNT_MISMATCH + } + + private record JudgeValidationResult( + NlgEvaluationResult result, + JudgeValidationReason reason, + int responseQuestionAnalysisEvaluations + ) { + private static JudgeValidationResult success(NlgEvaluationResult result) { + return new JudgeValidationResult(result, JudgeValidationReason.NONE, 0); + } + + private static JudgeValidationResult failed( + NlgEvaluationAiClient.NlgJudgeInput input, + JudgeValidationReason reason, + List responseQuestionAnalysisEvaluations + ) { + return new JudgeValidationResult( + NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"), + reason, + responseQuestionAnalysisEvaluations == null ? 0 : responseQuestionAnalysisEvaluations.size() + ); + } + } + private enum HeaderLocation { INPUT, SOURCE, diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java index f37b38e..11dcae5 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java @@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -83,6 +84,7 @@ void validatesScoreRange() throws Exception { assertThat(row.get("averageRelevance")).isBlank(); assertThat(row.get("strengthsPrecision")).isEqualTo("5"); assertThat(row.get("errorCodes")).contains("FALSE_POSITIVE_ANALYSIS"); + verify(aiClient, times(1)).evaluate(any()); } @Test @@ -117,52 +119,109 @@ void keepsAnalysisAveragesBlankForEmptyQuestionAnalyses() throws Exception { assertThat(row.get("averageProblemValidity")).isBlank(); assertThat(row.get("overallUsefulness")).isEqualTo("5"); assertThat(row.get("failureStage")).isBlank(); + verify(aiClient, times(1)).evaluate(any()); } @Test - @DisplayName("빈 questionAnalyses에 대해 judge가 문장 평가를 생성하면 검증 실패로 기록한다") - void rejectsHallucinatedQuestionEvaluationForEmptyQuestionAnalyses() throws Exception { + @DisplayName("빈 questionAnalyses에 대해 judge가 문장 평가를 생성하면 한 번 재시도하고 성공 결과를 기록한다") + void retriesQuestionAnalysisCountMismatchOnceAndUsesRetrySuccess() throws Exception { + NlgEvaluationAiClient aiClient = mock(NlgEvaluationAiClient.class); + when(aiClient.evaluate(any())) + .thenReturn(new NlgEvaluationAiClient.JudgeCallResult( + responseWithHallucinatedQuestionEvaluations("EV-02", 2), + 90L, + null, + null + )) + .thenReturn(new NlgEvaluationAiClient.JudgeCallResult( + responseWithoutQuestionEvaluations("EV-02"), + 95L, + 11, + 22 + )); + + Path input = writeJudgeInput("EV-02", "[]"); + Path output = tempDir.resolve("judge_empty_retry_success.csv"); + + new NlgEvaluationBatchService(aiClient, objectMapper).run(input, output); + + Map row = EvaluationCsvSupport.read(output).getFirst(); + assertThat(row.get("analysisCount")).isEqualTo("0"); + assertThat(row.get("failureStage")).isBlank(); + assertThat(row.get("judgeInputTokens")).isEqualTo("11"); + assertThat(row.get("judgeOutputTokens")).isEqualTo("22"); + verify(aiClient, times(2)).evaluate(any()); + } + + @Test + @DisplayName("빈 questionAnalyses 개수 불일치가 재시도 후에도 남으면 validation failure로 기록한다") + void recordsValidationFailureWhenQuestionAnalysisCountMismatchRetryAlsoFails() throws Exception { + NlgEvaluationAiClient aiClient = mock(NlgEvaluationAiClient.class); + when(aiClient.evaluate(any())) + .thenReturn(new NlgEvaluationAiClient.JudgeCallResult( + responseWithHallucinatedQuestionEvaluations("EV-02", 2), + 90L, + null, + null + )) + .thenReturn(new NlgEvaluationAiClient.JudgeCallResult( + responseWithHallucinatedQuestionEvaluations("EV-02", 1), + 95L, + null, + null + )); + + Path input = writeJudgeInput("EV-02", "[]"); + Path output = tempDir.resolve("judge_empty_retry_failure.csv"); + + new NlgEvaluationBatchService(aiClient, objectMapper).run(input, output); + + Map row = EvaluationCsvSupport.read(output).getFirst(); + assertThat(row.get("analysisCount")).isBlank(); + assertThat(row.get("failureStage")).isEqualTo("judge_validation_failed"); + verify(aiClient, times(2)).evaluate(any()); + } + + @Test + @DisplayName("caseId mismatch는 재시도하지 않고 validation failure로 기록한다") + void doesNotRetryCaseIdMismatch() throws Exception { NlgEvaluationAiClient aiClient = mock(NlgEvaluationAiClient.class); when(aiClient.evaluate(any())).thenReturn(new NlgEvaluationAiClient.JudgeCallResult( - new NlgEvaluationResponse( - "EV-02", - List.of(new NlgEvaluationResponse.QuestionAnalysisEvaluation( - 0, - "없는 분석 문장입니다.", - 5, - 5, - 5, - 5, - 5, - 5, - 5, - 5, - 5, - 5, - List.of(NlgEvaluationErrorCode.NONE) - )), - 5, - 5, - 5, - 5, - 5, - 5, - List.of(NlgEvaluationErrorCode.NONE), - "빈 분석인데 문장 평가를 생성했습니다." - ), + responseWithoutQuestionEvaluations("OTHER"), 90L, null, null )); Path input = writeJudgeInput("EV-02", "[]"); - Path output = tempDir.resolve("judge_empty_hallucination.csv"); + Path output = tempDir.resolve("judge_case_id_mismatch.csv"); + + new NlgEvaluationBatchService(aiClient, objectMapper).run(input, output); + + Map row = EvaluationCsvSupport.read(output).getFirst(); + assertThat(row.get("failureStage")).isEqualTo("judge_validation_failed"); + verify(aiClient, times(1)).evaluate(any()); + } + + @Test + @DisplayName("null judge response는 재시도하지 않고 validation failure로 기록한다") + void doesNotRetryNullJudgeResponse() throws Exception { + NlgEvaluationAiClient aiClient = mock(NlgEvaluationAiClient.class); + when(aiClient.evaluate(any())).thenReturn(new NlgEvaluationAiClient.JudgeCallResult( + null, + 90L, + null, + null + )); + + Path input = writeJudgeInput("EV-02", "[]"); + Path output = tempDir.resolve("judge_null_response.csv"); new NlgEvaluationBatchService(aiClient, objectMapper).run(input, output); Map row = EvaluationCsvSupport.read(output).getFirst(); - assertThat(row.get("analysisCount")).isBlank(); assertThat(row.get("failureStage")).isEqualTo("judge_validation_failed"); + verify(aiClient, times(1)).evaluate(any()); } @Test @@ -466,6 +525,7 @@ void recordsJudgeFailureRow() throws Exception { Map row = EvaluationCsvSupport.read(output).getFirst(); assertThat(row.get("caseId")).isEqualTo("EV-04"); assertThat(row.get("failureStage")).isEqualTo("judge_call_failed"); + verify(aiClient, times(1)).evaluate(any()); } @Test @@ -840,22 +900,58 @@ private Path writeJudgeInputWithMissingKeywordState( private void stubJudge(NlgEvaluationAiClient aiClient, String caseId) { when(aiClient.evaluate(any())).thenReturn(new NlgEvaluationAiClient.JudgeCallResult( - new NlgEvaluationResponse( - caseId, - List.of(), + responseWithoutQuestionEvaluations(caseId), + 100L, + 10, + 20 + )); + } + + private NlgEvaluationResponse responseWithoutQuestionEvaluations(String caseId) { + return new NlgEvaluationResponse( + caseId, + List.of(), + 5, + 5, + 5, + 5, + 5, + 5, + List.of(NlgEvaluationErrorCode.NONE), + "정상 평가입니다." + ); + } + + private NlgEvaluationResponse responseWithHallucinatedQuestionEvaluations(String caseId, int count) { + List evaluations = java.util.stream.IntStream.range(0, count) + .mapToObj(index -> new NlgEvaluationResponse.QuestionAnalysisEvaluation( + index, + "없는 분석 문장입니다. " + index, 5, 5, 5, 5, 5, 5, - List.of(NlgEvaluationErrorCode.NONE), - "정상 평가입니다." - ), - 100L, - 10, - 20 - )); + 5, + 5, + 5, + 5, + List.of(NlgEvaluationErrorCode.NONE) + )) + .toList(); + return new NlgEvaluationResponse( + caseId, + evaluations, + 5, + 5, + 5, + 5, + 5, + 5, + List.of(NlgEvaluationErrorCode.NONE), + "빈 분석인데 문장 평가를 생성했습니다." + ); } private void writeSourceCases( From df589b9e8b6c6be61489156416d15826a99721e7 Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 29 Jul 2026 23:16:59 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[Fix]=20NLG=20judge=20=EB=B9=88=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=8F=89=EA=B0=80=20=EB=B0=B0=EC=97=B4=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=A0=95=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - questionAnalyses가 빈 입력이면 questionAnalysisEvaluations를 서버에서 빈 배열로 정규화 - 빈 입력에서 Judge가 생성한 문장 평가를 실패 처리하지 않고 구조적으로 제거 - noAnalysisAppropriateness, strengths, missingKeywords, overallUsefulness, errorCodes, rationale 보존 - response null 및 caseId mismatch는 기존 validation failure 유지 - question_analysis_count_mismatch 재시도 로직과 내부 validation reason 타입 제거 - 빈 입력 정규화 시 원본/정규화 평가 개수 WARN 로그 추가 - NLG judge 빈 입력 정규화 및 비대상 실패 회귀 테스트 보강 - DTO, enum, 프롬프트, CSV 포맷은 변경하지 않음 --- ...ge_two_pass_candidate_prompt_fix_retry.csv | 21 ++ .../evaluation/NlgEvaluationBatchService.java | 180 ++++++------------ .../NlgEvaluationBatchServiceTest.java | 92 ++++----- 3 files changed, 116 insertions(+), 177 deletions(-) create mode 100644 evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_retry.csv diff --git a/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_retry.csv b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_retry.csv new file mode 100644 index 0000000..64072ab --- /dev/null +++ b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_retry.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","답변에서 명확한 문제 문장이 없으나, 분석이 부재하여 중요한 첨삭 대상을 놓쳤습니다.",4739,128,4022, +EV-02,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,1,1,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]",지원자의 경험이 구체적이지 않아 주요 요구사항을 충족하지 못하고 있습니다. 분석이 부재하여 중요한 첨삭 대상을 놓쳤습니다.,3770,144,2804, +EV-03,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",자기소개서에서 구체적인 성과와 행동이 부족하여 개선이 필요합니다. 분석 결과의 신뢰성을 높이기 위한 추가적인 정보가 요구됩니다.,5551,437,6710, +EV-04,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,3.0,3.0,3.0,3.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 구체적인 방법이나 절차가 결여된 점이 아쉬웠습니다. 전반적으로 직무 적합성을 나타내는 경험이 있으나, 문맥을 충분히 반영하지 못했습니다.",5972,301,7018, +EV-05,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",분석이 부족하여 구체적인 행동이나 결과에 대한 설명이 미흡했습니다. 문맥을 고려하지 않은 점이 아쉬웠습니다.,4043,293,6821, +EV-06,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,2.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 구체적인 방법론이 결여된 점이 아쉬우며, 문맥을 무시한 평가가 있었습니다.",6937,336,6297, +EV-07,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 없으나, 답변에서 구체적인 성과와 방법이 부족하여 중요한 첨삭 대상을 놓쳤습니다.",3737,130,2664, +EV-08,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 경험이 잘 드러나지만, 구체적인 실행 방법이 부족하여 중요한 첨삭 대상을 놓쳤습니다.",5422,128,2991, +EV-09,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",경험을 잘 서술하였으나 성과 수치가 부족하여 전반적인 유용성이 낮게 평가되었습니다.,4915,435,6946, +EV-10,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-11,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,1,1,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기와 관련된 구체적인 사례가 부족하며, 자격증 관련 누락이 확인되었습니다. 분석이 전혀 없어서 중요한 문제를 놓쳤습니다.",4762,146,3435, +EV-12,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,3.0,4.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",분석이 부족하여 중요한 문제를 놓쳤습니다. 구체적인 행동과 방법을 강조할 필요가 있습니다.,5057,439,7066, +EV-13,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",분석이 없으나 명백한 문제 문장이 존재하여 적절한 평가가 이루어지지 않았습니다. 구체적인 행동과 방법이 부족한 점이 확인되었습니다.,4603,140,2849, +EV-14,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.5,2.5,3.5,3.5,2.5,5.0,5.0,3.5,3.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원자의 경험이 구체적이지 않고 성과 수치가 부족하여 개선이 필요합니다. 중요한 첨삭 대상을 놓쳤습니다.,5046,426,6534, +EV-15,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,3,3,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험은 잘 연결되었으나, 구체적인 성과와 JD의 핵심 요구사항이 부족하여 전반적인 유용성이 낮습니다.",3886,144,9086, +EV-16,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,3.0,3.0,2.0,4.0,4.0,4.0,3.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","전반적으로 경험을 잘 서술했으나, 구체적인 행동과 결과에 대한 설명이 부족하여 개선이 필요합니다.",5255,424,18648, +EV-17,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 전혀 없으며, 명백한 문제 문장이 존재합니다. 구체적인 행동과 계획이 부족하다는 점이 강조됩니다.",4044,132,2989, +EV-18,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 전혀 없으며, 명백한 문제 문장이 존재하여 적절한 평가가 이루어지지 않았습니다.",4200,128,2965, +EV-19,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,4.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","지원자의 QA 직무에 대한 이해는 좋지만, 구체적인 행동과 방법론이 부족하여 개선이 필요합니다.",4364,449,7973, +EV-20,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원 동기와 입사 후 포부에서 구체성이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 분석이 없어서 평가가 어려웠습니다.,4317,135,6799, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java index e9a7956..55f673e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java @@ -79,7 +79,7 @@ NlgEvaluationSummary run(Path inputPath, Path outputPath) throws IOException { try { NlgEvaluationAiClient.NlgJudgeInput input = buildJudgeInput(inputPath, row, sourceCaseRows, resolvedHeaders); NlgEvaluationAiClient.JudgeCallResult callResult = nlgEvaluationAiClient.evaluate(input); - NlgEvaluationResult result = validateAndBuildResultWithRetry(input, callResult); + NlgEvaluationResult result = validateAndBuildResult(input, callResult); results.add(result); if (!StringUtils.hasText(result.failureStage())) { successCount++; @@ -148,36 +148,7 @@ private NlgEvaluationAiClient.NlgJudgeInput buildJudgeInput( ); } - private NlgEvaluationResult validateAndBuildResultWithRetry( - NlgEvaluationAiClient.NlgJudgeInput input, - NlgEvaluationAiClient.JudgeCallResult callResult - ) { - JudgeValidationResult firstValidation = validateAndBuildResult(input, callResult); - if (firstValidation.reason() != JudgeValidationReason.QUESTION_ANALYSIS_COUNT_MISMATCH) { - return firstValidation.result(); - } - - log.warn( - "Retrying NLG Judge after validation failure. caseId={}, sourceResultFile={}, retryAttempt=1, reason=question_analysis_count_mismatch, inputQuestionAnalyses={}, responseQuestionAnalysisEvaluations={}", - input.caseId(), - input.sourceResultFile(), - input.questionAnalyses().size(), - firstValidation.responseQuestionAnalysisEvaluations() - ); - NlgEvaluationAiClient.JudgeCallResult retryCallResult = nlgEvaluationAiClient.evaluate(input); - JudgeValidationResult retryValidation = validateAndBuildResult(input, retryCallResult); - if (retryValidation.reason() == JudgeValidationReason.NONE) { - log.info("NLG Judge retry succeeded. caseId={}, retryAttempt=1", input.caseId()); - } else if (retryValidation.reason() == JudgeValidationReason.QUESTION_ANALYSIS_COUNT_MISMATCH) { - log.warn( - "NLG Judge retry failed. caseId={}, retryAttempt=1, reason=question_analysis_count_mismatch", - input.caseId() - ); - } - return retryValidation.result(); - } - - private JudgeValidationResult validateAndBuildResult( + private NlgEvaluationResult validateAndBuildResult( NlgEvaluationAiClient.NlgJudgeInput input, NlgEvaluationAiClient.JudgeCallResult callResult ) { @@ -194,11 +165,7 @@ private JudgeValidationResult validateAndBuildResult( null, true ); - return JudgeValidationResult.failed( - input, - JudgeValidationReason.RESPONSE_NULL, - null - ); + return NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"); } if (!Objects.equals(input.caseId(), response.caseId())) { log.warn( @@ -213,32 +180,10 @@ private JudgeValidationResult validateAndBuildResult( response.caseId(), false ); - return JudgeValidationResult.failed( - input, - JudgeValidationReason.CASE_ID_MISMATCH, - response.questionAnalysisEvaluations() - ); + return NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"); } - if (input.questionAnalyses().isEmpty() - && response.questionAnalysisEvaluations() != null - && !response.questionAnalysisEvaluations().isEmpty()) { - log.warn( - "Judge validation failed. reason=question_analysis_count_mismatch, caseId={}, sourceResultFile={}", - input.caseId(), - input.sourceResultFile() - ); - log.warn( - "Judge validation failed. inputQuestionAnalyses={}, responseQuestionAnalysisEvaluations={}", - input.questionAnalyses().size(), - response.questionAnalysisEvaluations().size() - ); - return JudgeValidationResult.failed( - input, - JudgeValidationReason.QUESTION_ANALYSIS_COUNT_MISMATCH, - response.questionAnalysisEvaluations() - ); - } + response = normalizeQuestionAnalysisEvaluations(input, response); List evaluations = validQuestionEvaluations(input.questionAnalyses(), response.questionAnalysisEvaluations()); @@ -250,34 +195,64 @@ private JudgeValidationResult validateAndBuildResult( if (missedValidatedMissingKeywords) { missingKeywordsCoverage = missingKeywordsCoverage == null ? 1 : Math.min(missingKeywordsCoverage, 1); } - return JudgeValidationResult.success( - new NlgEvaluationResult( - input.caseId(), - input.sourceResultFile(), - input.questionAnalyses().size(), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::relevance), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::problemValidity), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::sentenceTypeConsistency), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::reasonCorrectness), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::contextAwareness), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::faithfulness), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::tenseConsistency), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::usability), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::nonMeta), - average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::meaningPreservation), - validCaseScore(response.noAnalysisAppropriateness()), - validCaseScore(response.strengthsPrecision()), - validCaseScore(response.strengthsCoverage()), - validCaseScore(response.missingKeywordsPrecision()), - missingKeywordsCoverage, - validOverallUsefulness(response.overallUsefulness(), hasFatalError), - writeJson(errorCodes.stream().map(Enum::name).toList()), - truncateShortRationale(response.shortRationale()), - callResult.inputTokens(), - callResult.outputTokens(), - callResult.latencyMs(), - "" - ) + return new NlgEvaluationResult( + input.caseId(), + input.sourceResultFile(), + input.questionAnalyses().size(), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::relevance), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::problemValidity), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::sentenceTypeConsistency), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::reasonCorrectness), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::contextAwareness), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::faithfulness), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::tenseConsistency), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::usability), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::nonMeta), + average(evaluations, NlgEvaluationResponse.QuestionAnalysisEvaluation::meaningPreservation), + validCaseScore(response.noAnalysisAppropriateness()), + validCaseScore(response.strengthsPrecision()), + validCaseScore(response.strengthsCoverage()), + validCaseScore(response.missingKeywordsPrecision()), + missingKeywordsCoverage, + validOverallUsefulness(response.overallUsefulness(), hasFatalError), + writeJson(errorCodes.stream().map(Enum::name).toList()), + truncateShortRationale(response.shortRationale()), + callResult.inputTokens(), + callResult.outputTokens(), + callResult.latencyMs(), + "" + ); + } + + private NlgEvaluationResponse normalizeQuestionAnalysisEvaluations( + NlgEvaluationAiClient.NlgJudgeInput input, + NlgEvaluationResponse response + ) { + if (!input.questionAnalyses().isEmpty()) { + return response; + } + int originalCount = response.questionAnalysisEvaluations() == null + ? 0 + : response.questionAnalysisEvaluations().size(); + if (originalCount > 0) { + log.warn( + "Normalized NLG Judge question analysis evaluations. reason=empty_input_evaluations_normalized, caseId={}, sourceResultFile={}, inputQuestionAnalyses=0, originalResponseQuestionAnalysisEvaluations={}, normalizedResponseQuestionAnalysisEvaluations=0", + input.caseId(), + input.sourceResultFile(), + originalCount + ); + } + return new NlgEvaluationResponse( + response.caseId(), + List.of(), + response.noAnalysisAppropriateness(), + response.strengthsPrecision(), + response.strengthsCoverage(), + response.missingKeywordsPrecision(), + response.missingKeywordsCoverage(), + response.overallUsefulness(), + response.caseErrorCodes(), + response.shortRationale() ); } @@ -1022,35 +997,6 @@ record NlgEvaluationComparisonSummary( ) { } - private enum JudgeValidationReason { - NONE, - RESPONSE_NULL, - CASE_ID_MISMATCH, - QUESTION_ANALYSIS_COUNT_MISMATCH - } - - private record JudgeValidationResult( - NlgEvaluationResult result, - JudgeValidationReason reason, - int responseQuestionAnalysisEvaluations - ) { - private static JudgeValidationResult success(NlgEvaluationResult result) { - return new JudgeValidationResult(result, JudgeValidationReason.NONE, 0); - } - - private static JudgeValidationResult failed( - NlgEvaluationAiClient.NlgJudgeInput input, - JudgeValidationReason reason, - List responseQuestionAnalysisEvaluations - ) { - return new JudgeValidationResult( - NlgEvaluationResult.failed(input.caseId(), input.sourceResultFile(), "judge_validation_failed"), - reason, - responseQuestionAnalysisEvaluations == null ? 0 : responseQuestionAnalysisEvaluations.size() - ); - } - } - private enum HeaderLocation { INPUT, SOURCE, diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java index 11dcae5..1a1762b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java @@ -123,63 +123,47 @@ void keepsAnalysisAveragesBlankForEmptyQuestionAnalyses() throws Exception { } @Test - @DisplayName("빈 questionAnalyses에 대해 judge가 문장 평가를 생성하면 한 번 재시도하고 성공 결과를 기록한다") - void retriesQuestionAnalysisCountMismatchOnceAndUsesRetrySuccess() throws Exception { + @DisplayName("빈 questionAnalyses에 대해 judge가 문장 평가를 생성하면 빈 평가 배열로 정규화하고 case-level 결과를 보존한다") + void normalizesHallucinatedQuestionEvaluationsForEmptyQuestionAnalyses() throws Exception { NlgEvaluationAiClient aiClient = mock(NlgEvaluationAiClient.class); - when(aiClient.evaluate(any())) - .thenReturn(new NlgEvaluationAiClient.JudgeCallResult( - responseWithHallucinatedQuestionEvaluations("EV-02", 2), - 90L, - null, - null - )) - .thenReturn(new NlgEvaluationAiClient.JudgeCallResult( - responseWithoutQuestionEvaluations("EV-02"), - 95L, - 11, - 22 - )); + when(aiClient.evaluate(any())).thenReturn(new NlgEvaluationAiClient.JudgeCallResult( + new NlgEvaluationResponse( + "EV-02", + hallucinatedQuestionEvaluations(2), + 2, + 3, + 4, + 1, + 2, + 3, + List.of(NlgEvaluationErrorCode.MISSED_ANALYSIS), + "빈 분석이지만 중요한 첨삭 대상을 놓쳤습니다." + ), + 90L, + 11, + 22 + )); Path input = writeJudgeInput("EV-02", "[]"); - Path output = tempDir.resolve("judge_empty_retry_success.csv"); + Path output = tempDir.resolve("judge_empty_normalized.csv"); new NlgEvaluationBatchService(aiClient, objectMapper).run(input, output); Map row = EvaluationCsvSupport.read(output).getFirst(); assertThat(row.get("analysisCount")).isEqualTo("0"); + assertThat(row.get("averageProblemValidity")).isBlank(); + assertThat(row.get("noAnalysisAppropriateness")).isEqualTo("2"); + assertThat(row.get("strengthsPrecision")).isEqualTo("3"); + assertThat(row.get("strengthsCoverage")).isEqualTo("4"); + assertThat(row.get("missingKeywordsPrecision")).isEqualTo("1"); + assertThat(row.get("missingKeywordsCoverage")).isEqualTo("2"); + assertThat(row.get("overallUsefulness")).isEqualTo("3"); + assertThat(row.get("errorCodes")).contains("MISSED_ANALYSIS"); + assertThat(row.get("shortRationale")).isEqualTo("빈 분석이지만 중요한 첨삭 대상을 놓쳤습니다."); assertThat(row.get("failureStage")).isBlank(); assertThat(row.get("judgeInputTokens")).isEqualTo("11"); assertThat(row.get("judgeOutputTokens")).isEqualTo("22"); - verify(aiClient, times(2)).evaluate(any()); - } - - @Test - @DisplayName("빈 questionAnalyses 개수 불일치가 재시도 후에도 남으면 validation failure로 기록한다") - void recordsValidationFailureWhenQuestionAnalysisCountMismatchRetryAlsoFails() throws Exception { - NlgEvaluationAiClient aiClient = mock(NlgEvaluationAiClient.class); - when(aiClient.evaluate(any())) - .thenReturn(new NlgEvaluationAiClient.JudgeCallResult( - responseWithHallucinatedQuestionEvaluations("EV-02", 2), - 90L, - null, - null - )) - .thenReturn(new NlgEvaluationAiClient.JudgeCallResult( - responseWithHallucinatedQuestionEvaluations("EV-02", 1), - 95L, - null, - null - )); - - Path input = writeJudgeInput("EV-02", "[]"); - Path output = tempDir.resolve("judge_empty_retry_failure.csv"); - - new NlgEvaluationBatchService(aiClient, objectMapper).run(input, output); - - Map row = EvaluationCsvSupport.read(output).getFirst(); - assertThat(row.get("analysisCount")).isBlank(); - assertThat(row.get("failureStage")).isEqualTo("judge_validation_failed"); - verify(aiClient, times(2)).evaluate(any()); + verify(aiClient, times(1)).evaluate(any()); } @Test @@ -922,8 +906,8 @@ private NlgEvaluationResponse responseWithoutQuestionEvaluations(String caseId) ); } - private NlgEvaluationResponse responseWithHallucinatedQuestionEvaluations(String caseId, int count) { - List evaluations = java.util.stream.IntStream.range(0, count) + private List hallucinatedQuestionEvaluations(int count) { + return java.util.stream.IntStream.range(0, count) .mapToObj(index -> new NlgEvaluationResponse.QuestionAnalysisEvaluation( index, "없는 분석 문장입니다. " + index, @@ -940,18 +924,6 @@ private NlgEvaluationResponse responseWithHallucinatedQuestionEvaluations(String List.of(NlgEvaluationErrorCode.NONE) )) .toList(); - return new NlgEvaluationResponse( - caseId, - evaluations, - 5, - 5, - 5, - 5, - 5, - 5, - List.of(NlgEvaluationErrorCode.NONE), - "빈 분석인데 문장 평가를 생성했습니다." - ); } private void writeSourceCases( From 408061d7f85c24ff0935c4ed85bc2d95b286b042 Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 29 Jul 2026 23:37:49 +0900 Subject: [PATCH 08/12] =?UTF-8?q?[Fix]=20NLG=20judge=20=EB=B0=8F=20replay?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - missing keyword replay 입력 필수 헤더에 question 추가 - question 헤더 누락 fail-fast 회귀 테스트 추가 - NLG judge root cause 추적을 Spring NestedExceptionUtils로 통일 - NLG judge validation 실패 WARN 중복 제거 - NLG judge 실패 로그를 공통 경로로 정리 - JsonProcessingException 상세 originalMessage 로그 유지 - missing keyword negative example의 JD와 답변 문맥 정합성 수정 - NLG judge evaluate 예외 전파 회귀 테스트 추가 --- ...o_pass_candidate_prompt_fix_normalized.csv | 21 +++++ .../MissingKeywordSanitizerReplayService.java | 1 + .../evaluation/NlgEvaluationAiClient.java | 11 +-- .../evaluation/NlgEvaluationBatchService.java | 79 ++++--------------- .../analysis/service/ai/AnalysisAiClient.java | 4 +- ...singKeywordSanitizerReplayServiceTest.java | 18 +++++ .../evaluation/NlgEvaluationAiClientTest.java | 35 +++++++- 7 files changed, 93 insertions(+), 76 deletions(-) create mode 100644 evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_normalized.csv diff --git a/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_normalized.csv b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_normalized.csv new file mode 100644 index 0000000..f4f59b1 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_normalized.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","답변에서 명확한 문제 문장이 없으나, 분석이 부재하여 중요한 첨삭 대상을 놓쳤습니다.",4739,128,4451, +EV-02,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,1,1,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 구체적이지 않아 분석이 부족하며, JD의 핵심 요구사항이 누락되었습니다.",3770,134,3935, +EV-03,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",자기소개서에서 구체적인 성과와 행동이 부족하여 개선이 필요합니다. 분석 대상이 명확히 존재하지 않아 평가가 낮아졌습니다.,5551,438,10791, +EV-04,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 구체적인 방법이나 절차가 결여된 점이 아쉬웠습니다. 전반적으로 직무 적합성을 나타내는 경험이 있으나, 문맥을 충분히 반영하지 못했습니다.",5972,303,6793, +EV-05,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",분석이 부족하여 구체적인 행동이나 결과에 대한 설명이 미흡했습니다. 문맥을 고려하지 않은 점이 아쉬웠습니다.,4043,293,7925, +EV-06,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,1,3.0,2.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 문맥을 무시한 부분이 있으며, 구체적인 방법론이 결여되어 있습니다. 전반적으로 개선이 필요합니다.",6937,336,14960, +EV-07,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 경험과 계획이 잘 설명되었으나, 구체적인 성과와 방법에 대한 설명이 부족하여 분석이 결여된 것으로 평가됩니다.",3737,135,2628, +EV-08,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 경험이 잘 드러나지만, 구체적인 실행 방법이 부족하여 분석이 결여된 부분이 있습니다.",5422,127,3280, +EV-09,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원자의 경험은 잘 서술되었으나 성과 수치가 부족하여 분석이 미흡하게 평가되었습니다. 중요한 첨삭 대상을 놓쳤습니다.,4915,443,8913, +EV-10,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원서에서 구체적인 실행 계획과 방법이 부족하여 주요 문제를 놓쳤습니다. 분석이 없어서 평가가 어려웠습니다.,4558,130,3934, +EV-11,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,1,1,2,2,3,"[""MISSED_MISSING_KEYWORD""]","지원 동기와 포부에 대한 구체적인 사례와 성과가 부족하여 전반적인 평가가 낮았습니다. 또한, 자격증 관련 누락이 확인되었습니다.",4762,140,11103, +EV-12,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,2.0,3.0,3.0,2.0,5.0,5.0,3.0,3.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 부족하여 구체적인 행동과 방법이 명확하지 않으며, 중요한 첨삭 대상을 놓쳤습니다.",5057,422,7474, +EV-13,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 전혀 없으며, 명백한 문제 문장이 존재합니다. 구체적인 행동과 방법이 부족하여 개선이 필요합니다.",4603,131,6199, +EV-14,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.5,3.5,3.5,4.0,2.5,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원자의 경험이 구체적이지 않고 성과 수치가 부족하여 개선이 필요합니다. 중요한 첨삭 대상을 놓쳤습니다.,5046,426,15626, +EV-15,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,3,3,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]",지원자의 경험은 잘 연결되었으나 구체적인 성과와 JD의 핵심 요구사항이 부족하여 평가가 낮아졌습니다.,3886,141,3265, +EV-16,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",분석이 부족하여 명백한 문제를 놓쳤습니다. 구체적인 행동과 결과 설명이 필요합니다.,5255,420,17959, +EV-17,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","답변에 명백한 첨삭 대상이 없으나, 구체적인 행동과 계획이 부족하여 평가가 낮아졌습니다.",4044,131,3590, +EV-18,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 전혀 없으며, 명백한 문제 문장이 존재하여 적절한 평가가 이루어지지 않았습니다.",4200,128,4465, +EV-19,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,2,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","지원자의 QA 직무에 대한 이해는 좋지만, 구체적인 행동과 방법론이 부족하여 개선이 필요합니다.",4364,449,9822, +EV-20,evaluation/evaluation_ai_results_two_pass_candidate_prompt_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원 동기와 입사 후 포부에서 구체성이 부족하며, 분석이 전혀 이루어지지 않아 중요한 문제를 놓쳤습니다.",4317,132,5696, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java index 41c1d56..5cfcd7b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java @@ -30,6 +30,7 @@ class MissingKeywordSanitizerReplayService { "caseId", "mainTasks", "qualifications", + "question", "answer", "rawCandidateResponseJson", "sanitizedCandidateResponseJson" diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java index fa7a9e1..079d178 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.NestedExceptionUtils; import org.springframework.stereotype.Component; import java.util.List; @@ -44,7 +45,7 @@ JudgeCallResult evaluate(NlgJudgeInput input) { toIntegerTokenCount(usage == null ? null : usage.outputTokens()) ); } catch (RuntimeException e) { - Throwable rootCause = rootCause(e); + Throwable rootCause = NestedExceptionUtils.getMostSpecificCause(e); log.error( "NLG Judge call failed. caseId={}, sourceResultFile={}, exceptionType={}, message={}, rootCauseType={}, rootCauseMessage={}, rawJudgeResponseAvailable=false", input.caseId(), @@ -193,14 +194,6 @@ private Integer toIntegerTokenCount(Long tokens) { return tokens > Integer.MAX_VALUE ? Integer.MAX_VALUE : tokens.intValue(); } - private Throwable rootCause(Throwable throwable) { - Throwable current = throwable; - while (current.getCause() != null && current.getCause() != current) { - current = current.getCause(); - } - return current; - } - record JudgeCallResult( NlgEvaluationResponse response, Long latencyMs, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java index 55f673e..556ea2b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java @@ -8,6 +8,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.NestedExceptionUtils; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -155,12 +156,9 @@ private NlgEvaluationResult validateAndBuildResult( NlgEvaluationResponse response = callResult.response(); if (response == null) { log.warn( - "Judge validation failed. reason=response_null, caseId={}, sourceResultFile={}", + "Judge validation failed. reason=response_null, caseId={}, sourceResultFile={}, expectedCaseId={}, actualCaseId={}, responseNull={}", input.caseId(), - input.sourceResultFile() - ); - log.warn( - "Judge validation failed. expectedCaseId={}, actualCaseId={}, responseNull={}", + input.sourceResultFile(), input.caseId(), null, true @@ -169,13 +167,9 @@ private NlgEvaluationResult validateAndBuildResult( } if (!Objects.equals(input.caseId(), response.caseId())) { log.warn( - "Judge validation failed. reason=case_id_mismatch, expectedCaseId={}, actualCaseId={}, sourceResultFile={}", + "Judge validation failed. reason=case_id_mismatch, caseId={}, sourceResultFile={}, expectedCaseId={}, actualCaseId={}, responseNull={}", input.caseId(), - response.caseId(), - input.sourceResultFile() - ); - log.warn( - "Judge validation failed. expectedCaseId={}, actualCaseId={}, responseNull={}", + input.sourceResultFile(), input.caseId(), response.caseId(), false @@ -842,59 +836,18 @@ private String failureStage(Exception e) { } private void logEvaluationFailure(String caseId, String sourceResultFile, Exception e) { - Throwable rootCause = rootCause(e); + Throwable rootCause = NestedExceptionUtils.getMostSpecificCause(e); String internalReason = failureReason(e); String jacksonPath = jacksonPath(e); String failedField = failedFieldName(e); Object unknownEnumValue = unknownEnumValue(e); - if (e instanceof JsonProcessingException jsonProcessingException) { - log.error( - "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, reason={}, exceptionType={}, message={}, rootCauseType={}, rootCauseMessage={}, jacksonPath={}, failedField={}, targetEnum={}, unknownValue={}, rawJudgeResponseAvailable=false", - caseId, - sourceResultFile, - internalReason, - jsonProcessingException.getClass().getSimpleName(), - jsonProcessingException.getMessage(), - rootCause.getClass().getName(), - rootCause.getMessage(), - jacksonPath, - failedField, - targetEnumName(e), - unknownEnumValue, - e - ); - log.error( - "NLG Judge JsonProcessingException detail. caseId={}, originalMessage={}", - caseId, - jsonProcessingException.getOriginalMessage(), - e - ); - return; - } - if (e instanceof IllegalArgumentException illegalArgumentException) { - log.error( - "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, reason={}, exceptionType={}, message={}, rootCauseType={}, rootCauseMessage={}, jacksonPath={}, failedField={}, targetEnum={}, unknownValue={}, rawJudgeResponseAvailable=false", - caseId, - sourceResultFile, - internalReason, - illegalArgumentException.getClass().getSimpleName(), - illegalArgumentException.getMessage(), - rootCause.getClass().getName(), - rootCause.getMessage(), - jacksonPath, - failedField, - targetEnumName(e), - unknownEnumValue, - e - ); - return; - } + String exceptionType = e.getClass().getSimpleName(); log.error( "NLG Judge evaluation failed. caseId={}, sourceResultFile={}, reason={}, exceptionType={}, message={}, rootCauseType={}, rootCauseMessage={}, jacksonPath={}, failedField={}, targetEnum={}, unknownValue={}, rawJudgeResponseAvailable=false", caseId, sourceResultFile, internalReason, - e.getClass().getName(), + exceptionType, e.getMessage(), rootCause.getClass().getName(), rootCause.getMessage(), @@ -904,6 +857,14 @@ private void logEvaluationFailure(String caseId, String sourceResultFile, Except unknownEnumValue, e ); + if (e instanceof JsonProcessingException jsonProcessingException) { + log.error( + "NLG Judge JsonProcessingException detail. caseId={}, originalMessage={}", + caseId, + jsonProcessingException.getOriginalMessage(), + e + ); + } } private String failureReason(Exception e) { @@ -962,14 +923,6 @@ private String targetEnumName(Exception e) { .orElse(""); } - private Throwable rootCause(Throwable throwable) { - Throwable current = throwable; - while (current.getCause() != null && current.getCause() != current) { - current = current.getCause(); - } - return current; - } - private Optional findCause(Throwable throwable, Class type) { Throwable current = throwable; while (current != null) { 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 7044dce..c91201b 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 @@ -673,9 +673,9 @@ String buildCandidatePrompt( 이유: 브랜드 운영은 이미 답변에 있으며, BI/CI 구축 경험으로 일반화하지 않는다. - Example 3 JD main_tasks: 사업 및 행정지원 - Answer: 총무부에서 행정 업무와 예산 관리를 담당했습니다. + Answer: 총무부에서 사업 및 행정지원 업무와 예산 관리를 담당했습니다. missingKeywordCandidates: [] - 이유: 행정지원 역량이 이미 답변에 충분히 존재한다. + 이유: 사업 및 행정지원 역량이 이미 답변에 충분히 존재한다. [문장 유형별 후보 기준] - EXPERIENCE: 역할, 행동, 방법, 결과, 직무 연결성 중 실제로 부족한 요소가 있어야 한다. diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java index 408099c..8d90ffd 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java @@ -132,6 +132,24 @@ void missingRequiredHeaderFailsFast() throws Exception { .hasMessageContaining("sanitizedCandidateResponseJson"); } + @Test + @DisplayName("question 헤더가 누락되면 fail-fast 한다") + void missingQuestionHeaderFailsFast() throws Exception { + Path input = tempDir.resolve("input.csv"); + List headersWithoutQuestion = headers().stream() + .filter(header -> !header.equals("question")) + .toList(); + EvaluationCsvSupport.writeRows( + input, + headersWithoutQuestion, + List.of(row("EV-01", response(List.of()), response(List.of()))) + ); + + assertThatThrownBy(() -> service.replay(input, tempDir.resolve("replay.csv"), tempDir.resolve("review.csv"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("question"); + } + @Test @DisplayName("UTF-8 BOM과 쉼표, 줄바꿈, 큰따옴표가 포함된 후보를 처리한다") void replayHandlesBomAndEscapedCsvValues() throws Exception { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java index e8d3f87..6fa9f40 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java @@ -4,11 +4,16 @@ import com.openai.client.OpenAIClient; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class NlgEvaluationAiClientTest { @@ -126,6 +131,20 @@ void buildPromptKeepsExistingJudgeCriteria() { .contains("MISSED_MISSING_KEYWORD"); } + @Test + @DisplayName("NLG judge evaluate는 limiter 예외를 감싸지 않고 그대로 전파한다") + void evaluateRethrowsLimiterRuntimeException() { + OpenAIClient openAIClient = mock(OpenAIClient.class); + LlmConcurrencyLimiter llmConcurrencyLimiter = mock(LlmConcurrencyLimiter.class); + NlgEvaluationAiClient client = new NlgEvaluationAiClient(openAIClient, llmConcurrencyLimiter); + ReflectionTestUtils.setField(client, "judgeModel", "gpt-4o-mini"); + RuntimeException failure = new RuntimeException("limiter failure"); + when(llmConcurrencyLimiter.execute(anyString(), any())).thenThrow(failure); + + assertThatThrownBy(() -> client.evaluate(judgeInput("[]", "", ""))) + .isSameAs(failure); + } + private String buildPrompt( String questionAnalysesJson, String rawCandidateResponseJson, @@ -135,7 +154,19 @@ private String buildPrompt( mock(OpenAIClient.class), mock(LlmConcurrencyLimiter.class) ); - return client.buildPrompt(new NlgEvaluationAiClient.NlgJudgeInput( + return client.buildPrompt(judgeInput( + questionAnalysesJson, + rawCandidateResponseJson, + candidateReviewResponseJson + )); + } + + private NlgEvaluationAiClient.NlgJudgeInput judgeInput( + String questionAnalysesJson, + String rawCandidateResponseJson, + String candidateReviewResponseJson + ) { + return new NlgEvaluationAiClient.NlgJudgeInput( "EV-01", "evaluation/result.csv", "재고 분석", @@ -152,6 +183,6 @@ private String buildPrompt( 0, 0, List.of() - )); + ); } } From 5dc4a501cbeb9e20c4be779e53271befc33339a6 Mon Sep 17 00:00:00 2001 From: wooh Date: Thu, 30 Jul 2026 00:08:46 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[Fix]=20Two-pass=20review=20provenance=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=95=ED=98=95=20=EC=9E=90=EA=B2=A9=EC=9A=94?= =?UTF-8?q?=EA=B1=B4=20=ED=95=84=ED=84=B0=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Candidate Review decision을 sanitized analysis candidateId 기준으로 검증 - Review strength를 sanitized strength quote 기반으로 제한 - Review missingKeyword를 sanitized keyword/source 기반으로 제한 - 입력 후보 없는 review 출력 제거 및 provenance WARN 로그 추가 - 사회복지사·청소년상담사 등 정형 자격요건 누락 키워드 제외 보강 - Spring Boot·포토샵 등 실무 역량은 정형 자격요건으로 오인하지 않도록 테스트 추가 - Candidate Generation 프롬프트에 도구·실무·문제 해결 기반 strength recall 규칙 추가 - EV-02, EV-15 유형 positive strength 예시 추가 --- .../analysis/service/ai/AnalysisAiClient.java | 195 +++++++++++++++++- .../AnalysisSanitizationRules.java | 4 +- .../service/ai/AnalysisAiClientTest.java | 118 ++++++++++- .../AnalysisSanitizationRulesTest.java | 8 + .../MissingKeywordSanitizerTest.java | 40 ++++ 5 files changed, 352 insertions(+), 13 deletions(-) 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 c91201b..c22f7a8 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 @@ -644,6 +644,15 @@ String buildCandidatePrompt( - status 다양성이나 개수를 채우기 위해 후보를 만들지 않는다. - improvement를 생성하지 않는다. + [strengthCandidates 생성 규칙] + - 반드시 수치 성과가 있어야만 strengthCandidates가 되는 것은 아니다. + - JD와 직접 연결된다면 구체적인 도구·기술 사용 경험은 strengthCandidates가 될 수 있다. + - 실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다. + - 문제를 발견하고 해결한 과정, 수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다. + - 자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다. + - JD 우대사항과 직접 연결되는 경험도 strengthCandidates가 될 수 있으나 preference-only 강점을 과대평가하지 않는다. + - 추상적인 포부, 근거 없는 자기평가, 단순 성격 표현, JD와 무관한 경험, 답변에 없는 추론 강점, 동일 근거의 중복 강점은 생성하지 않는다. + [missingKeywordCandidates 생성 규칙] - missingKeywordCandidates는 recall보다 precision을 우선한다. 확실한 누락 역량이 없으면 []를 반환한다. - 개수를 채우기 위해 missingKeywordCandidates를 생성하지 않는다. 애매하면 생성하지 않는다. @@ -677,6 +686,21 @@ String buildCandidatePrompt( missingKeywordCandidates: [] 이유: 사업 및 행정지원 역량이 이미 답변에 충분히 존재한다. + [strengthCandidates Positive Examples] + - Example 1 + JD main_tasks: 상세페이지 제작 + JD qualifications: 포토샵·일러스트 활용 + JD preferences: 라이노·키샷 우대 + Answer: 포토샵과 일러스트로 상세페이지를 제작했고, 1인 제품 브랜드를 직접 기획·운영했습니다. 라이노와 Fusion360으로 제품을 설계한 뒤 키샷으로 렌더링하고 실제 제품 제작까지 완료했습니다. + strengthCandidates: 포토샵과 일러스트 상세페이지 제작 경험, 1인 제품 브랜드 기획·운영 경험, 라이노·Fusion360·키샷 기반 제품 제작 경험 중 JD와 직접 연결되는 quote를 사용한다. + 이유: 수치 성과가 없어도 도구, 수행 업무, 제작 완료 결과가 확인 가능하다. + - Example 2 + JD main_tasks: 엑셀 고급 활용, 4대보험 신고, 회계 업무 + JD preferences: 더존 활용 우대 + Answer: 급여 정산표를 만들어 함수로 자동 비교했고, 전표 입력과 계정 분류를 실습했습니다. 4대보험 자료 작성 방법을 학습하고 더존 프로그램 입력 기준을 비교했으며, 5만 원 시재 차이 원인을 거래 자료 대조로 추적해 수정 요청을 주 3~4건에서 1~2건으로 줄였습니다. + strengthCandidates: 엑셀 자동 비교, 회계 전표·계정 분류 실습, 4대보험 자료 작성 학습, 더존 입력 기준 비교, 시재 차이 원인 추적과 수정 요청 감소 중 JD와 직접 연결되는 quote를 사용한다. + 이유: 직무 도구, 실무 처리 과정, 문제 해결, 개선 결과가 확인 가능하다. + [문장 유형별 후보 기준] - EXPERIENCE: 역할, 행동, 방법, 결과, 직무 연결성 중 실제로 부족한 요소가 있어야 한다. - EXPERIENCE: 주변 문장에 역할, 방법, 성과가 이어지면 부족하다고 판단하지 않는다. @@ -1667,11 +1691,22 @@ CandidateReviewResponse validateCandidateReview( sanitizedCandidates == null || sanitizedCandidates.analysisCandidates() == null ? List.of() : sanitizedCandidates.analysisCandidates(); + List strengths = validateReviewStrengths( + promptInput, + sanitizedCandidates, + reviewResponse.strengths() + ); + List missingKeywords = validateReviewMissingKeywords( + promptInput, + sanitizedCandidates, + reviewResponse.missingKeywords() + ); if (analysisCandidates.isEmpty()) { + logDroppedDecisionsWithoutInput(promptInput, reviewResponse.decisions()); return new CandidateReviewResponse( List.of(), - reviewResponse.strengths() == null ? List.of() : reviewResponse.strengths(), - reviewResponse.missingKeywords() == null ? List.of() : reviewResponse.missingKeywords(), + strengths, + missingKeywords, reviewResponse.jobFit(), reviewResponse.impact(), reviewResponse.completeness(), @@ -1698,10 +1733,12 @@ CandidateReviewResponse validateCandidateReview( } String candidateId = decision.candidateId().trim(); if (!seenCandidateIds.add(candidateId)) { + logReviewDrop(promptInput, "review_unknown_candidate_id", candidateId, null, null); continue; } AnalysisCandidateResponse.AnalysisCandidate candidate = candidateById.get(candidateId); if (candidate == null || decision.accepted() == null || decision.rejectionCode() == null) { + logReviewDrop(promptInput, "review_unknown_candidate_id", candidateId, null, null); continue; } if (Boolean.TRUE.equals(decision.accepted())) { @@ -1723,8 +1760,8 @@ CandidateReviewResponse validateCandidateReview( } return new CandidateReviewResponse( decisions, - reviewResponse.strengths() == null ? List.of() : reviewResponse.strengths(), - reviewResponse.missingKeywords() == null ? List.of() : reviewResponse.missingKeywords(), + strengths, + missingKeywords, reviewResponse.jobFit(), reviewResponse.impact(), reviewResponse.completeness(), @@ -1732,6 +1769,156 @@ CandidateReviewResponse validateCandidateReview( ); } + private void logDroppedDecisionsWithoutInput( + AnalysisPromptInput promptInput, + List decisions + ) { + if (decisions == null) { + return; + } + for (CandidateReviewResponse.CandidateDecision decision : decisions) { + if (decision != null) { + logReviewDrop( + promptInput, + "review_output_without_input_candidate", + decision.candidateId(), + null, + null + ); + } + } + } + + private List validateReviewStrengths( + AnalysisPromptInput promptInput, + AnalysisCandidateResponse sanitizedCandidates, + List reviewStrengths + ) { + if (reviewStrengths == null || sanitizedCandidates == null || sanitizedCandidates.strengthCandidates() == null) { + if (reviewStrengths != null) { + logReviewStrengthsWithoutInput(promptInput, reviewStrengths); + } + return List.of(); + } + Set allowedQuotes = sanitizedCandidates.strengthCandidates().stream() + .filter(candidate -> isPrimarySource(candidate.relatedSource())) + .map(candidate -> normalize(candidate.quote())) + .collect(Collectors.toSet()); + if (allowedQuotes.isEmpty()) { + logReviewStrengthsWithoutInput(promptInput, reviewStrengths); + return List.of(); + } + + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (CandidateReviewResponse.FinalStrengthCandidate strength : reviewStrengths) { + if (strength == null || !StringUtils.hasText(strength.quote())) { + logReviewDrop(promptInput, "review_strength_without_candidate", null, null, null); + continue; + } + String normalizedQuote = normalize(strength.quote()); + if (!allowedQuotes.contains(normalizedQuote) || !seen.add(normalizedQuote)) { + logReviewDrop(promptInput, "review_strength_without_candidate", null, null, null); + continue; + } + result.add(strength); + } + return List.copyOf(result); + } + + private void logReviewStrengthsWithoutInput( + AnalysisPromptInput promptInput, + List strengths + ) { + for (CandidateReviewResponse.FinalStrengthCandidate strength : strengths) { + logReviewDrop(promptInput, "review_strength_without_candidate", null, null, null); + } + } + + private List validateReviewMissingKeywords( + AnalysisPromptInput promptInput, + AnalysisCandidateResponse sanitizedCandidates, + List reviewMissingKeywords + ) { + if (reviewMissingKeywords == null || sanitizedCandidates == null || sanitizedCandidates.missingKeywordCandidates() == null) { + if (reviewMissingKeywords != null) { + logReviewMissingKeywordsWithoutInput(promptInput, reviewMissingKeywords); + } + return List.of(); + } + Set allowedKeywords = sanitizedCandidates.missingKeywordCandidates().stream() + .filter(candidate -> StringUtils.hasText(candidate.keyword())) + .map(candidate -> missingKeywordProvenanceKey(candidate.keyword(), candidate.source())) + .collect(Collectors.toSet()); + if (allowedKeywords.isEmpty()) { + logReviewMissingKeywordsWithoutInput(promptInput, reviewMissingKeywords); + return List.of(); + } + + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (CandidateReviewResponse.FinalMissingKeywordCandidate keyword : reviewMissingKeywords) { + if (keyword == null || !StringUtils.hasText(keyword.keyword())) { + logReviewDrop(promptInput, "review_missing_keyword_without_candidate", null, null, null); + continue; + } + String key = missingKeywordProvenanceKey(keyword.keyword(), keyword.source()); + if (!allowedKeywords.contains(key) || !seen.add(key)) { + logReviewDrop( + promptInput, + "review_missing_keyword_without_candidate", + null, + keyword.keyword(), + keyword.source() + ); + continue; + } + result.add(keyword); + } + return List.copyOf(result); + } + + private void logReviewMissingKeywordsWithoutInput( + AnalysisPromptInput promptInput, + List keywords + ) { + for (CandidateReviewResponse.FinalMissingKeywordCandidate keyword : keywords) { + logReviewDrop( + promptInput, + "review_missing_keyword_without_candidate", + null, + keyword == null ? null : keyword.keyword(), + keyword == null ? null : keyword.source() + ); + } + } + + private String missingKeywordProvenanceKey(String keyword, String source) { + String normalizedKeyword = AnalysisSanitizationRules.normalizeText(keyword); + String normalizedSource = parseCandidateSource(source) + .map(Enum::name) + .orElseGet(() -> AnalysisSanitizationRules.normalizeText(source)); + return normalizedKeyword + ":" + normalizedSource; + } + + private void logReviewDrop( + AnalysisPromptInput promptInput, + String reason, + String candidateId, + String keyword, + String source + ) { + log.warn( + "Candidate review output removed. reason={}, companyName={}, jobName={}, candidateId={}, keyword={}, source={}", + reason, + promptInput == null ? null : promptInput.companyName(), + promptInput == null ? null : promptInput.jobName(), + candidateId, + keyword, + source + ); + } + private CandidateReviewResponse.CandidateDecision validateAcceptedDecision( CandidateReviewResponse.CandidateDecision decision, AnalysisCandidateResponse.AnalysisCandidate candidate, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java index 08194cd..a917afb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java @@ -15,7 +15,9 @@ public final class AnalysisSanitizationRules { private static final Pattern CAREER_YEAR_PATTERN = Pattern.compile("경력\\s*\\d+\\s*년|\\d+\\s*년\\s*(이상|이하|미만|초과)"); private static final String[] STRUCTURED_QUALIFICATION_TERMS = { "자격증", "면허", "면허증", "공인성적", "어학성적", "토익", "toeic", "토플", "toefl", - "opic", "ielts", "학위", "전공", "졸업", "학력", "신입", "근무 가능", "국적", "나이", "연령" + "opic", "ielts", "학위", "전공", "졸업", "학력", "신입", "근무 가능", "국적", "나이", "연령", + "사회복지사", "청소년지도사", "청소년상담사", "직업상담사", "임상심리사", + "대졸", "초대졸", "전문대졸", "고졸" }; private static final String[] META_IMPROVEMENT_TERMS = { "구체적으로 작성했습니다", "명확히 설명했습니다", "성과를 강조했습니다", "수치로 명시했습니다", 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 f97aab3..f9cf203 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 @@ -327,7 +327,18 @@ void buildCandidatePromptIncludesCandidateRulesOnly() { .contains("[missingKeywordCandidates Negative Examples]") .contains("상세페이지 제작을 UX/UI 인터페이스 설계 같은 JD 밖 개념으로 확장하지 않는다.") .contains("브랜드 운영은 이미 답변에 있으며, BI/CI 구축 경험으로 일반화하지 않는다.") - .contains("행정지원 역량이 이미 답변에 충분히 존재한다."); + .contains("사업 및 행정지원 역량이 이미 답변에 충분히 존재한다.") + .contains("[strengthCandidates 생성 규칙]") + .contains("반드시 수치 성과가 있어야만 strengthCandidates가 되는 것은 아니다.") + .contains("구체적인 도구·기술 사용 경험은 strengthCandidates가 될 수 있다.") + .contains("실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다.") + .contains("수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다.") + .contains("자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다.") + .contains("[strengthCandidates Positive Examples]") + .contains("포토샵과 일러스트로 상세페이지를 제작") + .contains("라이노와 Fusion360으로 제품을 설계") + .contains("급여 정산표를 만들어 함수로 자동 비교") + .contains("5만 원 시재 차이 원인을 거래 자료 대조로 추적"); } @Test @@ -1022,14 +1033,18 @@ void applyRecheckResponseRecoversAtMostOneAndKeepsMissingKeywords() { @DisplayName("candidate review는 검증된 candidateId와 accepted/rejectionCode 조합만 유지한다") void validateCandidateReviewKeepsOnlyConsistentDecisions() { AnalysisCandidateResponse candidates = new AnalysisCandidateResponse( - List.of(), + List.of(strengthCandidate("Spring Boot API를 개발했습니다.")), List.of( candidate("accepted-1", "Spring Boot API를 개발했습니다."), candidate("rejected-1", "장애 대응 경험이 있습니다."), candidate("bad-accepted-code", "Spring Boot API를 개발했습니다."), candidate("bad-rejected-code", "장애 대응 경험이 있습니다.") ), - List.of() + List.of(new AnalysisCandidateResponse.MissingKeywordCandidate( + "Spring Boot API 개발 경험", + "QUALIFICATION", + "Spring Boot 경험" + )) ); CandidateReviewResponse validated = analysisAiClient.validateCandidateReview( @@ -1078,8 +1093,22 @@ void validateCandidateReviewKeepsOnlyConsistentDecisions() { null ) ), - List.of(), - List.of(), + List.of( + new CandidateReviewResponse.FinalStrengthCandidate( + "API 개발 경험", + "Spring Boot API를 개발했습니다.", + "MAIN_TASK" + ), + new CandidateReviewResponse.FinalStrengthCandidate( + "입력 후보에 없는 강점", + "장애 대응 경험이 있습니다.", + "MAIN_TASK" + ) + ), + List.of( + new CandidateReviewResponse.FinalMissingKeywordCandidate("Spring Boot API 개발 경험", "qualification"), + new CandidateReviewResponse.FinalMissingKeywordCandidate("채용 파이프라인 소싱 경험", "JD") + ), 80, 70, 60, @@ -1092,10 +1121,14 @@ void validateCandidateReviewKeepsOnlyConsistentDecisions() { .containsExactly("accepted-1", "rejected-1"); assertThat(validated.decisions().getFirst().improvement()).isNull(); assertThat(validated.decisions().get(1).improvement()).isNull(); + assertThat(validated.strengths()).extracting("quote") + .containsExactly("Spring Boot API를 개발했습니다."); + assertThat(validated.missingKeywords()).extracting("keyword") + .containsExactly("Spring Boot API 개발 경험"); } @Test - @DisplayName("candidate가 없으면 review decisions는 빈 배열로 검증된다") + @DisplayName("candidate가 없으면 review 출력은 빈 배열로 검증된다") void validateCandidateReviewReturnsEmptyDecisionsWhenNoCandidates() { CandidateReviewResponse validated = analysisAiClient.validateCandidateReview( promptInput(), @@ -1109,8 +1142,12 @@ void validateCandidateReviewReturnsEmptyDecisionsWhenNoCandidates() { "1차에 없는 후보입니다.", null )), - List.of(), - List.of(), + List.of(new CandidateReviewResponse.FinalStrengthCandidate( + "입력 후보에 없는 강점", + "Spring Boot API를 개발했습니다.", + "MAIN_TASK" + )), + List.of(new CandidateReviewResponse.FinalMissingKeywordCandidate("채용 파이프라인 소싱 경험", "JD")), 80, 70, 60, @@ -1119,6 +1156,61 @@ void validateCandidateReviewReturnsEmptyDecisionsWhenNoCandidates() { ); assertThat(validated.decisions()).isEmpty(); + assertThat(validated.strengths()).isEmpty(); + assertThat(validated.missingKeywords()).isEmpty(); + } + + @Test + @DisplayName("review strength와 missingKeyword는 sanitized 후보에 존재하는 항목만 유지한다") + void validateCandidateReviewKeepsOnlyProvenancedStrengthsAndMissingKeywords() { + AnalysisCandidateResponse candidates = new AnalysisCandidateResponse( + List.of(strengthCandidate("Spring Boot API를 개발했습니다.")), + List.of(), + List.of(new AnalysisCandidateResponse.MissingKeywordCandidate( + "Spring Boot API 개발 경험", + "QUALIFICATION", + "Spring Boot 경험" + )) + ); + + CandidateReviewResponse validated = analysisAiClient.validateCandidateReview( + promptInput(), + candidates, + new CandidateReviewResponse( + List.of(), + List.of( + new CandidateReviewResponse.FinalStrengthCandidate( + "API 개발 경험", + "Spring Boot API를 개발했습니다.", + "MAIN_TASK" + ), + new CandidateReviewResponse.FinalStrengthCandidate( + "후보 없는 강점", + "장애 대응 경험이 있습니다.", + "MAIN_TASK" + ) + ), + List.of( + new CandidateReviewResponse.FinalMissingKeywordCandidate( + " spring boot api 개발 경험 ", + "qualification" + ), + new CandidateReviewResponse.FinalMissingKeywordCandidate( + "채용 파이프라인 소싱 경험", + "JD" + ) + ), + 80, + 70, + 60, + "피드백" + ) + ); + + assertThat(validated.strengths()).extracting("quote") + .containsExactly("Spring Boot API를 개발했습니다."); + assertThat(validated.missingKeywords()).extracting("keyword") + .containsExactly(" spring boot api 개발 경험 "); } @Test @@ -1266,6 +1358,16 @@ private AnalysisCandidateResponse.AnalysisCandidate candidate(String candidateId ); } + private AnalysisCandidateResponse.StrengthCandidate strengthCandidate(String quote) { + return new AnalysisCandidateResponse.StrengthCandidate( + 1L, + quote, + "MAIN_TASK", + "API 개발", + "직접 근거입니다." + ); + } + private static class RecheckBuilder { private final String candidateId; private RecheckDecision decision = RecheckDecision.KEEP_BEST_CANDIDATE; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java index da07f03..f2d8505 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java @@ -74,6 +74,14 @@ void filtersStructuredQualificationKeywordsConservatively() { assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("반도체 공정 분석 경험")).isFalse(); assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("경력 3년 이상")).isTrue(); assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("장애 대응 경험")).isFalse(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("사회복지사")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("청소년상담사 3급")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("직업상담사")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("운전면허")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("컴퓨터활용능력 자격증")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("대졸 이상")).isTrue(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("Spring Boot 실무 경험")).isFalse(); + assertThat(AnalysisSanitizationRules.isStructuredQualificationKeyword("포토샵 활용 능력")).isFalse(); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java index 99676fa..a987a2c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java @@ -108,6 +108,46 @@ void structuredQualificationRejected() { .containsExactly(MissingKeywordRejectionReason.CERTIFICATE_OR_QUANTITATIVE_NOISE); } + @Test + @DisplayName("자격증, 면허, 학력, 경력 연차 누락 키워드는 정형 자격요건으로 제거한다") + void structuredCertificateAndEducationKeywordsRejected() { + List candidates = List.of( + candidate("사회복지사", "QUALIFICATION"), + candidate("청소년상담사 3급", "QUALIFICATION"), + candidate("운전면허", "QUALIFICATION"), + candidate("대졸 이상", "QUALIFICATION"), + candidate("경력 3년 이상", "QUALIFICATION") + ); + + MissingKeywordSanitizationResult result = MissingKeywordSanitizer.sanitize( + MAIN_TASKS, + QUALIFICATIONS, + "", + candidates + ); + + assertThat(result.acceptedCandidates()).isEmpty(); + assertThat(result.decisions()).extracting(MissingKeywordSanitizationDecision::rejectionReason) + .containsOnly(MissingKeywordRejectionReason.CERTIFICATE_OR_QUANTITATIVE_NOISE); + } + + @Test + @DisplayName("자격요건 섹션의 기술·실무 역량은 정형 자격요건으로 오인하지 않는다") + void practicalSkillQualificationKeywordsAreNotStructuredNoise() { + MissingKeywordSanitizationResult result = MissingKeywordSanitizer.sanitize( + MAIN_TASKS, + "Spring Boot 실무 경험과 포토샵 활용 능력", + "", + List.of( + candidate("Spring Boot 실무 경험", "QUALIFICATION"), + candidate("포토샵 활용 능력", "QUALIFICATION") + ) + ); + + assertThat(result.acceptedCandidates()).extracting("keyword") + .containsExactly("Spring Boot 실무 경험", "포토샵 활용 능력"); + } + @Test @DisplayName("유효 후보는 ACCEPTED로 기록한다") void validCandidateAccepted() { From d54e0a8cca91ee4078462bbe70fd589d77dd8336 Mon Sep 17 00:00:00 2001 From: wooh Date: Thu, 30 Jul 2026 01:07:19 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[Fix]=20NLG=20Judge=20=ED=8F=89=EA=B0=80?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=EC=9D=84=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MISSED_ANALYSIS 판정 기준을 명확한 문제 문장과 첨삭 가치 손실 중심으로 강화 - 명확한 문제 부재와 MISSED_ANALYSIS가 공존하지 않도록 Judge 프롬프트 보강 - MISSED_MISSING_KEYWORD 평가에서 자격증·면허·학력·경력 연차 등 정형 자격요건 제외 - JD에 없는 키워드와 OR 자격요건의 미선택지를 누락으로 보지 않도록 규칙 추가 - empty questionAnalyses 입력 시 평가 배열 생성 금지 계약 재강조 - overallUsefulness와 noAnalysisAppropriateness 1~5 rubric 구체화 - Judge 프롬프트 contract 테스트 추가 --- ...judge_two_pass_provenance_strength_fix.csv | 21 ++++++ ...ass_provenance_strength_fix_comparison.csv | 3 + .../evaluation/NlgEvaluationAiClient.java | 64 ++++++++++++++-- .../evaluation/NlgEvaluationAiClientTest.java | 74 ++++++++++++++++++- 4 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix.csv create mode 100644 evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix_comparison.csv diff --git a/evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix.csv b/evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix.csv new file mode 100644 index 0000000..d22110a --- /dev/null +++ b/evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","전반적으로 경험이 잘 드러나지만, 구체적인 절차와 결과 수치가 부족하여 개선이 필요합니다.",4822,296,9360, +EV-02,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 전혀 없으며, 명백한 문제 문장이 존재하여 적절한 평가가 이루어지지 않았습니다.",4260,128,3008, +EV-03,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.5,2.5,3.5,3.5,2.5,4.0,4.0,3.5,3.5,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",분석이 부족하여 구체적인 행동과 결과가 강조되지 않았습니다. 전반적으로 개선이 필요합니다.,5856,445,5936, +EV-04,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]","답변에 명백한 첨삭 대상이 없으나, 분석이 부재하여 중요한 문제를 놓쳤습니다.",5928,131,2865, +EV-05,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,3.0,3.0,3.0,2.0,5.0,5.0,3.0,5.0,5.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",지원 동기와 포부가 구체성이 부족하여 문맥을 무시한 평가가 발생했습니다. 전반적으로 개선이 필요합니다.,4729,452,6455, +EV-06,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,1,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5,5,5,5,5,5,"[""NONE""]","자기소개서에서 구체적인 성과와 실행 방법이 잘 드러나 있으며, 모든 평가 기준에서 높은 점수를 기록했습니다.",7428,314,7448, +EV-07,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 전혀 없으며, 명백한 문제 문장이 존재하여 평가가 낮아졌습니다.",3806,123,3500, +EV-08,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,2.0,2.0,3.0,2.0,4.0,5.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",자기소개서에서 구체적인 실행 방안과 방법론이 부족하여 개선이 필요합니다. 전반적으로 유용성은 낮은 편입니다.,5759,326,5067, +EV-09,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원자의 경험은 유용하지만 구체적인 행동과 방법론이 부족하여 개선이 필요합니다.,4752,413,6602, +EV-10,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",분석이 부족하여 구체적인 경험과 성과를 연결하지 못한 점이 아쉬웠습니다. 전반적으로 개선이 필요합니다.,4965,610,7273, +EV-11,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,2,2,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]",지원자의 경험과 비전이 구체적이지 않아 평가가 낮았습니다. 중요한 첨삭 대상을 놓쳤습니다.,5019,136,3160, +EV-12,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,3.0,4.0,4,5,5,5,5,4,"[""NONE""]","지원자의 경험이 직무 적합성을 잘 드러내고 있으나, 구체적인 행동이나 방법이 부족하여 개선 여지가 있습니다.",5246,416,4925, +EV-13,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,4.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,5,5,5,5,5,4,"[""NONE""]","전반적으로 경험과 방법론이 잘 서술되었으나, 구체성이 부족한 부분이 있어 개선 여지가 있습니다.",4876,430,5282, +EV-14,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원 동기에서 구체적인 행동이나 방법이 부족하여 명확성이 떨어집니다. 중요한 첨삭 대상을 놓쳤습니다.,4929,130,2090, +EV-15,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,3,3,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기와 직무 적합성에 대한 구체적인 사례가 부족하며, 중요한 첨삭 대상을 놓쳤습니다.",3851,138,3582, +EV-16,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",답변에서 구체적인 행동 계획과 방법론이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 분석이 없어서 평가가 어려웠습니다.,4926,134,3050, +EV-17,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,2.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",경험의 구체성이 부족하여 간호사 역할을 명확히 드러내지 못하고 있습니다. 중요한 첨삭 대상을 놓쳤습니다.,4258,414,4997, +EV-18,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,1,1,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","자기소개서에서 구체적인 경험이 부족하고, 중요한 첨삭 대상을 놓쳤습니다. 누락된 키워드가 있으며, 분석이 전혀 이루어지지 않았습니다.",4279,152,2686, +EV-19,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",지원자의 경험이 구체적이지 않아 QA의 중요성을 강조하는 데 부족함이 있습니다. 문맥을 고려한 분석이 필요합니다.,4432,428,5713, +EV-20,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원 동기와 포부가 구체적이지 않아 분석이 부족하다고 판단됩니다. 명백한 문제 문장이 존재하지만 분석이 없었습니다.,4274,133,2388, diff --git a/evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix_comparison.csv b/evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix_comparison.csv new file mode 100644 index 0000000..32b90ff --- /dev/null +++ b/evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix_comparison.csv @@ -0,0 +1,3 @@ +sourceResultFile,caseCount,successCount,judgeFailedCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,averageJudgeInputTokens,averageJudgeOutputTokens,averageJudgeLatencyMs,averageAnalysisCount,metaImprovementRate,unsupportedFactRate,falsePositiveAnalysisRate,fatalErrorRate,errorCodeCounts +evaluation/evaluation_nlg_judge_two_pass_candidate_prompt_fix_normalized.csv,20,20,0,3.17,2.83,3.17,3.22,2.17,4.22,4.22,3.22,3.44,4.22,2.0,4.5,4.5,4.55,4.55,3.0,4758.9,249.35,7640.45,0.75,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":4,""MISSED_ANALYSIS"":19,""MISSED_MISSING_KEYWORD"":3}" +evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix.csv,20,20,0,3.45,2.95,3.35,3.45,2.55,4.1,4.4,3.55,3.65,4.2,2.35,4.55,4.55,4.55,4.55,3.05,4919.75,287.45,4769.35,0.9,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":4,""MISSED_ANALYSIS"":17,""MISSED_MISSING_KEYWORD"":3,""NONE"":3}" diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java index 079d178..0dd7bf6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java @@ -76,24 +76,37 @@ String buildPrompt(NlgJudgeInput input) { 5. improvement가 원문 사실을 보존하고 메타 첨삭 문장이 아닌지 확인한다. 6. keyStrengths quote가 answer 원문에 있고 좋은 문장인지 확인한다. 7. missingKeywords가 JD 원문 기반 경험형 키워드인지 확인한다. - 8. questionAnalyses가 비어 있으면 실제로 첨삭 대상이 없는지, 중요한 문제를 놓친 것인지 평가한다. - 9. strengths와 missingKeywords는 precision뿐 아니라 coverage도 평가한다. + 8. questionAnalyses가 비어 있으면 명확한 첨삭 대상이 실제로 있는지 엄격하게 확인한다. + 9. strengths와 missingKeywords는 precision뿐 아니라 coverage도 평가하되 JobDri 서비스 정책의 제외 대상을 따른다. 10. 1~5 점수와 허용 errorCodes만 반환한다. [점수 기준] - questionAnalysis별: relevance, problemValidity, sentenceTypeConsistency, reasonCorrectness, contextAwareness를 1~5로 평가한다. - improvement별: faithfulness, tenseConsistency, usability, nonMeta, meaningPreservation을 1~5로 평가한다. - - 빈 분석 평가: noAnalysisAppropriateness를 1~5로 평가한다. questionAnalyses가 비어 있고 명백한 문제 문장이 있으면 낮게 평가한다. + - 빈 분석 평가: noAnalysisAppropriateness를 1~5로 평가한다. questionAnalyses가 비어 있고 명확한 문제 문장이 있으면 낮게 평가한다. + 5점: 명확한 첨삭 대상이 없으므로 0개가 적절함. + 4점: 아주 사소한 개선 여지는 있으나 분석 없음이 대체로 적절함. + 3점: 판단이 애매함. + 2점: 명확한 첨삭 대상이 일부 존재함. + 1점: 여러 개의 명확한 문제를 놓침. + - questionAnalyses가 1개 이상이면 noAnalysisAppropriateness는 분석 부재 평가가 아니므로 전체 품질을 대표하지 않는 중립적 보조값으로만 둔다. - keyStrengths: strengthsPrecision과 strengthsCoverage를 각각 1~5로 평가한다. 명백한 좋은 문장을 놓치면 MISSED_STRENGTH를 사용한다. - missingKeywords: missingKeywordsPrecision과 missingKeywordsCoverage를 각각 1~5로 평가한다. JD 핵심 경험 요구사항 누락을 놓치면 MISSED_MISSING_KEYWORD를 사용한다. - actual missingKeywords가 빈 배열이라고 해서 자동으로 정확한 것이 아니다. - JD와 answer 기준상 필요한 누락 키워드가 존재하면 actual=[]라도 missingKeywordsCoverage를 낮게 평가하고 MISSED_MISSING_KEYWORD를 사용한다. - actual=[]이고 실제로 누락 키워드가 없을 때만 정상 빈 배열로 평가한다. - 빈 배열은 precision과 coverage를 분리해 판단한다. - - case 전체: overallUsefulness를 1~5로 독립 평가한다. 기본값을 4로 두지 말고 1,2,3,4,5 전체 범위를 실제 품질에 맞게 사용한다. + - case 전체: overallUsefulness를 1~5로 독립 평가한다. 기본값을 3이나 4로 두지 말고 1,2,3,4,5 전체 범위를 실제 품질에 맞게 사용한다. + 5점: 분석, 강점, 누락 키워드가 대부분 정확하고 사용자가 바로 수정에 활용할 수 있음. + 4점: 일부 아쉬움은 있으나 핵심 진단과 개선 방향이 유용함. + 3점: 일부는 유용하지만 중요한 누락 또는 부정확성이 존재하는 혼합 품질. + 2점: 다수 평가가 부정확하거나 핵심 문제를 놓쳐 활용도가 낮음. + 1점: 대부분 잘못됐거나 JD/답변과 무관하여 실질적으로 사용할 수 없음. + - overallUsefulness는 question analysis 정확성, 문제 문장 식별 적절성, reason 정확성, 문맥 반영, strengths precision/coverage, missingKeywords precision/coverage, 서비스 정책 준수를 종합한다. + - analysisCount가 0이라는 이유만으로 overallUsefulness를 자동으로 3점 처리하지 않는다. - 치명적 오류(UNSUPPORTED_FACT, TENSE_CHANGED, FALSE_POSITIVE_ANALYSIS, INVALID_MISSING_KEYWORD)가 있으면 overallUsefulness에 4~5점을 주지 않는다. - questionAnalyses가 없으면 questionAnalysisEvaluations는 []로 둔다. 가짜 평가를 생성하지 않는다. - - questionAnalyses가 비어 있어도 중요한 첨삭 대상을 놓쳤다면 MISSED_ANALYSIS를 부여하고 overallUsefulness를 낮게 평가한다. + - questionAnalyses가 비어 있어도 중요한 첨삭 대상을 놓쳤다면 아래 MISSED_ANALYSIS 엄격 기준을 만족할 때만 MISSED_ANALYSIS를 부여하고 overallUsefulness를 낮게 평가한다. [questionAnalysisEvaluations 출력 계약] - questionAnalysisEvaluations는 questionAnalysesJson에 명시적으로 포함된 분석 항목만 평가한다. @@ -110,6 +123,40 @@ String buildPrompt(NlgJudgeInput input) { - 빈 questionAnalysesJson은 그 자체로 validation error가 아니며, 분석 부재의 적절성은 noAnalysisAppropriateness로 평가한다. - 출력 예: questionAnalysesJson=[]이면 반드시 "questionAnalysisEvaluations": [] 이어야 한다. - analysisIndex는 questionAnalysesJson 입력 배열의 index만 사용한다. + - questionAnalysesJson이 빈 배열이면 output questionAnalysisEvaluations도 빈 배열이어야 한다. + - 입력에 없는 분석 평가를 생성하거나, answer에서 임의 문장을 골라 evaluation 배열을 만들거나, 분석 개수를 0보다 크게 만들지 않는다. + + [MISSED_ANALYSIS 판정 기준] + - MISSED_ANALYSIS는 다음을 모두 만족할 때만 caseErrorCodes에 포함한다. + 1. answer에 독립적으로 식별 가능한 명확한 문제 문장 또는 핵심 구절이 존재한다. + 2. 그 문제는 단순한 "더 구체적이면 좋음" 수준이 아니다. + 3. 문제 유형을 특정할 수 있다. 예: 근거 없는 과장, 명시적 모순, 문항 의도 불충족, 핵심 행동/역할/방법/결과의 명확한 부재. + 4. 해당 문장을 분석하지 않아 사용자에게 실질적인 첨삭 가치 손실이 발생한다. + 5. 현재 questionAnalyses에 동일하거나 충분히 유사한 문제 분석이 존재하지 않는다. + - 다음 경우에는 MISSED_ANALYSIS를 부여하지 않는다. + 문장이 더 좋아질 수 있다는 정도, 수치가 없다는 이유만 있는 경우, 추상적인 포부지만 문항 목적상 자연스러운 경우, + 이미 다른 분석이 동일한 핵심 문제를 다루는 경우, 명확한 문제 문장을 특정할 수 없는 경우, + 답변 전반에 대한 일반적 아쉬움만 있는 경우, 단순히 분석 개수가 적다는 이유, + "구체성 부족"만 있고 어느 문장이 왜 문제인지 특정할 수 없는 경우. + - MISSED_ANALYSIS를 부여할 때 shortRationale에는 놓친 원문 문장 또는 핵심 구절, 문제 유형, 기존 분석으로 커버되지 않는 이유를 간결하게 포함한다. + - 이 근거를 제시할 수 없으면 MISSED_ANALYSIS를 부여하지 않는다. + - 명확한 문제 문장이 없다고 판단했다면 MISSED_ANALYSIS를 errorCodes에 포함하지 않는다. + - "명확한 문제 문장이 없다"와 "MISSED_ANALYSIS"를 동시에 주장하지 않는다. + - "첨삭 대상이 명확하지 않다"와 "중요한 첨삭 대상을 놓쳤다"를 동시에 주장하지 않는다. + + [MISSED_MISSING_KEYWORD 판정 기준] + - MISSED_MISSING_KEYWORD는 다음을 모두 만족할 때만 caseErrorCodes에 포함한다. + 1. 실제 JD 원문의 mainTasks 또는 qualifications에 요구사항이 존재한다. + 2. JobDri 서비스 정책상 missing keyword 제외 대상이 아니다. + 3. answer에 동일하거나 의미상 충족되는 내용이 없다. + 4. 현재 missingKeywords에 동일하거나 충분히 유사한 키워드가 없다. + - missing keyword 평가 대상에서 제외한다: 자격증, 면허, 학력, 경력 연차, 나이, 법적/정형 보유 조건, 선택형 자격요건의 다른 선택지. + - 제외 예시: 사회복지사, 청소년상담사, 직업상담사, 운전면허, 대졸 이상, 경력 3년 이상. + - 제외 대상이 아닌 예시: Spring Boot 실무 경험, 포토샵 활용 능력, 엑셀 고급 활용, 더존 사용 능력, 4대보험 신고 경험. + - source=QUALIFICATION이라는 이유만으로 모든 항목을 제외하지 않는다. + - JD에 없는 키워드, 다른 직군의 일반 요구사항, 답변에 이미 존재하는 키워드, OR 조건에서 하나를 충족했을 때 나머지 선택지를 누락으로 판단하지 않는다. + - MISSED_MISSING_KEYWORD를 부여할 때 shortRationale에는 JD의 실제 요구사항, 답변에서 충족되지 않은 이유, 기존 missingKeywords로 커버되지 않는 이유를 간결하게 포함한다. + - 이 근거를 제시할 수 없으면 MISSED_MISSING_KEYWORD를 부여하지 않는다. [errorCode와 점수 정합성] - problemValidity <= 2이면 FALSE_POSITIVE_ANALYSIS 후보로 검토한다. @@ -132,9 +179,12 @@ String buildPrompt(NlgJudgeInput input) { - 좋은 문장 오탐: 이미 수치/방법/결과가 충분한 문장을 MENTIONED로 잡으면 problemValidity=1~2, errorCodes=[FALSE_POSITIVE_ANALYSIS]. - 문맥 무시: 앞뒤 문장에 근거가 있는데 대상 문장만 보고 부족하다고 하면 contextAwareness=1~2, errorCodes=[CONTEXT_IGNORED]. - 메타 improvement: "구체적으로 작성했습니다"처럼 첨삭 행위를 설명하면 nonMeta=1~2, errorCodes=[META_IMPROVEMENT]. - - 빈 결과의 false negative: questionAnalyses=[]이지만 답변에 명백한 첨삭 대상이 있으면 noAnalysisAppropriateness=1~2, errorCodes=[MISSED_ANALYSIS]. + - MISSED_ANALYSIS를 부여하지 않는 빈 결과: 구체적인 경험과 직무 연결이 충분하고 일부 문장이 더 자세해질 수는 있지만 명백한 오류나 첨삭 대상이 없으면 questionAnalyses=[], noAnalysisAppropriateness=4~5, MISSED_ANALYSIS 없음. + - MISSED_ANALYSIS를 부여하는 빈 결과: answer에 "저는 모든 업무를 완벽하게 해낼 수 있습니다."처럼 근거 없는 과장 표현이라는 명확한 문제 문장이 있고 questionAnalyses=[]이면 noAnalysisAppropriateness=1~2, errorCodes=[MISSED_ANALYSIS]. shortRationale에 원문 구절과 문제 유형을 명시한다. - 적절한 빈 결과: 답변에 명백한 문제 문장이 없고 강점/누락 키워드도 적절하면 noAnalysisAppropriateness=4~5, errorCodes=[NONE]. - - 누락 키워드 실패: JD 핵심 경험 요구사항 후보가 있는데 missingKeywordsJson=[]이면 missingKeywordsCoverage=1~2, errorCodes=[MISSED_MISSING_KEYWORD]. + - 정형 자격증 누락 금지: JD가 "사회복지사, 청소년상담사 중 1개 이상"이고 answer에 "청소년상담사 3급 보유"가 있으면 missingKeywordsJson=[]이어도 MISSED_MISSING_KEYWORD 없음. 사회복지사를 누락으로 보지 않는다. + - JD 밖 키워드 금지: JD가 "엑셀 고급 활용, 4대보험 신고, 더존 사용"인데 채용 파이프라인 소싱, 온보딩 프로그램 기획을 기대하면 안 된다. 실제 JD에 없으므로 MISSED_MISSING_KEYWORD 금지. + - 실제 누락 키워드 실패: JD에 "Spring Boot 기반 REST API 개발"이 있고 answer에 Java 학습 경험만 있으며 Spring Boot 사용 언급이 없고 missingKeywordsJson=[]이면 missingKeywordsCoverage=1~2, errorCodes=[MISSED_MISSING_KEYWORD] 가능. - 누락 키워드 정상 빈 배열: JD 핵심 경험 요구사항이 답변에 충분히 반영되어 missingKeywordsJson=[]이면 MISSED_MISSING_KEYWORD를 사용하지 않는다. - 점수 예시는 anchoring용이 아니다. 1,2,3,4,5를 실제 오류 심각도에 따라 분산해서 사용한다. diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java index 6fa9f40..5c6e40f 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java @@ -62,7 +62,7 @@ void buildPromptContainsJudgeRulesWithoutChainOfThoughtOutput() { assertThat(prompt).contains("actual missingKeywords가 빈 배열이라고 해서 자동으로 정확한 것이 아니다"); assertThat(prompt).contains("actualMissingKeywordCount"); assertThat(prompt).contains("validatedMissingKeywordCandidateCount"); - assertThat(prompt).contains("기본값을 4로 두지 말고"); + assertThat(prompt).contains("기본값을 3이나 4로 두지 말고"); assertThat(prompt).contains("1,2,3,4,5를 실제 오류 심각도에 따라 분산"); } @@ -131,6 +131,78 @@ void buildPromptKeepsExistingJudgeCriteria() { .contains("MISSED_MISSING_KEYWORD"); } + @Test + @DisplayName("MISSED_ANALYSIS는 명확한 문제 문장과 근거가 있을 때만 허용하도록 지시한다") + void buildPromptRestrictsMissedAnalysisCriteria() { + String prompt = buildPrompt("[]", "{}", "{}"); + + assertThat(prompt) + .contains("[MISSED_ANALYSIS 판정 기준]") + .contains("독립적으로 식별 가능한 명확한 문제 문장 또는 핵심 구절") + .contains("단순한 \"더 구체적이면 좋음\" 수준이 아니다") + .contains("문제 유형을 특정할 수 있다") + .contains("실질적인 첨삭 가치 손실") + .contains("현재 questionAnalyses에 동일하거나 충분히 유사한 문제 분석이 존재하지 않는다") + .contains("문장이 더 좋아질 수 있다는 정도") + .contains("수치가 없다는 이유만 있는 경우") + .contains("명확한 문제 문장을 특정할 수 없는 경우") + .contains("\"구체성 부족\"만 있고 어느 문장이 왜 문제인지 특정할 수 없는 경우") + .contains("놓친 원문 문장 또는 핵심 구절, 문제 유형, 기존 분석으로 커버되지 않는 이유") + .contains("명확한 문제 문장이 없다고 판단했다면 MISSED_ANALYSIS를 errorCodes에 포함하지 않는다") + .contains("\"명확한 문제 문장이 없다\"와 \"MISSED_ANALYSIS\"를 동시에 주장하지 않는다") + .contains("noAnalysisAppropriateness=4~5, MISSED_ANALYSIS 없음") + .contains("저는 모든 업무를 완벽하게 해낼 수 있습니다."); + } + + @Test + @DisplayName("MISSED_MISSING_KEYWORD는 서비스의 정형 자격요건 제외 정책과 JD 근거를 따른다") + void buildPromptAlignsMissedMissingKeywordWithServicePolicy() { + String prompt = buildPrompt("[]", "{}", "{}"); + + assertThat(prompt) + .contains("[MISSED_MISSING_KEYWORD 판정 기준]") + .contains("실제 JD 원문의 mainTasks 또는 qualifications에 요구사항이 존재한다") + .contains("JobDri 서비스 정책상 missing keyword 제외 대상이 아니다") + .contains("answer에 동일하거나 의미상 충족되는 내용이 없다") + .contains("현재 missingKeywords에 동일하거나 충분히 유사한 키워드가 없다") + .contains("자격증, 면허, 학력, 경력 연차, 나이") + .contains("선택형 자격요건의 다른 선택지") + .contains("사회복지사") + .contains("청소년상담사") + .contains("운전면허") + .contains("대졸 이상") + .contains("경력 3년 이상") + .contains("Spring Boot 실무 경험") + .contains("포토샵 활용 능력") + .contains("엑셀 고급 활용") + .contains("더존 사용 능력") + .contains("4대보험 신고 경험") + .contains("JD에 없는 키워드") + .contains("OR 조건에서 하나를 충족했을 때 나머지 선택지를 누락으로 판단하지 않는다") + .contains("사회복지사, 청소년상담사 중 1개 이상") + .contains("채용 파이프라인 소싱, 온보딩 프로그램 기획") + .contains("Spring Boot 기반 REST API 개발"); + } + + @Test + @DisplayName("overallUsefulness와 noAnalysisAppropriateness는 1~5 전체 범위를 쓰도록 rubric을 제공한다") + void buildPromptDefinesUsefulnessAndNoAnalysisRubrics() { + String prompt = buildPrompt("[]", "{}", "{}"); + + assertThat(prompt) + .contains("5점: 명확한 첨삭 대상이 없으므로 0개가 적절함.") + .contains("4점: 아주 사소한 개선 여지는 있으나 분석 없음이 대체로 적절함.") + .contains("3점: 판단이 애매함.") + .contains("2점: 명확한 첨삭 대상이 일부 존재함.") + .contains("1점: 여러 개의 명확한 문제를 놓침.") + .contains("5점: 분석, 강점, 누락 키워드가 대부분 정확하고 사용자가 바로 수정에 활용할 수 있음.") + .contains("4점: 일부 아쉬움은 있으나 핵심 진단과 개선 방향이 유용함.") + .contains("3점: 일부는 유용하지만 중요한 누락 또는 부정확성이 존재하는 혼합 품질.") + .contains("2점: 다수 평가가 부정확하거나 핵심 문제를 놓쳐 활용도가 낮음.") + .contains("1점: 대부분 잘못됐거나 JD/답변과 무관하여 실질적으로 사용할 수 없음.") + .contains("analysisCount가 0이라는 이유만으로 overallUsefulness를 자동으로 3점 처리하지 않는다"); + } + @Test @DisplayName("NLG judge evaluate는 limiter 예외를 감싸지 않고 그대로 전파한다") void evaluateRethrowsLimiterRuntimeException() { From 7a269e4cc3811abff0d24549502a505efdf141bb Mon Sep 17 00:00:00 2001 From: wooh Date: Thu, 30 Jul 2026 01:22:35 +0900 Subject: [PATCH 11/12] =?UTF-8?q?[Fix]=20NLG=20Judge=20=ED=94=84=EB=A1=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8=20=EC=A0=95=EC=B1=85=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MISSED_ANALYSIS 판정 기준을 별도 상수로 분리 - MISSED_MISSING_KEYWORD 판정 기준을 별도 상수로 분리 - actual=[] missingKeywords 평가에서 정형 자격요건 제외 정책 우선순위 명시 - NLG Judge 프롬프트 contract 테스트 보강 --- .../evaluation_nlg_judge_policy_alignment.csv | 21 ++++++ ..._nlg_judge_policy_alignment_comparison.csv | 3 + .../evaluation/NlgEvaluationAiClient.java | 72 +++++++++++-------- .../evaluation/NlgEvaluationAiClientTest.java | 2 + 4 files changed, 67 insertions(+), 31 deletions(-) create mode 100644 evaluation/evaluation_nlg_judge_policy_alignment.csv create mode 100644 evaluation/evaluation_nlg_judge_policy_alignment_comparison.csv diff --git a/evaluation/evaluation_nlg_judge_policy_alignment.csv b/evaluation/evaluation_nlg_judge_policy_alignment.csv new file mode 100644 index 0000000..3a2489a --- /dev/null +++ b/evaluation/evaluation_nlg_judge_policy_alignment.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,1,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험이 잘 드러나지만, 구체적인 절차와 결과 수치가 부족합니다.",6315,270,15590, +EV-02,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,5,5,5,5,5,4,"[""NONE""]","전반적으로 경험과 강점이 잘 드러나 있으며, 분석이 없지만 주요 문제는 발견되지 않았습니다.",5753,117,9338, +EV-03,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.5,3.5,3.5,4.0,3.5,4.0,4.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 분석 역량과 경험이 잘 드러나 있으나, 구체적인 행동과 결과를 더 강조할 필요가 있습니다.",7349,441,19185, +EV-04,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",명확한 문제 문장이 존재하나 분석이 없었습니다. 구체적인 행동 계획과 방법이 부족한 점이 주요 문제입니다.,7421,130,13282, +EV-05,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,3.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","지원 동기와 포부가 구체적이지 않아 개선 여지가 있으며, 전반적으로 유용한 분석이 제공되었습니다.",6222,420,11695, +EV-06,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,1,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,5,5,5,5,5,5,"[""NONE""]","전반적으로 구체적인 성과와 실행 방법이 잘 드러나 있으며, 분석이 적절하게 이루어졌습니다.",8921,312,9913, +EV-07,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,5,5,5,5,5,4,"[""NONE""]","전반적으로 강점과 누락 키워드가 적절하게 반영되어 있으며, 분석이 없지만 명확한 문제 문장이 없습니다.",5299,123,5722, +EV-08,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,3.0,3.0,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 자기소개서의 강점이 잘 드러나지만, 구체적인 실행 방안과 방법론이 부족하여 개선 여지가 있습니다.",7252,421,7677, +EV-09,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 구체적인 행동과 방법론이 부족하지만, 경험을 잘 설명하고 있어 유용한 분석입니다.",6245,408,7479, +EV-10,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,4,5,5,5,5,3,"[""NONE""]",전반적으로 구체적인 경험과 성과가 부족하여 활용도가 낮습니다.,6458,109,2962, +EV-11,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","명확한 문제 문장이 존재하나 분석이 없으며, JD의 핵심 경험 요구사항이 누락되었습니다.",6512,135,4096, +EV-12,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,4.0,4.0,4.0,4.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 직무 적합성을 잘 드러내고 있으나, 구체적인 행동이나 방법을 추가하면 더욱 명확해질 것입니다.",6739,417,8190, +EV-13,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험과 방법론이 잘 서술되었으나, 구체성이 부족한 부분이 있어 개선 여지가 있습니다.",6369,430,7414, +EV-14,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,4,5,5,5,5,4,"[""NONE""]","지원 동기와 포부에서 구체적인 행동과 방법이 부족하지만, 전반적으로 강점이 잘 드러나고 있습니다.",6422,121,5742, +EV-15,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기와 직무 적합성에서 구체적인 사례가 부족하며, 채용 관련 경험이 누락되었습니다.",5344,136,3224, +EV-16,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",명확한 문제 문장이 존재하지만 분석이 없었습니다. 구체적인 행동 계획과 방법론이 부족한 점이 주요 문제입니다.,6419,130,5137, +EV-17,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험의 구체성이 부족하지만, 분석이 유용하며 개선 방향이 명확합니다.",5751,394,7867, +EV-18,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,4,3,3,2,2,3,"[""MISSED_MISSING_KEYWORD""]",답변에서 구체적인 경험이 부족하여 JD의 '사내 일반 행정 지원' 요구사항을 충족하지 못하고 있습니다.,5772,132,3890, +EV-19,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,2,3.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,3,5,5,5,5,4,"[""NONE""]",지원 동기와 성격의 장단점에서 구체적인 경험이 부족하여 개선 여지가 있습니다. 그러나 전반적으로 유용한 분석이 이루어졌습니다.,5925,403,8503, +EV-20,evaluation/evaluation_ai_results_two_pass_provenance_strength_fix.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",지원 동기와 포부에서 구체적인 행동이나 계획이 결여된 명확한 문제 문장이 존재하지만 분석이 없었습니다.,5767,130,2413, diff --git a/evaluation/evaluation_nlg_judge_policy_alignment_comparison.csv b/evaluation/evaluation_nlg_judge_policy_alignment_comparison.csv new file mode 100644 index 0000000..dc01f30 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_policy_alignment_comparison.csv @@ -0,0 +1,3 @@ +sourceResultFile,caseCount,successCount,judgeFailedCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,averageJudgeInputTokens,averageJudgeOutputTokens,averageJudgeLatencyMs,averageAnalysisCount,metaImprovementRate,unsupportedFactRate,falsePositiveAnalysisRate,fatalErrorRate,errorCodeCounts +evaluation/evaluation_nlg_judge_two_pass_provenance_strength_fix.csv,20,20,0,3.45,2.95,3.35,3.45,2.55,4.1,4.4,3.55,3.65,4.2,2.35,4.55,4.55,4.55,4.55,3.05,4919.75,287.45,4769.35,0.9,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":4,""MISSED_ANALYSIS"":17,""MISSED_MISSING_KEYWORD"":3,""NONE"":3}" +evaluation/evaluation_nlg_judge_policy_alignment.csv,20,20,0,3.85,3.35,3.85,4.1,3.35,4.1,4.6,4.0,4.1,4.1,3.35,4.7,4.7,4.55,4.55,3.45,6412.75,258.95,7965.95,0.9,0.0,0.0,0.0,0.0,"{""MISSED_ANALYSIS"":5,""MISSED_MISSING_KEYWORD"":3,""NONE"":14}" diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java index 0dd7bf6..117d39f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java @@ -18,6 +18,42 @@ @RequiredArgsConstructor @Slf4j class NlgEvaluationAiClient { + private static final String MISSED_ANALYSIS_RULES = """ + [MISSED_ANALYSIS 판정 기준] + - MISSED_ANALYSIS는 다음을 모두 만족할 때만 caseErrorCodes에 포함한다. + 1. answer에 독립적으로 식별 가능한 명확한 문제 문장 또는 핵심 구절이 존재한다. + 2. 그 문제는 단순한 "더 구체적이면 좋음" 수준이 아니다. + 3. 문제 유형을 특정할 수 있다. 예: 근거 없는 과장, 명시적 모순, 문항 의도 불충족, 핵심 행동/역할/방법/결과의 명확한 부재. + 4. 해당 문장을 분석하지 않아 사용자에게 실질적인 첨삭 가치 손실이 발생한다. + 5. 현재 questionAnalyses에 동일하거나 충분히 유사한 문제 분석이 존재하지 않는다. + - 다음 경우에는 MISSED_ANALYSIS를 부여하지 않는다. + 문장이 더 좋아질 수 있다는 정도, 수치가 없다는 이유만 있는 경우, 추상적인 포부지만 문항 목적상 자연스러운 경우, + 이미 다른 분석이 동일한 핵심 문제를 다루는 경우, 명확한 문제 문장을 특정할 수 없는 경우, + 답변 전반에 대한 일반적 아쉬움만 있는 경우, 단순히 분석 개수가 적다는 이유, + "구체성 부족"만 있고 어느 문장이 왜 문제인지 특정할 수 없는 경우. + - MISSED_ANALYSIS를 부여할 때 shortRationale에는 놓친 원문 문장 또는 핵심 구절, 문제 유형, 기존 분석으로 커버되지 않는 이유를 간결하게 포함한다. + - 이 근거를 제시할 수 없으면 MISSED_ANALYSIS를 부여하지 않는다. + - 명확한 문제 문장이 없다고 판단했다면 MISSED_ANALYSIS를 errorCodes에 포함하지 않는다. + - "명확한 문제 문장이 없다"와 "MISSED_ANALYSIS"를 동시에 주장하지 않는다. + - "첨삭 대상이 명확하지 않다"와 "중요한 첨삭 대상을 놓쳤다"를 동시에 주장하지 않는다. + """; + + private static final String MISSED_MISSING_KEYWORD_RULES = """ + [MISSED_MISSING_KEYWORD 판정 기준] + - MISSED_MISSING_KEYWORD는 다음을 모두 만족할 때만 caseErrorCodes에 포함한다. + 1. 실제 JD 원문의 mainTasks 또는 qualifications에 요구사항이 존재한다. + 2. JobDri 서비스 정책상 missing keyword 제외 대상이 아니다. + 3. answer에 동일하거나 의미상 충족되는 내용이 없다. + 4. 현재 missingKeywords에 동일하거나 충분히 유사한 키워드가 없다. + - missing keyword 평가 대상에서 제외한다: 자격증, 면허, 학력, 경력 연차, 나이, 법적/정형 보유 조건, 선택형 자격요건의 다른 선택지. + - 제외 예시: 사회복지사, 청소년상담사, 직업상담사, 운전면허, 대졸 이상, 경력 3년 이상. + - 제외 대상이 아닌 예시: Spring Boot 실무 경험, 포토샵 활용 능력, 엑셀 고급 활용, 더존 사용 능력, 4대보험 신고 경험. + - source=QUALIFICATION이라는 이유만으로 모든 항목을 제외하지 않는다. + - JD에 없는 키워드, 다른 직군의 일반 요구사항, 답변에 이미 존재하는 키워드, OR 조건에서 하나를 충족했을 때 나머지 선택지를 누락으로 판단하지 않는다. + - MISSED_MISSING_KEYWORD를 부여할 때 shortRationale에는 JD의 실제 요구사항, 답변에서 충족되지 않은 이유, 기존 missingKeywords로 커버되지 않는 이유를 간결하게 포함한다. + - 이 근거를 제시할 수 없으면 MISSED_MISSING_KEYWORD를 부여하지 않는다. + """; + private final OpenAIClient openAIClient; private final LlmConcurrencyLimiter llmConcurrencyLimiter; @@ -93,7 +129,7 @@ String buildPrompt(NlgJudgeInput input) { - keyStrengths: strengthsPrecision과 strengthsCoverage를 각각 1~5로 평가한다. 명백한 좋은 문장을 놓치면 MISSED_STRENGTH를 사용한다. - missingKeywords: missingKeywordsPrecision과 missingKeywordsCoverage를 각각 1~5로 평가한다. JD 핵심 경험 요구사항 누락을 놓치면 MISSED_MISSING_KEYWORD를 사용한다. - actual missingKeywords가 빈 배열이라고 해서 자동으로 정확한 것이 아니다. - - JD와 answer 기준상 필요한 누락 키워드가 존재하면 actual=[]라도 missingKeywordsCoverage를 낮게 평가하고 MISSED_MISSING_KEYWORD를 사용한다. + - JD와 answer 기준상 필요한 비정형 업무·역량 누락 키워드가 존재하면 actual=[]라도 missingKeywordsCoverage를 낮게 평가하고 MISSED_MISSING_KEYWORD를 사용한다. 단, 자격증, 면허, 학력, 경력 연차, 나이 등 정형 자격요건은 이후 [MISSED_MISSING_KEYWORD 판정 기준]의 제외 정책이 우선하며 MISSED_MISSING_KEYWORD 대상이 아니다. - actual=[]이고 실제로 누락 키워드가 없을 때만 정상 빈 배열로 평가한다. - 빈 배열은 precision과 coverage를 분리해 판단한다. - case 전체: overallUsefulness를 1~5로 독립 평가한다. 기본값을 3이나 4로 두지 말고 1,2,3,4,5 전체 범위를 실제 품질에 맞게 사용한다. @@ -126,37 +162,9 @@ String buildPrompt(NlgJudgeInput input) { - questionAnalysesJson이 빈 배열이면 output questionAnalysisEvaluations도 빈 배열이어야 한다. - 입력에 없는 분석 평가를 생성하거나, answer에서 임의 문장을 골라 evaluation 배열을 만들거나, 분석 개수를 0보다 크게 만들지 않는다. - [MISSED_ANALYSIS 판정 기준] - - MISSED_ANALYSIS는 다음을 모두 만족할 때만 caseErrorCodes에 포함한다. - 1. answer에 독립적으로 식별 가능한 명확한 문제 문장 또는 핵심 구절이 존재한다. - 2. 그 문제는 단순한 "더 구체적이면 좋음" 수준이 아니다. - 3. 문제 유형을 특정할 수 있다. 예: 근거 없는 과장, 명시적 모순, 문항 의도 불충족, 핵심 행동/역할/방법/결과의 명확한 부재. - 4. 해당 문장을 분석하지 않아 사용자에게 실질적인 첨삭 가치 손실이 발생한다. - 5. 현재 questionAnalyses에 동일하거나 충분히 유사한 문제 분석이 존재하지 않는다. - - 다음 경우에는 MISSED_ANALYSIS를 부여하지 않는다. - 문장이 더 좋아질 수 있다는 정도, 수치가 없다는 이유만 있는 경우, 추상적인 포부지만 문항 목적상 자연스러운 경우, - 이미 다른 분석이 동일한 핵심 문제를 다루는 경우, 명확한 문제 문장을 특정할 수 없는 경우, - 답변 전반에 대한 일반적 아쉬움만 있는 경우, 단순히 분석 개수가 적다는 이유, - "구체성 부족"만 있고 어느 문장이 왜 문제인지 특정할 수 없는 경우. - - MISSED_ANALYSIS를 부여할 때 shortRationale에는 놓친 원문 문장 또는 핵심 구절, 문제 유형, 기존 분석으로 커버되지 않는 이유를 간결하게 포함한다. - - 이 근거를 제시할 수 없으면 MISSED_ANALYSIS를 부여하지 않는다. - - 명확한 문제 문장이 없다고 판단했다면 MISSED_ANALYSIS를 errorCodes에 포함하지 않는다. - - "명확한 문제 문장이 없다"와 "MISSED_ANALYSIS"를 동시에 주장하지 않는다. - - "첨삭 대상이 명확하지 않다"와 "중요한 첨삭 대상을 놓쳤다"를 동시에 주장하지 않는다. + %s - [MISSED_MISSING_KEYWORD 판정 기준] - - MISSED_MISSING_KEYWORD는 다음을 모두 만족할 때만 caseErrorCodes에 포함한다. - 1. 실제 JD 원문의 mainTasks 또는 qualifications에 요구사항이 존재한다. - 2. JobDri 서비스 정책상 missing keyword 제외 대상이 아니다. - 3. answer에 동일하거나 의미상 충족되는 내용이 없다. - 4. 현재 missingKeywords에 동일하거나 충분히 유사한 키워드가 없다. - - missing keyword 평가 대상에서 제외한다: 자격증, 면허, 학력, 경력 연차, 나이, 법적/정형 보유 조건, 선택형 자격요건의 다른 선택지. - - 제외 예시: 사회복지사, 청소년상담사, 직업상담사, 운전면허, 대졸 이상, 경력 3년 이상. - - 제외 대상이 아닌 예시: Spring Boot 실무 경험, 포토샵 활용 능력, 엑셀 고급 활용, 더존 사용 능력, 4대보험 신고 경험. - - source=QUALIFICATION이라는 이유만으로 모든 항목을 제외하지 않는다. - - JD에 없는 키워드, 다른 직군의 일반 요구사항, 답변에 이미 존재하는 키워드, OR 조건에서 하나를 충족했을 때 나머지 선택지를 누락으로 판단하지 않는다. - - MISSED_MISSING_KEYWORD를 부여할 때 shortRationale에는 JD의 실제 요구사항, 답변에서 충족되지 않은 이유, 기존 missingKeywords로 커버되지 않는 이유를 간결하게 포함한다. - - 이 근거를 제시할 수 없으면 MISSED_MISSING_KEYWORD를 부여하지 않는다. + %s [errorCode와 점수 정합성] - problemValidity <= 2이면 FALSE_POSITIVE_ANALYSIS 후보로 검토한다. @@ -205,6 +213,8 @@ String buildPrompt(NlgJudgeInput input) { actualMissingKeywordCount: %s validatedMissingKeywordCandidateCount: %s """.formatted( + MISSED_ANALYSIS_RULES, + MISSED_MISSING_KEYWORD_RULES, input.caseId(), input.sourceResultFile(), input.mainTasks(), diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java index 5c6e40f..7b79fdc 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java @@ -161,6 +161,8 @@ void buildPromptAlignsMissedMissingKeywordWithServicePolicy() { assertThat(prompt) .contains("[MISSED_MISSING_KEYWORD 판정 기준]") + .contains("필요한 비정형 업무·역량 누락 키워드가 존재하면 actual=[]라도") + .contains("정형 자격요건은 이후 [MISSED_MISSING_KEYWORD 판정 기준]의 제외 정책이 우선") .contains("실제 JD 원문의 mainTasks 또는 qualifications에 요구사항이 존재한다") .contains("JobDri 서비스 정책상 missing keyword 제외 대상이 아니다") .contains("answer에 동일하거나 의미상 충족되는 내용이 없다") From db3a4c0a9b550c279c14a0c66709acf429708614 Mon Sep 17 00:00:00 2001 From: wooh Date: Thu, 30 Jul 2026 01:30:40 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[Fix]=20NLG=20Judge=20missing=20keyword?= =?UTF-8?q?=20provenance=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MISSED_MISSING_KEYWORD 근거 DTO 추가 - Judge prompt에 missing keyword provenance 출력 계약 추가 - relatedRequirement JD 원문 포함 여부와 정형 자격요건 제외 검증 - 유효 근거 없는 MISSED_MISSING_KEYWORD errorCode 제거 - provenance 검증 및 prompt contract 테스트 추가 --- .../evaluation/NlgEvaluationAiClient.java | 6 + .../evaluation/NlgEvaluationBatchService.java | 166 ++++++++++++++- .../evaluation/NlgEvaluationResponse.java | 36 ++++ .../evaluation/NlgEvaluationAiClientTest.java | 3 + .../NlgEvaluationBatchServiceTest.java | 190 ++++++++++++++++++ 5 files changed, 399 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java index 117d39f..4bca620 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java @@ -45,6 +45,12 @@ class NlgEvaluationAiClient { 2. JobDri 서비스 정책상 missing keyword 제외 대상이 아니다. 3. answer에 동일하거나 의미상 충족되는 내용이 없다. 4. 현재 missingKeywords에 동일하거나 충분히 유사한 키워드가 없다. + - MISSED_MISSING_KEYWORD를 부여한다면 missedMissingKeywordEvaluations에도 같은 개수 이상의 근거를 작성한다. + - missedMissingKeywordEvaluations의 각 항목은 keyword, source, relatedRequirement, reason을 모두 포함한다. + - relatedRequirement는 mainTasks 또는 qualifications에 실제 존재하는 JD 원문 일부를 그대로 사용한다. + - source는 MAIN_TASK 또는 QUALIFICATION만 사용한다. + - "JD의 핵심 경험 요구사항", "채용 관련 경험", "업무 경험 부족", "핵심 경험"처럼 추상적인 표현을 keyword나 relatedRequirement로 사용하지 않는다. + - JD에 없는 요구사항을 생성하지 않는다. - missing keyword 평가 대상에서 제외한다: 자격증, 면허, 학력, 경력 연차, 나이, 법적/정형 보유 조건, 선택형 자격요건의 다른 선택지. - 제외 예시: 사회복지사, 청소년상담사, 직업상담사, 운전면허, 대졸 이상, 경력 3년 이상. - 제외 대상이 아닌 예시: Spring Boot 실무 경험, 포토샵 활용 능력, 엑셀 고급 활용, 더존 사용 능력, 4대보험 신고 경험. diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java index 556ea2b..85af0d9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java @@ -6,6 +6,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.NestedExceptionUtils; @@ -181,9 +183,17 @@ private NlgEvaluationResult validateAndBuildResult( List evaluations = validQuestionEvaluations(input.questionAnalyses(), response.questionAnalysisEvaluations()); + List validMissingKeywordMissEvaluations = + validMissingKeywordMissEvaluations(input, response.missedMissingKeywordEvaluations()); boolean missedValidatedMissingKeywords = input.validatedMissingKeywordCandidateCount() > 0 && input.actualMissingKeywordCount() == 0; - List errorCodes = mergeErrorCodes(response, evaluations, missedValidatedMissingKeywords); + List errorCodes = mergeErrorCodes( + input, + response, + evaluations, + missedValidatedMissingKeywords, + !validMissingKeywordMissEvaluations.isEmpty() + ); boolean hasFatalError = hasFatalError(errorCodes); Integer missingKeywordsCoverage = validCaseScore(response.missingKeywordsCoverage()); if (missedValidatedMissingKeywords) { @@ -245,6 +255,7 @@ private NlgEvaluationResponse normalizeQuestionAnalysisEvaluations( response.missingKeywordsPrecision(), response.missingKeywordsCoverage(), response.overallUsefulness(), + response.missedMissingKeywordEvaluations(), response.caseErrorCodes(), response.shortRationale() ); @@ -323,15 +334,28 @@ && validScore(evaluation.nonMeta()) } private List mergeErrorCodes( + NlgEvaluationAiClient.NlgJudgeInput input, NlgEvaluationResponse response, List evaluations, - boolean missedValidatedMissingKeywords + boolean missedValidatedMissingKeywords, + boolean hasValidMissingKeywordMissEvidence ) { boolean hasLowScore = hasLowCaseScore(response) || missedValidatedMissingKeywords; Set codes = new HashSet<>(sanitizeErrorCodes( response.caseErrorCodes(), hasLowScore )); + if (codes.remove(NlgEvaluationErrorCode.MISSED_MISSING_KEYWORD)) { + if (hasValidMissingKeywordMissEvidence || missedValidatedMissingKeywords) { + codes.add(NlgEvaluationErrorCode.MISSED_MISSING_KEYWORD); + } else { + log.warn( + "Removed NLG Judge missing keyword error without valid evidence. reason=judge_missing_keyword_without_valid_evidence, caseId={}, sourceResultFile={}", + input.caseId(), + input.sourceResultFile() + ); + } + } if (missedValidatedMissingKeywords) { codes.add(NlgEvaluationErrorCode.MISSED_MISSING_KEYWORD); codes.remove(NlgEvaluationErrorCode.NONE); @@ -358,6 +382,106 @@ private List mergeErrorCodes( .toList(); } + private List validMissingKeywordMissEvaluations( + NlgEvaluationAiClient.NlgJudgeInput input, + List evaluations + ) { + if (evaluations == null || evaluations.isEmpty()) { + return List.of(); + } + List actualMissingKeywords = + readActualMissingKeywords(input.missingKeywordsJson(), input.caseId()); + List valid = new ArrayList<>(); + for (NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation : evaluations) { + Optional invalidReason = + missingKeywordMissInvalidReason(input, actualMissingKeywords, evaluation); + if (invalidReason.isPresent()) { + log.warn( + "Removed NLG Judge missing keyword evidence. caseId={}, keyword={}, relatedRequirement={}, reason={}, invalidReason={}", + input.caseId(), + evaluation == null ? null : evaluation.keyword(), + evaluation == null ? null : evaluation.relatedRequirement(), + evaluation == null ? null : evaluation.reason(), + invalidReason.get() + ); + continue; + } + valid.add(evaluation); + } + return valid; + } + + private Optional missingKeywordMissInvalidReason( + NlgEvaluationAiClient.NlgJudgeInput input, + List actualMissingKeywords, + NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation + ) { + if (evaluation == null) { + return Optional.of(MissingKeywordMissInvalidReason.NULL_EVIDENCE); + } + if (!StringUtils.hasText(evaluation.keyword())) { + return Optional.of(MissingKeywordMissInvalidReason.BLANK_KEYWORD); + } + if (!StringUtils.hasText(evaluation.relatedRequirement())) { + return Optional.of(MissingKeywordMissInvalidReason.BLANK_RELATED_REQUIREMENT); + } + if (!StringUtils.hasText(evaluation.reason())) { + return Optional.of(MissingKeywordMissInvalidReason.BLANK_REASON); + } + Optional source = parseJudgeMissingKeywordSource(evaluation.source()); + if (source.isEmpty() || source.get() == MissingKeywordSource.PREFERENCE) { + return Optional.of(MissingKeywordMissInvalidReason.INVALID_SOURCE); + } + if (AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.keyword()) + || AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.relatedRequirement())) { + return Optional.of(MissingKeywordMissInvalidReason.STRUCTURED_QUALIFICATION); + } + String sourceText = source.get() == MissingKeywordSource.MAIN_TASK + ? input.mainTasks() + : input.qualifications(); + if (!containsNormalized(sourceText, evaluation.relatedRequirement())) { + return Optional.of(MissingKeywordMissInvalidReason.RELATED_REQUIREMENT_NOT_IN_JD); + } + if (!AnalysisSanitizationRules.isValidMissingKeyword( + evaluation.keyword(), + source.get(), + input.mainTasks(), + input.qualifications() + )) { + return Optional.of(MissingKeywordMissInvalidReason.INVALID_KEYWORD); + } + if (actualMissingKeywords.stream().anyMatch(keyword -> sameMissingKeyword(keyword, evaluation, source.get()))) { + return Optional.of(MissingKeywordMissInvalidReason.ALREADY_PRESENT_IN_FINAL_MISSING_KEYWORDS); + } + return Optional.empty(); + } + + private Optional parseJudgeMissingKeywordSource(String source) { + if (!StringUtils.hasText(source)) { + return Optional.empty(); + } + String normalized = source.trim(); + if ("MAIN_TASK".equalsIgnoreCase(normalized) || "MAIN_TASKS".equalsIgnoreCase(normalized)) { + return Optional.of(MissingKeywordSource.MAIN_TASK); + } + if ("QUALIFICATION".equalsIgnoreCase(normalized) || "QUALIFICATIONS".equalsIgnoreCase(normalized)) { + return Optional.of(MissingKeywordSource.QUALIFICATION); + } + return MissingKeywordSource.from(normalized); + } + + private boolean sameMissingKeyword( + AnalysisLlmResponse.MissingKeywordItem actual, + NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation, + MissingKeywordSource source + ) { + return actual != null + && normalize(actual.keyword()).equals(normalize(evaluation.keyword())) + && parseJudgeMissingKeywordSource(actual.source()) + .map(actualSource -> actualSource == source) + .orElse(false); + } + private List sanitizeErrorCodes( List errorCodes, boolean hasLowScore @@ -477,6 +601,22 @@ private int readJsonArraySize(String json, String fieldName, String caseId) { } } + private List readActualMissingKeywords(String json, String caseId) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + try { + List values = objectMapper.readValue( + json, + new TypeReference<>() { + } + ); + return values == null ? List.of() : values; + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId, e); + } + } + private int readMissingKeywordCandidateCount(String sanitizedCandidateResponseJson) { if (!StringUtils.hasText(sanitizedCandidateResponseJson)) { return 0; @@ -741,6 +881,16 @@ private Double parseDouble(String value) { } } + private boolean containsNormalized(String sourceText, String fragment) { + return StringUtils.hasText(sourceText) + && StringUtils.hasText(fragment) + && normalize(sourceText).contains(normalize(fragment)); + } + + private String normalize(String value) { + return AnalysisSanitizationRules.normalizeText(value); + } + private String truncateShortRationale(String rationale) { if (!StringUtils.hasText(rationale)) { return ""; @@ -957,6 +1107,18 @@ private enum HeaderLocation { MISSING } + private enum MissingKeywordMissInvalidReason { + NULL_EVIDENCE, + BLANK_KEYWORD, + BLANK_RELATED_REQUIREMENT, + BLANK_REASON, + INVALID_SOURCE, + STRUCTURED_QUALIFICATION, + RELATED_REQUIREMENT_NOT_IN_JD, + INVALID_KEYWORD, + ALREADY_PRESENT_IN_FINAL_MISSING_KEYWORDS + } + private record ResolvedHeader(HeaderLocation location, String headerName) { static ResolvedHeader input(String headerName) { return new ResolvedHeader(HeaderLocation.INPUT, headerName); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java index c402989..8d7cd57 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java @@ -11,9 +11,37 @@ public record NlgEvaluationResponse( Integer missingKeywordsPrecision, Integer missingKeywordsCoverage, Integer overallUsefulness, + List missedMissingKeywordEvaluations, List caseErrorCodes, String shortRationale ) { + public NlgEvaluationResponse( + String caseId, + List questionAnalysisEvaluations, + Integer noAnalysisAppropriateness, + Integer strengthsPrecision, + Integer strengthsCoverage, + Integer missingKeywordsPrecision, + Integer missingKeywordsCoverage, + Integer overallUsefulness, + List caseErrorCodes, + String shortRationale + ) { + this( + caseId, + questionAnalysisEvaluations, + noAnalysisAppropriateness, + strengthsPrecision, + strengthsCoverage, + missingKeywordsPrecision, + missingKeywordsCoverage, + overallUsefulness, + List.of(), + caseErrorCodes, + shortRationale + ); + } + public record QuestionAnalysisEvaluation( Integer analysisIndex, String sentence, @@ -30,4 +58,12 @@ public record QuestionAnalysisEvaluation( List errorCodes ) { } + + public record MissingKeywordMissEvaluation( + String keyword, + String source, + String relatedRequirement, + String reason + ) { + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java index 7b79fdc..304b71c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java @@ -163,6 +163,9 @@ void buildPromptAlignsMissedMissingKeywordWithServicePolicy() { .contains("[MISSED_MISSING_KEYWORD 판정 기준]") .contains("필요한 비정형 업무·역량 누락 키워드가 존재하면 actual=[]라도") .contains("정형 자격요건은 이후 [MISSED_MISSING_KEYWORD 판정 기준]의 제외 정책이 우선") + .contains("missedMissingKeywordEvaluations에도 같은 개수 이상의 근거를 작성한다") + .contains("relatedRequirement는 mainTasks 또는 qualifications에 실제 존재하는 JD 원문 일부를 그대로 사용한다") + .contains("\"JD의 핵심 경험 요구사항\", \"채용 관련 경험\", \"업무 경험 부족\", \"핵심 경험\"") .contains("실제 JD 원문의 mainTasks 또는 qualifications에 요구사항이 존재한다") .contains("JobDri 서비스 정책상 missing keyword 제외 대상이 아니다") .contains("answer에 동일하거나 의미상 충족되는 내용이 없다") diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java index 1a1762b..dbcbe46 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java @@ -375,6 +375,12 @@ void writesCoverageScores() throws Exception { 5, 2, 3, + List.of(new NlgEvaluationResponse.MissingKeywordMissEvaluation( + "장애 대응 경험", + "QUALIFICATION", + "장애 대응 경험", + "답변에서 해당 경험을 확인할 수 없습니다." + )), List.of(NlgEvaluationErrorCode.MISSED_STRENGTH, NlgEvaluationErrorCode.MISSED_MISSING_KEYWORD), "강점과 누락 키워드 coverage가 낮습니다." ), @@ -396,6 +402,116 @@ void writesCoverageScores() throws Exception { .contains("MISSED_MISSING_KEYWORD"); } + @Test + @DisplayName("MISSED_MISSING_KEYWORD는 JD requirement 근거가 유효하면 유지한다") + void keepsMissedMissingKeywordWhenEvidenceIsGroundedInJd() throws Exception { + Map row = runJudgeWithMissedMissingKeywordEvidence( + new NlgEvaluationResponse.MissingKeywordMissEvaluation( + "4대보험 신고", + "MAIN_TASK", + "4대보험 신고 및 지원금 신청", + "답변에서 4대보험 신고 경험을 확인할 수 없습니다." + ), + "4대보험 신고 및 지원금 신청", + "엑셀 고급 활용", + "[]" + ); + + assertThat(row.get("errorCodes")).contains("MISSED_MISSING_KEYWORD"); + } + + @Test + @DisplayName("MISSED_MISSING_KEYWORD는 Spring Boot 실무 경험처럼 비정형 역량 근거가 유효하면 유지한다") + void keepsTechnicalMissingKeywordEvidence() throws Exception { + Map row = runJudgeWithMissedMissingKeywordEvidence( + new NlgEvaluationResponse.MissingKeywordMissEvaluation( + "Spring Boot 실무 경험", + "QUALIFICATION", + "Spring Boot 실무 경험", + "답변에서 Spring Boot 사용 경험을 확인할 수 없습니다." + ), + "REST API 개발", + "Spring Boot 실무 경험", + "[]" + ); + + assertThat(row.get("errorCodes")).contains("MISSED_MISSING_KEYWORD"); + } + + @Test + @DisplayName("MISSED_MISSING_KEYWORD는 JD에 없는 추상 relatedRequirement면 제거한다") + void removesMissedMissingKeywordWhenRelatedRequirementIsNotInJd() throws Exception { + for (String relatedRequirement : List.of("채용 관련 경험", "핵심 경험")) { + Map row = runJudgeWithMissedMissingKeywordEvidence( + new NlgEvaluationResponse.MissingKeywordMissEvaluation( + relatedRequirement, + "MAIN_TASK", + relatedRequirement, + "답변에서 해당 경험을 확인할 수 없습니다." + ), + "엑셀 고급 활용, 4대보험 신고, 더존 사용", + "인사 회계 경험", + "[]" + ); + + assertThat(row.get("errorCodes")).doesNotContain("MISSED_MISSING_KEYWORD"); + } + } + + @Test + @DisplayName("MISSED_MISSING_KEYWORD는 정형 자격요건 근거면 제거한다") + void removesStructuredQualificationMissedMissingKeywordEvidence() throws Exception { + for (String keyword : List.of("사회복지사", "청소년상담사", "운전면허", "대졸", "경력 3년")) { + Map row = runJudgeWithMissedMissingKeywordEvidence( + new NlgEvaluationResponse.MissingKeywordMissEvaluation( + keyword, + "QUALIFICATION", + keyword, + "답변에서 해당 조건을 확인할 수 없습니다." + ), + "상담 지원", + keyword, + "[]" + ); + + assertThat(row.get("errorCodes")).doesNotContain("MISSED_MISSING_KEYWORD"); + } + } + + @Test + @DisplayName("MISSED_MISSING_KEYWORD는 이미 final missingKeywords에 있으면 제거한다") + void removesMissedMissingKeywordAlreadyCoveredByFinalMissingKeywords() throws Exception { + Map row = runJudgeWithMissedMissingKeywordEvidence( + new NlgEvaluationResponse.MissingKeywordMissEvaluation( + "4대보험 신고", + "MAIN_TASK", + "4대보험 신고 및 지원금 신청", + "답변에서 4대보험 신고 경험을 확인할 수 없습니다." + ), + "4대보험 신고 및 지원금 신청", + "엑셀 고급 활용", + objectMapper.writeValueAsString(List.of(new AnalysisLlmResponse.MissingKeywordItem( + "4대보험 신고", + "mainTask" + ))) + ); + + assertThat(row.get("errorCodes")).doesNotContain("MISSED_MISSING_KEYWORD"); + } + + @Test + @DisplayName("MISSED_MISSING_KEYWORD는 evidence 없이 errorCode만 있으면 제거한다") + void removesMissedMissingKeywordWithoutEvidence() throws Exception { + Map row = runJudgeWithMissedMissingKeywordEvidence( + null, + "4대보험 신고 및 지원금 신청", + "엑셀 고급 활용", + "[]" + ); + + assertThat(row.get("errorCodes")).doesNotContain("MISSED_MISSING_KEYWORD"); + } + @Test @DisplayName("검증된 missing keyword 후보가 있는데 actual이 빈 배열이면 coverage와 errorCode를 서버에서 보정한다") void correctsEmptyActualMissingKeywordsWhenValidatedCandidatesExist() throws Exception { @@ -882,6 +998,80 @@ private Path writeJudgeInputWithMissingKeywordState( return input; } + private Map runJudgeWithMissedMissingKeywordEvidence( + NlgEvaluationResponse.MissingKeywordMissEvaluation evidence, + String mainTasks, + String qualifications, + String missingKeywordsJson + ) throws Exception { + NlgEvaluationAiClient aiClient = mock(NlgEvaluationAiClient.class); + when(aiClient.evaluate(any())).thenReturn(new NlgEvaluationAiClient.JudgeCallResult( + new NlgEvaluationResponse( + "EV-20", + List.of(), + 2, + 5, + 5, + 2, + 2, + 3, + evidence == null ? List.of() : List.of(evidence), + List.of(NlgEvaluationErrorCode.MISSED_MISSING_KEYWORD), + "누락 키워드 근거를 평가했습니다." + ), + 100L, + null, + null + )); + Path input = writeJudgeInputWithContext("EV-20", mainTasks, qualifications, missingKeywordsJson, "[]"); + Path output = tempDir.resolve("judge_missed_keyword_" + System.nanoTime() + ".csv"); + + new NlgEvaluationBatchService(aiClient, objectMapper).run(input, output); + + return EvaluationCsvSupport.read(output).getFirst(); + } + + private Path writeJudgeInputWithContext( + String caseId, + String mainTasks, + String qualifications, + String missingKeywordsJson, + String analysesJson + ) throws Exception { + Path input = tempDir.resolve(caseId + "_context_" + System.nanoTime() + ".csv"); + Files.writeString( + input, + String.join(",", List.of( + "caseId", + "mainTasks", + "qualifications", + "preferences", + "question", + "answer", + "aiQuestionAnalysesJson", + "aiMissingKeywordsJson", + "rawLlmResponseJson", + "rawCandidateResponseJson", + "sanitizedCandidateResponseJson", + "candidateReviewResponseJson" + )) + "\n" + + csv(caseId) + "," + + csv(mainTasks) + "," + + csv(qualifications) + "," + + csv("") + "," + + csv("지원 동기") + "," + + csv("답변") + "," + + csv(analysesJson) + "," + + csv(missingKeywordsJson) + "," + + csv(rawLlmResponseJson()) + "," + + csv("") + "," + + csv("{\"missingKeywordCandidates\":[]}") + "," + + csv("") + "\n", + StandardCharsets.UTF_8 + ); + return input; + } + private void stubJudge(NlgEvaluationAiClient aiClient, String caseId) { when(aiClient.evaluate(any())).thenReturn(new NlgEvaluationAiClient.JudgeCallResult( responseWithoutQuestionEvaluations(caseId),