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
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
package io.wisoft.prepair.prepair_api.interview.answer.event;

import io.wisoft.prepair.prepair_api.interview.answer.dto.CombinedFeedbackResult;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import io.wisoft.prepair.prepair_api.interview.answer.dto.FinalFeedbackResult;
import io.wisoft.prepair.prepair_api.interview.answer.dto.FinalFeedbackResponse;
import io.wisoft.prepair.prepair_api.interview.answer.entity.InterviewAnswer;
import io.wisoft.prepair.prepair_api.interview.answer.entity.InterviewFeedback;
import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion;
import io.wisoft.prepair.prepair_api.interview.session.entity.InterviewSession;
import io.wisoft.prepair.prepair_api.interview.answer.entity.FeedbackType;
import io.wisoft.prepair.prepair_api.interview.answer.repository.AnswerRepository;
import io.wisoft.prepair.prepair_api.interview.answer.repository.FeedbackRepository;
import io.wisoft.prepair.prepair_api.interview.question.repository.QuestionRepository;
import io.wisoft.prepair.prepair_api.interview.session.repository.SessionRepository;
import io.wisoft.prepair.prepair_api.interview.answer.service.AnswerPersistenceService;
import io.wisoft.prepair.prepair_api.interview.answer.service.FeedbackGenerator;
import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion;
import io.wisoft.prepair.prepair_api.interview.question.repository.QuestionRepository;
import io.wisoft.prepair.prepair_api.interview.session.entity.InterviewSession;
import io.wisoft.prepair.prepair_api.interview.session.repository.SessionRepository;
import io.wisoft.prepair.prepair_api.common.support.SseEmitterManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -25,10 +22,15 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;

@Slf4j
@Component
Expand All @@ -48,92 +50,133 @@ public class AllAnalysisCompletedHandler {
@Transactional
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

handle 메서드에 @Transactional이 선언되어 있어, 내부 로직 수행 중 런타임 예외가 발생하면 해당 트랜잭션은 rollback-only로 마킹됩니다. 이 경우 catch 블록에서 호출하는 failSession 메서드 내의 DB 저장 로직(sessionRepository.save)이 정상적으로 반영되지 않아, 세션 상태가 FAILED로 변경되지 않는 문제가 발생할 수 있습니다. 비즈니스 로직의 실패 여부와 관계없이 세션 상태를 업데이트하려면 트랜잭션 경계를 적절히 분리하거나 failSession을 별도의 트랜잭션(예: Propagation.REQUIRES_NEW)으로 처리하는 것을 권장합니다.

public void handle(AllAnalysisCompletedEvent event) {
UUID answerId = event.answerId();

deleteTempFile(event.videoPath());

if (event.hasFailed()) {
log.error("[종합평가] 분석 실패로 종합평가 생략 - answerId: {}", answerId);
sendFailureToSession(answerId, "분석 중 오류가 발생했습니다.");
failSession(answerId, "분석 중 오류가 발생했습니다.");
return;
}

try {
List<InterviewFeedback> feedbacks = feedbackRepository.findByInterviewAnswerId(answerId);

InterviewFeedback sttFeedback = feedbacks.stream()
.filter(f -> f.getFeedbackType() == FeedbackType.STT)
.findFirst().orElse(null);

InterviewFeedback videoFeedback = feedbacks.stream()
.filter(f -> f.getFeedbackType() == FeedbackType.VIDEO)
.findFirst().orElse(null);

if (sttFeedback == null || videoFeedback == null) {
log.error("[종합평가] STT 또는 Video 피드백 없음 - answerId: {}", answerId);
Optional<AnalysisFeedbacks> feedbacksOpt = findAnalysisFeedbacks(answerId);
if (feedbacksOpt.isEmpty()) {
failSession(answerId, "분석 결과가 누락되어 종합 평가를 생성할 수 없습니다.");
return;
}

InterviewAnswer answer = answerRepository.findByIdWithQuestionAndSession(answerId).orElse(null);
if (answer == null) {
return;
}

String question = answer.getInterviewQuestion().getQuestion();

CombinedFeedbackResult result = feedbackGenerator.generateCombined(
question,
sttFeedback.getFeedback(),
videoFeedback.getFeedback()
);
if (answer == null) return;

answerPersistenceService.saveCombinedFeedback(answerId, result);
log.info("[종합평가] 완료 - answerId: {}, score: {}", answerId, result.score());
saveCombinedFeedback(answerId, answer, feedbacksOpt.get());
tryGenerateFinalFeedback(answer);

checkAndGenerateFinal(answer);
} catch (Exception e) {
log.error("[종합평가] 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
sendFailureToSession(answerId, "종합 평가 생성 중 오류가 발생했습니다.");
failSession(answerId, "종합 평가 생성 중 오류가 발생했습니다.");
}
}

private void checkAndGenerateFinal(InterviewAnswer answer) {
private Optional<AnalysisFeedbacks> findAnalysisFeedbacks(UUID answerId) {
List<InterviewFeedback> feedbacks = feedbackRepository.findByInterviewAnswerId(answerId);

InterviewFeedback stt = feedbacks.stream()
.filter(f -> f.getFeedbackType() == FeedbackType.STT)
.findFirst()
.orElse(null);

InterviewFeedback video = feedbacks.stream()
.filter(f -> f.getFeedbackType() == FeedbackType.VIDEO)
.findFirst()
.orElse(null);

if (stt == null || video == null) {
log.error("[종합평가] STT 또는 Video 피드백 없음 - answerId: {}", answerId);
return Optional.empty();
}

return Optional.of(new AnalysisFeedbacks(stt, video));
}

private void saveCombinedFeedback(UUID answerId, InterviewAnswer answer, AnalysisFeedbacks feedbacks) {
String question = answer.getInterviewQuestion().getQuestion();

CombinedFeedbackResult result = feedbackGenerator.generateCombined(
question,
feedbacks.stt().getFeedback(),
feedbacks.video().getFeedback()
);

answerPersistenceService.saveCombinedFeedback(answerId, result);
log.info("[종합평가] 완료 - answerId: {}, score: {}", answerId, result.score());
}

private void tryGenerateFinalFeedback(InterviewAnswer answer) {
InterviewSession session = answer.getInterviewQuestion().getInterviewSession();
if (session == null) {
log.warn("[최종평가] 세션 없음 - answerId: {}", answer.getId());
return;
}

UUID sessionId = session.getId();
if (!isFinalFeedbackReady(sessionId, session.getTotalQuestionCount())) return;

List<InterviewQuestion> questions = questionRepository.findByInterviewSessionId(sessionId);

Map<UUID, InterviewAnswer> answerMap = answerRepository.findBySessionId(sessionId).stream()
.collect(Collectors.toMap(a -> a.getInterviewQuestion().getId(), a -> a));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Collectors.toMap 사용 시 중복된 키(동일한 questionId를 가진 답변)가 존재할 경우 IllegalStateException이 발생할 수 있습니다. 현재 비즈니스 로직상 질문당 하나의 답변만 존재한다고 가정하더라도, 예기치 못한 데이터 중복 상황에 대비하여 merge function을 추가하는 것이 안전합니다.

Suggested change
.collect(Collectors.toMap(a -> a.getInterviewQuestion().getId(), a -> a));
.collect(Collectors.toMap(a -> a.getInterviewQuestion().getId(), a -> a, (existing, replacement) -> existing));


Map<UUID, List<InterviewFeedback>> feedbackMap = feedbackRepository.findAllBySessionId(sessionId).stream()
.collect(Collectors.groupingBy(f -> f.getInterviewAnswer().getId()));

FinalFeedbackData data = buildFinalData(questions, answerMap, feedbackMap);
FinalFeedbackResult finalResult = feedbackGenerator.generateFinal(data.promptInput());

completeSession(session, data, finalResult);
}

private boolean isFinalFeedbackReady(UUID sessionId, int totalQuestionCount) {
long combinedCount = feedbackRepository.countBySessionIdAndFeedbackType(sessionId, FeedbackType.COMBINED);

if (combinedCount < session.getTotalQuestionCount()) {
log.info("[최종평가] 아직 모든 질문 완료되지 않음 - sessionId: {}, {}/{}", sessionId, combinedCount, session.getTotalQuestionCount());
return;
if (combinedCount < totalQuestionCount) {
log.info("[최종평가] 아직 모든 질문 완료되지 않음 - sessionId: {}, {}/{}", sessionId, combinedCount, totalQuestionCount);
return false;
}
return true;
}

List<InterviewQuestion> questions = questionRepository.findByInterviewSessionId(sessionId);

private FinalFeedbackData buildFinalData(
List<InterviewQuestion> questions,
Map<UUID, InterviewAnswer> answerMap,
Map<UUID, List<InterviewFeedback>> feedbackMap
) {
StringBuilder promptInput = new StringBuilder();
List<FinalFeedbackResponse.QuestionFeedback> questionFeedbacks = new ArrayList<>();
int totalScore = 0;

for (InterviewQuestion q : questions) {
InterviewAnswer ans = answerRepository.findByInterviewQuestionId(q.getId()).orElse(null);
InterviewAnswer ans = answerMap.get(q.getId());
if (ans == null) continue;

List<InterviewFeedback> answerFeedbacks = feedbackRepository.findByInterviewAnswerId(ans.getId());
List<InterviewFeedback> answerFeedbacks = feedbackMap.getOrDefault(ans.getId(), List.of());

InterviewFeedback combined = answerFeedbacks.stream()
.filter(f -> f.getFeedbackType() == FeedbackType.COMBINED).findFirst().orElse(null);
.filter(f -> f.getFeedbackType() == FeedbackType.COMBINED)
.findFirst()
.orElse(null);
if (combined == null) continue;

String sttFeedbackStr = answerFeedbacks.stream()
.filter(f -> f.getFeedbackType() == FeedbackType.STT).findFirst()
.map(InterviewFeedback::getFeedback).orElse(null);
.filter(f -> f.getFeedbackType() == FeedbackType.STT)
.findFirst()
.map(InterviewFeedback::getFeedback)
.orElse(null);

String videoFeedbackStr = answerFeedbacks.stream()
.filter(f -> f.getFeedbackType() == FeedbackType.VIDEO).findFirst()
.map(InterviewFeedback::getFeedback).orElse(null);
.filter(f -> f.getFeedbackType() == FeedbackType.VIDEO)
.findFirst()
.map(InterviewFeedback::getFeedback)
.orElse(null);

promptInput.append("질문: ").append(q.getQuestion()).append("\n");
promptInput.append("종합 평가: ").append(combined.getFeedback()).append("\n");
Expand All @@ -152,23 +195,38 @@ private void checkAndGenerateFinal(InterviewAnswer answer) {
}

int finalScore = questionFeedbacks.isEmpty() ? 0 : totalScore / questionFeedbacks.size();
return new FinalFeedbackData(promptInput.toString(), questionFeedbacks, finalScore);
}

FinalFeedbackResult finalResult = feedbackGenerator.generateFinal(promptInput.toString());
private void completeSession(InterviewSession session, FinalFeedbackData data, FinalFeedbackResult finalResult) {
UUID sessionId = session.getId();

session.complete(finalScore, finalResult.finalFeedback());
session.complete(data.finalScore(), finalResult.finalFeedback());
sessionRepository.save(session);

FinalFeedbackResponse response = new FinalFeedbackResponse(
sessionId,
finalScore,
data.finalScore(),
finalResult.finalFeedback(),
questionFeedbacks
data.questionFeedbacks()
);

sseEmitterManager.send(sessionId, "final-complete", response);
sseEmitterManager.complete(sessionId);

log.info("[최종평가] 완료 - sessionId: {}, finalScore: {}", sessionId, finalScore);
log.info("[최종평가] 완료 - sessionId: {}, finalScore: {}", sessionId, data.finalScore());
}

private void failSession(UUID answerId, String message) {
InterviewAnswer answer = answerRepository.findByIdWithQuestionAndSession(answerId).orElse(null);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

failSession 메서드에서 answerRepository.findByIdWithQuestionAndSession(answerId)를 호출하여 세션 정보를 조회하고 있습니다. handle 메서드에서 이미 answer 객체를 조회한 상태라면 이를 재사용하여 불필요한 DB 조회를 줄일 수 있습니다. 또한, answerId가 유효하지 않을 경우 세션 실패 처리가 누락될 수 있으므로, 이벤트 객체에 sessionId를 포함하거나 조회 방식을 개선하는 것을 고려해 보세요.

if (answer == null || answer.getInterviewQuestion().getInterviewSession() == null) return;

InterviewSession session = answer.getInterviewQuestion().getInterviewSession();
session.fail();
sessionRepository.save(session);

sseEmitterManager.send(session.getId(), "analysis-failed", Map.of("message", message));
sseEmitterManager.complete(session.getId());
}

private void deleteTempFile(Path videoPath) {
Expand All @@ -180,15 +238,16 @@ private void deleteTempFile(Path videoPath) {
}
}

private void sendFailureToSession(UUID answerId, String message) {
InterviewAnswer answer = answerRepository.findByIdWithQuestionAndSession(answerId).orElse(null);
if (answer != null && answer.getInterviewQuestion().getInterviewSession() != null) {
InterviewSession session = answer.getInterviewQuestion().getInterviewSession();
session.fail();
sessionRepository.save(session);
private record AnalysisFeedbacks(
InterviewFeedback stt,
InterviewFeedback video
) {
}

sseEmitterManager.send(session.getId(), "analysis-failed", Map.of("message", message));
sseEmitterManager.complete(session.getId());
}
private record FinalFeedbackData(
String promptInput,
List<FinalFeedbackResponse.QuestionFeedback> questionFeedbacks,
int finalScore
) {
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package io.wisoft.prepair.prepair_api.interview.answer.repository;

import io.wisoft.prepair.prepair_api.interview.answer.entity.InterviewAnswer;

import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface AnswerRepository extends JpaRepository<InterviewAnswer, UUID> {

Expand All @@ -14,5 +17,8 @@ public interface AnswerRepository extends JpaRepository<InterviewAnswer, UUID> {
"WHERE a.id = :answerId")
Optional<InterviewAnswer> findByIdWithQuestionAndSession(UUID answerId);

Optional<InterviewAnswer> findByInterviewQuestionId(UUID questionId);
@Query("SELECT a FROM InterviewAnswer a " +
"JOIN FETCH a.interviewQuestion q " +
"WHERE q.interviewSession.id = :sessionId")
List<InterviewAnswer> findBySessionId(@Param("sessionId") UUID sessionId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import io.wisoft.prepair.prepair_api.interview.answer.entity.InterviewFeedback;
import io.wisoft.prepair.prepair_api.interview.answer.entity.FeedbackType;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface FeedbackRepository extends JpaRepository<InterviewFeedback, UUID> {
List<InterviewFeedback> findByInterviewAnswerId(UUID answerId);
Expand All @@ -16,5 +19,9 @@ public interface FeedbackRepository extends JpaRepository<InterviewFeedback, UUI
"AND f.feedbackType = :feedbackType")
long countBySessionIdAndFeedbackType(UUID sessionId, FeedbackType feedbackType);

Optional<InterviewFeedback> findByInterviewAnswerIdAndFeedbackType(UUID answerId, FeedbackType feedbackType);
@Query("SELECT f FROM InterviewFeedback f " +
"JOIN FETCH f.interviewAnswer a " +
"JOIN FETCH a.interviewQuestion q " +
"WHERE q.interviewSession.id = :sessionId")
List<InterviewFeedback> findAllBySessionId(@Param("sessionId") UUID sessionId);
}
Loading