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: 8 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,14 @@ docker compose up -d
target_evidence 필수 조건은 면제되지 않는다).
- `@Value` 필드에 **자바 초기값도 함께** 둔다(`= 70`). Spring 밖(단위 테스트)에서는 주입이
안 돼 0.0 이 되고, 그러면 모든 축이 '기준 이상'으로 판정돼 조용히 다른 동작을 한다.
- **오답노트 본 구현 (B-4)**: `PUT /api/sessions/{sid}/messages/{mid}/bookmark` (표시/해제) +
`GET /api/users/me/bookmarks` (모아보기). V26 으로 `interview_messages.bookmarked` + 부분 인덱스.
**질문(INTERVIEWER) 메시지에만** 걸 수 있다(`MESSAGE_NOT_BOOKMARKABLE`) — 답변을 표시해도 복습할 게 없다.
요청은 토글이 아니라 **명시적 상태**를 받는다: 토글이면 재전송·더블클릭이 상태를 뒤집는다.
목록은 질문 + 그때 내 답변 + 모범답안/코칭을 한 묶음으로 반환하며, 답변은
`findByParentMessage_IdIn` 으로 한 번에 받아 매핑한다(질문마다 조회하면 N+1).
`QuestionBookmarkController` 는 URL 이 `/api/users/me/*` 지만 `UserStatsController` 와 같은 이유로
session 슬라이스에 둔다(user → session 직접 의존 회피).
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.

Expand Down
177 changes: 177 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
}, {
"name" : "Documents",
"description" : "분석 문서(이력서/레포 공통) 조회. 상세 조회 시 분석 마크다운의 presigned S3 URL 포함."
}, {
"name" : "Users (Bookmarks)",
"description" : "오답노트 — 다시 볼 질문 모아보기."
}, {
"name" : "Users (Stats)",
"description" : "사용자 면접 통계 — 총/완료 세션 수, 평균 점수, 최근 점수 추이."
Expand Down Expand Up @@ -67,6 +70,83 @@
"description" : "X-Internal-API-Key 필요. AI 서버가 질문/피드백 생성 시 컨텍스트 청크 검색 (pgvector cosine)."
} ],
"paths" : {
"/api/sessions/{sessionId}/messages/{messageId}/bookmark" : {
"put" : {
"tags" : [ "Session Messages" ],
"summary" : "질문 오답노트 표시/해제",
"description" : "다시 볼 질문을 표시한다. 질문(INTERVIEWER) 메시지에만 걸 수 있다. 모아보기는 GET /api/users/me/bookmarks.",
"operationId" : "setQuestionBookmark",
"parameters" : [ {
"name" : "sessionId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
}, {
"name" : "messageId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/QuestionBookmarkRequest"
}
}
},
"required" : true
},
"responses" : {
"200" : {
"description" : "표시 상태",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/QuestionBookmarkResponse"
}
}
}
},
"401" : {
"description" : "인증 실패",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/QuestionBookmarkResponse"
}
}
}
},
"404" : {
"description" : "세션 또는 메시지 없음",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/QuestionBookmarkResponse"
}
}
}
},
"422" : {
"description" : "질문이 아닌 메시지",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/QuestionBookmarkResponse"
}
}
}
}
}
}
},
"/api/internal/documents/{documentId}/embeddings" : {
"put" : {
"tags" : [ "Internal: Document Embeddings" ],
Expand Down Expand Up @@ -2028,6 +2108,42 @@
}
}
},
"/api/users/me/bookmarks" : {
"get" : {
"tags" : [ "Users (Bookmarks)" ],
"summary" : "오답노트 목록",
"description" : "표시해 둔 질문과 그때 내 답변·모범 답안·코칭을 함께 반환한다. 표시/해제는 PUT /api/sessions/{sessionId}/messages/{messageId}/bookmark.",
"operationId" : "listBookmarkedQuestions",
"responses" : {
"200" : {
"description" : "최근 표시 순 목록",
"content" : {
"*/*" : {
"schema" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/BookmarkedQuestionResponse"
}
}
}
}
},
"401" : {
"description" : "인증 실패",
"content" : {
"*/*" : {
"schema" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/BookmarkedQuestionResponse"
}
}
}
}
}
}
}
},
"/api/system/ready" : {
"get" : {
"tags" : [ "system-controller" ],
Expand Down Expand Up @@ -2832,6 +2948,26 @@
},
"components" : {
"schemas" : {
"QuestionBookmarkRequest" : {
"type" : "object",
"properties" : {
"bookmarked" : {
"type" : "boolean"
}
}
},
"QuestionBookmarkResponse" : {
"type" : "object",
"properties" : {
"messageId" : {
"type" : "integer",
"format" : "int64"
},
"bookmarked" : {
"type" : "boolean"
}
}
},
"ChunkRequest" : {
"type" : "object",
"properties" : {
Expand Down Expand Up @@ -3219,6 +3355,9 @@
},
"deliveryComment" : {
"type" : "string"
},
"bookmarked" : {
"type" : "boolean"
}
}
},
Expand Down Expand Up @@ -3722,6 +3861,44 @@
}
}
},
"BookmarkedQuestionResponse" : {
"type" : "object",
"properties" : {
"messageId" : {
"type" : "integer",
"format" : "int64"
},
"sessionId" : {
"type" : "integer",
"format" : "int64"
},
"sessionTitle" : {
"type" : "string"
},
"category" : {
"type" : "string"
},
"question" : {
"type" : "string"
},
"expectedSignal" : {
"type" : "string"
},
"myAnswer" : {
"type" : "string"
},
"modelAnswer" : {
"type" : "string"
},
"coachingComment" : {
"type" : "string"
},
"createdAt" : {
"type" : "string",
"format" : "date-time"
}
}
},
"ComponentHealthResponse" : {
"type" : "object",
"properties" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ public enum ApiErrorCode {
VOICE_INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 음성 형식입니다."),
VOICE_MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "음성 메시지를 찾을 수 없습니다."),
VOICE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "음성 파일 업로드에 실패했습니다."),
MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "메시지를 찾을 수 없습니다."),
MESSAGE_NOT_BOOKMARKABLE(HttpStatus.UNPROCESSABLE_ENTITY, "질문만 오답노트에 담을 수 있습니다."),

VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "요청 값이 올바르지 않습니다."),
ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.stackup.stackup.session.application;

import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.session.application.dto.BookmarkedQuestionResult;
import com.stackup.stackup.session.domain.InterviewMessage;
import com.stackup.stackup.session.domain.InterviewMessageRepository;
import com.stackup.stackup.session.domain.InterviewSession;
import com.stackup.stackup.session.domain.InterviewSessionRepository;
import com.stackup.stackup.session.domain.MessageRole;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

// 오답노트 — 다시 볼 질문 표시 + 모아보기.
// 표시는 질문(INTERVIEWER) 메시지에만 건다. 답변·부연 메시지를 표시해도 복습할 게 없다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class QuestionBookmarkService {

private final InterviewSessionRepository sessionRepository;
private final InterviewMessageRepository messageRepository;

@Transactional
public boolean setBookmark(Long userId, Long sessionId, Long messageId, boolean bookmarked) {
// 소유권은 세션으로 확인한다(메시지에는 user 가 없다).
sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND));

InterviewMessage message = messageRepository.findById(messageId)
.filter(m -> m.getSession().getId().equals(sessionId))
.orElseThrow(() -> new DomainException(ApiErrorCode.MESSAGE_NOT_FOUND));
if (message.getRole() != MessageRole.INTERVIEWER) {
throw new DomainException(ApiErrorCode.MESSAGE_NOT_BOOKMARKABLE);
}
message.applyBookmark(bookmarked);
return bookmarked;
}

public List<BookmarkedQuestionResult> list(Long userId) {
List<InterviewMessage> questions = messageRepository.findBookmarkedByOwner(userId);
if (questions.isEmpty()) {
return List.of();
}
// 질문마다 답변을 따로 조회하면 N+1 이 된다 — 한 번에 받아 매핑한다.
Map<Long, InterviewMessage> answerByQuestionId = messageRepository
.findByParentMessage_IdIn(questions.stream().map(InterviewMessage::getId).toList())
.stream()
.filter(m -> m.getRole() == MessageRole.INTERVIEWEE)
.collect(Collectors.toMap(
m -> m.getParentMessage().getId(), Function.identity(), (a, b) -> a));

return questions.stream()
.map(q -> toResult(q, answerByQuestionId.get(q.getId())))
.toList();
}

private BookmarkedQuestionResult toResult(InterviewMessage question, InterviewMessage answer) {
InterviewSession session = question.getSession();
return new BookmarkedQuestionResult(
question.getId(),
session.getId(),
session.getTitle(),
question.getCategory(),
question.getContent(),
question.getExpectedSignal(),
answer == null ? null : answer.getContent(),
answer == null ? null : answer.getModelAnswer(),
answer == null ? null : answer.getCoachingComment(),
question.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.stackup.stackup.session.application.dto;

import java.time.Instant;

// 오답노트 항목 — 표시한 질문 + 그때 내 답변 + 복기 재료.
public record BookmarkedQuestionResult(
Long messageId,
Long sessionId,
String sessionTitle,
String category,
String question,
// 좋은 답변이 드러내야 할 핵심(질문에 기록됨).
String expectedSignal,
// 그때 내가 한 답변. 답변 전에 표시했거나 실패한 턴이면 null.
String myAnswer,
// 답변에 붙은 복기 재료(피드백 생성 시 기록). 피드백 전이면 null.
String modelAnswer,
String coachingComment,
Instant createdAt
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ public record MessageResult(
Double pronunciationAccuracy,
// 위 메트릭에서 결정론적으로 산정한 전달력 평가(배지 GOOD/FAIR/POOR + 한 줄 코칭). 음성 답변에만.
String deliveryRating,
String deliveryComment
String deliveryComment,
// 오답노트 표시 여부(질문 메시지에만 의미 있음).
boolean bookmarked
) {
public static MessageResult of(InterviewMessage m) {
return of(m, null, null, false);
Expand Down Expand Up @@ -98,7 +100,8 @@ public static MessageResult of(
revealInsights ? fillerWordCounts : null,
revealInsights ? pronunciationAccuracy : null,
delivery == null ? null : delivery.rating(),
delivery == null ? null : delivery.comment()
delivery == null ? null : delivery.comment(),
m.isBookmarked()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ public class InterviewMessage extends BaseTimeEntity {
@Column(nullable = false)
private boolean clarification = false;

// 오답노트 — 다시 볼 질문으로 표시됐는지. 질문(INTERVIEWER) 메시지에만 쓴다.
@Column(nullable = false)
private boolean bookmarked = false;

private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role,
String content, InterviewMessage parentMessage,
MessageStatus initialStatus, String idempotencyKey) {
Expand Down Expand Up @@ -215,6 +219,12 @@ public static InterviewMessage voiceInterviewee(InterviewSession session, int se
return m;
}

// 오답노트 토글. 질문이 아닌 메시지를 표시하려는 시도는 호출부에서 막는다.
// (setter 네이밍은 ArchUnit 이 막는다 — 엔티티는 의미 있는 도메인 메서드로만 바뀐다.)
public void applyBookmark(boolean value) {
this.bookmarked = value;
}

public void markStatus(MessageStatus newStatus) {
if (newStatus != null) {
this.status = newStatus;
Expand Down
Loading
Loading