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: 2 additions & 0 deletions docs/api/cover-letters.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ questions는 1개 이상이어야 한다.

`REVIEW_FAILED` 상태의 사용자 수동 재시도에는 제품 도메인상 횟수 제한을 두지 않는다. 단, LLM 비용과 남용 방지를 위한 rate limit, 사용자 quota, 운영 정책은 별도로 적용할 수 있다.

새 Job이 생성되면 submit transaction commit 이후 최초 첨삭 worker가 비동기로 실행된다. worker 성공 시 `ReviewVersion`이 생성되고 `CoverLetter.status`는 `REVIEWED`가 된다. worker 실패 시 `LlmJob.status`는 `FAILED`, `CoverLetter.status`는 `REVIEW_FAILED`가 된다.

이미 최초 첨삭 Job이 `PENDING` 또는 `PROCESSING` 상태이면 새 Job을 만들지 않고 기존 진행 중 Job을 반환한다.

이미 `REVIEWED` 상태이면 새 Job을 만들지 않고 기존 최신 `ReviewVersion` 정보를 반환한다.
Expand Down
6 changes: 3 additions & 3 deletions docs/api/llm-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
GET /llm-jobs/{jobId}
```

현재 구현된 skeleton 범위에서는 저장된 Job 상태를 조회할 수 있다. `partialResult`는 아직 서버 메모리/cache 저장소와 실제 LLM 실행 흐름이 없으므로 항상 `null`이다.
현재 구현 범위에서는 최초 첨삭 Job이 submit 이후 비동기 worker로 실행될 수 있다. `partialResult`는 아직 서버 메모리/cache 저장소가 없으므로 항상 `null`이다.

`targetType=COVER_LETTER`인 Job은 연결된 자기소개서의 owner와 soft delete 상태를 기준으로 접근 권한을 검증한다. 존재하지 않는 Job, 다른 사용자 소유 자기소개서에 연결된 Job, 삭제된 자기소개서에 연결된 Job은 모두 `NOT_FOUND`를 반환한다.

Expand Down Expand Up @@ -76,7 +76,7 @@ Response:
"total": 3,
"message": "LLM 첨삭에 실패했습니다."
},
"attempt": 2,
"attempt": 1,
"maxAttempts": 2,
"resultRef": null,
"error": {
Expand All @@ -94,7 +94,7 @@ Response:
GET /llm-jobs/{jobId}/stream
```

아직 구현되지 않았다. API-016 SSE stream은 실제 LLM 실행 흐름과 partial result 저장소가 준비되는 후속 이슈에서 구현한다.
아직 구현되지 않았다. API-016 SSE stream은 partial result 저장소와 스트리밍 이벤트 발행이 준비되는 후속 이슈에서 구현한다.

SSE는 LLM 작업의 실시간 표시 채널이다. 서버는 `Last-Event-ID` 기반 이벤트 replay를 지원하지 않는다.

Expand Down
7 changes: 4 additions & 3 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메
| REQ-002 | 개발용 현재 사용자 Provider | Verified | High | 인증 필요 API 공통 | [#4](https://github.com/Rewrite-Team/Rewrite-BE/issues/4), [PR #6](https://github.com/Rewrite-Team/Rewrite-BE/pull/6) | `DevCurrentUserProviderTest`, `./gradlew test` | `CurrentUserProvider`, `DevCurrentUserProvider` 구현됨 |
| 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) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회 skeleton, 최초 첨삭 성공 결과 완료 경계, OpenAI 최초 첨삭 client 구현됨. API-016 SSE와 비동기 worker 연결은 후속 이슈로 분리 |
| 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) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 구현됨. API-017~019, API-024는 후속 범위 |
| 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) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `OpenAiFirstReviewClientTest`, `FirstReviewJobWorkerTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 및 worker 연결 구현됨. API-017~019, API-024는 후속 범위 |
| 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 기반 |
Expand Down Expand Up @@ -59,7 +59,8 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메

| Candidate | Related REQ | Related APIs | Suggested Scope |
|---|---|---|---|
| 최초 첨삭 비동기 worker | REQ-005, REQ-006 | API-014 이후 내부 실행 | PENDING Job 실행, provider client 호출, 성공 시 ReviewVersion 완료 경계 연결, 실패 상태와 자동 재시도 처리 |
| 첨삭 버전 목록/상세 조회 API | REQ-006 | API-017, API-018 | 완료된 `ReviewVersion`과 문항별 첨삭 결과를 사용자-facing API로 조회 |
| 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
Expand Up @@ -108,6 +108,11 @@ public void completeReview(String reviewVersionId, Instant now) {
this.updatedAt = now;
}

public void failReview(Instant now) {
this.status = CoverLetterStatus.REVIEW_FAILED;
this.updatedAt = now;
}

public void setLatestReviewVersionId(String latestReviewVersionId) {
this.latestReviewVersionId = latestReviewVersionId;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
import com.daon.rewrite.llmjob.entity.LlmJobTargetType;
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.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
Expand Down Expand Up @@ -59,6 +61,7 @@ public class CoverLetterService {
private final LlmJobRepository llmJobRepository;
private final IdGenerator idGenerator;
private final Clock clock;
private final ApplicationEventPublisher eventPublisher;

@Transactional
public CoverLetter createDraft() {
Expand Down Expand Up @@ -231,6 +234,7 @@ public SubmitCoverLetterResult submit(String coverLetterId) {
questions.size()
));
coverLetter.startReview(now);
eventPublisher.publishEvent(new LlmJobCreatedEvent(job.getId()));

return new SubmitCoverLetterResult(coverLetter, job);
}
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/daon/rewrite/global/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.daon.rewrite.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.daon.rewrite.llmjob.service;

public record LlmJobCreatedEvent(String jobId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@ private FirstReviewClientException(Reason reason, String message, Throwable caus
this.reason = reason;
}

static FirstReviewClientException outputValidationFailed() {
public static FirstReviewClientException outputValidationFailed() {
return new FirstReviewClientException(
Reason.OUTPUT_VALIDATION_FAILED,
"최초 첨삭 결과 구조가 올바르지 않습니다.",
null
);
}

static FirstReviewClientException outputValidationFailed(Throwable cause) {
public static FirstReviewClientException outputValidationFailed(Throwable cause) {
return new FirstReviewClientException(
Reason.OUTPUT_VALIDATION_FAILED,
"최초 첨삭 결과를 변환할 수 없습니다.",
cause
);
}

static FirstReviewClientException providerError(Throwable cause) {
public static FirstReviewClientException providerError(Throwable cause) {
return new FirstReviewClientException(
Reason.PROVIDER_ERROR,
"최초 첨삭 provider 호출에 실패했습니다.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.daon.rewrite.reviewversion.service;

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 FirstReviewJobEventListener {

private final FirstReviewJobWorker worker;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(LlmJobCreatedEvent event) {
worker.execute(event.jobId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.daon.rewrite.reviewversion.service;

import com.daon.rewrite.coverletter.entity.CoverLetter;
import com.daon.rewrite.coverletter.entity.CoverLetterQuestion;
import com.daon.rewrite.coverletter.entity.CoverLetterStatus;
import com.daon.rewrite.coverletter.repository.CoverLetterQuestionRepository;
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.llmjob.entity.LlmJob;
import com.daon.rewrite.llmjob.entity.LlmJobStatus;
import com.daon.rewrite.llmjob.entity.LlmJobTargetType;
import com.daon.rewrite.llmjob.entity.LlmJobType;
import com.daon.rewrite.llmjob.repository.LlmJobRepository;
import com.daon.rewrite.reviewversion.client.FirstReviewClientException;
import com.daon.rewrite.reviewversion.client.FirstReviewQuestion;
import com.daon.rewrite.reviewversion.client.FirstReviewRequest;
import lombok.RequiredArgsConstructor;
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
class FirstReviewJobTransactionService {

private static final String STARTED_MESSAGE = "첨삭을 시작합니다.";
private static final String FAILED_MESSAGE = "LLM 첨삭에 실패했습니다.";
private static final String PROVIDER_ERROR_CODE = "LLM_PROVIDER_ERROR";
private static final String PROVIDER_ERROR_MESSAGE = "LLM 응답 생성에 실패했습니다.";
private static final String OUTPUT_VALIDATION_ERROR_CODE = "LLM_OUTPUT_VALIDATION_FAILED";
private static final String OUTPUT_VALIDATION_ERROR_MESSAGE = "LLM 출력 형식이 올바르지 않습니다.";

private final LlmJobRepository llmJobRepository;
private final CoverLetterRepository coverLetterRepository;
private final CoverLetterQuestionRepository questionRepository;
private final Clock clock;

@Transactional
public FirstReviewWork start(String jobId) {
LlmJob job = findFirstReviewJobForUpdate(jobId);
if (job.getStatus() != LlmJobStatus.PENDING) {
return null;
}

CoverLetter coverLetter = coverLetterRepository.findActiveByIdForUpdate(job.getTargetId())
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
if (coverLetter.getStatus() != CoverLetterStatus.REVIEWING) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
}

List<CoverLetterQuestion> questions = questionRepository
.findByCoverLetterIdOrderByQuestionOrderAsc(coverLetter.getId());
if (questions.isEmpty()) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
}

job.startProcessing(STARTED_MESSAGE);
return new FirstReviewWork(new FirstReviewRequest(
coverLetter.getTitle(),
coverLetter.getCompanyName(),
coverLetter.getPositionTitle(),
coverLetter.getJobPostingUrl(),
coverLetter.getPreferences(),
questions.stream()
.map(question -> new FirstReviewQuestion(
question.getId(),
question.getQuestionOrder(),
question.getQuestion(),
question.getMaxAnswerLength(),
question.getOriginalAnswer()
))
.toList()
));
}

@Transactional
public void fail(String jobId, FirstReviewClientException.Reason reason) {
LlmJob job = findFirstReviewJobForUpdate(jobId);
if (job.getStatus() == LlmJobStatus.COMPLETED || job.getStatus() == LlmJobStatus.FAILED) {
return;
}

CoverLetter coverLetter = coverLetterRepository.findActiveByIdForUpdate(job.getTargetId())
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));

Instant now = Instant.now(clock);
job.markFailed(
job.getProgressCurrent(),
FAILED_MESSAGE,
errorCode(reason),
errorMessage(reason),
now
);
coverLetter.failReview(now);
}

private LlmJob findFirstReviewJobForUpdate(String jobId) {
LlmJob job = llmJobRepository.findByIdForUpdate(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
if (job.getType() != LlmJobType.COVER_LETTER_REVIEW
|| job.getTargetType() != LlmJobTargetType.COVER_LETTER) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
}
return job;
}

private String errorCode(FirstReviewClientException.Reason reason) {
if (reason == FirstReviewClientException.Reason.OUTPUT_VALIDATION_FAILED) {
return OUTPUT_VALIDATION_ERROR_CODE;
}
return PROVIDER_ERROR_CODE;
}

private String errorMessage(FirstReviewClientException.Reason reason) {
if (reason == FirstReviewClientException.Reason.OUTPUT_VALIDATION_FAILED) {
return OUTPUT_VALIDATION_ERROR_MESSAGE;
}
return PROVIDER_ERROR_MESSAGE;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.daon.rewrite.reviewversion.service;

import com.daon.rewrite.reviewversion.client.FirstReviewClient;
import com.daon.rewrite.reviewversion.client.FirstReviewClientException;
import com.daon.rewrite.reviewversion.client.FirstReviewResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class FirstReviewJobWorker {

private final FirstReviewJobTransactionService transactionService;
private final FirstReviewClient firstReviewClient;
private final ReviewVersionService reviewVersionService;

public void execute(String jobId) {
FirstReviewWork work = transactionService.start(jobId);
if (work == null) {
return;
}

try {
List<FirstReviewResult> results = firstReviewClient.review(work.request());
reviewVersionService.completeFirstReview(
jobId,
results.stream()
.map(result -> new ReviewQuestionResultInput(
result.questionId(),
result.aiReport(),
result.rewrittenAnswer()
))
.toList()
);
} catch (FirstReviewClientException exception) {
transactionService.fail(jobId, exception.getReason());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.daon.rewrite.reviewversion.service;

import com.daon.rewrite.reviewversion.client.FirstReviewRequest;

record FirstReviewWork(FirstReviewRequest request) {
}
Loading
Loading