Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께
| 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` | Implemented | REQ-009 | 키워드 분석 시작/재분석 |
| API-021 | GET | `/cover-letters/{coverLetterId}/keyword-analysis/latest` | Planned | REQ-009 | 최신 키워드 분석 조회 |
| API-021 | GET | `/cover-letters/{coverLetterId}/keyword-analysis/latest` | Implemented | REQ-009 | 최신 키워드 분석 조회 |
| API-022 | POST | `/cover-letters/{coverLetterId}/interviews` | Planned | REQ-010 | 면접 세션/질문 생성 |
| API-023 | POST | `/interview-threads/{threadId}/messages` | Planned | REQ-010 | 면접 답변 전송 |
| API-024 | POST | `/cover-letters/{coverLetterId}/review-versions` | Implemented | REQ-006 | AI 첨삭 다시받기 |
Expand Down
34 changes: 16 additions & 18 deletions docs/api/keyword-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,22 +76,31 @@ Conflict Response:
GET /cover-letters/{coverLetterId}/keyword-analysis/latest
```

`status`는 `NOT_STARTED`, `PROCESSING`, `COMPLETED`, `FAILED` 중 하나다.
`keywords`는 완료 상태에서만 분석 결과를 담고, 그 외 상태에서는 빈 배열이다.

키워드 분석 결과가 없는 경우:

```json
{
"coverLetterId": "cl_01HZ...",
"keywordAnalysis": null
"status": "NOT_STARTED",
"keywords": []
}
```

진행 중인 키워드 분석 결과가 있는 경우:

```json
{
"status": "PROCESSING",
"keywords": []
}
```

Response:

```json
{
"id": "ka_01HZ...",
"coverLetterId": "cl_01HZ...",
"sourceReviewVersionId": "rv_01HZ...",
"status": "COMPLETED",
"keywords": [
{
Expand All @@ -102,26 +111,15 @@ Response:
"keyword": "Spring",
"importance": 88
}
],
"createdAt": "2026-06-20T15:00:00",
"completedAt": "2026-06-20T15:00:30"
]
}
```

실패한 키워드 분석 결과가 있는 경우:

```json
{
"id": "ka_01HZ...",
"coverLetterId": "cl_01HZ...",
"sourceReviewVersionId": "rv_01HZ...",
"status": "FAILED",
"keywords": [],
"error": {
"code": "LLM_PROVIDER_ERROR",
"message": "키워드 분석에 실패했습니다."
},
"createdAt": "2026-06-20T15:00:00",
"completedAt": "2026-06-20T15:01:00"
"keywords": []
}
```
4 changes: 2 additions & 2 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메
| 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 | 키워드 분석 | 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-009 | 키워드 분석 | In Progress | Medium | API-020, API-021 | [#56](https://github.com/Rewrite-Team/Rewrite-BE/issues/56), [#58](https://github.com/Rewrite-Team/Rewrite-BE/issues/58) | `KeywordAnalysisRepositoryTest`, `KeywordAnalysisKeywordRepositoryTest`, `KeywordAnalysisServiceTest`, `KeywordAnalysisControllerTest`, `LlmJobRepositoryTest`, `./gradlew test`, `./gradlew check` | API-020 키워드 분석 시작/재분석 Job 생성과 `KeywordAnalysis` persistence 구현됨. API-021 최신 결과 조회와 `KeywordAnalysisKeyword` 결과 저장 구조 구현됨. 실제 LLM 키워드 분석 worker는 후속 이슈로 분리 |
| REQ-010 | AI 면접 | Planned | Medium | API-022, API-023, API-025 - API-029 | - | - | LLM Job/SSE 기반 |

## Open Maintenance Work
Expand Down Expand Up @@ -59,7 +59,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메

| Candidate | Related REQ | Related APIs | Suggested Scope |
|---|---|---|---|
| 최신 키워드 분석 조회 API | REQ-009 | API-021 | `KeywordAnalysis` 최신 상태와 완료 결과 조회 계약 구현 |
| 키워드 분석 worker 구현 | REQ-009 | API-020 이후 내부 실행 | OpenAI 키워드 분석 client/worker를 연결하고 완료 결과를 `KeywordAnalysisKeyword`로 저장 |
| LLM Job 자동 재시도 | REQ-005, REQ-006 | API-014 이후 내부 실행 | provider/output 실패 시 Decision 017 기준 1회 자동 재시도 처리 |

## Update Rules
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.daon.rewrite.keywordanalysis.controller;

import com.daon.rewrite.keywordanalysis.dto.LatestKeywordAnalysisResponse;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -27,4 +29,11 @@ public StartKeywordAnalysisResponse startKeywordAnalysis(
)
);
}

@GetMapping("/cover-letters/{coverLetterId}/keyword-analysis/latest")
public LatestKeywordAnalysisResponse getLatestKeywordAnalysis(@PathVariable String coverLetterId) {
return LatestKeywordAnalysisResponse.from(
keywordAnalysisService.findMyLatestKeywordAnalysis(coverLetterId)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.daon.rewrite.keywordanalysis.dto;

import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword;
import com.daon.rewrite.keywordanalysis.service.LatestKeywordAnalysisResult;

import java.util.List;

public record LatestKeywordAnalysisResponse(
String status,
List<KeywordResponse> keywords
) {

private static final String NOT_STARTED_STATUS = "NOT_STARTED";

public static LatestKeywordAnalysisResponse from(LatestKeywordAnalysisResult result) {
if (result.keywordAnalysis() == null) {
return new LatestKeywordAnalysisResponse(NOT_STARTED_STATUS, List.of());
}
return new LatestKeywordAnalysisResponse(
result.keywordAnalysis().getStatus().name(),
result.keywords().stream()
.map(KeywordResponse::from)
.toList()
);
}

private record KeywordResponse(
String keyword,
int importance
) {

private static KeywordResponse from(KeywordAnalysisKeyword keyword) {
return new KeywordResponse(keyword.getKeyword(), keyword.getImportance());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,14 @@ public void restart(String sourceReviewVersionId) {
this.status = KeywordAnalysisStatus.PROCESSING;
this.completedAt = null;
}

public void complete(Instant completedAt) {
this.status = KeywordAnalysisStatus.COMPLETED;
this.completedAt = completedAt;
}

public void fail(Instant completedAt) {
this.status = KeywordAnalysisStatus.FAILED;
this.completedAt = completedAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.daon.rewrite.keywordanalysis.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
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;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
name = "keyword_analysis_keywords",
uniqueConstraints = @UniqueConstraint(
name = "uk_keyword_analysis_keywords_order",
columnNames = {"keyword_analysis_id", "keyword_order"}
)
)
public class KeywordAnalysisKeyword {

@Id
@Column(name = "id", nullable = false, length = 64)
private String id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "keyword_analysis_id", nullable = false)
private KeywordAnalysis keywordAnalysis;

@Column(name = "keyword_order", nullable = false)
private int keywordOrder;

@Column(name = "keyword", nullable = false, length = 100)
private String keyword;

@Column(name = "importance", nullable = false)
private int importance;

private KeywordAnalysisKeyword(
String id,
KeywordAnalysis keywordAnalysis,
int keywordOrder,
String keyword,
int importance
) {
this.id = id;
this.keywordAnalysis = keywordAnalysis;
this.keywordOrder = keywordOrder;
this.keyword = keyword;
this.importance = importance;
}

public static KeywordAnalysisKeyword of(
String id,
KeywordAnalysis keywordAnalysis,
int keywordOrder,
String keyword,
int importance
) {
return new KeywordAnalysisKeyword(
id,
keywordAnalysis,
keywordOrder,
keyword,
importance
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.daon.rewrite.keywordanalysis.repository;

import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface KeywordAnalysisKeywordRepository extends JpaRepository<KeywordAnalysisKeyword, String> {

List<KeywordAnalysisKeyword> findByKeywordAnalysisIdOrderByKeywordOrderAsc(String keywordAnalysisId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
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.KeywordAnalysisKeyword;
import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisKeywordRepository;
import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository;
import com.daon.rewrite.llmjob.entity.LlmJob;
import com.daon.rewrite.llmjob.entity.LlmJobStatus;
Expand Down Expand Up @@ -40,6 +42,7 @@ public class KeywordAnalysisService {
private final CoverLetterRepository coverLetterRepository;
private final ReviewVersionRepository reviewVersionRepository;
private final KeywordAnalysisRepository keywordAnalysisRepository;
private final KeywordAnalysisKeywordRepository keywordAnalysisKeywordRepository;
private final LlmJobRepository llmJobRepository;
private final IdGenerator idGenerator;
private final Clock clock;
Expand Down Expand Up @@ -87,6 +90,22 @@ public StartKeywordAnalysisResult startMyKeywordAnalysis(
return new StartKeywordAnalysisResult(keywordAnalysis, job);
}

@Transactional(readOnly = true)
public LatestKeywordAnalysisResult findMyLatestKeywordAnalysis(String coverLetterId) {
CurrentUser currentUser = currentUserProvider.currentUser();
CoverLetter coverLetter = coverLetterRepository
.findByIdAndOwnerIdAndDeletedAtIsNull(coverLetterId, currentUser.id())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

return keywordAnalysisRepository.findByCoverLetterId(coverLetter.getId())
.map(keywordAnalysis -> {
List<KeywordAnalysisKeyword> keywords = keywordAnalysisKeywordRepository
.findByKeywordAnalysisIdOrderByKeywordOrderAsc(keywordAnalysis.getId());
return LatestKeywordAnalysisResult.of(coverLetter.getId(), keywordAnalysis, keywords);
})
.orElseGet(() -> LatestKeywordAnalysisResult.empty(coverLetter.getId()));
}

private String selectSourceReviewVersionId(CoverLetter coverLetter, String sourceReviewVersionId) {
String normalizedSourceReviewVersionId = normalize(sourceReviewVersionId);
String selectedSourceReviewVersionId = normalizedSourceReviewVersionId == null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.daon.rewrite.keywordanalysis.service;

import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis;
import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword;

import java.util.List;

public record LatestKeywordAnalysisResult(
String coverLetterId,
KeywordAnalysis keywordAnalysis,
List<KeywordAnalysisKeyword> keywords
) {

public static LatestKeywordAnalysisResult empty(String coverLetterId) {
return new LatestKeywordAnalysisResult(coverLetterId, null, List.of());
}

public static LatestKeywordAnalysisResult of(
String coverLetterId,
KeywordAnalysis keywordAnalysis,
List<KeywordAnalysisKeyword> keywords
) {
return new LatestKeywordAnalysisResult(coverLetterId, keywordAnalysis, keywords);
}
}
Loading
Loading