diff --git a/docs/demo-day/demo-ai-repeat-results.csv b/docs/demo-day/demo-ai-repeat-results.csv new file mode 100644 index 00000000..8c19fb87 --- /dev/null +++ b/docs/demo-day/demo-ai-repeat-results.csv @@ -0,0 +1,4 @@ +run,requestId,proven,mentioned,missing,fabricated,score,jobFit,impact,completeness,note +1,req_bf4838f926b847139ef844971fd4cb3e,4,3,1,1,73,75,70,70,"서버 가중합 반올림 및 검증 적용 후 Redis MISSING 1개, 문항별 FABRICATED 1개" +2,req_a8787cea501341cfb51cc7e424e5017e,5,2,1,1,72,75,70,65,"서버 가중합 반올림 및 검증 적용 후 Redis MISSING 1개, 문항별 FABRICATED 1개" +3,req_4b0fefc6b9c6483badaf32382adda344,5,2,1,1,72,75,70,65,"서버 가중합 반올림 및 검증 적용 후 Redis MISSING 1개, 문항별 FABRICATED 1개" diff --git a/docs/demo-day/demo-analysis-report.md b/docs/demo-day/demo-analysis-report.md new file mode 100644 index 00000000..37723320 --- /dev/null +++ b/docs/demo-day/demo-analysis-report.md @@ -0,0 +1,168 @@ +# JobDri 데모데이 분석 설계 보고서 + +## 1. 결론 + +현재 구조 그대로는 네 상태를 한 결과 화면에 안정적으로 노출할 수 없었다. 운영 Python 워커는 PROVEN을 생성했지만 Spring API가 저장 전에 제거했고, MISSING은 별도 배열로만 저장되는데 프론트가 렌더링하지 않았으며, 프론트의 MENTIONED/FABRICATED 한글 라벨이 서로 뒤바뀌어 있었다. + +최소 호환 변경 후에는 다음 방식으로 네 상태를 함께 보여 줄 수 있다. + +- PROVEN, MENTIONED, FABRICATED: 기존 `questionAnalyses`에 저장하고 원문 문장을 하이라이트한다. +- MISSING: 원문 문장이 없으므로 기존 `missingKeywords`에 저장하고 결과 개요에서 `요건 누락`으로 표시한다. +- 기존 DB 컬럼과 API 필드는 유지했다. 데모 전용 플래그, 특정 문장 하드코딩, 점수 강제 로직은 추가하지 않았다. + +## 2. 실제 분석 파이프라인 조사 결과 + +| 구간 | 확인한 구현 | 조사 결과 | +|---|---|---| +| 분석 요청 | `AnalysisController` → `AnalysisAsyncFacadeService` | `POST /api/mock-applies/{mockApplyId}/analysis`가 비동기 분석 작업을 만든다. | +| 운영 AI 실행 | Python `AnalysisOpenAiWorker` | RabbitMQ 작업을 소비한 Python 워커가 실제 OpenAI Responses API를 호출한다. | +| 완료 연결 | `AnalysisWorkerBridgeService.completeTask` | 워커의 `llmResponse`를 `AnalysisService.finalizeAnalysis`에 전달한다. | +| 저장/조회 | `AnalysisService`, `Analysis`, `QuestionAnalysis` | 점수·강약점·누락 키워드와 문장 분석을 저장한 뒤 `AnalysisResponse`로 조회한다. | +| 문장 위치 | `AnalysisService.findNextSentenceStart` | 모델이 index를 만들지 않고, 서버가 답변의 정확한 substring을 찾아 Java 문자열 index로 `start/end`를 계산한다. 반복 문장은 다음 검색 위치를 기억한다. | +| 상태 | `QuestionAnalysisStatus` | `PROVEN`, `MENTIONED`, `MISSING`, `FABRICATED`가 있다. lowercase/uppercase를 정규화하고 `GOOD`, `NEEDS_IMPROVEMENT`, `RISK`도 호환한다. | +| 문항별 제한 | `AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION` | 문항당 최대 3개다. 운영 워커 프롬프트는 대표 문장을 우선 선택한다. | +| MISSING | `Analysis.missingKeywordsJson` | `QuestionAnalysis`가 아니라 별도 JSON으로 저장한다. `sentence`, index가 필요 없어서 현 모델과 충돌하지 않는다. | +| 응답 DTO | `AnalysisResponse` | `keyStrengths`, `keyWeaknesses`, `missingKeywords`, `questions[].analyses[]`를 이미 반환한다. | +| Java 평가 Runner | `EvaluationAnalysisRunner` | CSV 한 행을 한 문항으로 평가하며 `analysis-eval` 프로필과 비용 확인 플래그가 필수다. 단, 실제 운영 Python 워커가 아니라 Java `AnalysisAiClient` 프롬프트를 사용한다. | + +추가 확인 사항: + +- `weaknessType`, `dimension`, `relatedRequirement` 필드는 현재 분석 enum/DTO/Entity에 없다. +- 요청서에서 언급한 `fixed-fewshot-cases.json`은 저장소에 없다. 고정 예시는 `fewshot-prompt-block.txt`이며, `curated-fewshot-cases.json`은 현재 빈 배열이다. +- 변경 전 Java 단일 패스 프롬프트는 좋은 문장을 `keyStrengths`로만 보내고 PROVEN을 `questionAnalyses`에서 금지했다. +- 변경 전 운영 Python 워커는 모든 답변 문항을 분석하도록 했지만, FABRICATED를 단순 과장 위험까지 포함하는 넓은 의미로 설명했다. +- 변경 전 운영 Python 워커의 Responses API 호출은 JSON 텍스트를 받은 뒤 Pydantic으로 파싱했을 뿐 OpenAI Structured Output을 요청하지 않았다. +- MISSING의 `keyword`가 현재 구조에서 누락된 JD 요건 역할을 한다. 별도 `relatedRequirement`를 추가하려면 API/DB 마이그레이션이 필요하므로 이번에는 기존 호환 구조를 유지했다. +- 직무 분류 리소스에서 `AI·개발·데이터 > 백엔드 개발`을 확인해 평가 CSV에 사용했다. + +## 3. 발견한 구조적 문제와 변경 + +### PROVEN 유실 + +Spring API와 평가 Runner가 유효한 PROVEN도 무조건 제외하고 있었다. 이제 긍정적인 reason의 PROVEN은 저장하고, reason이 `근거 부족`, `보완 필요`처럼 상태와 모순될 때만 제외한다. PROVEN의 improvement는 기존 정책대로 빈 문자열로 정규화한다. + +### MISSING 오탐과 표현 방식 + +MISSING을 `QuestionAnalysis`에 넣으려면 non-null sentence/index 모델을 바꿔야 한다. 기존 `missingKeywords` 구조를 유지하고 프론트에서 별도 `요건 누락` 영역을 추가했다. + +모델이 이미 답변에 있는 DB·Git·장애 분석 요건을 간헐적으로 MISSING으로 반환하는 현상도 확인했다. 서버는 이제 JD 근거 검증 후 답변 전체의 정규화된 핵심 토큰과 충분히 겹치는 후보를 제거한다. Redis처럼 핵심 토큰이 실제로 없는 요건은 유지한다. + +### FABRICATED 의미와 중복 + +FABRICATED는 단순 근거 부족이 아니라 같은 대상의 기간·인원·역할·수치가 직접 충돌하는 경우로 좁혔다. reason에 직접 충돌 근거가 없으면 서버가 계속 제거한다. 한 모순의 양쪽 문장을 두 번 보여 주지 않도록 동일 문항에서는 대표 FABRICATED 한 개만 저장한다. + +### Structured Output + +운영 Python 워커의 분석 모델을 `extra=forbid`, `Literal` status/source, 필수 배열, nullable improvement로 정의하고 Responses API에 strict JSON Schema를 전달한다. 임의 상태, 필드 누락, 추가 필드는 모델 응답 단계와 Pydantic 파싱 단계에서 모두 거부된다. + +### 프론트 라벨 + +최종 사용자 라벨은 다음과 같이 정렬했다. + +| 내부 상태 | 사용자 라벨 | 색상 | +|---|---|---| +| `proven` | 적절함 | 초록 | +| `mentioned` | 구체성 부족 | 분홍 | +| `fabricated` | 신뢰성 부족 | 빨강 | +| `missingKeywords` | 요건 누락 | 빨강 칩 + 누락 요건 목록 | + +따라서 질문한 “프론트에서 mentioned → 구체성 부족, fabricated → 신뢰성 부족으로 교체해야 하느냐”는 맞다. 다만 라벨만 바꾸면 PROVEN 저장과 MISSING 미노출 문제가 남으므로 백엔드·워커 변경도 함께 필요했다. + +## 4. 데모 데이터 설계 + +공고 원본은 `demo-job-posting.txt`, 복사용 문항과 답변은 `demo-cover-letter.md`에 있다. + +| 상태 | 유도 데이터 | 판정 이유 | +|---|---|---| +| PROVEN | 복합 인덱스와 쿼리 재작성으로 응답 시간을 `1.8초 → 0.6초`로 개선 | 기술, 행동, 동일 조건의 전후 결과가 구체적이다. | +| MENTIONED | 여러 프로젝트에서 문제 원인을 분석하고 해결 방안을 적용해 역량을 길렀다는 주장 | 관련 요건은 언급하지만 어느 프로젝트에서 본인이 무엇을 했는지 없다. | +| MISSING | 공고의 `Redis 기반 캐시 또는 세션 관리 기능 설계 및 운영` | 세 답변 전체에 Redis·캐시·세션 저장소·인메모리 저장소 경험이 없다. | +| FABRICATED | 동일 JobBoard 프로젝트를 `시작부터 종료까지 1~3월, 다른 참여자 없음`과 `시작부터 종료까지 1~6월, 5명`으로 설명 | 동일 대상의 기간과 전체 참여 인원이 함께 성립할 수 없다. | + +좋은 답변 하나, 보완 가능한 추상 답변 하나, 읽기 쉬운 직접 모순 하나로 분산했다. Redis 외 핵심 업무는 답변에서 실질적으로 다뤄 MISSING 화면이 과도하게 늘지 않도록 했다. + +## 5. 실제 AI 반복 실행 결과 + +- 실행 모델: `gpt-4.1-mini` +- 호출 방식: 운영 Python `AnalysisOpenAiWorker.analyze` +- temperature: `0.2` +- 입력: `src/test/resources/evaluation/demo-day-cases.csv`의 공고·세 문항·답변을 하나의 운영 컨텍스트로 조립 +- 비용 확인: 실행 명령에서 `CONFIRM_OPENAI_COST=true`를 명시적으로 검사 + +최종 3회 결과는 다음과 같다. 원시 모델 출력에는 이미 언급된 복합 JD 문구 또는 동일 모순의 반대쪽 문장이 추가되기도 했고, 표는 이번에 추가한 운영 서버 검증을 적용했을 때 최종 저장·노출되는 개수다. 실제 모델 호출과 Spring 저장 검증은 각각 실행했으며, RabbitMQ를 포함한 배포 환경 전체 E2E 호출은 수행하지 않았다. + +| 실행 | PROVEN | MENTIONED | MISSING | FABRICATED | 총점 | 비고 | +|---|---:|---:|---:|---:|---:|---| +| 1 | 4 | 3 | 1 | 1 | 73 | 네 상태 모두 충족 | +| 2 | 5 | 2 | 1 | 1 | 72 | 네 상태 모두 충족 | +| 3 | 5 | 2 | 1 | 1 | 72 | 네 상태 모두 충족 | + +OpenAI request ID와 세부 점수는 `demo-ai-repeat-results.csv`에 기록했다. + +## 6. 테스트 결과 + +- Spring API 전체 테스트: `./gradlew test` 성공 +- API 핵심 통합 테스트: 네 상태 저장·조회, PROVEN improvement 제거, substring/index, MISSING 답변 언급 필터, FABRICATED 문항별 중복 제거 성공 +- Python 분석 변경 테스트: 33개 성공 + - strict JSON Schema 전달 + - nullable PROVEN improvement + - 잘못된 status/추가 필드 거부 + - 직접 모순·MISSING·점수 규칙 프롬프트 포함 +- 프론트 변경 파일 ESLint: 성공 +- 프론트 전체 TypeScript 검사: 이번 변경과 무관한 기존 오류 8건 때문에 실패했다. 변경 파일에서는 ESLint 오류가 없다. +- Python 전체 `unittest discover`는 테스트들이 전역 `openai` stub을 공유해 import 순서에 따라 실패하는 기존 격리 문제가 있다. 이번 변경 대상인 분석 스키마·복구 흐름 테스트는 독립 실행으로 모두 성공했다. +- 예상 JSON 파싱과 두 CSV 파일의 행·헤더 검사: 성공 + +## 7. EvaluationAnalysisRunner 사용법 + +입력 파일: + +`src/test/resources/evaluation/demo-day-cases.csv` + +실행 예시: + +```bash +./gradlew bootRun --args='--spring.profiles.active=analysis-eval --evaluation.analysis.enabled=true --evaluation.input=src/test/resources/evaluation/demo-day-cases.csv --evaluation.output=docs/demo-day/demo-evaluation-output.csv --evaluation.confirm-openai-cost=true' +``` + +환경에는 `OPENAI_API_KEY`가 필요하다. 이 Runner는 안전 플래그가 없거나 `prod` 프로필이면 실행을 거부한다. 또한 운영 Python 워커와 프롬프트 구현이 다르므로 회귀 비교용으로 사용하고, 배포 전 최종 안정성은 운영 워커로 확인한다. + +## 8. 생성·수정 범위 + +### Spring API 저장소 + +- PROVEN 저장/조회 허용 +- 실제 언급된 MISSING 후보 제거 +- 동일 문항 FABRICATED 중복 제거 +- Java 단일 패스 프롬프트와 고정 few-shot의 PROVEN 계약 정렬 +- Evaluation Runner의 최종 검증 계약 정렬 +- 데모 문서, 예상 JSON, 평가 CSV, 반복 실행 CSV 추가 + +### Python 분석 워커 저장소 + +- 직접 모순 우선 검사, MISSING source/전체 답변 검사, 점수 구간 규칙 보강 +- strict Structured Output JSON Schema 적용 +- 상태·source enum 및 필수 필드 Pydantic 검증 강화 +- 프롬프트·스키마·호출 통합 테스트 추가 + +### 프론트 소스 + +- MENTIONED/FABRICATED 사용자 라벨과 색상 정렬 +- `keyStrengths`, `keyWeaknesses`, `missingKeywords` 응답 타입 추가 +- 결과 개요에 요건 누락 목록 추가 +- 비어 있는 improvement 카드 숨김 + +프론트 디렉터리는 현재 Git 저장소가 아니어서 해당 변경은 API/워커와 별도로 버전 관리 위치를 확인해야 한다. + +## 9. 내일 시연 절차 + +1. 데모 계정, 크레딧, API·워커·DB·RabbitMQ·OpenAI 상태와 DLQ를 확인한다. +2. `demo-job-posting.txt` 전체를 복사해 모의 공고를 생성한다. +3. 생성 결과에서 Redis 핵심 요건이 남았는지 확인한다. +4. `demo-cover-letter.md`의 방문자 시연용 문항 3개와 각 답변만 복사한다. +5. 분석을 한 번 실행하고 처리 중 중복 클릭하지 않는다. +6. 적절함 → 구체성 부족 → 요건 누락 → 신뢰성 부족 순서로 결과를 설명한다. +7. 한 라벨이 없으면 입력 원문 보존 여부를 먼저 확인하고 새 모의지원으로 한 번만 재시도한다. +8. 2분 안에 복구되지 않으면 사전 생성 결과 URL 또는 화면 캡처로 전환한다. + +세부 장애 대응과 금지 사항은 `demo-operator-guide.md`를 따른다. diff --git a/docs/demo-day/demo-cover-letter.md b/docs/demo-day/demo-cover-letter.md new file mode 100644 index 00000000..6c8411b2 --- /dev/null +++ b/docs/demo-day/demo-cover-letter.md @@ -0,0 +1,40 @@ +# JobDri 데모데이 자기소개서 복사본 + +아래의 `방문자 시연용` 영역만 서비스에 복사한다. `운영자 확인용` 영역은 라벨 확인을 위한 설명이며 서비스에 입력하지 않는다. + +## 방문자 시연용 + +### 문항 1 + +- 문항: 지원 직무와 관련된 경험 및 역량을 구체적으로 작성해주세요. +- 글자 수 제한: 1,000자 +- 답변: + +> 커머스 사이드 프로젝트에서 Spring Boot로 상품 조회 REST API를 개발했습니다. 데이터가 늘면서 평균 응답 시간이 1.8초까지 증가해 PostgreSQL 실행 계획을 확인했고, 전체 테이블 스캔이 발생하는 조회 조건을 찾았습니다. 제가 복합 인덱스를 추가하고 쿼리를 재작성한 뒤 동일 데이터 기준 평균 응답 시간을 0.6초로 줄였습니다. Git을 활용해 동료와 협업했고 코드 리뷰를 주고받은 뒤 테스트를 거쳐 병합했습니다. + +### 문항 2 + +- 문항: 문제를 해결했던 경험과 그 과정에서 본인이 수행한 역할을 작성해주세요. +- 글자 수 제한: 1,000자 +- 답변: + +> 서비스 장애가 발생했을 때 모니터링 대시보드의 지표와 로그를 살펴봤습니다. 저는 여러 백엔드 프로젝트에서 문제의 원인을 분석하고 해결 방안을 적용하며 뛰어난 문제 해결 역량을 길렀습니다. 상황에 맞게 대응해 서비스를 정상화했습니다. + +### 문항 3 + +- 문항: 협업 경험과 입사 후 기여하고 싶은 내용을 작성해주세요. +- 글자 수 제한: 1,000자 +- 답변: + +> JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 3월까지 진행했으며, 기획·개발·테스트 전 과정을 저 혼자 맡아 다른 참여자는 없었습니다. 저는 요구사항을 정리하며 진행 상황을 관리했습니다. 같은 JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 6월까지 진행했으며, 저를 포함한 5명의 팀원이 역할을 나누었습니다. 저는 함께 일하는 과정에서 역할을 나누고 의견을 조율했습니다. 입사 후에도 협업 기준을 먼저 정리하고 안정적인 API 운영에 기여하겠습니다. + +## 운영자 확인용 + +| 기대 상태 | 확인할 내용 | 배치 이유 | +|---|---|---| +| PROVEN | 문항 1의 복합 인덱스·쿼리 재작성·`1.8초 → 0.6초` 문장 | 기술, 본인 행동, 동일 조건의 전후 결과가 함께 있다. | +| MENTIONED | 문항 2의 `여러 백엔드 프로젝트를 수행하며 문제 해결 역량을 길렀다`는 문장 | 관련 역량은 언급하지만 구체적인 프로젝트, 역할, 해결 조치와 결과가 없다. | +| MISSING | 공고의 `Redis 기반 캐시 또는 세션 관리` | 세 답변 전체에서 Redis·캐시·세션 저장소·인메모리 저장소를 의도적으로 언급하지 않았다. | +| FABRICATED | 문항 3의 동일한 JobBoard 프로젝트 기간과 팀 구성 | `시작부터 종료까지 1~3월·다른 참여자 없음`과 `시작부터 종료까지 1~6월·5인 팀`이 한 문항 안에서 직접 충돌한다. | + +문항 3의 모순은 데모를 위한 명시적 예시다. `시작부터 종료까지`, 동일 프로젝트명, 기간, 참여 인원 표현을 삭제하면 FABRICATED가 흔들릴 수 있다. diff --git a/docs/demo-day/demo-expected-analysis.json b/docs/demo-day/demo-expected-analysis.json new file mode 100644 index 00000000..cee6612a --- /dev/null +++ b/docs/demo-day/demo-expected-analysis.json @@ -0,0 +1,83 @@ +{ + "mockApplyId": 1001, + "analysisId": 2001, + "status": "COMPLETED", + "sequence": 1, + "score": 73, + "jobFit": 75, + "impact": 70, + "completeness": 70, + "feedback": "데이터베이스 성능 개선 과정과 결과는 구체적이지만, 문제 해결 역할의 근거와 핵심 운영 요건을 보완하고 프로젝트 설명의 일관성을 확인할 필요가 있습니다.", + "keyStrengths": [ + { + "title": "쿼리 최적화 과정과 성과가 구체적으로 드러나요", + "quote": "제가 복합 인덱스를 추가하고 쿼리를 재작성한 뒤 동일 데이터 기준 평균 응답 시간을 0.6초로 줄였습니다." + } + ], + "keyWeaknesses": [ + { + "title": "핵심 캐시 운영 경험이 답변에서 확인되지 않아요", + "quote": "Redis 기반 캐시 또는 세션 관리 기능 설계 및 운영" + }, + { + "title": "프로젝트 기간과 팀 구성 설명이 일관되지 않아요", + "quote": "같은 JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 6월까지 진행했으며, 저를 포함한 5명의 팀원이 역할을 나누었습니다." + } + ], + "missingKeywords": [ + { + "keyword": "Redis 기반 캐시 또는 세션 관리", + "source": "mainTask" + } + ], + "questions": [ + { + "questionId": 101, + "questionContent": "지원 직무와 관련된 경험 및 역량을 구체적으로 작성해주세요.", + "answer": "커머스 사이드 프로젝트에서 Spring Boot로 상품 조회 REST API를 개발했습니다. 데이터가 늘면서 평균 응답 시간이 1.8초까지 증가해 PostgreSQL 실행 계획을 확인했고, 전체 테이블 스캔이 발생하는 조회 조건을 찾았습니다. 제가 복합 인덱스를 추가하고 쿼리를 재작성한 뒤 동일 데이터 기준 평균 응답 시간을 0.6초로 줄였습니다. Git을 활용해 동료와 협업했고 코드 리뷰를 주고받은 뒤 테스트를 거쳐 병합했습니다.", + "analyses": [ + { + "questionAnalysisId": 301, + "sentence": "제가 복합 인덱스를 추가하고 쿼리를 재작성한 뒤 동일 데이터 기준 평균 응답 시간을 0.6초로 줄였습니다.", + "status": "proven", + "reason": "관계형 데이터베이스 최적화를 위해 수행한 행동과 동일 조건의 수치 결과가 구체적으로 드러납니다.", + "improvement": "", + "start": 136, + "end": 195 + } + ] + }, + { + "questionId": 102, + "questionContent": "문제를 해결했던 경험과 그 과정에서 본인이 수행한 역할을 작성해주세요.", + "answer": "서비스 장애가 발생했을 때 모니터링 대시보드의 지표와 로그를 살펴봤습니다. 저는 여러 백엔드 프로젝트에서 문제의 원인을 분석하고 해결 방안을 적용하며 뛰어난 문제 해결 역량을 길렀습니다. 상황에 맞게 대응해 서비스를 정상화했습니다.", + "analyses": [ + { + "questionAnalysisId": 302, + "sentence": "저는 여러 백엔드 프로젝트에서 문제의 원인을 분석하고 해결 방안을 적용하며 뛰어난 문제 해결 역량을 길렀습니다.", + "status": "mentioned", + "reason": "문제 원인 분석과 해결 방안 적용 경험은 언급했지만, 구체적인 프로젝트 상황과 본인의 역할·조치가 드러나지 않아 수행 수준을 확인하기 어렵습니다.", + "improvement": "", + "start": 42, + "end": 104 + } + ] + }, + { + "questionId": 103, + "questionContent": "협업 경험과 입사 후 기여하고 싶은 내용을 작성해주세요.", + "answer": "JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 3월까지 진행했으며, 기획·개발·테스트 전 과정을 저 혼자 맡아 다른 참여자는 없었습니다. 저는 요구사항을 정리하며 진행 상황을 관리했습니다. 같은 JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 6월까지 진행했으며, 저를 포함한 5명의 팀원이 역할을 나누었습니다. 저는 함께 일하는 과정에서 역할을 나누고 의견을 조율했습니다. 입사 후에도 협업 기준을 먼저 정리하고 안정적인 API 운영에 기여하겠습니다.", + "analyses": [ + { + "questionAnalysisId": 303, + "sentence": "같은 JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 6월까지 진행했으며, 저를 포함한 5명의 팀원이 역할을 나누었습니다.", + "status": "fabricated", + "reason": "같은 JobBoard 프로젝트를 시작부터 종료까지 1~3월에 혼자 수행해 다른 참여자가 없었다는 앞 문장과, 시작부터 종료까지 1~6월에 5명이 수행했다는 이 문장이 직접 충돌합니다.", + "improvement": "", + "start": 116, + "end": 193 + } + ] + } + ] +} diff --git a/docs/demo-day/demo-job-posting.txt b/docs/demo-day/demo-job-posting.txt new file mode 100644 index 00000000..b4ab4690 --- /dev/null +++ b/docs/demo-day/demo-job-posting.txt @@ -0,0 +1,32 @@ +[잡드리랩] 백엔드 개발자(신입) + +[회사 소개] +잡드리랩은 구직자가 채용공고와 자기소개서를 더 정확하게 이해하고 준비할 수 있도록 돕는 커리어 서비스를 만들고 있습니다. 작은 팀이 사용자 피드백을 빠르게 제품에 반영하며, 안정적인 API와 데이터 기반 기능을 함께 개선하고 있습니다. + +[모집 직무] +백엔드 개발자 + +[주요 업무] +- Java와 Spring Boot 기반 REST API 개발 및 운영 +- PostgreSQL 등 관계형 데이터베이스 설계와 쿼리 최적화 +- 로그와 모니터링 지표를 활용한 장애 원인 분석 및 안정적인 서비스 운영 +- Redis 기반 캐시 또는 세션 관리 기능 설계 및 운영 +- 기획자·프론트엔드 개발자와의 기능 설계 협업 및 Git 기반 코드 리뷰 + +[자격요건] +- Java와 Spring Boot를 활용한 프로젝트 경험 +- REST API를 설계하고 구현한 경험 +- PostgreSQL 등 관계형 데이터베이스를 활용한 경험 +- Git을 활용해 협업하고 코드 리뷰를 주고받은 경험 +- 문제의 원인을 분석하고 해결 방안을 적용한 경험 + +[우대사항] +- Redis 사용 경험 +- 메시지 큐 또는 비동기 처리 경험 +- AWS 또는 Docker 기반 배포 경험 +- API나 데이터베이스 성능을 개선한 경험 + +[근무 조건] +- 고용 형태: 정규직(수습 3개월) +- 근무지: 서울특별시 영등포구 +- 채용 절차: 서류 전형 > 실무 인터뷰 > 컬처 인터뷰 > 최종 합격 diff --git a/docs/demo-day/demo-operator-guide.md b/docs/demo-day/demo-operator-guide.md new file mode 100644 index 00000000..e2ee7f36 --- /dev/null +++ b/docs/demo-day/demo-operator-guide.md @@ -0,0 +1,61 @@ +# JobDri 데모데이 부스 운영 가이드 + +## 시작 전 체크리스트 + +- 데모 계정에 로그인하고 분석 결과 조회까지 한 번 이동한다. +- 데모 계정의 크레딧이 최소 시연 횟수보다 넉넉한지 확인한다. +- API 서버, 분석 워커, DB, RabbitMQ, OpenAI 연동 상태를 확인한다. +- 분석 큐의 적체와 DLQ 메시지 유무를 확인한다. +- 브라우저 자동완성과 이전 공고·자기소개서 입력을 지운다. +- `demo-job-posting.txt`와 `demo-cover-letter.md`를 별도 창에 미리 연다. +- 정상 분석 결과 화면을 미리 하나 생성하고 결과 URL과 화면 캡처를 준비한다. +- 생성된 공고에 `Redis 기반 캐시 또는 세션 관리`가 남는지 사전 리허설한다. + +## 시연 순서 + +1. `demo-job-posting.txt`의 내용을 처음부터 끝까지 복사해 모의 공고를 생성한다. +2. 생성된 공고에서 다음 항목을 짧게 확인한다. + - 직무: 백엔드 개발자 + - 주요 업무: Spring Boot REST API, 관계형 DB 쿼리 최적화, 장애 분석, Redis, Git 코드 리뷰 + - 핵심 확인: `Redis 기반 캐시 또는 세션 관리`가 반드시 남아 있어야 한다. +3. `demo-cover-letter.md`의 방문자 시연용 문항 3개를 순서대로 입력한다. 각 글자 수 제한은 1,000자로 설정한다. +4. 각 문항의 인용문 안 답변만 정확히 복사한다. `운영자 확인용` 표는 입력하지 않는다. +5. 세 답변이 올바른 문항에 들어갔는지 확인한 뒤 분석을 한 번만 실행한다. +6. 일반적인 예상 시간은 약 30~90초다. 큐 적체나 외부 API 상황에 따라 더 걸릴 수 있다. +7. 처리 중에는 분석 버튼을 반복 클릭하지 말고 현재 상태를 유지한다. +8. 결과에서 아래 네 가지를 확인하고 방문자에게 차이를 설명한다. + - 적절함(PROVEN): 쿼리 최적화의 행동과 `1.8초 → 0.6초` 결과가 입증됨 + - 구체성 부족(MENTIONED): 문제 해결 역량은 말했지만 본인 역할과 적용 조치가 부족함 + - 요건 누락(MISSING): 공고에는 Redis 요건이 있지만 답변 전체에는 관련 경험이 없음 + - 신뢰성 부족(FABRICATED): 시작부터 종료까지의 기간과 전체 참여 인원 설명이 직접 충돌함 + +## 관람객에게 설명할 핵심 문장 + +- “구체적인 근거가 있는 문장은 적절함으로 인정하고, 무조건 약점만 찾지는 않습니다.” +- “관련 경험을 말했지만 행동과 결과가 부족하면 구체성 부족으로 안내합니다.” +- “요건 누락은 억지로 원문 문장을 고르는 대신 채용공고의 핵심 요건을 기준으로 보여줍니다.” +- “신뢰성 부족은 근거가 약하다는 뜻이 아니라, 함께 성립할 수 없는 두 진술의 직접 모순에만 사용합니다.” + +## 라벨이 누락됐을 때 + +- PROVEN이 없으면 문항 1의 `복합 인덱스`, `쿼리 재작성`, `1.8초`, `0.6초`가 모두 유지됐는지 확인한다. 유지됐는데도 없으면 API가 PROVEN을 저장 전에 제거하는 버전인지 확인한다. +- MENTIONED가 없으면 문항 2를 원본과 비교한다. 역할·조치·수치를 현장에서 임의로 덧붙이지 않는다. +- MISSING이 없으면 생성된 공고에 Redis 요건이 남아 있는지 먼저 확인한다. 답변에 Redis·캐시·세션 저장소 관련 문구가 자동 입력되지 않았는지도 확인한다. +- FABRICATED가 없으면 문항 3에서 동일한 `JobBoard 프로젝트` 명칭, 두 문장의 `시작부터 종료까지`, `1~3월/다른 참여자 없음`, `1~6월/5명` 표현이 모두 유지됐는지 확인한다. +- 입력이 정확한데 한 라벨만 누락되면 결과를 저장한 뒤 한 번만 새 모의지원으로 재시도한다. 같은 처리 건에 분석 요청을 연속으로 보내지 않는다. + +## 장애 대응과 백업 시연 + +1. 화면에 처리 중 상태가 남아 있으면 새 요청을 보내기 전에 API와 워커 로그에서 해당 task ID 상태를 확인한다. +2. RabbitMQ에서 ready/unacked 수와 DLQ를 확인한다. 운영 정책에 따른 자동 재시도가 끝나기 전에 수동 중복 실행하지 않는다. +3. 실패가 확정되면 새 모의지원으로 한 번 재시도한다. +4. 관람객 앞에서 2분 이상 디버깅하지 않는다. 사전에 만든 정상 결과 URL이나 화면 캡처로 전환해 네 라벨의 의미를 설명한다. +5. 장애 시간, task ID, API 응답 코드, worker failure reason을 기록해 시연 후 조사한다. + +## 금지 사항 + +- Redis 누락을 만들기 위해 답변의 관계없는 문장을 MISSING 문장으로 지정하지 않는다. +- FABRICATED를 더 잘 띄우려고 “거짓말했다” 같은 자극적인 문구를 추가하지 않는다. +- 점수나 라벨을 화면·응답에서 하드코딩하지 않는다. +- 시연 중 답변을 즉석에서 개선해 상태 유도 조건을 없애지 않는다. +- 처리 중 버튼을 여러 번 누르거나, 원인을 확인하지 않은 채 큐 메시지를 임의 삭제하지 않는다. diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java index 9f3a9d8d..59bad6f3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java @@ -222,6 +222,12 @@ private List buildMissingKeywords( )) { continue; } + if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers( + keyword, + evaluationCase.answer() + )) { + continue; + } String dedupeKey = normalizeKeyword(keyword); if (!seenKeywords.add(dedupeKey)) { @@ -253,6 +259,7 @@ private List buildQuestionAnalyses( Map analysisCountByQuestionId = new HashMap<>(); Map nextSearchIndexByQuestionId = new HashMap<>(); Set seenSentences = new HashSet<>(); + Set fabricatedQuestionIds = new HashSet<>(); Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { @@ -264,9 +271,11 @@ private List buildQuestionAnalyses( } QuestionAnalysisStatus status = parseStatus(item.status()); - if (status == null - || status == QuestionAnalysisStatus.MISSING - || status == QuestionAnalysisStatus.PROVEN) { + if (status == null || status == QuestionAnalysisStatus.MISSING) { + continue; + } + if (status == QuestionAnalysisStatus.PROVEN + && !AnalysisSanitizationRules.hasValidProvenReason(item.reason())) { continue; } if (status == QuestionAnalysisStatus.FABRICATED @@ -280,7 +289,8 @@ private List buildQuestionAnalyses( } String sentence = item.sentence(); - if (keyStrengthQuotes.contains(normalizeKeyword(sentence))) { + if (status != QuestionAnalysisStatus.PROVEN + && keyStrengthQuotes.contains(normalizeKeyword(sentence))) { continue; } String dedupeKey = item.questionId() + ":" + sentence.trim(); @@ -296,6 +306,10 @@ private List buildQuestionAnalyses( if (start < 0) { continue; } + if (status == QuestionAnalysisStatus.FABRICATED + && !fabricatedQuestionIds.add(item.questionId())) { + continue; + } nextSearchIndexByQuestionId.put(item.questionId(), start + sentence.length()); analysisCountByQuestionId.put(item.questionId(), currentCount + 1); 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 dcf99754..37d8628f 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 @@ -58,6 +58,7 @@ public class AnalysisAiClient { private static final int MAX_REFERENCE_SECTION_LENGTH = 3000; private static final int MAX_REFERENCE_FIELD_LENGTH = 300; private static final int MAX_CRITERIA_ITEMS = 5; + private static final int MAX_CANDIDATES_PER_QUESTION = 3; private static final int RECHECK_MIN_PROBLEM_CLARITY = 4; private static final int RECHECK_MIN_JOB_RELEVANCE = 4; private static final int RECHECK_MIN_IMPROVEMENT_USEFULNESS = 4; @@ -72,7 +73,7 @@ public class AnalysisAiClient { - keyStrengths: 없으면 [] - keyWeaknesses: 없으면 [] - missingKeywords: 없으면 [] - - questionAnalyses: 실제 첨삭이 필요한 문장에 따라 0~3개 + - questionAnalyses: 비어 있지 않은 문항마다 대표 평가 문장을 1~3개 - improvement: 안전한 개선문을 만들 수 없으면 null - 프롬프트 안에 특정 점수 숫자 조합을 JSON 예시로 넣지 않는다. - 0, 50, 70, 100 같은 임의 점수도 출력 예시로 사용하지 않는다. @@ -89,8 +90,8 @@ public class AnalysisAiClient { 2. mainTask와 qualification 중 직접 관련된 요구사항을 찾는다. 3. preference만 근거인 경우 첨삭 대상에서 제외한다. 4. 문장 유형에 맞는 평가 기준만 적용한다. - 5. 실제로 첨삭이 필요한 문장인지 판단한다. - 6. mentioned 또는 fabricated 상태를 결정한다. + 5. 문항을 대표해 평가할 문장인지 판단한다. + 6. proven, mentioned 또는 fabricated 상태를 결정한다. 7. reason이 status 및 문장 유형과 일치하는지 확인한다. 8. 원문 사실만으로 안전한 improvement를 작성할 수 있는지 확인한다. 9. 새 경험, 수치, 기술, 계획, 시제 변경, 메타 조언이 없는지 재검사한다. @@ -186,14 +187,15 @@ public class AnalysisAiClient { """; private static final String STATUS_AND_WRITING_RULES = """ [status 판정 기준] - - questionAnalyses의 허용 status는 mentioned, fabricated뿐이다. + - questionAnalyses의 허용 status는 proven, mentioned, fabricated다. + - proven: JD와 관련된 구체적인 행동, 근거 또는 결과가 충분히 드러난 문장 - mentioned: 관련 경험이나 의도는 있으나 구체성이 부족한 문장 - missing: 해당 역량이나 요건을 자기소개서에서 전혀 다루지 않음 - fabricated: JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우 [status 중요 규칙] - - PROVEN은 questionAnalyses에 반환하지 않는다. - - 충분히 좋은 문장은 questionAnalyses에 넣지 않고 keyStrengths로 반환한다. + - 충분히 좋은 대표 문장은 proven으로 questionAnalyses에 반환할 수 있다. + - proven 문장의 improvement는 null로 반환한다. - MISSING은 sentence가 없으므로 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. - 직접적인 증거가 부족해도 관련 경험이 있으면 mentioned로 분류한다. - missing은 관련 언급이 전혀 없을 때만 사용한다. @@ -207,14 +209,12 @@ public class AnalysisAiClient { - 원문에 없는 문장을 생성하지 않는다. - sentence를 요약하거나 수정하지 않는다. - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. - - 좋은 문장은 questionAnalyses에 넣지 않고 keyStrengths로 반환한다. - - questionAnalyses는 실제 첨삭이 필요한 문장만 반환한다. - - questionAnalyses는 문항당 0~3개 반환한다. - - 항상 1개를 반환할 필요가 없다. - - 실제로 보완이 필요한 문장만 문항당 최대 3개 반환한다. - - 실제로 독립적인 문제 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다. + - answer가 비어 있지 않은 모든 문항은 가장 평가 가치가 큰 실제 문장을 최소 1개 반환한다. + - questionAnalyses는 비어 있지 않은 문항마다 1~3개 반환한다. + - 구체적인 강점 문장은 proven, 보완이 필요한 문장은 mentioned 또는 fabricated로 반환한다. + - 실제로 독립적인 평가 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다. - 동일한 문제를 반복하는 문장은 하나만 선택한다. - - 보완할 문장이 없으면 빈 배열 []을 반환한다. + - answer가 비어 있는 문항만 분석을 반환하지 않는다. - 동일하거나 거의 동일한 문장을 중복 반환하지 않는다. - start/end index는 출력하지 않는다. 서버가 Java String character index 기준으로 계산한다. - missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다. @@ -254,7 +254,8 @@ public class AnalysisAiClient { - 충분히 구체적인 좋은 문장은 keyStrengths 후보로 사용한다. - keyStrengths는 mainTask 또는 qualification과 직접 연결된 근거를 우선한다. - preference만 충족하는 문장은 핵심 강점으로 과대평가하지 않는다. - - keyStrengths와 questionAnalyses에 같은 quote/sentence를 동시에 넣지 않는다. + - proven 문장은 keyStrengths의 quote와 questionAnalyses의 sentence에 함께 사용할 수 있다. + - mentioned 또는 fabricated 문장은 keyStrengths와 중복하지 않는다. - keyWeaknesses의 첫 항목들은 missingKeywords와 같은 누락 요건을 다룬다. - missingKeywords 기반 keyWeaknesses의 quote는 JD의 주요 업무, 자격 요건, 우대 사항에 실제 포함된 표현을 사용한다. - missingKeywords가 없으면 keyWeaknesses는 questionAnalyses의 보완 대상 문장 quote를 우선 사용한다. @@ -640,8 +641,8 @@ String buildCandidatePrompt( reasoning, analysis 같은 별도 필드를 추가하지 않는다. [1차 출력 필드] - - strengthCandidates: 충분히 구체적이고 mainTask 또는 qualification과 직접 연결된 좋은 문장 후보. 없으면 []. - - analysisCandidates: 실제 첨삭이 필요한 명확한 문장 후보. status는 MENTIONED 또는 FABRICATED만 사용. 최대 3개. + - strengthCandidates: 충분히 구체적이고 mainTask 또는 qualification과 직접 연결된 최종 PROVEN 문장 후보. 없으면 []. + - analysisCandidates: 실제 첨삭이 필요한 명확한 문장 후보. status는 MENTIONED 또는 FABRICATED만 사용. 문항별 최대 3개. - missingKeywordCandidates: sentence가 없는 누락 역량 후보. 없으면 []. - 점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다. - analysisCandidates.candidateId는 각 후보마다 고유한 문자열로 반드시 채운다. @@ -663,10 +664,11 @@ String buildCandidatePrompt( 7. 보완이 필요한 문장만 analysisCandidates로 분류한다. 8. 실제 충돌이 있는 경우에만 FABRICATED를 사용한다. 9. 누락 요구사항은 sentence가 아니라 missingKeywordCandidates로 분리한다. - 10. 대표 1개만 기계적으로 고르지 말고, 독립적인 문제가 있으면 최대 3개까지 반환한다. + 10. 대표 1개만 기계적으로 고르지 말고, 독립적인 평가 근거가 있으면 문항별 1~3개를 반환한다. [1차 후보 규칙] - 후보 수보다 정확도를 우선한다. + - 후보 개수 제한은 전체 답변 합계가 아니라 questionId별로 독립 적용한다. - 애매한 문장은 후보로 만들지 않는다. - 각 문장을 앞뒤 문맥과 함께 판단한다. - 문장 자체 또는 주변 문맥에 충분한 근거가 있으면 제외한다. @@ -679,6 +681,7 @@ String buildCandidatePrompt( - sentence와 quote는 해당 answer에 실제 포함된 정확한 부분 문자열만 사용한다. - preference-only 후보는 strengthCandidates, analysisCandidates, missingKeywordCandidates에서 제외한다. - 충분히 좋은 문장은 analysisCandidates에 넣지 않는다. + - strengthCandidates로 검증된 문장은 서버가 status=PROVEN, improvement=null인 questionAnalyses로 변환한다. - 포부/계획 문장에는 과거 성과 수치나 Before-After를 요구하지 않는다. - 지원동기는 수치 부족으로 분류하지 않는다. - MISSING은 analysisCandidates에 넣지 않고 missingKeywordCandidates로만 분리한다. @@ -824,7 +827,8 @@ String buildFinalPrompt( - strengths: 검증된 strengthCandidates 범위 안에서 최종 keyStrengths 후보를 반환한다. - missingKeywords: 검증된 missingKeywordCandidates 범위 안에서 최종 missingKeywords 후보를 반환한다. - jobFit, impact, completeness, feedback을 함께 반환한다. - - 기존 최종 AnalysisLlmResponse를 직접 만들지 않는다. 서버가 accepted decision만 최종 questionAnalyses로 변환한다. + - 기존 최종 AnalysisLlmResponse를 직접 만들지 않는다. 서버가 검증된 strengths는 PROVEN으로, accepted decision은 MENTIONED 또는 FABRICATED로 최종 questionAnalyses에 변환한다. + - 최종 questionAnalyses 개수 제한은 전체 합계가 아니라 questionId별 1~3개로 적용한다. [rejectionCode 허용값] - ALREADY_SPECIFIC @@ -1152,9 +1156,9 @@ String buildSinglePassPrompt( [출력 전 자체 검증] - JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다. - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. - - questionAnalyses의 status는 proven, mentioned, missing, fabricated 중 하나만 사용한다. + - questionAnalyses의 status는 proven, mentioned, fabricated 중 하나만 사용한다. - sentence는 answer에 포함된 정확한 substring만 사용한다. - - missing 상태를 questionAnalyses에 넣기 위해 원문에 없는 sentence를 만들지 않는다. + - missing은 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. - keyStrengths와 keyWeaknesses는 각각 최대 3개이며, 없으면 []로 출력한다. - keyStrengths의 quote는 answer에 실제 포함된 substring만 사용한다. - keyWeaknesses에서 missingKeywords를 다루는 항목의 quote는 실제 JD 문구만 사용한다. @@ -1243,6 +1247,7 @@ private List sanitizeStrengthCandid } List result = new ArrayList<>(); Set seen = new HashSet<>(); + Map countByQuestionId = new HashMap<>(); for (AnalysisCandidateResponse.StrengthCandidate candidate : candidates.strengthCandidates()) { if (candidate == null || candidate.questionId() == null || !StringUtils.hasText(candidate.quote())) { continue; @@ -1257,10 +1262,12 @@ private List sanitizeStrengthCandid if (!seen.add(dedupeKey)) { continue; } - result.add(candidate); - if (result.size() >= 3) { - break; + int currentCount = countByQuestionId.getOrDefault(candidate.questionId(), 0); + if (currentCount >= MAX_CANDIDATES_PER_QUESTION) { + continue; } + result.add(candidate); + countByQuestionId.put(candidate.questionId(), currentCount + 1); } return result; } @@ -1274,6 +1281,7 @@ private List sanitizeAnalysisCandid } List result = new ArrayList<>(); Set seen = new HashSet<>(); + Map countByQuestionId = new HashMap<>(); for (AnalysisCandidateResponse.AnalysisCandidate candidate : candidates.analysisCandidates()) { if (candidate == null || candidate.questionId() == null || !StringUtils.hasText(candidate.sentence())) { continue; @@ -1299,6 +1307,10 @@ private List sanitizeAnalysisCandid if (!seen.add(dedupeKey)) { continue; } + int currentCount = countByQuestionId.getOrDefault(candidate.questionId(), 0); + if (currentCount >= MAX_CANDIDATES_PER_QUESTION) { + continue; + } result.add(new AnalysisCandidateResponse.AnalysisCandidate( candidate.candidateId().trim(), candidate.questionId(), @@ -1312,9 +1324,7 @@ private List sanitizeAnalysisCandid candidate.issueType(), candidate.reasonBasis() )); - if (result.size() >= 3) { - break; - } + countByQuestionId.put(candidate.questionId(), currentCount + 1); } return result; } @@ -1715,7 +1725,7 @@ private List buildAcceptedQuestionAnal AnalysisCandidateResponse sanitizedCandidates, CandidateReviewResponse reviewResponse ) { - if (reviewResponse == null || reviewResponse.decisions() == null || sanitizedCandidates == null) { + if (reviewResponse == null || sanitizedCandidates == null) { return List.of(); } Map candidateById = sanitizedCandidates.analysisCandidates().stream() @@ -1729,8 +1739,59 @@ private List buildAcceptedQuestionAnal .collect(Collectors.toMap(AnalysisPromptInput.QuestionAnswer::questionId, AnalysisPromptInput.QuestionAnswer::answer)); List result = new ArrayList<>(); + Map countByQuestionId = new HashMap<>(); + Set seenSentences = new HashSet<>(); + + Map reviewedStrengthReasonByQuote = reviewResponse.strengths() == null + ? Map.of() + : reviewResponse.strengths().stream() + .filter(strength -> strength != null && StringUtils.hasText(strength.quote())) + .collect(Collectors.toMap( + strength -> normalize(strength.quote()), + strength -> defaultString(strength.title()).trim(), + (left, right) -> left + )); + for (AnalysisCandidateResponse.StrengthCandidate strength : sanitizedCandidates.strengthCandidates()) { + if (strength == null || strength.questionId() == null || !StringUtils.hasText(strength.quote())) { + continue; + } + String normalizedQuote = normalize(strength.quote()); + if (!reviewedStrengthReasonByQuote.containsKey(normalizedQuote)) { + continue; + } + String answer = answerByQuestionId.get(strength.questionId()); + if (!containsExact(answer, strength.quote()) || isBracketedSubheading(answer, strength.quote())) { + continue; + } + int currentCount = countByQuestionId.getOrDefault(strength.questionId(), 0); + if (currentCount >= MAX_CANDIDATES_PER_QUESTION) { + continue; + } + String reason = AnalysisSanitizationRules.hasValidProvenReason(strength.reasonBasis()) + ? strength.reasonBasis().trim() + : reviewedStrengthReasonByQuote.get(normalizedQuote); + if (!AnalysisSanitizationRules.hasValidProvenReason(reason)) { + continue; + } + String dedupeKey = strength.questionId() + ":" + normalizedQuote; + if (!seenSentences.add(dedupeKey)) { + continue; + } + result.add(new AnalysisLlmResponse.QuestionAnalysisItem( + strength.questionId(), + strength.quote(), + QuestionAnalysisStatus.PROVEN.name().toLowerCase(), + reason, + null + )); + countByQuestionId.put(strength.questionId(), currentCount + 1); + } + Set seenCandidateIds = new HashSet<>(); - for (CandidateReviewResponse.CandidateDecision decision : reviewResponse.decisions()) { + List decisions = reviewResponse.decisions() == null + ? List.of() + : reviewResponse.decisions(); + for (CandidateReviewResponse.CandidateDecision decision : decisions) { if (decision == null || !StringUtils.hasText(decision.candidateId())) { continue; } @@ -1765,6 +1826,14 @@ private List buildAcceptedQuestionAnal || isBracketedSubheading(answer, candidate.sentence())) { continue; } + int currentCount = countByQuestionId.getOrDefault(candidate.questionId(), 0); + if (currentCount >= MAX_CANDIDATES_PER_QUESTION) { + continue; + } + String dedupeKey = candidate.questionId() + ":" + normalize(candidate.sentence()); + if (!seenSentences.add(dedupeKey)) { + continue; + } String improvement = AnalysisSanitizationRules.normalizeImprovement( candidate.sentence(), answer, @@ -1778,9 +1847,7 @@ private List buildAcceptedQuestionAnal decision.reason().trim(), StringUtils.hasText(improvement) ? improvement : null )); - if (result.size() >= 3) { - break; - } + countByQuestionId.put(candidate.questionId(), currentCount + 1); } return result; } @@ -2094,7 +2161,8 @@ private List buildFinalStrengths( .filter(candidate -> isPrimarySource(candidate.relatedSource())) .map(candidate -> normalize(candidate.quote())) .collect(Collectors.toSet()); - Set analysisSentences = questionAnalyses.stream() + Set nonProvenAnalysisSentences = questionAnalyses.stream() + .filter(item -> !QuestionAnalysisStatus.PROVEN.name().equalsIgnoreCase(defaultString(item.status()))) .map(item -> normalize(item.sentence())) .collect(Collectors.toSet()); List result = new ArrayList<>(); @@ -2104,7 +2172,9 @@ private List buildFinalStrengths( continue; } String normalizedQuote = normalize(strength.quote()); - if (!allowedQuotes.contains(normalizedQuote) || analysisSentences.contains(normalizedQuote) || !seen.add(normalizedQuote)) { + if (!allowedQuotes.contains(normalizedQuote) + || nonProvenAnalysisSentences.contains(normalizedQuote) + || !seen.add(normalizedQuote)) { continue; } result.add(new AnalysisLlmResponse.HighlightItem(strength.title().trim(), strength.quote().trim())); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java index bc0b7f14..322fdb6c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java @@ -190,7 +190,13 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques user, request.mockApplyId(), contextSnapshot.similarJobPostings() - ); + ).withAnswerSnapshots(contextSnapshot.questions().stream() + .filter(question -> question.answer() != null && !question.answer().isBlank()) + .map(question -> new AnalysisExecutionPayload.AnswerSnapshot( + question.questionId(), + question.answer() + )) + .toList()); AnalysisLlmResponse llmResponse = request.llmResponse(); AnalysisResponse response = analysisService.finalizeAnalysis( user, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java index 15dc3c5e..6b62e49d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java @@ -17,7 +17,8 @@ public record AnalysisExecutionPayload( List answeredQuestions, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, RetrievalContext retrievalContext, - List similarJobPostings + List similarJobPostings, + List answerSnapshots ) { public AnalysisExecutionPayload( Long userId, @@ -26,7 +27,7 @@ public AnalysisExecutionPayload( List questions, List answeredQuestions ) { - this(userId, mockApplyId, jobPosting, questions, answeredQuestions, null, null, List.of()); + this(userId, mockApplyId, jobPosting, questions, answeredQuestions, null, null, List.of(), null); } public AnalysisExecutionPayload( @@ -37,7 +38,7 @@ public AnalysisExecutionPayload( List answeredQuestions, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - this(userId, mockApplyId, jobPosting, questions, answeredQuestions, jobCategoryEvaluationCriteria, null, List.of()); + this(userId, mockApplyId, jobPosting, questions, answeredQuestions, jobCategoryEvaluationCriteria, null, List.of(), null); } public AnalysisExecutionPayload( @@ -57,7 +58,31 @@ public AnalysisExecutionPayload( answeredQuestions, jobCategoryEvaluationCriteria, retrievalContext, - List.of() + List.of(), + null + ); + } + + public AnalysisExecutionPayload( + Long userId, + Long mockApplyId, + JobPosting jobPosting, + List questions, + List answeredQuestions, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + RetrievalContext retrievalContext, + List similarJobPostings + ) { + this( + userId, + mockApplyId, + jobPosting, + questions, + answeredQuestions, + jobCategoryEvaluationCriteria, + retrievalContext, + similarJobPostings, + null ); } @@ -65,5 +90,38 @@ public AnalysisExecutionPayload( questions = questions == null ? List.of() : List.copyOf(questions); answeredQuestions = answeredQuestions == null ? List.of() : List.copyOf(answeredQuestions); similarJobPostings = similarJobPostings == null ? List.of() : List.copyOf(similarJobPostings); + answerSnapshots = answerSnapshots == null + ? snapshotAnswers(answeredQuestions) + : List.copyOf(answerSnapshots); + } + + public AnalysisExecutionPayload withAnswerSnapshots(List snapshots) { + return new AnalysisExecutionPayload( + userId, + mockApplyId, + jobPosting, + questions, + answeredQuestions, + jobCategoryEvaluationCriteria, + retrievalContext, + similarJobPostings, + snapshots + ); + } + + public static List snapshotAnswers(List questions) { + if (questions == null) { + return List.of(); + } + return questions.stream() + .filter(question -> question != null && question.getAnswer() != null && !question.getAnswer().isBlank()) + .map(question -> new AnswerSnapshot(question.getId(), question.getAnswer())) + .toList(); + } + + public record AnswerSnapshot(Long questionId, String answer) { + public AnswerSnapshot { + answer = answer == null ? "" : answer; + } } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java index d8dc6e28..4992db01 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -80,6 +81,22 @@ public String create(AnalysisExecutionPayload payload) { return sha256Hex(writeFingerprintSource(fingerprintSource)); } + public String createAnswerFingerprint(List answerSnapshots) { + List orderedSnapshots = answerSnapshots == null + ? List.of() + : answerSnapshots.stream() + .sorted(Comparator.comparing(AnalysisExecutionPayload.AnswerSnapshot::questionId)) + .toList(); + Map fingerprintSource = new LinkedHashMap<>(); + fingerprintSource.put("schemaVersion", "analysis-answer-fingerprint-v1"); + fingerprintSource.put("answers", orderedSnapshots); + return sha256Hex(writeFingerprintSource(fingerprintSource)); + } + + public String createAnswerFingerprintFromQuestions(List questions) { + return createAnswerFingerprint(AnalysisExecutionPayload.snapshotAnswers(questions)); + } + private Map retrievalPolicy() { Map retrievalPolicy = new LinkedHashMap<>(); retrievalPolicy.put("embeddingModel", embeddingModel); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index 007b5928..62e8da66 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -45,6 +45,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -234,13 +235,18 @@ public AnalysisResponse finalizeAnalysis( ) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + VerifiedAnswerSnapshot answerSnapshot = verifyAnswerSnapshot(questions, payload.answerSnapshots()); validateRequiredScores(llmResponse); int jobFit = validateScore("jobFit", llmResponse.jobFit()); int impact = validateScore("impact", llmResponse.impact()); int completeness = validateScore("completeness", llmResponse.completeness()); List keyStrengths = buildHighlights(llmResponse.keyStrengths()); List keyWeaknesses = buildNonOverlappingHighlights(llmResponse.keyWeaknesses(), keyStrengths); - List missingKeywords = buildMissingKeywords(mockApply.getJobPosting(), llmResponse); + List missingKeywords = buildMissingKeywords( + mockApply.getJobPosting(), + answerSnapshot.combinedAnswers(), + llmResponse + ); replaceExistingAnalysis(mockApply); Analysis analysis = analysisRepository.save(Analysis.create( @@ -259,7 +265,7 @@ public AnalysisResponse finalizeAnalysis( List questionAnalyses = buildQuestionAnalyses( analysis, questions, - payload.answeredQuestions(), + answerSnapshot.answerByQuestionId(), llmResponse ); questionAnalysisRepository.saveAll(questionAnalyses); @@ -391,6 +397,37 @@ private void replaceExistingAnalysis(MockApply mockApply) { analysisRepository.flush(); } + private VerifiedAnswerSnapshot verifyAnswerSnapshot( + List databaseQuestions, + List payloadSnapshots + ) { + String databaseFingerprint = analysisInputFingerprintProvider + .createAnswerFingerprintFromQuestions(databaseQuestions); + String payloadFingerprint = analysisInputFingerprintProvider + .createAnswerFingerprint(payloadSnapshots); + if (!databaseFingerprint.equals(payloadFingerprint)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석 실행 이후 자소서 답변이 변경되어 결과를 저장할 수 없습니다." + ); + } + + List immutableSnapshots = List.copyOf(payloadSnapshots); + Map answerByQuestionId = new LinkedHashMap<>(); + for (AnalysisExecutionPayload.AnswerSnapshot snapshot : immutableSnapshots) { + if (snapshot == null || snapshot.questionId() == null || !StringUtils.hasText(snapshot.answer())) { + continue; + } + if (answerByQuestionId.putIfAbsent(snapshot.questionId(), snapshot.answer()) != null) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석 답변 snapshot에 중복된 questionId가 있습니다. questionId=" + snapshot.questionId() + ); + } + } + return new VerifiedAnswerSnapshot(immutableSnapshots, Map.copyOf(answerByQuestionId)); + } + private AnalysisResponse reuseExistingAnalysisIfSameInput(User user, Long mockApplyId, String inputFingerprint) { return analysisRepository.findByMockApplyId(mockApplyId) .filter(analysis -> inputFingerprint.equals(analysis.getInputFingerprint())) @@ -402,20 +439,31 @@ private String analysisCreditReferenceId(Long mockApplyId, String inputFingerpri return "mockApplyId=" + mockApplyId + ":fingerprint=" + inputFingerprint; } + private record VerifiedAnswerSnapshot( + List answers, + Map answerByQuestionId + ) { + private String combinedAnswers() { + return answers.stream() + .map(AnalysisExecutionPayload.AnswerSnapshot::answer) + .filter(StringUtils::hasText) + .collect(Collectors.joining("\n")); + } + } + private List buildQuestionAnalyses( Analysis analysis, List questions, - List answeredQuestions, + Map answerByQuestionId, AnalysisLlmResponse llmResponse ) { Map questionMap = questions.stream() .collect(Collectors.toMap(Question::getId, Function.identity())); - Map answerByQuestionId = answeredQuestions.stream() - .collect(Collectors.toMap(Question::getId, Question::getAnswer)); List result = new ArrayList<>(); Map analysisCountByQuestionId = new HashMap<>(); Map nextSearchIndexByQuestionId = new HashMap<>(); Set seenSentences = new HashSet<>(); + Set fabricatedQuestionIds = new HashSet<>(); Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); if (llmResponse.questionAnalyses() == null) { @@ -437,9 +485,11 @@ private List buildQuestionAnalyses( continue; } QuestionAnalysisStatus status = parseStatus(item.status()); - if (status == null - || status == QuestionAnalysisStatus.MISSING - || status == QuestionAnalysisStatus.PROVEN) { + if (status == null || status == QuestionAnalysisStatus.MISSING) { + continue; + } + if (status == QuestionAnalysisStatus.PROVEN + && !AnalysisSanitizationRules.hasValidProvenReason(item.reason())) { continue; } if (status == QuestionAnalysisStatus.FABRICATED @@ -451,7 +501,8 @@ private List buildQuestionAnalyses( continue; } String sentence = item.sentence(); - if (keyStrengthQuotes.contains(normalizeKeyword(sentence))) { + if (status != QuestionAnalysisStatus.PROVEN + && keyStrengthQuotes.contains(normalizeKeyword(sentence))) { continue; } String dedupeKey = question.getId() + ":" + sentence.trim(); @@ -466,6 +517,10 @@ private List buildQuestionAnalyses( if (start < 0) { continue; } + if (status == QuestionAnalysisStatus.FABRICATED + && !fabricatedQuestionIds.add(question.getId())) { + continue; + } nextSearchIndexByQuestionId.put(question.getId(), start + sentence.length()); analysisCountByQuestionId.put(question.getId(), currentCount + 1); @@ -591,7 +646,11 @@ private List buildNonOverlappingHighlights( ); } - private List buildMissingKeywords(JobPosting jobPosting, AnalysisLlmResponse llmResponse) { + private List buildMissingKeywords( + JobPosting jobPosting, + String combinedAnswers, + AnalysisLlmResponse llmResponse + ) { if (llmResponse == null || llmResponse.missingKeywords() == null) { return List.of(); } @@ -621,6 +680,9 @@ private List buildMissingKeywords(JobPosting jobPosting, )) { continue; } + if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers(keyword, combinedAnswers)) { + continue; + } String dedupeKey = normalizeKeyword(keyword); if (!seenKeywords.add(dedupeKey)) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java index a917afb4..e6bea6ea 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRules.java @@ -89,6 +89,29 @@ public static boolean isGroundedMissingKeyword( return false; } + public static boolean isMissingKeywordMentionedInAnswers(String keyword, String answers) { + if (!StringUtils.hasText(keyword) || !StringUtils.hasText(answers)) { + return false; + } + if (containsNormalized(answers, keyword)) { + return true; + } + + Set keywordTokens = coreTokens(keyword); + Set answerTokens = coreTokens(answers); + if (keywordTokens.isEmpty() || answerTokens.isEmpty()) { + return false; + } + + long matchCount = keywordTokens.stream() + .filter(answerTokens::contains) + .count(); + if (keywordTokens.size() == 1) { + return matchCount == 1; + } + return (double) matchCount / keywordTokens.size() >= MIN_KEYWORD_TOKEN_MATCH_RATIO; + } + public static boolean hasMissingKeywordCoreTokens(String keyword) { return !coreTokens(keyword).isEmpty(); } @@ -131,6 +154,10 @@ public static boolean isContradictoryProvenReason(String reason) { return false; } + public static boolean hasValidProvenReason(String reason) { + return StringUtils.hasText(reason) && !isContradictoryProvenReason(reason); + } + public static boolean isPositiveMentionedReason(String reason) { if (!StringUtils.hasText(reason)) { return false; @@ -218,7 +245,7 @@ private static String stripKoreanSuffix(String token) { return token == null ? "" : token; } String result = token; - String[] suffixes = {"으로", "에서", "하며", "하고", "하는", "까지", "부터", "에게", "보다", + String[] suffixes = {"했습니다", "았습니다", "었습니다", "으로", "에서", "하며", "하고", "하는", "까지", "부터", "에게", "보다", "은", "는", "이", "가", "을", "를", "와", "과", "의", "에", "로", "한"}; boolean changed; do { diff --git a/src/main/resources/ai/analysis/fewshot-prompt-block.txt b/src/main/resources/ai/analysis/fewshot-prompt-block.txt index 2f946592..f6fecc36 100644 --- a/src/main/resources/ai/analysis/fewshot-prompt-block.txt +++ b/src/main/resources/ai/analysis/fewshot-prompt-block.txt @@ -2,11 +2,10 @@ Few-shot 예시는 판정 기준과 출력 형식의 참고 사례다. 예시의 분석 개수, 상태 비율, 문장 표현, 점수를 실제 입력에 복사하지 않는다. 실제 입력마다 독립적으로 판단한다. -questionAnalyses는 실제 첨삭이 필요한 문장에 따라 0~3개가 될 수 있다. -항상 1개를 반환할 필요가 없다. +questionAnalyses는 비어 있지 않은 문항마다 대표 평가 문장을 1~3개 반환한다. Few-shot 출력에는 전체 점수를 포함하지 않는다. -## 예시 A: 좋은 근거가 있으면 보완 문장이 0개일 수 있음 +## 예시 A: 구체적인 행동과 결과가 있으면 proven 관련 JD 요구사항: - mainTask: API 성능 개선 및 데이터베이스 쿼리 최적화 @@ -16,7 +15,8 @@ Few-shot 출력에는 전체 점수를 포함하지 않는다. 판정 참고: - 위 문장은 구체적인 행동, 사용 기술, 수치 결과가 있어 좋은 강점으로 볼 수 있다. -- 그러나 이미 충분히 구체적이므로 questionAnalyses에는 포함하지 않는다. +- 문항을 대표하는 긍정 근거이므로 questionAnalyses에는 proven으로 포함한다. +- proven은 개선이 필요하지 않으므로 improvement는 null이다. 출력 중 강점/문장/누락 관련 필드: { @@ -27,7 +27,15 @@ Few-shot 출력에는 전체 점수를 포함하지 않는다. } ], "missingKeywords": [], - "questionAnalyses": [] + "questionAnalyses": [ + { + "questionId": 1, + "sentence": "쿼리 실행 계획을 분석하고 복합 인덱스를 적용해 평균 응답 시간을 1.2초에서 300ms로 단축했습니다.", + "status": "proven", + "reason": "API 성능 개선과 데이터베이스 최적화에 사용한 행동과 수치 결과가 구체적으로 드러납니다.", + "improvement": null + } + ] } ## 예시 B: 관련 경험은 있으나 근거가 약한 경우 diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java index 7233559e..7c358441 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java @@ -175,7 +175,7 @@ void runAppliesProductionSanitizationRules() throws Exception { } @Test - @DisplayName("평가 결과도 운영과 동일하게 PROVEN/FABRICATED 상태 필터를 적용하고 raw/final 비교 정보를 남긴다") + @DisplayName("평가 결과도 운영과 동일하게 유효한 PROVEN/FABRICATED를 보존하고 raw/final 비교 정보를 남긴다") void runKeepsRawAndAppliesFinalStatusFilter() throws Exception { AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); ObjectMapper objectMapper = new ObjectMapper(); @@ -244,12 +244,62 @@ void runKeepsRawAndAppliesFinalStatusFilter() throws Exception { .contains("\"status\":\"proven\"") .contains("\"status\":\"fabricated\""); assertThat(row.get("aiQuestionAnalysesJson")) + .contains("\"status\":\"proven\"") .contains("\"status\":\"mentioned\"") .contains("\"status\":\"fabricated\"") - .doesNotContain("\"status\":\"proven\"") .doesNotContain("네 번째 문장입니다."); } + @Test + @DisplayName("평가 저장 경로도 reason이 null 또는 빈 PROVEN을 제외한다") + void runSkipsProvenWithMissingReason() throws Exception { + AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( + analysisAiClient, + new JobCategoryEvaluationCriteriaProvider(objectMapper), + objectMapper + ); + when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + .thenReturn(result(new AnalysisLlmResponse( + 80, + 70, + 60, + "PROVEN reason 필수 검증", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "첫 번째 성과입니다.", + "proven", + null, + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "두 번째 성과입니다.", + "proven", + " ", + null + ) + ) + ))); + Path input = tempDir.resolve("evaluation_missing_proven_reason.csv"); + Path output = tempDir.resolve("evaluation_missing_proven_reason_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-PROVEN-REASON,AI·개발·데이터,백엔드,API 개발,Spring Boot 경험,,성과를 쓰세요,첫 번째 성과입니다. 두 번째 성과입니다.\n", + StandardCharsets.UTF_8 + ); + + service.run(input, output); + + Map row = EvaluationCsvSupport.read(output).getFirst(); + assertThat(row.get("aiQuestionAnalysesJson")) + .doesNotContain("첫 번째 성과입니다.") + .doesNotContain("두 번째 성과입니다."); + } + @Test @DisplayName("없는 직무 중분류는 보조 기준 없이 분석한다") void runOmitsCriteriaWhenMiddleNameNotFound() throws Exception { @@ -452,8 +502,10 @@ void runWritesValidatedCandidateDecisionCounts() throws Exception { assertThat(row.get("candidateCount")).isEqualTo("2"); assertThat(row.get("candidateMissingKeywordCount")).isEqualTo("2"); assertThat(row.get("missingKeywordCandidateCount")).isEqualTo("2"); - assertThat(row.get("finalMissingKeywordCount")).isEqualTo("2"); - assertThat(row.get("aiMissingKeywordsJson")).contains("Spring Boot 경험").contains("API 개발"); + assertThat(row.get("finalMissingKeywordCount")).isEqualTo("0"); + assertThat(row.get("aiMissingKeywordsJson")) + .doesNotContain("Spring Boot 경험") + .doesNotContain("API 개발"); assertThat(row.get("acceptedCandidateCount")).isEqualTo("1"); assertThat(row.get("rejectedCandidateCount")).isEqualTo("1"); assertThat(row.get("rejectionCodeCounts")).contains("NOT_ACTIONABLE"); 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 de76f24a..5b278acc 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 @@ -237,7 +237,7 @@ void buildPromptIncludesReviewPolicyRules() { .contains("jobFit: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수") .contains("impact: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수") .contains("completeness: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수") - .contains("questionAnalyses: 실제 첨삭이 필요한 문장에 따라 0~3개") + .contains("questionAnalyses: 비어 있지 않은 문항마다 대표 평가 문장을 1~3개") .contains("improvement: 안전한 개선문을 만들 수 없으면 null") .contains("[문장 유형 구분]") .contains("경험/성과") @@ -255,7 +255,7 @@ void buildPromptIncludesReviewPolicyRules() { .contains("실제 입력 JD의 주요 업무, 자격 요건 원문에 존재하지만 자소서에 충분히 드러나지 않은 경험형 역량만 추출한다.") .contains("유사 JD 검색 결과, 직무별 보조 평가 기준, few-shot 예시, 모델의 일반 지식에서 키워드를 생성하지 않는다.") .contains("자격증, 면허, 어학성적, 학위, 전공, 경력 연차, 근무 가능 여부") - .contains("좋은 문장은 questionAnalyses에 넣지 않고 keyStrengths로 반환한다.") + .contains("충분히 좋은 대표 문장은 proven으로 questionAnalyses에 반환할 수 있다.") .contains("개선이 필요하지 않으면 improvement는 null로 반환한다.") .contains("원문 정보만으로 개선문을 만들 수 없으면 improvement는 null로 반환한다.") .contains("원문과 실질적으로 동일한 문장을 improvement로 반환하지 않는다.") @@ -282,20 +282,19 @@ void buildPromptIncludesFewShotV4Rules() { assertThat(prompt) .contains("[Few-shot 예시]") - .contains("questionAnalyses\": []") + .contains("status\": \"proven\"") .contains("status\": \"mentioned\"") .contains("status\": \"fabricated\"") - .contains("충분히 구체적이므로 questionAnalyses에는 포함하지 않는다.") + .contains("문항을 대표하는 긍정 근거이므로 questionAnalyses에는 proven으로 포함한다.") .contains("예시의 분석 개수, 상태 비율, 문장 표현, 점수를 실제 입력에 복사하지 않는다.") - .contains("questionAnalyses는 실제 첨삭이 필요한 문장에 따라 0~3개가 될 수 있다.") - .contains("항상 1개를 반환할 필요가 없다.") + .contains("questionAnalyses는 비어 있지 않은 문항마다 대표 평가 문장을 1~3개 반환한다.") .contains("Few-shot 출력에는 전체 점수를 포함하지 않는다.") .contains("포부/계획 문장의 reason에는 \"성과 수치가 부족\"") .contains("preference가 없다는 이유만으로 mentioned를 생성하지 않는다.") - .contains("questionAnalyses의 허용 status는 mentioned, fabricated뿐이다.") - .contains("PROVEN은 questionAnalyses에 반환하지 않는다.") + .contains("questionAnalyses의 허용 status는 proven, mentioned, fabricated다.") + .contains("proven 문장의 improvement는 null로 반환한다.") .contains("MISSING은 sentence가 없으므로 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다.") - .contains("실제로 독립적인 문제 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다.") + .contains("실제로 독립적인 평가 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다.") .contains("내부 판단 과정이나 chain-of-thought를 응답에 출력하지 않는다.") .contains("원문이 과거 경험이면 개선문도 과거 경험을 유지한다.") .contains("원문이 포부이면 개선문도 포부를 유지한다.") @@ -325,10 +324,10 @@ void buildPromptUsesSelectedFewShotsWhenDynamicSelectionEnabled() { List.of("Spring Boot"), "직무 경험", "API를 개발했습니다.", - "{\"questionAnalyses\":[]}", + "{\"questionAnalyses\":[{\"questionId\":1,\"sentence\":\"API를 개발했습니다.\",\"status\":\"proven\",\"reason\":\"API 개발 경험이 구체적으로 드러납니다.\",\"improvement\":null}]}", List.of("api"), "fewshot-test-v1", - "## 예시 Z: 동적 선택 예시\n출력 중 문장/누락 관련 필드:\n{}" + "## 예시 Z: 동적 선택 예시\n출력 중 문장/누락 관련 필드:\n{\"questionAnalyses\":[{\"questionId\":1,\"sentence\":\"API를 개발했습니다.\",\"status\":\"proven\",\"reason\":\"API 개발 경험이 구체적으로 드러납니다.\",\"improvement\":null}]}" ); when(fewShotSearchService.searchRelevantFewShots(any(), eq(fewShotProperties.getSearch().getTopK()))) .thenReturn(List.of(new SelectedFewShotCase(selectedCase, 0.91, "test"))); @@ -342,7 +341,9 @@ void buildPromptUsesSelectedFewShotsWhenDynamicSelectionEnabled() { assertThat(prompt) .contains("## 예시 Z: 동적 선택 예시") - .doesNotContain("## 예시 A: 좋은 근거가 있으면 보완 문장이 0개일 수 있음"); + .contains("\"sentence\":\"API를 개발했습니다.\"") + .contains("\"status\":\"proven\"") + .doesNotContain("## 예시 A: 구체적인 행동과 결과가 있으면 proven"); verify(fewShotSearchService).searchRelevantFewShots( any(), eq(fewShotProperties.getSearch().getTopK()) @@ -363,7 +364,7 @@ void buildPromptFallsBackToFixedFewShotsWhenDynamicSelectionIsEmpty() { null ); - assertThat(prompt).contains("## 예시 A: 좋은 근거가 있으면 보완 문장이 0개일 수 있음"); + assertThat(prompt).contains("## 예시 A: 구체적인 행동과 결과가 있으면 proven"); } @Test @@ -380,7 +381,7 @@ void buildPromptFallsBackToFixedFewShotsWhenDynamicSelectionFails() { null ); - assertThat(prompt).contains("## 예시 A: 좋은 근거가 있으면 보완 문장이 0개일 수 있음"); + assertThat(prompt).contains("## 예시 A: 구체적인 행동과 결과가 있으면 proven"); } @Test @@ -400,9 +401,11 @@ void buildCandidatePromptIncludesCandidateRulesOnly() { .contains("sentenceType: EXPERIENCE, PLAN, MOTIVATION, COMPETENCY") .contains("preference만 근거인 후보는 제외한다.") .contains("충분한 문장은 strengthCandidates로 분류한다.") + .contains("status=PROVEN, improvement=null인 questionAnalyses로 변환한다.") .contains("보완이 필요한 문장만 analysisCandidates로 분류한다.") .contains("MISSING은 analysisCandidates에 넣지 않고 missingKeywordCandidates로만 분리한다.") - .contains("독립적인 문제가 있으면 최대 3개까지 반환한다.") + .contains("문항별 1~3개를 반환한다.") + .contains("후보 개수 제한은 전체 답변 합계가 아니라 questionId별로 독립 적용한다.") .contains("내부 판단 과정이나 chain-of-thought를 출력하지 않는다.") .contains("점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다.") .contains("[missingKeywordCandidates 생성 규칙]") @@ -463,7 +466,9 @@ void buildFinalPromptRejectsBracketedSubheadingCandidate() { assertThat(prompt) .contains("한 줄 전체가 대괄호로 감싸진 소제목") - .contains("NOT_ACTIONABLE로 거절한다."); + .contains("NOT_ACTIONABLE로 거절한다.") + .contains("검증된 strengths는 PROVEN으로") + .contains("questionId별 1~3개로 적용한다."); } @Test @@ -609,6 +614,45 @@ void sanitizeCandidatesFiltersInvalidCandidateItems() { .containsExactly("API 개발 경험"); } + @Test + @DisplayName("후보 sanitizer는 3개 제한을 전체 합계가 아닌 문항별로 적용한다") + void sanitizeCandidatesAppliesLimitPerQuestion() { + AnalysisPromptInput input = new AnalysisPromptInput( + "잡드리", + "백엔드 개발", + "API 개발", + "Spring Boot 경험", + "", + List.of( + new AnalysisPromptInput.QuestionAnswer(1L, "첫 문항", "첫 문장입니다. 둘째 문장입니다. 셋째 문장입니다."), + new AnalysisPromptInput.QuestionAnswer(2L, "둘째 문항", "넷째 문장입니다.") + ) + ); + AnalysisCandidateResponse sanitized = analysisAiClient.sanitizeCandidates( + input, + new AnalysisCandidateResponse( + List.of( + strengthCandidate(1L, "첫 문장입니다."), + strengthCandidate(1L, "둘째 문장입니다."), + strengthCandidate(1L, "셋째 문장입니다."), + strengthCandidate(2L, "넷째 문장입니다.") + ), + List.of( + candidate(1L, "candidate-1", "첫 문장입니다."), + candidate(1L, "candidate-2", "둘째 문장입니다."), + candidate(1L, "candidate-3", "셋째 문장입니다."), + candidate(2L, "candidate-4", "넷째 문장입니다.") + ), + List.of() + ) + ); + + assertThat(sanitized.strengthCandidates()).hasSize(4); + assertThat(sanitized.strengthCandidates()).filteredOn(item -> item.questionId().equals(2L)).hasSize(1); + assertThat(sanitized.analysisCandidates()).hasSize(4); + assertThat(sanitized.analysisCandidates()).filteredOn(item -> item.questionId().equals(2L)).hasSize(1); + } + @Test @DisplayName("후보 sanitizer는 독립된 대괄호 소제목을 강점과 분석 후보에서 제외한다") void sanitizeCandidatesExcludesBracketedSubheadings() { @@ -890,15 +934,62 @@ void buildFinalResponseUsesAcceptedCandidateDecisionsOnly() { ) ); - assertThat(response.questionAnalyses()).hasSize(1); - assertThat(response.questionAnalyses().getFirst().sentence()).isEqualTo("장애 대응 경험이 있습니다."); - assertThat(response.questionAnalyses().getFirst().improvement()).isNull(); + assertThat(response.questionAnalyses()).hasSize(2); + assertThat(response.questionAnalyses()).extracting("sentence") + .containsExactly("Spring Boot API를 개발했습니다.", "장애 대응 경험이 있습니다."); + assertThat(response.questionAnalyses()).extracting("status") + .containsExactly("proven", "mentioned"); + assertThat(response.questionAnalyses()).extracting("improvement") + .containsOnlyNulls(); assertThat(response.keyStrengths()).extracting("quote") .containsExactly("Spring Boot API를 개발했습니다."); assertThat(response.missingKeywords()).extracting("keyword") .containsExactly("장애 대응 경험"); } + @Test + @DisplayName("2차 강점 후보는 문항별 최대 3개의 PROVEN 분석으로 변환한다") + void buildFinalResponseConvertsStrengthsToPerQuestionProvenAnalyses() { + AnalysisPromptInput input = new AnalysisPromptInput( + "잡드리", + "백엔드 개발", + "API 개발", + "Spring Boot 경험", + "", + List.of( + new AnalysisPromptInput.QuestionAnswer(1L, "첫 문항", "첫 강점입니다. 둘째 강점입니다. 셋째 강점입니다. 넷째 강점입니다."), + new AnalysisPromptInput.QuestionAnswer(2L, "둘째 문항", "다섯째 강점입니다. 여섯째 강점입니다.") + ) + ); + List strengths = List.of( + strengthCandidate(1L, "첫 강점입니다."), + strengthCandidate(1L, "둘째 강점입니다."), + strengthCandidate(1L, "셋째 강점입니다."), + strengthCandidate(1L, "넷째 강점입니다."), + strengthCandidate(2L, "다섯째 강점입니다."), + strengthCandidate(2L, "여섯째 강점입니다.") + ); + List reviewedStrengths = strengths.stream() + .map(strength -> new CandidateReviewResponse.FinalStrengthCandidate( + "구체적인 직무 강점입니다.", + strength.quote(), + strength.relatedSource() + )) + .toList(); + + AnalysisLlmResponse response = analysisAiClient.buildFinalResponse( + input, + new AnalysisCandidateResponse(strengths, List.of(), List.of()), + new CandidateReviewResponse(List.of(), reviewedStrengths, List.of(), 80, 70, 60, "피드백") + ); + + assertThat(response.questionAnalyses()).hasSize(5); + assertThat(response.questionAnalyses()).filteredOn(item -> item.questionId().equals(1L)).hasSize(3); + assertThat(response.questionAnalyses()).filteredOn(item -> item.questionId().equals(2L)).hasSize(2); + assertThat(response.questionAnalyses()).extracting("status").containsOnly("proven"); + assertThat(response.questionAnalyses()).extracting("improvement").containsOnlyNulls(); + } + @Test @DisplayName("2차 review가 missingKeywords를 비워도 1차 검증 누락 키워드는 최종 응답에 유지한다") void buildFinalResponsePreservesSanitizedMissingKeywordsWhenReviewIsEmpty() { @@ -1576,9 +1667,13 @@ private AnalysisPromptInput subheadingPromptInput() { } private AnalysisCandidateResponse.AnalysisCandidate candidate(String candidateId, String sentence) { + return candidate(1L, candidateId, sentence); + } + + private AnalysisCandidateResponse.AnalysisCandidate candidate(Long questionId, String candidateId, String sentence) { return new AnalysisCandidateResponse.AnalysisCandidate( candidateId, - 1L, + questionId, sentence, "", "", @@ -1592,8 +1687,12 @@ private AnalysisCandidateResponse.AnalysisCandidate candidate(String candidateId } private AnalysisCandidateResponse.StrengthCandidate strengthCandidate(String quote) { + return strengthCandidate(1L, quote); + } + + private AnalysisCandidateResponse.StrengthCandidate strengthCandidate(Long questionId, String quote) { return new AnalysisCandidateResponse.StrengthCandidate( - 1L, + questionId, quote, "MAIN_TASK", "API 개발", diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/FewShotPromptProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/FewShotPromptProviderTest.java index eac08f92..cebb9eaa 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/FewShotPromptProviderTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/FewShotPromptProviderTest.java @@ -26,10 +26,11 @@ void readsFewShotPromptResource() { assertThat(EXAMPLE_HEADER_PATTERN.matcher(prompt).results().count()).isEqualTo(4); assertThat(prompt) .contains("\"keyStrengths\"") + .contains("\"status\": \"proven\"") .contains("\"status\": \"mentioned\"") .contains("\"status\": \"fabricated\"") .contains("\"missingKeywords\"") - .contains("\"questionAnalyses\": []") + .contains("문항을 대표하는 긍정 근거이므로 questionAnalyses에는 proven으로 포함한다.") .contains("예시의 분석 개수, 상태 비율, 문장 표현, 점수를 실제 입력에 복사하지 않는다."); assertThat(prompt).doesNotContain("\"jobFit\""); assertThat(prompt).doesNotContain("\"impact\""); @@ -55,10 +56,10 @@ void buildsPromptBlockFromSelectedFewShots() { List.of("Spring Boot"), "직무 경험", "API를 개발했습니다.", - "{\"questionAnalyses\":[]}", + "{\"questionAnalyses\":[{\"questionId\":1,\"sentence\":\"API를 개발했습니다.\",\"status\":\"proven\",\"reason\":\"API 개발 경험이 구체적으로 드러납니다.\",\"improvement\":null}]}", List.of("api"), "fewshot-test-v1", - "## 예시 Z: 선택 예시\n출력 중 문장/누락 관련 필드:\n{}" + "## 예시 Z: 선택 예시\n출력 중 문장/누락 관련 필드:\n{\"questionAnalyses\":[{\"questionId\":1,\"sentence\":\"API를 개발했습니다.\",\"status\":\"proven\",\"reason\":\"API 개발 경험이 구체적으로 드러납니다.\",\"improvement\":null}]}" ); String prompt = provider.buildPromptBlock(List.of(new SelectedFewShotCase(selectedCase, 0.9, "test"))); @@ -66,6 +67,8 @@ void buildsPromptBlockFromSelectedFewShots() { assertThat(prompt) .contains("[# Few-shot examples]") .contains("## 예시 Z: 선택 예시") + .contains("\"sentence\":\"API를 개발했습니다.\"") + .contains("\"status\":\"proven\"") .doesNotContain("## 예시 A:") .doesNotContain("## 예시 B:"); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index fa518bd5..06351ae8 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -8,6 +8,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; @@ -27,6 +28,7 @@ import org.mockito.InjectMocks; import org.mockito.InOrder; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; @@ -248,12 +250,17 @@ void completeTaskReusesInitialExecutionSnapshot() { when(jobPosting.getDetailClassification().getDetailName()).thenReturn("백엔드"); when(jobPosting.getDetailClassification().getMiddleClassification().getMiddleName()).thenReturn("서버"); when(jobPosting.getDetailClassification().getMiddleClassification().getClassification().getBigName()).thenReturn("개발"); + Question initialQuestion = mock(Question.class); + when(initialQuestion.getId()).thenReturn(101L); + when(initialQuestion.getContent()).thenReturn("직무 경험"); + when(initialQuestion.getAnswer()).thenReturn("최초 답변입니다."); + when(initialQuestion.getLimit()).thenReturn(700); AnalysisExecutionPayload initialPayload = new AnalysisExecutionPayload( 1L, 10L, jobPosting, - List.of(), - List.of(), + List.of(initialQuestion), + List.of(initialQuestion), null, new RetrievalContext( List.of(new RetrievedJobPostingReference( @@ -272,8 +279,18 @@ void completeTaskReusesInitialExecutionSnapshot() { SimilarJobPostingContext laterContext = new SimilarJobPostingContext( 31L, "유사 회사", "유사 공고", "서버 개발자", "API 개발", "Java", "AWS", 1, 0.91 ); + Question changedQuestion = mock(Question.class); + when(changedQuestion.getId()).thenReturn(101L); + when(changedQuestion.getAnswer()).thenReturn("완료 전에 변경된 답변입니다."); AnalysisExecutionPayload completionPayload = new AnalysisExecutionPayload( - 1L, 10L, jobPosting, List.of(), List.of(), null, null, List.of() + 1L, + 10L, + jobPosting, + List.of(changedQuestion), + List.of(changedQuestion), + null, + null, + List.of() ); AnalysisLlmResponse llmResponse = mock(AnalysisLlmResponse.class); AnalysisResponse analysisResponse = mock(AnalysisResponse.class); @@ -290,13 +307,8 @@ void completeTaskReusesInitialExecutionSnapshot() { .thenReturn(initialPayload, changedRetrievalPayload); when(analysisInputFingerprintProvider.create(initialPayload)).thenReturn("initial-fingerprint"); when(analysisService.prepareAnalysisExecution(user, 10L, List.of())).thenReturn(completionPayload); - when(analysisService.finalizeAnalysis( - user, - 10L, - completionPayload, - llmResponse, - "initial-fingerprint" - )).thenReturn(analysisResponse); + when(analysisService.finalizeAnalysis(eq(user), eq(10L), any(), eq(llmResponse), eq("initial-fingerprint"))) + .thenReturn(analysisResponse); var initialContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); var retriedContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); @@ -307,13 +319,18 @@ void completeTaskReusesInitialExecutionSnapshot() { assertThat(retriedContext.corpusReferences().getFirst().corpusId()).isEqualTo(11L); verify(analysisService, times(1)).prepareAnalysisExecution(user, 10L); verify(analysisService).prepareAnalysisExecution(user, 10L, List.of()); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(AnalysisExecutionPayload.class); verify(analysisService).finalizeAnalysis( - user, - 10L, - completionPayload, - llmResponse, - "initial-fingerprint" + eq(user), + eq(10L), + payloadCaptor.capture(), + eq(llmResponse), + eq("initial-fingerprint") ); + assertThat(payloadCaptor.getValue().answerSnapshots()) + .containsExactly(new AnalysisExecutionPayload.AnswerSnapshot(101L, "최초 답변입니다.")); + assertThat(payloadCaptor.getValue().answeredQuestions().getFirst().getAnswer()) + .isEqualTo("완료 전에 변경된 답변입니다."); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java index 1c4a7c65..f35c3e1b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java @@ -179,6 +179,99 @@ void analyzeSavesAnalysis() { )).hasSize(1); } + @Test + @DisplayName("데모 픽스처의 PROVEN, MENTIONED, MISSING, FABRICATED 결과를 저장하고 조회한다") + void analyzeStoresAndReadsAllDemoStatuses() { + User user = saveUser("analysis-demo-all-statuses@example.com"); + JobPosting jobPosting = saveJobPosting( + user, + "Spring Boot REST API 개발, 관계형 데이터베이스 쿼리 최적화, Redis 기반 캐시 또는 세션 관리", + "Java와 Spring Boot 프로젝트 경험, 문제 분석 및 해결 경험", + "비동기 처리 경험" + ); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); + String provenSentence = "실행 계획을 분석해 복합 인덱스를 적용했고 평균 응답 시간을 1.8초에서 0.6초로 줄였습니다."; + String mentionedSentence = "여러 백엔드 프로젝트를 수행하며 문제 해결 역량을 길렀습니다."; + String fabricatedSentence = "같은 JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 6월까지 진행했으며, 저를 포함한 5명의 팀원이 역할을 나누었습니다."; + Question provenQuestion = saveQuestion(mockApply, "직무 경험", provenSentence); + Question mentionedQuestion = saveQuestion( + mockApply, + "문제 해결 경험", + "서비스 장애가 발생했을 때 모니터링 대시보드의 지표와 로그를 살펴보고 원인을 찾아 대응했습니다. " + + mentionedSentence + ); + Question fabricatedQuestion = saveQuestion( + mockApply, + "협업 경험", + "JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 3월까지 진행했으며, " + + "기획·개발·테스트 전 과정을 저 혼자 맡아 다른 참여자는 없었습니다. " + + fabricatedSentence + ); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 72, + 74, + 76, + "구체적인 성과가 있으나 일부 근거와 요건 보완이 필요합니다.", + List.of(new AnalysisLlmResponse.HighlightItem("성능 개선 근거가 구체적입니다.", provenSentence)), + List.of(new AnalysisLlmResponse.HighlightItem( + "핵심 운영 경험이 드러나지 않습니다.", + "Redis 기반 캐시 또는 세션 관리" + )), + List.of(new AnalysisLlmResponse.MissingKeywordItem( + "Redis 기반 캐시 또는 세션 관리", + "mainTask" + )), + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + provenQuestion.getId(), + provenSentence, + "PROVEN", + "쿼리 최적화 행동과 수치 결과가 구체적으로 드러납니다.", + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + mentionedQuestion.getId(), + mentionedSentence, + "mentioned", + "관련 경험은 언급했지만 본인의 행동과 결과가 드러나지 않습니다.", + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + fabricatedQuestion.getId(), + fabricatedSentence, + "fabricated", + "같은 프로젝트의 기간과 팀 구성 진술이 앞 문장과 직접 충돌합니다.", + null + ) + ) + )); + + AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); + entityManager.clear(); + AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + + assertThat(saved.score()).isBetween(65, 78); + var analyses = response.questions().stream() + .flatMap(question -> question.analyses().stream()) + .toList(); + assertThat(analyses) + .extracting("status") + .containsExactlyInAnyOrder("proven", "mentioned", "fabricated"); + assertThat(analyses) + .filteredOn(item -> "proven".equals(item.status())) + .extracting("improvement") + .containsExactly(""); + for (var question : response.questions()) { + for (var item : question.analyses()) { + assertThat(question.answer().substring(item.start(), item.end())).isEqualTo(item.sentence()); + } + } + assertThat(response.missingKeywords()).hasSize(1); + assertThat(response.missingKeywords().getFirst().keyword()) + .isEqualTo("Redis 기반 캐시 또는 세션 관리"); + assertThat(response.missingKeywords().getFirst().source().value()).isEqualTo("mainTask"); + } + @Test @DisplayName("총점은 하위 점수 가중합으로 계산한다") void analyzeCalculatesScoreFromDimensionScores() { @@ -438,7 +531,42 @@ void analyzeSkipsProvenWithContradictoryReason() { } @Test - @DisplayName("PROVEN questionAnalysis는 최종 결과에서 제외한다") + @DisplayName("PROVEN reason이 null 또는 빈 문자열이면 저장하지 않는다") + void analyzeSkipsProvenWithMissingReason() { + User user = saveUser("analysis-proven-missing-reason@example.com"); + MockApply mockApply = saveMockApply(user); + Question nullReasonQuestion = saveQuestion(mockApply, "첫 성과 경험", "API 응답 시간을 30% 단축했습니다."); + Question blankReasonQuestion = saveQuestion(mockApply, "둘째 성과 경험", "배포 오류율을 5%에서 1%로 낮췄습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 80, + 80, + 80, + "PROVEN reason 필수 검증입니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + nullReasonQuestion.getId(), + "API 응답 시간을 30% 단축했습니다.", + "proven", + null, + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + blankReasonQuestion.getId(), + "배포 오류율을 5%에서 1%로 낮췄습니다.", + "proven", + " ", + null + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions()).allSatisfy(question -> assertThat(question.analyses()).isEmpty()); + } + + @Test + @DisplayName("유효한 PROVEN questionAnalysis는 개선문을 비우고 최종 결과에 포함한다") void analyzeRemovesImprovementForProvenStatus() { User user = saveUser("analysis-proven-improvement-empty@example.com"); MockApply mockApply = saveMockApply(user); @@ -459,11 +587,13 @@ void analyzeRemovesImprovementForProvenStatus() { AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); - assertThat(response.questions().get(0).analyses()).isEmpty(); + assertThat(response.questions().get(0).analyses()).hasSize(1); + assertThat(response.questions().get(0).analyses().get(0).status()).isEqualTo("proven"); + assertThat(response.questions().get(0).analyses().get(0).improvement()).isEmpty(); } @Test - @DisplayName("PROVEN reason이 긍정 문맥이어도 questionAnalyses에서는 제외한다") + @DisplayName("PROVEN reason이 긍정 문맥이면 questionAnalyses에 포함한다") void analyzeKeepsValidProvenReasonWithPositiveNeedContext() { User user = saveUser("analysis-valid-proven-need-context@example.com"); MockApply mockApply = saveMockApply(user); @@ -484,7 +614,9 @@ void analyzeKeepsValidProvenReasonWithPositiveNeedContext() { AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); - assertThat(response.questions().get(0).analyses()).isEmpty(); + assertThat(response.questions().get(0).analyses()).hasSize(1); + assertThat(response.questions().get(0).analyses().get(0).status()).isEqualTo("proven"); + assertThat(response.questions().get(0).analyses().get(0).improvement()).isEmpty(); } @Test @@ -543,6 +675,53 @@ void analyzeKeepsTwoValidQuestionAnalyses() { .containsExactly("mentioned", "fabricated"); } + @Test + @DisplayName("같은 문항의 직접 충돌은 대표 FABRICATED 한 개만 저장한다") + void analyzeKeepsOneFabricatedPerQuestion() { + User user = saveUser("analysis-one-fabricated-per-question@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion( + mockApply, + "프로젝트 설명", + "프로젝트를 혼자 진행했습니다. 같은 프로젝트를 5명이 진행했습니다. 결과를 공유했습니다." + ); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 70, + 70, + 70, + "중복 모순 검증입니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "프로젝트를 혼자 진행했습니다.", + "fabricated", + "5명이 진행했다는 뒤 문장과 직접 충돌합니다.", + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "같은 프로젝트를 5명이 진행했습니다.", + "fabricated", + "혼자 진행했다는 앞 문장과 직접 충돌합니다.", + null + ), + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "결과를 공유했습니다.", + "mentioned", + "공유 대상과 방식이 구체적으로 드러나지 않습니다.", + null + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().getFirst().analyses()) + .extracting("status") + .containsExactly("fabricated", "mentioned"); + } + @Test @DisplayName("reason이 비어 있는 FABRICATED는 최종 결과에서 제외한다") void analyzeSkipsFabricatedWithoutReason() { @@ -827,7 +1006,7 @@ void analyzeMapsLegacyWorkerStatuses() { assertThat(response.questions().get(0).analyses()) .extracting("status") - .containsExactly("mentioned", "fabricated"); + .containsExactly("proven", "mentioned", "fabricated"); } @Test @@ -1027,6 +1206,40 @@ void prepareAnalysisExecutionKeepsCriteriaNullWhenMiddleNameIsUnknown() { assertThat(payload.jobCategoryEvaluationCriteria()).isNull(); } + @Test + @DisplayName("분석 실행 뒤 DB 답변이 바뀌면 최초 답변 snapshot과 달라 결과 저장을 중단한다") + void finalizeAnalysisRejectsChangedDatabaseAnswer() { + User user = saveUser("analysis-answer-snapshot-mismatch@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "지원 직무 경험", "Spring Boot API를 개발했습니다."); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApply.getId()); + jdbcTemplate.update( + "update questions set answer = ? where id = ?", + "완료 전에 답변을 변경했습니다.", + question.getId() + ); + entityManager.clear(); + AnalysisLlmResponse llmResponse = new AnalysisLlmResponse( + 80, + 70, + 60, + "답변 snapshot 검증", + List.of() + ); + + assertThatThrownBy(() -> analysisService.finalizeAnalysis( + user, + mockApply.getId(), + payload, + llmResponse, + "initial-fingerprint" + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + assertThat(analysisRepository.findByMockApplyId(mockApply.getId())).isEmpty(); + } + @Test @DisplayName("Curated Corpus retrieval 실패 시 빈 context로 분석 준비를 계속한다") void prepareAnalysisExecutionFailsOpenWhenCorpusRetrievalFails() { @@ -1346,9 +1559,11 @@ void analyzeReplacesExistingAnalysisWhenFingerprintChanges() { assertThat(second.analysisId()).isNotEqualTo(first.analysisId()); assertThat(second.score()).isEqualTo(90); assertThat(second.feedback()).isEqualTo("두 번째 분석"); - assertThat(second.questions().get(0).analyses()).isEmpty(); + assertThat(second.questions().get(0).analyses()).hasSize(1); + assertThat(second.questions().get(0).analyses().get(0).status()).isEqualTo("proven"); + assertThat(second.questions().get(0).analyses().get(0).improvement()).isEmpty(); assertThat(analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow().getScore()).isEqualTo(90); - assertThat(questionAnalysisRepository.findAllByAnalysisId(second.analysisId())).isEmpty(); + assertThat(questionAnalysisRepository.findAllByAnalysisId(second.analysisId())).hasSize(1); assertThat(questionAnalysisRepository.findAllByAnalysisId(first.analysisId())).isEmpty(); assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(initialCredit - 2); assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java index f2d8505e..a46e1dc3 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisSanitizationRulesTest.java @@ -112,4 +112,33 @@ void validatesMissingKeywordByCoreTokens() { "고객 데이터 분석" )).isFalse(); } + + @Test + @DisplayName("답변에 핵심 내용이 이미 있으면 missingKeyword 후보로 보지 않는다") + void detectsMissingKeywordAlreadyMentionedInAnswers() { + String answers = "PostgreSQL 실행 계획을 분석해 쿼리를 최적화했습니다. " + + "Git을 활용해 동료와 협업했고 코드 리뷰를 주고받았습니다. " + + "모니터링 지표와 로그로 장애 원인을 분석하고 해결 방안을 적용했습니다."; + + assertThat(AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers( + "PostgreSQL 등 관계형 데이터베이스 설계와 쿼리 최적화", + answers + )).isTrue(); + assertThat(AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers( + "Git을 활용해 협업하고 코드 리뷰를 주고받은 경험", + answers + )).isTrue(); + assertThat(AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers( + "기획자·프론트엔드 개발자와의 기능 설계 협업 및 Git 기반 코드 리뷰", + answers + )).isFalse(); + assertThat(AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers( + "문제의 원인을 분석하고 해결 방안을 적용한 경험", + answers + )).isTrue(); + assertThat(AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers( + "Redis 기반 캐시 또는 세션 관리 기능 설계 및 운영", + answers + )).isFalse(); + } } diff --git a/src/test/resources/evaluation/demo-day-cases.csv b/src/test/resources/evaluation/demo-day-cases.csv new file mode 100644 index 00000000..dae459dc --- /dev/null +++ b/src/test/resources/evaluation/demo-day-cases.csv @@ -0,0 +1,4 @@ +caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer +DEMO-01-PROVEN,AI·개발·데이터,백엔드 개발,"Java와 Spring Boot 기반 REST API 개발 및 운영; PostgreSQL 등 관계형 데이터베이스 설계와 쿼리 최적화; 로그와 모니터링 지표를 활용한 장애 원인 분석 및 안정적인 서비스 운영; Redis 기반 캐시 또는 세션 관리 기능 설계 및 운영; 기획자·프론트엔드 개발자와의 기능 설계 협업 및 Git 기반 코드 리뷰","Java와 Spring Boot를 활용한 프로젝트 경험; REST API를 설계하고 구현한 경험; PostgreSQL 등 관계형 데이터베이스를 활용한 경험; Git을 활용해 협업하고 코드 리뷰를 주고받은 경험; 문제의 원인을 분석하고 해결 방안을 적용한 경험","Redis 사용 경험; 메시지 큐 또는 비동기 처리 경험; AWS 또는 Docker 기반 배포 경험; API나 데이터베이스 성능을 개선한 경험",지원 직무와 관련된 경험 및 역량을 구체적으로 작성해주세요.,"커머스 사이드 프로젝트에서 Spring Boot로 상품 조회 REST API를 개발했습니다. 데이터가 늘면서 평균 응답 시간이 1.8초까지 증가해 PostgreSQL 실행 계획을 확인했고, 전체 테이블 스캔이 발생하는 조회 조건을 찾았습니다. 제가 복합 인덱스를 추가하고 쿼리를 재작성한 뒤 동일 데이터 기준 평균 응답 시간을 0.6초로 줄였습니다. Git을 활용해 동료와 협업했고 코드 리뷰를 주고받은 뒤 테스트를 거쳐 병합했습니다." +DEMO-02-MENTIONED,AI·개발·데이터,백엔드 개발,"Java와 Spring Boot 기반 REST API 개발 및 운영; PostgreSQL 등 관계형 데이터베이스 설계와 쿼리 최적화; 로그와 모니터링 지표를 활용한 장애 원인 분석 및 안정적인 서비스 운영; Redis 기반 캐시 또는 세션 관리 기능 설계 및 운영; 기획자·프론트엔드 개발자와의 기능 설계 협업 및 Git 기반 코드 리뷰","Java와 Spring Boot를 활용한 프로젝트 경험; REST API를 설계하고 구현한 경험; PostgreSQL 등 관계형 데이터베이스를 활용한 경험; Git을 활용해 협업하고 코드 리뷰를 주고받은 경험; 문제의 원인을 분석하고 해결 방안을 적용한 경험","Redis 사용 경험; 메시지 큐 또는 비동기 처리 경험; AWS 또는 Docker 기반 배포 경험; API나 데이터베이스 성능을 개선한 경험",문제를 해결했던 경험과 그 과정에서 본인이 수행한 역할을 작성해주세요.,"서비스 장애가 발생했을 때 모니터링 대시보드의 지표와 로그를 살펴봤습니다. 저는 여러 백엔드 프로젝트에서 문제의 원인을 분석하고 해결 방안을 적용하며 뛰어난 문제 해결 역량을 길렀습니다. 상황에 맞게 대응해 서비스를 정상화했습니다." +DEMO-03-FABRICATED,AI·개발·데이터,백엔드 개발,"Java와 Spring Boot 기반 REST API 개발 및 운영; PostgreSQL 등 관계형 데이터베이스 설계와 쿼리 최적화; 로그와 모니터링 지표를 활용한 장애 원인 분석 및 안정적인 서비스 운영; Redis 기반 캐시 또는 세션 관리 기능 설계 및 운영; 기획자·프론트엔드 개발자와의 기능 설계 협업 및 Git 기반 코드 리뷰","Java와 Spring Boot를 활용한 프로젝트 경험; REST API를 설계하고 구현한 경험; PostgreSQL 등 관계형 데이터베이스를 활용한 경험; Git을 활용해 협업하고 코드 리뷰를 주고받은 경험; 문제의 원인을 분석하고 해결 방안을 적용한 경험","Redis 사용 경험; 메시지 큐 또는 비동기 처리 경험; AWS 또는 Docker 기반 배포 경험; API나 데이터베이스 성능을 개선한 경험",협업 경험과 입사 후 기여하고 싶은 내용을 작성해주세요.,"JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 3월까지 진행했으며, 기획·개발·테스트 전 과정을 저 혼자 맡아 다른 참여자는 없었습니다. 저는 요구사항을 정리하며 진행 상황을 관리했습니다. 같은 JobBoard 프로젝트는 시작부터 종료까지 2026년 1월부터 6월까지 진행했으며, 저를 포함한 5명의 팀원이 역할을 나누었습니다. 저는 함께 일하는 과정에서 역할을 나누고 의견을 조율했습니다. 입사 후에도 협업 기준을 먼저 정리하고 안정적인 API 운영에 기여하겠습니다."