From 9cc95517f859419f801434f642a209143b2cad37 Mon Sep 17 00:00:00 2001 From: wooh Date: Wed, 29 Jul 2026 18:43:12 +0900 Subject: [PATCH 1/8] =?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 2/8] =?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 3/8] =?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 4/8] =?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 5/8] =?UTF-8?q?[Fix]=20NLG=20judge=20=EB=B9=88=20questionA?= =?UTF-8?q?nalyses=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 6/8] =?UTF-8?q?[Fix]=20NLG=20judge=20=EB=B9=88=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=ED=8F=89=EA=B0=80=20=EA=B0=9C=EC=88=98=20=EB=B6=88?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=B6=94?= =?UTF-8?q?=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 7/8] =?UTF-8?q?[Fix]=20NLG=20judge=20=EB=B9=88=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=ED=8F=89=EA=B0=80=20=EB=B0=B0=EC=97=B4=20=EC=84=9C?= =?UTF-8?q?=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 8/8] =?UTF-8?q?[Fix]=20NLG=20judge=20=EB=B0=8F=20replay=20?= =?UTF-8?q?=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() - )); + ); } }