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/keyword-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Request:

재분석 요청에서는 `sourceReviewVersionId`를 보내지 않는다. 서버는 항상 `CoverLetter.latestReviewVersionId`를 기준으로 다시 분석한다.

현재 구현 범위에서는 `KeywordAnalysis` persistence와 `KEYWORD_ANALYSIS` LLM Job 생성 계약을 제공한다. 실제 LLM 키워드 추출 worker와 완료 결과 저장은 후속 이슈에서 구현한다.
`KEYWORD_ANALYSIS` LLM Job은 커밋 이후 비동기 worker에서 실행된다. worker는 분석 기준 첨삭 버전의 문항별 최종 작성본을 입력으로 사용하고, 완료 시 최신 키워드 결과를 저장한다.

Response:

Expand Down
3 changes: 1 addition & 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), [#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-009 | 키워드 분석 | Implemented | 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), [#60](https://github.com/Rewrite-Team/Rewrite-BE/issues/60) | `KeywordAnalysisRepositoryTest`, `KeywordAnalysisKeywordRepositoryTest`, `KeywordAnalysisServiceTest`, `KeywordAnalysisControllerTest`, `OpenAiKeywordAnalysisClientTest`, `KeywordAnalysisJobWorkerTest`, `KeywordAnalysisJobEventIntegrationTest`, `LlmJobRepositoryTest`, `./gradlew test`, `./gradlew check` | API-020 키워드 분석 시작/재분석 Job 생성, API-021 최신 결과 조회, OpenAI 키워드 분석 client와 after-commit worker, `KeywordAnalysisKeyword` 완료 결과 저장 구현됨 |
| 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,6 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메

| Candidate | Related REQ | Related APIs | Suggested Scope |
|---|---|---|---|
| 키워드 분석 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
@@ -0,0 +1,9 @@
package com.daon.rewrite.keywordanalysis.client;

public record KeywordAnalysisAnswer(
String questionId,
int questionOrder,
String question,
String finalAnswer
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.daon.rewrite.keywordanalysis.client;

import java.util.List;

public interface KeywordAnalysisClient {

List<KeywordAnalysisResult> analyze(KeywordAnalysisRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.daon.rewrite.keywordanalysis.client;

public class KeywordAnalysisClientException extends RuntimeException {

private final Reason reason;

private KeywordAnalysisClientException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}

public static KeywordAnalysisClientException outputValidationFailed() {
return new KeywordAnalysisClientException(
Reason.OUTPUT_VALIDATION_FAILED,
"키워드 분석 결과 구조가 올바르지 않습니다.",
null
);
}

public static KeywordAnalysisClientException outputValidationFailed(Throwable cause) {
return new KeywordAnalysisClientException(
Reason.OUTPUT_VALIDATION_FAILED,
"키워드 분석 결과를 변환할 수 없습니다.",
cause
);
}

public static KeywordAnalysisClientException providerError(Throwable cause) {
return new KeywordAnalysisClientException(
Reason.PROVIDER_ERROR,
"키워드 분석 provider 호출에 실패했습니다.",
cause
);
}

public Reason getReason() {
return reason;
}

public enum Reason {
PROVIDER_ERROR,
OUTPUT_VALIDATION_FAILED
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.daon.rewrite.keywordanalysis.client;

import java.util.List;

public record KeywordAnalysisRequest(
String title,
String companyName,
String positionTitle,
String preferences,
List<KeywordAnalysisAnswer> answers
) {

public KeywordAnalysisRequest {
answers = List.copyOf(answers);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.daon.rewrite.keywordanalysis.client;

public record KeywordAnalysisResult(
String keyword,
int importance
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.daon.rewrite.keywordanalysis.client;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;

import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Component
public class OpenAiKeywordAnalysisClient implements KeywordAnalysisClient {

private static final int MAX_KEYWORD_COUNT = 20;
private static final int MIN_IMPORTANCE = 1;
private static final int MAX_IMPORTANCE = 100;
private static final String SYSTEM_PROMPT = """
당신은 한국어 자기소개서의 핵심 키워드를 분석하는 전문가입니다.
자기소개서의 문항과 최종 작성본을 함께 읽고 워드클라우드에 사용할 핵심 키워드를 추출하세요.

다음 기준을 반드시 지키세요.
- 핵심 키워드는 중요도 기준 상위 20개를 반환합니다.
- importance는 1~100 범위의 정수입니다.
- keyword는 짧은 명사 또는 명사구로 작성합니다.
- 같은 의미의 키워드를 중복 반환하지 않습니다.
- 응답은 JSON 객체 하나만 반환합니다.
""";

private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build();

private final ChatClient chatClient;

public OpenAiKeywordAnalysisClient(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}

@Override
public List<KeywordAnalysisResult> analyze(KeywordAnalysisRequest request) {
OpenAiKeywordAnalysisResponse response;
try {
response = chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(buildUserPrompt(request))
.call()
.entity(OpenAiKeywordAnalysisResponse.class);
} catch (JacksonException exception) {
throw KeywordAnalysisClientException.outputValidationFailed(exception);
} catch (Exception exception) {
throw KeywordAnalysisClientException.providerError(exception);
}

return validateAndNormalize(response);
}

private String buildUserPrompt(KeywordAnalysisRequest request) {
return "다음 자기소개서 최종 작성본의 핵심 키워드를 분석하세요.\n입력 JSON:\n"
+ JSON_MAPPER.writeValueAsString(request);
}

private List<KeywordAnalysisResult> validateAndNormalize(OpenAiKeywordAnalysisResponse response) {
if (response == null || response.keywords() == null
|| response.keywords().isEmpty()) {
throw KeywordAnalysisClientException.outputValidationFailed();
}

Set<String> seenKeywords = new HashSet<>();
List<KeywordAnalysisResult> normalizedResults = response.keywords().stream()
.map(result -> normalize(result, seenKeywords))
.toList();

return normalizedResults.stream()
.sorted(Comparator.comparingInt(KeywordAnalysisResult::importance).reversed())
.limit(MAX_KEYWORD_COUNT)
.toList();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private KeywordAnalysisResult normalize(KeywordAnalysisResult result, Set<String> seenKeywords) {
if (result == null) {
throw KeywordAnalysisClientException.outputValidationFailed();
}
String keyword = normalizeRequired(result.keyword());
if (keyword == null
|| result.importance() < MIN_IMPORTANCE
|| result.importance() > MAX_IMPORTANCE
|| !seenKeywords.add(keyword)) {
throw KeywordAnalysisClientException.outputValidationFailed();
}
return new KeywordAnalysisResult(keyword, result.importance());
}

private String normalizeRequired(String value) {
if (value == null) {
return null;
}
String normalized = value.strip();
return normalized.isEmpty() ? null : normalized;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.daon.rewrite.keywordanalysis.client;

import java.util.List;

record OpenAiKeywordAnalysisResponse(List<KeywordAnalysisResult> keywords) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@
public interface KeywordAnalysisKeywordRepository extends JpaRepository<KeywordAnalysisKeyword, String> {

List<KeywordAnalysisKeyword> findByKeywordAnalysisIdOrderByKeywordOrderAsc(String keywordAnalysisId);

void deleteByKeywordAnalysisId(String keywordAnalysisId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.daon.rewrite.keywordanalysis.service;

import com.daon.rewrite.llmjob.entity.LlmJob;
import com.daon.rewrite.llmjob.entity.LlmJobType;
import com.daon.rewrite.llmjob.repository.LlmJobRepository;
import com.daon.rewrite.llmjob.service.LlmJobCreatedEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
class KeywordAnalysisJobEventListener {

private final LlmJobRepository llmJobRepository;
private final KeywordAnalysisJobWorker worker;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(LlmJobCreatedEvent event) {
llmJobRepository.findById(event.jobId()).ifPresent(this::dispatch);
}
Comment thread
yong203 marked this conversation as resolved.

private void dispatch(LlmJob job) {
if (job.getType() == LlmJobType.KEYWORD_ANALYSIS) {
worker.execute(job.getId());
}
}
}
Loading
Loading