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
8 changes: 4 additions & 4 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) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회 skeleton 구현됨. API-016 SSE와 실제 LLM provider 호출은 후속 이슈로 분리 |
| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | Planned | High | API-017, API-018, API-019, API-024 | - | - | 제출/Job skeleton 이후 진행 권장 |
| 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) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `ReviewVersionServiceTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회 skeleton, 최초 첨삭 성공 결과 완료 경계 구현됨. API-016 SSE와 실제 LLM provider 호출은 후속 이슈로 분리 |
| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | In Progress | High | API-017, API-018, API-019, API-024 | [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence와 최초 첨삭 성공 저장 경계 구현됨. 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 @@ -58,8 +58,8 @@ 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 |
| 자기소개서 삭제 시 진행 Job 취소 | REQ-003, REQ-005 | API-013 내부 동작 | soft delete transaction에서 연결된 PENDING/PROCESSING Job을 CANCELED로 전환하고 repository/service test 추가 |
| OpenAI 최초 첨삭 client | REQ-005, REQ-006 | LLM provider 내부 연동 | 자기소개서 입력을 구조화된 prompt로 변환하고 문항별 `aiReport`, `rewrittenAnswer` 응답을 검증하는 client 구현 |
| 최초 첨삭 비동기 worker | REQ-005, REQ-006 | API-014 이후 내부 실행 | PENDING Job 실행, provider client 호출, 성공 시 ReviewVersion 완료 경계 연결, 실패 상태와 자동 재시도 처리 |

## Update Rules

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ public void startReview(Instant now) {
this.updatedAt = now;
}

public void completeReview(String reviewVersionId, Instant now) {
this.status = CoverLetterStatus.REVIEWED;
this.latestReviewVersionId = reviewVersionId;
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 @@ -37,4 +37,13 @@ Optional<CoverLetter> findActiveByIdAndOwnerIdForUpdate(
@Param("id") String id,
@Param("ownerId") String ownerId
);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select coverLetter
from CoverLetter coverLetter
where coverLetter.id = :id
and coverLetter.deletedAt is null
""")
Optional<CoverLetter> findActiveByIdForUpdate(@Param("id") String id);
}
8 changes: 8 additions & 0 deletions src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ public static LlmJob pendingReview(String id, String coverLetterId, Instant now,
);
}

public void startProcessing(String progressMessage) {
if (this.status != LlmJobStatus.PENDING) {
throw new IllegalStateException("PENDING Job만 처리를 시작할 수 있습니다.");
}
this.status = LlmJobStatus.PROCESSING;
this.progressMessage = progressMessage;
}

public void markCompleted(
int progressCurrent,
String progressMessage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
import com.daon.rewrite.llmjob.entity.LlmJobStatus;
import com.daon.rewrite.llmjob.entity.LlmJobTargetType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import jakarta.persistence.LockModeType;

import java.util.Collection;
import java.util.Optional;
Expand All @@ -15,4 +20,8 @@ Optional<LlmJob> findFirstByTargetTypeAndTargetIdAndStatusInOrderByCreatedAtDesc
String targetId,
Collection<LlmJobStatus> statuses
);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select job from LlmJob job where job.id = :id")
Optional<LlmJob> findByIdForUpdate(@Param("id") String id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.daon.rewrite.reviewversion.entity;

import com.daon.rewrite.coverletter.entity.CoverLetter;
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;

import java.time.Instant;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
name = "review_versions",
uniqueConstraints = @UniqueConstraint(
name = "uk_review_versions_cover_letter_version",
columnNames = {"cover_letter_id", "version"}
)
)
public class ReviewVersion {

private static final String FIRST_VERSION = "v0.1";

@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 = "version", nullable = false, length = 20)
private String version;

@Column(name = "request_instruction", length = 1000)
private String requestInstruction;

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

private ReviewVersion(
String id,
CoverLetter coverLetter,
String version,
String requestInstruction,
Instant createdAt
) {
this.id = id;
this.coverLetter = coverLetter;
this.version = version;
this.requestInstruction = requestInstruction;
this.createdAt = createdAt;
}

public static ReviewVersion first(String id, CoverLetter coverLetter, Instant createdAt) {
return new ReviewVersion(id, coverLetter, FIRST_VERSION, null, createdAt);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.daon.rewrite.reviewversion.entity;

import com.daon.rewrite.coverletter.entity.CoverLetterQuestion;
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 = "review_version_question_results",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_review_question_results_version_question",
columnNames = {"review_version_id", "question_id"}
),
@UniqueConstraint(
name = "uk_review_question_results_version_order",
columnNames = {"review_version_id", "question_order"}
)
}
)
public class ReviewVersionQuestionResult {

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

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "review_version_id", nullable = false)
private ReviewVersion reviewVersion;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "question_id", nullable = false)
private CoverLetterQuestion question;

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

@Column(name = "question", nullable = false, length = 300)
private String questionText;

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

@Column(name = "original_answer", nullable = false, columnDefinition = "text")
private String originalAnswer;

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

@Column(name = "ai_report", nullable = false, columnDefinition = "text")
private String aiReport;

@Column(name = "rewritten_answer", nullable = false, columnDefinition = "text")
private String rewrittenAnswer;

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

@Column(name = "final_answer", nullable = false, columnDefinition = "text")
private String finalAnswer;

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

private ReviewVersionQuestionResult(
String id,
ReviewVersion reviewVersion,
CoverLetterQuestion question,
String aiReport,
String rewrittenAnswer
) {
this.id = id;
this.reviewVersion = reviewVersion;
this.question = question;
this.questionOrder = question.getQuestionOrder();
this.questionText = question.getQuestion();
this.maxAnswerLength = question.getMaxAnswerLength();
this.originalAnswer = question.getOriginalAnswer();
this.originalAnswerLength = countCodePoints(originalAnswer);
this.aiReport = aiReport;
this.rewrittenAnswer = rewrittenAnswer;
this.rewrittenAnswerLength = countCodePoints(rewrittenAnswer);
this.finalAnswer = rewrittenAnswer;
this.finalAnswerLength = rewrittenAnswerLength;
}

public static ReviewVersionQuestionResult create(
String id,
ReviewVersion reviewVersion,
CoverLetterQuestion question,
String aiReport,
String rewrittenAnswer
) {
return new ReviewVersionQuestionResult(id, reviewVersion, question, aiReport, rewrittenAnswer);
}

private static int countCodePoints(String value) {
return value.codePointCount(0, value.length());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.daon.rewrite.reviewversion.repository;

import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface ReviewVersionQuestionResultRepository extends JpaRepository<ReviewVersionQuestionResult, String> {

List<ReviewVersionQuestionResult> findByReviewVersionIdOrderByQuestionOrderAsc(String reviewVersionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.daon.rewrite.reviewversion.repository;

import com.daon.rewrite.reviewversion.entity.ReviewVersion;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ReviewVersionRepository extends JpaRepository<ReviewVersion, String> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.daon.rewrite.reviewversion.service;

import com.daon.rewrite.reviewversion.entity.ReviewVersion;
import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult;

import java.util.List;

public record CompleteFirstReviewResult(
ReviewVersion reviewVersion,
List<ReviewVersionQuestionResult> questionResults
) {

public CompleteFirstReviewResult {
questionResults = List.copyOf(questionResults);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.daon.rewrite.reviewversion.service;

public record ReviewQuestionResultInput(
String questionId,
String aiReport,
String rewrittenAnswer
) {
}
Loading
Loading