diff --git a/docs/api/README.md b/docs/api/README.md index c926dc3..1fd19b3 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -68,7 +68,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께 | API-017 | GET | `/cover-letters/{coverLetterId}/review-versions` | Implemented | REQ-006 | 첨삭 버전 목록 | | API-018 | GET | `/cover-letters/{coverLetterId}/review-versions/{versionId}` | Implemented | REQ-006 | 첨삭 버전 상세 | | API-019 | PUT | `/cover-letters/{coverLetterId}/review-versions/{versionId}/final-answers` | Implemented | REQ-006 | 최종 작성본 일괄 저장 | -| API-020 | POST | `/cover-letters/{coverLetterId}/keyword-analysis` | Planned | REQ-009 | 키워드 분석 시작/재분석 | +| API-020 | POST | `/cover-letters/{coverLetterId}/keyword-analysis` | Implemented | REQ-009 | 키워드 분석 시작/재분석 | | API-021 | GET | `/cover-letters/{coverLetterId}/keyword-analysis/latest` | Planned | REQ-009 | 최신 키워드 분석 조회 | | API-022 | POST | `/cover-letters/{coverLetterId}/interviews` | Planned | REQ-010 | 면접 세션/질문 생성 | | API-023 | POST | `/interview-threads/{threadId}/messages` | Planned | REQ-010 | 면접 답변 전송 | diff --git a/docs/api/common.md b/docs/api/common.md index deb9135..3891a01 100644 --- a/docs/api/common.md +++ b/docs/api/common.md @@ -413,9 +413,7 @@ MVP에서는 partial result를 서버 메모리에 저장한다. 서버 재시 `keywords`는 중요도 기준 상위 20개를 제공한다. `importance`는 1~100 범위의 정수다. -재첨삭 완료 시 기존 키워드 분석 결과는 삭제된다. 재첨삭 후에는 사용자가 다시 키워드 분석을 실행해야 최신 첨삭 기준 결과가 생성된다. - -키워드 분석 결과가 있는 상태에서 사용자가 `AI 키워드 재분석`을 실행하면 같은 `KeywordAnalysis` 리소스를 `PROCESSING`으로 전환하고, 가장 최근 `ReviewVersion`을 기준으로 다시 분석한다. 성공 시 기존 키워드 결과와 `sourceReviewVersionId`를 최신 분석 결과로 덮어쓴다. +재첨삭 완료만으로 기존 키워드 분석 결과를 삭제하지 않는다. 키워드 분석 결과가 있는 상태에서 사용자가 `AI 키워드 재분석`을 실행하면 같은 `KeywordAnalysis` 리소스를 `PROCESSING`으로 전환하고, 가장 최근 `ReviewVersion`을 기준으로 다시 분석한다. 성공 시 기존 키워드 결과와 `sourceReviewVersionId`를 최신 분석 결과로 덮어쓴다. 상태: diff --git a/docs/api/keyword-analysis.md b/docs/api/keyword-analysis.md index 60576e5..3ddb108 100644 --- a/docs/api/keyword-analysis.md +++ b/docs/api/keyword-analysis.md @@ -24,6 +24,8 @@ Request: 재분석 요청에서는 `sourceReviewVersionId`를 보내지 않는다. 서버는 항상 `CoverLetter.latestReviewVersionId`를 기준으로 다시 분석한다. +현재 구현 범위에서는 `KeywordAnalysis` persistence와 `KEYWORD_ANALYSIS` LLM Job 생성 계약을 제공한다. 실제 LLM 키워드 추출 worker와 완료 결과 저장은 후속 이슈에서 구현한다. + Response: ```json @@ -74,7 +76,7 @@ Conflict Response: GET /cover-letters/{coverLetterId}/keyword-analysis/latest ``` -키워드 분석 결과가 없거나 재첨삭 후 삭제된 경우: +키워드 분석 결과가 없는 경우: ```json { diff --git a/docs/api/review-versions.md b/docs/api/review-versions.md index cbc2d07..774354e 100644 --- a/docs/api/review-versions.md +++ b/docs/api/review-versions.md @@ -107,7 +107,7 @@ Response: 재첨삭 Job은 최신 `ReviewVersion`의 문항별 `finalAnswer`를 새 첨삭 입력 원본으로 사용한다. 새 버전의 `requestInstruction`에는 Job 생성 시 저장한 재첨삭 요구사항을 기록한다. -재첨삭 Job이 완료되어 새 `ReviewVersion`이 생성되면 기존 키워드 분석 결과는 삭제된다. 현재 구현 범위에서는 키워드 분석 persistence가 아직 없으므로 삭제 연결은 키워드 분석 구현 이슈에서 적용한다. +재첨삭 Job이 완료되어 새 `ReviewVersion`이 생성되어도 기존 키워드 분석 결과는 삭제하지 않는다. 최신 첨삭 버전 기준 키워드 분석이 필요하면 사용자가 `AI 키워드 재분석`을 실행해 기존 `KeywordAnalysis`를 갱신한다. 재첨삭 Job 시작 시점에는 `ReviewVersion`을 만들지 않는다. Job이 성공적으로 완료된 경우에만 새 `ReviewVersion`과 문항별 첨삭 결과를 생성한다. Job이 실패하면 새 `ReviewVersion`은 생성하지 않고, 기존 최신 버전은 그대로 유지한다. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 20d2439..d8052fb 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -50,7 +50,7 @@ API 계약: `../api/README.md` | Decision 025 | 삭제된 자기소개서 복구 API는 제공하지 않는다 | `cover-letters.md` | REQ-003, REQ-004, REQ-005 | API-007 - API-014 | Active | | Decision 026 | 자기소개서당 AI 면접 세션은 하나만 유지한다 | `interviews.md` | REQ-010 | API-022 - API-023, API-025 - API-029 | Active | | Decision 027 | 재첨삭 후에도 기존 AI 면접 세션은 유지한다 | `interviews.md` | REQ-010 | API-022 - API-023, API-025 - API-029 | Active | -| Decision 028 | 재첨삭 완료 시 기존 키워드 분석 결과를 삭제한다 | `keyword-analysis.md` | REQ-009 | API-020 - API-021 | Active | +| Decision 028 | 재첨삭 완료 시 기존 키워드 분석 결과를 유지한다 | `keyword-analysis.md` | REQ-009 | API-020 - API-021 | Active | | Decision 029 | 키워드 분석 결과 화면에서 재분석을 제공한다 | `keyword-analysis.md` | REQ-009 | API-020 - API-021 | Active | | Decision 030 | 면접 답변은 최대 2000자로 제한한다 | `interviews.md` | REQ-010 | API-022 - API-023, API-025 - API-029 | Active | | Decision 031 | 면접 꼬리질문은 피드백과 같은 assistant 메시지에 저장한다 | `interviews.md` | REQ-010 | API-022 - API-023, API-025 - API-029 | Active | diff --git a/docs/decisions/keyword-analysis.md b/docs/decisions/keyword-analysis.md index 3b83af9..53d0d6d 100644 --- a/docs/decisions/keyword-analysis.md +++ b/docs/decisions/keyword-analysis.md @@ -112,13 +112,13 @@ PRD는 키워드 분석을 히스토리 기능이 아니라 현재 자기소개 -## Decision 028: 재첨삭 완료 시 기존 키워드 분석 결과를 삭제한다 +## Decision 028: 재첨삭 완료 시 기존 키워드 분석 결과를 유지한다 ### 결정 -자기소개서 재첨삭이 완료되어 새 `ReviewVersion`이 생성되면, 기존 키워드 분석 결과를 삭제한다. +자기소개서 재첨삭이 완료되어 새 `ReviewVersion`이 생성되어도 기존 키워드 분석 결과는 삭제하지 않는다. -재첨삭 후 키워드 분석 화면은 다시 `키워드 분석하기` 버튼이 보이는 초기 상태가 된다. 사용자가 다시 분석을 실행하면 최신 첨삭 버전을 기준으로 새 키워드 분석 결과를 생성한다. +사용자가 `AI 키워드 재분석`을 실행하면 같은 `KeywordAnalysis` 리소스를 `PROCESSING`으로 전환하고, 가장 최근 첨삭 버전을 기준으로 다시 분석한다. 성공 시 기존 결과를 최신 분석 결과로 덮어쓴다. ### PRD 근거 @@ -128,9 +128,10 @@ PRD는 키워드 분석을 히스토리 기능이 아니라 현재 자기소개 ### 고려한 대안 -1. 기존 키워드 분석 결과 유지 +1. 기존 키워드 분석 결과 유지 후 사용자 재분석 시 갱신 - 분석 결과는 생성 당시 `sourceReviewVersionId` 기준으로 유지된다. - - 사용자가 다시 분석하기 전까지 기존 결과를 볼 수 있지만, 최신 첨삭 기준으로 오해될 수 있다. + - 사용자가 `AI 키워드 재분석`을 실행할 때만 최신 첨삭 버전 기준으로 결과를 갱신한다. + - 사용자가 요청하지 않은 LLM 비용이 발생하지 않고, 기존 결과도 갑자기 사라지지 않는다. 2. 재첨삭 완료 시 키워드 분석 결과 삭제 - 최신 첨삭 기준이 아닌 분석 결과를 화면에서 제거한다. @@ -142,18 +143,18 @@ PRD는 키워드 분석을 히스토리 기능이 아니라 현재 자기소개 ### 선택 이유 -키워드 분석은 자기소개서별 최신 결과만 유지하기로 결정했다. 재첨삭 후 이전 첨삭 버전 기준의 분석 결과를 계속 보여주면 사용자가 최신 첨삭 기준 결과로 오해할 수 있다. 따라서 재첨삭 완료 시 기존 분석 결과를 삭제하고, 사용자가 명시적으로 다시 분석하도록 한다. +키워드 분석은 사용자가 명시적으로 실행하는 LLM 작업이다. 재첨삭 완료만으로 기존 분석 결과를 삭제하면 사용자가 요청하지 않았는데 화면 상태가 사라진다. 따라서 기존 결과는 유지하고, 사용자가 `AI 키워드 재분석`을 실행할 때만 최신 첨삭 버전 기준으로 갱신한다. ### 트레이드오프 - 장점 - - 최신 첨삭 버전과 맞지 않는 분석 결과를 보여주지 않는다. + - 사용자가 요청하지 않은 상태 변경이 발생하지 않는다. + - 기존 키워드 분석 결과가 갑자기 사라지지 않는다. - 자동 LLM 비용이 발생하지 않는다. - - 화면 상태가 명확하다. - 단점 - - 재첨삭 후 사용자가 키워드 분석을 다시 실행해야 한다. - - 이전 분석 결과는 보존되지 않는다. + - 재첨삭 후 기존 분석 결과가 최신 첨삭 버전 기준이 아닐 수 있다. + - 화면에서 `sourceReviewVersionId` 또는 재분석 유도를 명확히 표현해야 할 수 있다. @@ -187,7 +188,7 @@ POST /cover-letters/{coverLetterId}/keyword-analysis 2. 결과가 있으면 재분석 불가 - 결과 화면만 보여준다. - - 재첨삭으로 기존 결과가 삭제된 뒤에만 다시 분석할 수 있다. + - 기존 결과를 최신 첨삭 버전 기준으로 갱신할 수 없다. ### 선택 이유 @@ -198,11 +199,9 @@ POST /cover-letters/{coverLetterId}/keyword-analysis - 장점 - 사용자가 명시적으로 키워드 분석을 다시 실행할 수 있다. - API와 화면은 계속 자기소개서별 최신 결과 1개만 다룬다. - - 재첨삭 후 결과가 삭제된 경우에는 초기 분석 흐름을 그대로 사용할 수 있다. + - 재첨삭 완료만으로 기존 결과가 사라지지 않는다. - 단점 - 반복 LLM 비용이 발생할 수 있다. - 같은 첨삭 버전에서 재분석해도 결과가 달라질 수 있다. - 이전 분석 결과는 보존되지 않는다. - - diff --git a/docs/erd.md b/docs/erd.md index e03ae84..3944f9c 100644 --- a/docs/erd.md +++ b/docs/erd.md @@ -322,7 +322,8 @@ Policy: - 키워드 분석 결과는 자기소개서별 최신 결과만 유지한다. - 재분석은 같은 row를 갱신한다. -- 재첨삭 완료 시 기존 키워드 분석 결과는 삭제한다. +- 재첨삭 완료만으로 기존 키워드 분석 결과를 삭제하지 않는다. +- 사용자가 재분석을 실행하면 같은 row를 최신 첨삭 버전 기준으로 갱신한다. ### keyword_analysis_keywords diff --git a/docs/requirements.md b/docs/requirements.md index 3dc0467..edadb32 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -253,7 +253,8 @@ API-008 자기소개서 초안 생성의 공개 API 계약은 유지하되, 현 - 키워드 중요도는 1-100 범위의 정수다. - 키워드 분석 결과는 자기소개서별 최신 결과만 유지한다. - 재분석은 새 리소스를 만들지 않고 기존 최신 결과를 갱신한다. -- 재첨삭 완료 시 기존 키워드 분석 결과는 삭제한다. +- 재첨삭 완료만으로 기존 키워드 분석 결과를 삭제하지 않는다. +- 사용자가 `AI 키워드 재분석`을 실행하면 최신 첨삭 버전을 기준으로 기존 결과를 갱신한다. - Job 실패 시 키워드 분석 리소스는 `FAILED` 상태로 저장한다. ### Acceptance Criteria diff --git a/docs/status.md b/docs/status.md index 3006b53..fd56ffc 100644 --- a/docs/status.md +++ b/docs/status.md @@ -23,10 +23,10 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | REQ-003 | 자기소개서 기본 CRUD | In Progress | High | API-007, API-008, API-012, API-013 | [#5](https://github.com/Rewrite-Team/Rewrite-BE/issues/5), [#18](https://github.com/Rewrite-Team/Rewrite-BE/issues/18), [#28](https://github.com/Rewrite-Team/Rewrite-BE/issues/28), [#30](https://github.com/Rewrite-Team/Rewrite-BE/issues/30), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test`, `./gradlew check` | API-007 현재 사용자 자기소개서 목록 조회, API-008 자기소개서 초안 생성, API-013 soft delete 계약 구현됨. 진행 중 Job cancel은 `llm_jobs` 구현 이후 연결 | | REQ-004 | 자기소개서 등록 step 저장 | Implemented | High | API-009, API-010, API-011 | [#32](https://github.com/Rewrite-Team/Rewrite-BE/issues/32), [#34](https://github.com/Rewrite-Team/Rewrite-BE/issues/34), [#36](https://github.com/Rewrite-Team/Rewrite-BE/issues/36) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterQuestionRepositoryTest`, `GlobalExceptionHandlerTest`, `./gradlew test`, `./gradlew check` | API-009 등록 step1 기본 정보 저장, API-010 등록 step2 채용 우대사항 저장, API-011 등록 step3 질문과 답변 저장 계약 구현됨 | | REQ-005 | 자기소개서 제출과 LLM Job 생성 | In Progress | High | API-014, API-015, API-016 | [#38](https://github.com/Rewrite-Team/Rewrite-BE/issues/38), [#40](https://github.com/Rewrite-Team/Rewrite-BE/issues/40), [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44), [#48](https://github.com/Rewrite-Team/Rewrite-BE/issues/48) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `FirstReviewJobWorkerTest`, `FirstReviewJobEventIntegrationTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회, 최초 첨삭 성공 결과 완료 경계, OpenAI 최초 첨삭 client, after-commit 비동기 worker 연결 구현됨. API-016 SSE, partial result, 자동 재시도는 후속 이슈로 분리 | -| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | In Progress | High | API-017, API-018, API-019, API-024 | [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44), [#48](https://github.com/Rewrite-Team/Rewrite-BE/issues/48), [#50](https://github.com/Rewrite-Team/Rewrite-BE/issues/50), [#52](https://github.com/Rewrite-Team/Rewrite-BE/issues/52), [#54](https://github.com/Rewrite-Team/Rewrite-BE/issues/54) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `ReviewVersionQueryServiceTest`, `ReviewVersionCommandServiceTest`, `ReviewVersionControllerTest`, `OpenAiFirstReviewClientTest`, `FirstReviewJobWorkerTest`, `ReReviewJobWorkerTest`, `FirstReviewJobEventIntegrationTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 및 worker 연결, API-017/018 첨삭 버전 목록/상세 조회, API-019 최종 작성본 일괄 저장, API-024 AI 첨삭 다시받기 구현됨. 키워드 분석 결과 삭제 연결은 키워드 분석 persistence 구현 이후 적용 | +| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | In Progress | High | API-017, API-018, API-019, API-024 | [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44), [#48](https://github.com/Rewrite-Team/Rewrite-BE/issues/48), [#50](https://github.com/Rewrite-Team/Rewrite-BE/issues/50), [#52](https://github.com/Rewrite-Team/Rewrite-BE/issues/52), [#54](https://github.com/Rewrite-Team/Rewrite-BE/issues/54) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `ReviewVersionQueryServiceTest`, `ReviewVersionCommandServiceTest`, `ReviewVersionControllerTest`, `OpenAiFirstReviewClientTest`, `FirstReviewJobWorkerTest`, `ReReviewJobWorkerTest`, `FirstReviewJobEventIntegrationTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 및 worker 연결, API-017/018 첨삭 버전 목록/상세 조회, API-019 최종 작성본 일괄 저장, API-024 AI 첨삭 다시받기 구현됨 | | REQ-007 | DB/JPA 전환 | Implemented | High | API-008 내부 persistence, persistence 내부 변경 | [#22](https://github.com/Rewrite-Team/Rewrite-BE/issues/22), [#24](https://github.com/Rewrite-Team/Rewrite-BE/issues/24), [#25](https://github.com/Rewrite-Team/Rewrite-BE/issues/25), [PR #26](https://github.com/Rewrite-Team/Rewrite-BE/pull/26) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `./gradlew test`, `./gradlew check` | API-008 공개 계약은 유지하고 `cover_letters` persistence를 DB/JPA로 전환. H2 file 로컬 DB, H2 in-memory 테스트 DB 사용. Flyway는 별도 이슈로 분리 | | REQ-008 | 실제 인증 경계 | Planned | Medium | API-001 - API-006 | - | - | 카카오 OAuth, cookie, CSRF | -| REQ-009 | 키워드 분석 | Planned | Medium | API-020, API-021 | - | - | LLM Job 기반 | +| REQ-009 | 키워드 분석 | In Progress | Medium | API-020, API-021 | [#56](https://github.com/Rewrite-Team/Rewrite-BE/issues/56) | `KeywordAnalysisRepositoryTest`, `KeywordAnalysisServiceTest`, `KeywordAnalysisControllerTest`, `LlmJobRepositoryTest`, `./gradlew test`, `./gradlew check` | API-020 키워드 분석 시작/재분석 Job 생성과 `KeywordAnalysis` persistence 구현됨. API-021 최신 결과 조회, 실제 LLM 키워드 분석 worker, keyword result 저장은 후속 이슈로 분리 | | REQ-010 | AI 면접 | Planned | Medium | API-022, API-023, API-025 - API-029 | - | - | LLM Job/SSE 기반 | ## Open Maintenance Work @@ -59,6 +59,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| +| 최신 키워드 분석 조회 API | REQ-009 | API-021 | `KeywordAnalysis` 최신 상태와 완료 결과 조회 계약 구현 | | LLM Job 자동 재시도 | REQ-005, REQ-006 | API-014 이후 내부 실행 | provider/output 실패 시 Decision 017 기준 1회 자동 재시도 처리 | ## Update Rules diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java b/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java new file mode 100644 index 0000000..4b2980f --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java @@ -0,0 +1,30 @@ +package com.daon.rewrite.keywordanalysis.controller; + +import com.daon.rewrite.keywordanalysis.dto.StartKeywordAnalysisRequest; +import com.daon.rewrite.keywordanalysis.dto.StartKeywordAnalysisResponse; +import com.daon.rewrite.keywordanalysis.service.KeywordAnalysisService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class KeywordAnalysisController { + + private final KeywordAnalysisService keywordAnalysisService; + + @PostMapping("/cover-letters/{coverLetterId}/keyword-analysis") + public StartKeywordAnalysisResponse startKeywordAnalysis( + @PathVariable String coverLetterId, + @RequestBody(required = false) StartKeywordAnalysisRequest request + ) { + return StartKeywordAnalysisResponse.from( + keywordAnalysisService.startMyKeywordAnalysis( + coverLetterId, + request == null ? null : request.sourceReviewVersionId() + ) + ); + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/dto/StartKeywordAnalysisRequest.java b/src/main/java/com/daon/rewrite/keywordanalysis/dto/StartKeywordAnalysisRequest.java new file mode 100644 index 0000000..3c67142 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/dto/StartKeywordAnalysisRequest.java @@ -0,0 +1,6 @@ +package com.daon.rewrite.keywordanalysis.dto; + +public record StartKeywordAnalysisRequest( + String sourceReviewVersionId +) { +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/dto/StartKeywordAnalysisResponse.java b/src/main/java/com/daon/rewrite/keywordanalysis/dto/StartKeywordAnalysisResponse.java new file mode 100644 index 0000000..4db630c --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/dto/StartKeywordAnalysisResponse.java @@ -0,0 +1,21 @@ +package com.daon.rewrite.keywordanalysis.dto; + +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import com.daon.rewrite.keywordanalysis.service.StartKeywordAnalysisResult; + +public record StartKeywordAnalysisResponse( + String jobId, + String keywordAnalysisId, + String coverLetterId, + KeywordAnalysisStatus status +) { + + public static StartKeywordAnalysisResponse from(StartKeywordAnalysisResult result) { + return new StartKeywordAnalysisResponse( + result.job().getId(), + result.keywordAnalysis().getId(), + result.keywordAnalysis().getCoverLetter().getId(), + result.keywordAnalysis().getStatus() + ); + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java new file mode 100644 index 0000000..cae9505 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java @@ -0,0 +1,87 @@ +package com.daon.rewrite.keywordanalysis.entity; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "keyword_analyses", + uniqueConstraints = @UniqueConstraint( + name = "uk_keyword_analyses_cover_letter", + columnNames = "cover_letter_id" + ) +) +public class KeywordAnalysis { + + @Id + @Column(name = "id", nullable = false, length = 64) + private String id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "cover_letter_id", nullable = false) + private CoverLetter coverLetter; + + @Column(name = "source_review_version_id", nullable = false, length = 64) + private String sourceReviewVersionId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private KeywordAnalysisStatus status; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "completed_at") + private Instant completedAt; + + private KeywordAnalysis( + String id, + CoverLetter coverLetter, + String sourceReviewVersionId, + KeywordAnalysisStatus status, + Instant createdAt + ) { + this.id = id; + this.coverLetter = coverLetter; + this.sourceReviewVersionId = sourceReviewVersionId; + this.status = status; + this.createdAt = createdAt; + } + + public static KeywordAnalysis processing( + String id, + CoverLetter coverLetter, + String sourceReviewVersionId, + Instant now + ) { + return new KeywordAnalysis( + id, + coverLetter, + sourceReviewVersionId, + KeywordAnalysisStatus.PROCESSING, + now + ); + } + + public void restart(String sourceReviewVersionId) { + this.sourceReviewVersionId = sourceReviewVersionId; + this.status = KeywordAnalysisStatus.PROCESSING; + this.completedAt = null; + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisStatus.java b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisStatus.java new file mode 100644 index 0000000..f6b76d0 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisStatus.java @@ -0,0 +1,7 @@ +package com.daon.rewrite.keywordanalysis.entity; + +public enum KeywordAnalysisStatus { + PROCESSING, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepository.java b/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepository.java new file mode 100644 index 0000000..4b6370c --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepository.java @@ -0,0 +1,11 @@ +package com.daon.rewrite.keywordanalysis.repository; + +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface KeywordAnalysisRepository extends JpaRepository { + + Optional findByCoverLetterId(String coverLetterId); +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java new file mode 100644 index 0000000..4881ad0 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java @@ -0,0 +1,118 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.auth.CurrentUser; +import com.daon.rewrite.auth.CurrentUserProvider; +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterStatus; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.global.exception.BusinessException; +import com.daon.rewrite.global.exception.ErrorCode; +import com.daon.rewrite.global.util.IdGenerator; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository; +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.entity.LlmJobTargetType; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.llmjob.service.LlmJobCreatedEvent; +import com.daon.rewrite.reviewversion.repository.ReviewVersionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class KeywordAnalysisService { + + private static final String KEYWORD_ANALYSIS_ID_PREFIX = "ka"; + private static final String LLM_JOB_ID_PREFIX = "job"; + private static final List RUNNING_JOB_STATUSES = List.of( + LlmJobStatus.PENDING, + LlmJobStatus.PROCESSING + ); + + private final CurrentUserProvider currentUserProvider; + private final CoverLetterRepository coverLetterRepository; + private final ReviewVersionRepository reviewVersionRepository; + private final KeywordAnalysisRepository keywordAnalysisRepository; + private final LlmJobRepository llmJobRepository; + private final IdGenerator idGenerator; + private final Clock clock; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public StartKeywordAnalysisResult startMyKeywordAnalysis( + String coverLetterId, + String sourceReviewVersionId + ) { + CurrentUser currentUser = currentUserProvider.currentUser(); + CoverLetter coverLetter = coverLetterRepository + .findActiveByIdAndOwnerIdForUpdate(coverLetterId, currentUser.id()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + + if (coverLetter.getStatus() != CoverLetterStatus.REVIEWED + || coverLetter.getLatestReviewVersionId() == null) { + throw new BusinessException(ErrorCode.CONFLICT); + } + if (findRunningJob(coverLetter.getId()) != null) { + throw new BusinessException(ErrorCode.LLM_JOB_ALREADY_RUNNING); + } + + String selectedSourceReviewVersionId = selectSourceReviewVersionId(coverLetter, sourceReviewVersionId); + Instant now = Instant.now(clock); + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.findByCoverLetterId(coverLetter.getId()) + .map(existing -> { + existing.restart(selectedSourceReviewVersionId); + return existing; + }) + .orElseGet(() -> keywordAnalysisRepository.save(KeywordAnalysis.processing( + idGenerator.generate(KEYWORD_ANALYSIS_ID_PREFIX), + coverLetter, + selectedSourceReviewVersionId, + now + ))); + + LlmJob job = llmJobRepository.save(LlmJob.pendingKeywordAnalysis( + idGenerator.generate(LLM_JOB_ID_PREFIX), + coverLetter.getId(), + now + )); + eventPublisher.publishEvent(new LlmJobCreatedEvent(job.getId())); + + return new StartKeywordAnalysisResult(keywordAnalysis, job); + } + + private String selectSourceReviewVersionId(CoverLetter coverLetter, String sourceReviewVersionId) { + String normalizedSourceReviewVersionId = normalize(sourceReviewVersionId); + String selectedSourceReviewVersionId = normalizedSourceReviewVersionId == null + ? coverLetter.getLatestReviewVersionId() + : normalizedSourceReviewVersionId; + reviewVersionRepository + .findByIdAndCoverLetterId(selectedSourceReviewVersionId, coverLetter.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + return selectedSourceReviewVersionId; + } + + private LlmJob findRunningJob(String coverLetterId) { + return llmJobRepository + .findFirstByTargetTypeAndTargetIdAndStatusInOrderByCreatedAtDesc( + LlmJobTargetType.COVER_LETTER, + coverLetterId, + RUNNING_JOB_STATUSES + ) + .orElse(null); + } + + private String normalize(String value) { + if (value == null) { + return null; + } + String normalized = value.strip(); + return normalized.isEmpty() ? null : normalized; + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/StartKeywordAnalysisResult.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/StartKeywordAnalysisResult.java new file mode 100644 index 0000000..1632361 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/StartKeywordAnalysisResult.java @@ -0,0 +1,10 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.llmjob.entity.LlmJob; + +public record StartKeywordAnalysisResult( + KeywordAnalysis keywordAnalysis, + LlmJob job +) { +} diff --git a/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java index febf219..8329a91 100644 --- a/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java +++ b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java @@ -132,6 +132,19 @@ public static LlmJob pendingReReview( ); } + public static LlmJob pendingKeywordAnalysis(String id, String coverLetterId, Instant now) { + return new LlmJob( + id, + LlmJobType.KEYWORD_ANALYSIS, + LlmJobStatus.PENDING, + LlmJobTargetType.COVER_LETTER, + coverLetterId, + null, + 1, + now + ); + } + public void startProcessing(String progressMessage) { if (this.status != LlmJobStatus.PENDING) { throw new IllegalStateException("PENDING Job만 처리를 시작할 수 있습니다."); diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java new file mode 100644 index 0000000..c9fc0ca --- /dev/null +++ b/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java @@ -0,0 +1,106 @@ +package com.daon.rewrite.keywordanalysis.controller; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.global.exception.BusinessException; +import com.daon.rewrite.global.exception.ErrorCode; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import com.daon.rewrite.keywordanalysis.service.KeywordAnalysisService; +import com.daon.rewrite.keywordanalysis.service.StartKeywordAnalysisResult; +import com.daon.rewrite.llmjob.entity.LlmJob; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(KeywordAnalysisController.class) +class KeywordAnalysisControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private KeywordAnalysisService keywordAnalysisService; + + @Test + void startKeywordAnalysisReturnsProcessingAnalysisAndPendingJob() throws Exception { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = CoverLetter.draft("cl_1", "user_1", now); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + ); + LlmJob job = LlmJob.pendingKeywordAnalysis("job_1", "cl_1", now.plusSeconds(60)); + given(keywordAnalysisService.startMyKeywordAnalysis("cl_1", "rv_1")) + .willReturn(new StartKeywordAnalysisResult(keywordAnalysis, job)); + + mockMvc.perform(post("/cover-letters/{coverLetterId}/keyword-analysis", "cl_1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "sourceReviewVersionId": "rv_1" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.jobId").value("job_1")) + .andExpect(jsonPath("$.keywordAnalysisId").value("ka_1")) + .andExpect(jsonPath("$.coverLetterId").value("cl_1")) + .andExpect(jsonPath("$.status").value(KeywordAnalysisStatus.PROCESSING.name())); + } + + @Test + void startKeywordAnalysisAcceptsEmptyBodyAndUsesLatestVersion() throws Exception { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = CoverLetter.draft("cl_1", "user_1", now); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + ); + LlmJob job = LlmJob.pendingKeywordAnalysis("job_1", "cl_1", now.plusSeconds(60)); + given(keywordAnalysisService.startMyKeywordAnalysis("cl_1", null)) + .willReturn(new StartKeywordAnalysisResult(keywordAnalysis, job)); + + mockMvc.perform(post("/cover-letters/{coverLetterId}/keyword-analysis", "cl_1") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.keywordAnalysisId").value("ka_1")); + } + + @Test + void startKeywordAnalysisReturnsNotFound() throws Exception { + given(keywordAnalysisService.startMyKeywordAnalysis("cl_missing", null)) + .willThrow(new BusinessException(ErrorCode.NOT_FOUND)); + + mockMvc.perform(post("/cover-letters/{coverLetterId}/keyword-analysis", "cl_missing") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("NOT_FOUND")); + } + + @Test + void startKeywordAnalysisReturnsConflictWhenJobAlreadyRunning() throws Exception { + given(keywordAnalysisService.startMyKeywordAnalysis("cl_1", null)) + .willThrow(new BusinessException(ErrorCode.LLM_JOB_ALREADY_RUNNING)); + + mockMvc.perform(post("/cover-letters/{coverLetterId}/keyword-analysis", "cl_1") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("LLM_JOB_ALREADY_RUNNING")); + } +} diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java new file mode 100644 index 0000000..5450f72 --- /dev/null +++ b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java @@ -0,0 +1,55 @@ +package com.daon.rewrite.keywordanalysis.repository; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class KeywordAnalysisRepositoryTest { + + @Autowired + private KeywordAnalysisRepository repository; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void saveAndFindProcessingKeywordAnalysisRoundTripsThroughJpa() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = coverLetterRepository.save(CoverLetter.draft("cl_1", "user_1", now)); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + ); + + repository.save(keywordAnalysis); + entityManager.flush(); + entityManager.clear(); + + KeywordAnalysis found = repository.findByCoverLetterId("cl_1").orElseThrow(); + + assertThat(found.getId()).isEqualTo("ka_1"); + assertThat(found.getCoverLetter().getId()).isEqualTo("cl_1"); + assertThat(found.getSourceReviewVersionId()).isEqualTo("rv_1"); + assertThat(found.getStatus()).isEqualTo(KeywordAnalysisStatus.PROCESSING); + assertThat(found.getCreatedAt()).isEqualTo(now.plusSeconds(60)); + assertThat(found.getCompletedAt()).isNull(); + } + +} diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java new file mode 100644 index 0000000..38a61b7 --- /dev/null +++ b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java @@ -0,0 +1,191 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.auth.CurrentUser; +import com.daon.rewrite.auth.CurrentUserProvider; +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.global.exception.BusinessException; +import com.daon.rewrite.global.exception.ErrorCode; +import com.daon.rewrite.global.util.IdGenerator; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository; +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.entity.LlmJobType; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.reviewversion.entity.ReviewVersion; +import com.daon.rewrite.reviewversion.repository.ReviewVersionRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.Clock; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@SpringBootTest +@ActiveProfiles("test") +class KeywordAnalysisServiceTest { + + @Autowired + private KeywordAnalysisService service; + + @Autowired + private KeywordAnalysisRepository keywordAnalysisRepository; + + @Autowired + private LlmJobRepository llmJobRepository; + + @Autowired + private ReviewVersionRepository reviewVersionRepository; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @MockitoBean + private CurrentUserProvider currentUserProvider; + + @MockitoBean + private IdGenerator idGenerator; + + @MockitoBean + private Clock clock; + + @AfterEach + void cleanUp() { + keywordAnalysisRepository.deleteAll(); + reviewVersionRepository.deleteAll(); + llmJobRepository.deleteAll(); + coverLetterRepository.deleteAll(); + } + + @Test + void startMyKeywordAnalysisCreatesProcessingAnalysisAndPendingJobWithLatestVersion() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + given(idGenerator.generate("ka")).willReturn("ka_1"); + given(idGenerator.generate("job")).willReturn("job_1"); + given(clock.instant()).willReturn(now.plusSeconds(120)); + + StartKeywordAnalysisResult result = service.startMyKeywordAnalysis("cl_1", null); + + assertThat(result.keywordAnalysis().getId()).isEqualTo("ka_1"); + assertThat(result.keywordAnalysis().getSourceReviewVersionId()).isEqualTo("rv_1"); + assertThat(result.keywordAnalysis().getStatus()).isEqualTo(KeywordAnalysisStatus.PROCESSING); + assertThat(result.job().getId()).isEqualTo("job_1"); + assertThat(result.job().getType()).isEqualTo(LlmJobType.KEYWORD_ANALYSIS); + assertThat(result.job().getStatus()).isEqualTo(LlmJobStatus.PENDING); + assertThat(result.job().getTargetId()).isEqualTo("cl_1"); + } + + @Test + void startMyKeywordAnalysisUsesRequestedSourceReviewVersionWhenItBelongsToCoverLetter() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = saveReviewedCoverLetter("cl_1", "user_1", "rv_2", now); + reviewVersionRepository.save(ReviewVersion.reReview( + "rv_1", + coverLetter, + "v0.0", + null, + now.plusSeconds(30) + )); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + given(idGenerator.generate("ka")).willReturn("ka_1"); + given(idGenerator.generate("job")).willReturn("job_1"); + given(clock.instant()).willReturn(now.plusSeconds(120)); + + StartKeywordAnalysisResult result = service.startMyKeywordAnalysis("cl_1", "rv_1"); + + assertThat(result.keywordAnalysis().getSourceReviewVersionId()).isEqualTo("rv_1"); + } + + @Test + void startMyKeywordAnalysisReusesExistingAnalysisForReanalysis() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = saveReviewedCoverLetter("cl_1", "user_1", "rv_2", now); + KeywordAnalysis existing = KeywordAnalysis.processing("ka_existing", coverLetter, "rv_1", now.plusSeconds(30)); + keywordAnalysisRepository.save(existing); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + given(idGenerator.generate("job")).willReturn("job_1"); + given(clock.instant()).willReturn(now.plusSeconds(120)); + + StartKeywordAnalysisResult result = service.startMyKeywordAnalysis("cl_1", null); + + assertThat(result.keywordAnalysis().getId()).isEqualTo("ka_existing"); + assertThat(result.keywordAnalysis().getSourceReviewVersionId()).isEqualTo("rv_2"); + assertThat(result.keywordAnalysis().getStatus()).isEqualTo(KeywordAnalysisStatus.PROCESSING); + assertThat(keywordAnalysisRepository.count()).isEqualTo(1); + } + + @Test + void startMyKeywordAnalysisRejectsMissingOtherOwnerDeletedOrNotReviewedCoverLetter() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + saveReviewedCoverLetter("cl_other", "user_2", "rv_other", now); + CoverLetter deleted = saveReviewedCoverLetter("cl_deleted", "user_1", "rv_deleted", now); + deleted.markDeleted(now.plusSeconds(120)); + coverLetterRepository.save(deleted); + coverLetterRepository.save(CoverLetter.draft("cl_draft", "user_1", now)); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertNotFound("cl_missing"); + assertNotFound("cl_other"); + assertNotFound("cl_deleted"); + assertThatThrownBy(() -> service.startMyKeywordAnalysis("cl_draft", null)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.CONFLICT); + } + + @Test + void startMyKeywordAnalysisRejectsSourceReviewVersionFromOtherCoverLetter() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + saveReviewedCoverLetter("cl_2", "user_1", "rv_other", now); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertThatThrownBy(() -> service.startMyKeywordAnalysis("cl_1", "rv_other")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + + @Test + void startMyKeywordAnalysisRejectsWhenJobAlreadyRunning() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + llmJobRepository.save(LlmJob.pendingReview("job_running", "cl_1", now.plusSeconds(60), 1)); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertThatThrownBy(() -> service.startMyKeywordAnalysis("cl_1", null)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.LLM_JOB_ALREADY_RUNNING); + } + + private void assertNotFound(String coverLetterId) { + assertThatThrownBy(() -> service.startMyKeywordAnalysis(coverLetterId, null)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + + private CoverLetter saveReviewedCoverLetter(String coverLetterId, String ownerId, String versionId, Instant now) { + CoverLetter coverLetter = CoverLetter.draft(coverLetterId, ownerId, now); + coverLetterRepository.save(coverLetter); + ReviewVersion reviewVersion = reviewVersionRepository.save(ReviewVersion.first( + versionId, + coverLetter, + now.plusSeconds(60) + )); + coverLetter.completeReview(reviewVersion.getId(), now.plusSeconds(90)); + return coverLetterRepository.saveAndFlush(coverLetter); + } +} diff --git a/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java b/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java index 0cfaf18..6af15dc 100644 --- a/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java +++ b/src/test/java/com/daon/rewrite/llmjob/repository/LlmJobRepositoryTest.java @@ -83,6 +83,28 @@ void saveAndFindPendingReReviewJobRoundTripsInstruction() { assertThat(found.getCompletedAt()).isNull(); } + @Test + void saveAndFindPendingKeywordAnalysisJobRoundTripsThroughJpa() { + Instant now = Instant.parse("2026-07-03T05:10:00Z"); + LlmJob job = LlmJob.pendingKeywordAnalysis("job_keyword", "cl_1", now); + + repository.save(job); + entityManager.flush(); + entityManager.clear(); + + LlmJob found = repository.findById("job_keyword").orElseThrow(); + + assertThat(found.getType()).isEqualTo(LlmJobType.KEYWORD_ANALYSIS); + assertThat(found.getStatus()).isEqualTo(LlmJobStatus.PENDING); + assertThat(found.getTargetType()).isEqualTo(LlmJobTargetType.COVER_LETTER); + assertThat(found.getTargetId()).isEqualTo("cl_1"); + assertThat(found.getRequestInstruction()).isNull(); + assertThat(found.getProgressCurrent()).isZero(); + assertThat(found.getProgressTotal()).isEqualTo(1); + assertThat(found.getCreatedAt()).isEqualTo(now); + assertThat(found.getCompletedAt()).isNull(); + } + @Test void saveAndFindCompletedJobRoundTripsResultReference() { Instant now = Instant.parse("2026-06-20T05:10:00Z");