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 @@ -63,7 +63,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께
| API-012 | GET | `/cover-letters/{coverLetterId}` | Planned | REQ-003 | 자기소개서 상세 |
| API-013 | DELETE | `/cover-letters/{coverLetterId}` | Implemented | REQ-003 | 자기소개서 soft delete |
| API-014 | POST | `/cover-letters/{coverLetterId}/submit` | Planned | REQ-005 | 첨삭 Job 생성 |
| API-015 | GET | `/llm-jobs/{jobId}` | Planned | REQ-005 | LLM Job 상태 조회 |
| API-015 | GET | `/llm-jobs/{jobId}` | Implemented | REQ-005 | LLM Job 상태 조회 |
| API-016 | GET | `/llm-jobs/{jobId}/stream` | Planned | REQ-005 | SSE 스트리밍 |
| API-017 | GET | `/cover-letters/{coverLetterId}/review-versions` | Planned | REQ-006 | 첨삭 버전 목록 |
| API-018 | GET | `/cover-letters/{coverLetterId}/review-versions/{versionId}` | Planned | REQ-006 | 첨삭 버전 상세 |
Expand Down
23 changes: 7 additions & 16 deletions docs/api/llm-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
GET /llm-jobs/{jobId}
```

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

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

Response:

```json
Expand All @@ -24,22 +28,7 @@ Response:
},
"attempt": 1,
"maxAttempts": 2,
"partialResult": {
"questions": [
{
"questionId": "clq_01HZ_1",
"order": 1,
"aiReport": "지원 동기는 구체적이지만 직무 경험과의 연결이 더 필요합니다.",
"rewrittenAnswer": "저는 백엔드 개발자로서..."
},
{
"questionId": "clq_01HZ_2",
"order": 2,
"aiReport": "프로젝트 경험의 문제 상황은 잘 드러나지만",
"rewrittenAnswer": ""
}
]
},
"partialResult": null,
"resultRef": null,
"error": null,
"createdAt": "2026-06-20T14:10:00",
Expand Down Expand Up @@ -105,6 +94,8 @@ Response:
GET /llm-jobs/{jobId}/stream
```

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

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

연결이 끊기면 클라이언트는 `GET /llm-jobs/{jobId}`로 상태를 다시 조회한다.
Expand Down
4 changes: 2 additions & 2 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ 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 생성 | Planned | High | API-014, API-015, API-016 | - | - | LLM provider 호출 전 skeleton 우선 |
| REQ-005 | 자기소개서 제출과 LLM Job 생성 | In Progress | High | API-014, API-015, API-016 | [#38](https://github.com/Rewrite-Team/Rewrite-BE/issues/38) | `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `./gradlew test`, `./gradlew check` | API-015 LLM Job 상태 조회 skeleton 구현됨. API-014 submit, API-016 SSE, 실제 LLM provider 호출은 후속 이슈로 분리 |
| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | Planned | High | API-017, API-018, API-019, API-024 | - | - | 제출/Job skeleton 이후 진행 권장 |
| 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 |
Expand Down Expand Up @@ -59,7 +59,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메
| Candidate | Related REQ | Related APIs | Suggested Scope |
|---|---|---|---|
| 자기소개서 상세 조회 | REQ-003 | API-012 | DB/JPA repository 기반 owner 검증, deletedAt 제외, 질문/최신 Job 요약 포함 skeleton, service/web/repository test |
| 자기소개서 제출과 LLM Job skeleton | REQ-005 | API-014, API-015 | 필수 step 데이터 검증, cover letter 상태 전환, LLM job persistence skeleton, service/web/repository test |
| 자기소개서 제출 API | REQ-005 | API-014 | 필수 step 데이터 검증, cover letter 상태 전환, COVER_LETTER_REVIEW Job 생성, service/web/repository test |

## Update Rules

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.daon.rewrite.llmjob.controller;

import com.daon.rewrite.llmjob.dto.LlmJobResponse;
import com.daon.rewrite.llmjob.service.LlmJobService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class LlmJobController {

private final LlmJobService llmJobService;

@GetMapping("/llm-jobs/{jobId}")
public LlmJobResponse findJob(@PathVariable String jobId) {
return LlmJobResponse.from(llmJobService.findMyJob(jobId));
}
}
80 changes: 80 additions & 0 deletions src/main/java/com/daon/rewrite/llmjob/dto/LlmJobResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.daon.rewrite.llmjob.dto;

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 java.time.LocalDateTime;
import java.time.ZoneId;

public record LlmJobResponse(
String id,
LlmJobType type,
LlmJobStatus status,
LlmJobTargetType targetType,
String targetId,
ProgressResponse progress,
int attempt,
int maxAttempts,
Object partialResult,
ResultRefResponse resultRef,
ErrorResponse error,
LocalDateTime createdAt,
LocalDateTime completedAt
) {
private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul");

public static LlmJobResponse from(LlmJob job) {
return new LlmJobResponse(
job.getId(),
job.getType(),
job.getStatus(),
job.getTargetType(),
job.getTargetId(),
new ProgressResponse(
job.getProgressCurrent(),
job.getProgressTotal(),
job.getProgressMessage()
),
job.getAttempt(),
job.getMaxAttempts(),
null,
ResultRefResponse.from(job),
ErrorResponse.from(job),
LocalDateTime.ofInstant(job.getCreatedAt(), API_ZONE),
job.getCompletedAt() == null ? null : LocalDateTime.ofInstant(job.getCompletedAt(), API_ZONE)
);
}

public record ProgressResponse(
int current,
int total,
String message
) {
}

public record ResultRefResponse(
String type,
String id
) {
private static ResultRefResponse from(LlmJob job) {
if (job.getResultRefType() == null || job.getResultRefId() == null) {
return null;
}
return new ResultRefResponse(job.getResultRefType().name(), job.getResultRefId());
}
}

public record ErrorResponse(
String code,
String message
) {
private static ErrorResponse from(LlmJob job) {
if (job.getErrorCode() == null) {
return null;
}
return new ErrorResponse(job.getErrorCode(), job.getErrorMessage());
}
}
}
139 changes: 139 additions & 0 deletions src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.daon.rewrite.llmjob.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.Instant;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "llm_jobs")
public class LlmJob {

private static final int DEFAULT_ATTEMPT = 1;
private static final int DEFAULT_MAX_ATTEMPTS = 2;

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

@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 40)
private LlmJobType type;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private LlmJobStatus status;

@Enumerated(EnumType.STRING)
@Column(name = "target_type", nullable = false, length = 40)
private LlmJobTargetType targetType;

@Column(name = "target_id", nullable = false, length = 64)
private String targetId;

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

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

@Column(name = "progress_message")
private String progressMessage;

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

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

@Enumerated(EnumType.STRING)
@Column(name = "result_ref_type", length = 40)
private LlmJobResultRefType resultRefType;

@Column(name = "result_ref_id", length = 64)
private String resultRefId;

@Column(name = "error_code", length = 100)
private String errorCode;

@Column(name = "error_message", columnDefinition = "text")
private String errorMessage;

@Column(name = "created_at", nullable = false)
private Instant createdAt;

@Column(name = "completed_at")
private Instant completedAt;

private LlmJob(
String id,
LlmJobType type,
LlmJobStatus status,
LlmJobTargetType targetType,
String targetId,
int progressTotal,
Instant createdAt
) {
this.id = id;
this.type = type;
this.status = status;
this.targetType = targetType;
this.targetId = targetId;
this.progressCurrent = 0;
this.progressTotal = progressTotal;
this.attempt = DEFAULT_ATTEMPT;
this.maxAttempts = DEFAULT_MAX_ATTEMPTS;
this.createdAt = createdAt;
}

public static LlmJob pendingReview(String id, String coverLetterId, Instant now, int progressTotal) {
return new LlmJob(
id,
LlmJobType.COVER_LETTER_REVIEW,
LlmJobStatus.PENDING,
LlmJobTargetType.COVER_LETTER,
coverLetterId,
progressTotal,
now
);
}

public void markCompleted(
int progressCurrent,
String progressMessage,
LlmJobResultRefType resultRefType,
String resultRefId,
Instant completedAt
) {
this.status = LlmJobStatus.COMPLETED;
this.progressCurrent = progressCurrent;
this.progressMessage = progressMessage;
this.resultRefType = resultRefType;
this.resultRefId = resultRefId;
this.completedAt = completedAt;
}

public void markFailed(
int progressCurrent,
String progressMessage,
String errorCode,
String errorMessage,
Instant completedAt
) {
this.status = LlmJobStatus.FAILED;
this.progressCurrent = progressCurrent;
this.progressMessage = progressMessage;
this.errorCode = errorCode;
this.errorMessage = errorMessage;
this.completedAt = completedAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.daon.rewrite.llmjob.entity;

public enum LlmJobResultRefType {
REVIEW_VERSION,
KEYWORD_ANALYSIS,
INTERVIEW_SESSION,
INTERVIEW_QUESTION,
INTERVIEW_MESSAGE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.daon.rewrite.llmjob.entity;

public enum LlmJobStatus {
PENDING,
PROCESSING,
COMPLETED,
FAILED,
CANCELED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.daon.rewrite.llmjob.entity;

public enum LlmJobTargetType {
COVER_LETTER
}
9 changes: 9 additions & 0 deletions src/main/java/com/daon/rewrite/llmjob/entity/LlmJobType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.daon.rewrite.llmjob.entity;

public enum LlmJobType {
COVER_LETTER_REVIEW,
COVER_LETTER_RE_REVIEW,
KEYWORD_ANALYSIS,
INTERVIEW_QUESTION_GENERATION,
INTERVIEW_MESSAGE_FEEDBACK
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.daon.rewrite.llmjob.repository;

import com.daon.rewrite.llmjob.entity.LlmJob;
import org.springframework.data.jpa.repository.JpaRepository;

public interface LlmJobRepository extends JpaRepository<LlmJob, String> {
}
39 changes: 39 additions & 0 deletions src/main/java/com/daon/rewrite/llmjob/service/LlmJobService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.daon.rewrite.llmjob.service;

import com.daon.rewrite.auth.CurrentUser;
import com.daon.rewrite.auth.CurrentUserProvider;
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.LlmJobTargetType;
import com.daon.rewrite.llmjob.repository.LlmJobRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class LlmJobService {

private final LlmJobRepository llmJobRepository;
private final CoverLetterRepository coverLetterRepository;
private final CurrentUserProvider currentUserProvider;

@Transactional(readOnly = true)
public LlmJob findMyJob(String jobId) {
LlmJob job = llmJobRepository.findById(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

if (job.getTargetType() != LlmJobTargetType.COVER_LETTER) {
throw new BusinessException(ErrorCode.NOT_FOUND);
}

CurrentUser currentUser = currentUserProvider.currentUser();
coverLetterRepository
.findByIdAndOwnerIdAndDeletedAtIsNull(job.getTargetId(), currentUser.id())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

return job;
}
}
Loading
Loading