Repository navigation
[FEAT] AI 멘토 질문 및 답변 스트리밍 API 구현 #119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
4f9d009
f494c71
c874eea
1910417
d51f940
6d81206
8e66d2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| package com.mr.domain.mentor.dto.req; | ||
|
|
||
| public record MentorQuestionRequestDTO(String content) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package com.mr.domain.mentor.dto.res; | ||
|
|
||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.mr.domain.mentor.entity.MentorMessage; | ||
| import com.mr.domain.mentor.entity.enums.MessageRole; | ||
| import java.time.LocalDateTime; | ||
|
|
||
| public final class MentorStreamEventDTO { | ||
|
|
||
| private MentorStreamEventDTO() { | ||
| } | ||
|
|
||
| public record Start(Long analysisId, Long mentorChatSessionId, Message userMessage) { | ||
| } | ||
|
|
||
| public record Chunk(String content) { | ||
| } | ||
|
|
||
| public record Complete(Message assistantMessage) { | ||
| } | ||
|
|
||
| public record Error(String code, String message) { | ||
| } | ||
|
|
||
| public record Message(Long mentorMessageId, MessageRole role, JsonNode referencesJson, | ||
| String content, LocalDateTime createdAt) { | ||
|
|
||
| public static Message user(MentorMessage message) { | ||
| return new Message(message.getId(), message.getRole(), null, message.getContent(), message.getCreatedAt()); | ||
| } | ||
|
|
||
| public static Message assistant(MentorMessage message, JsonNode referencesJson) { | ||
| return new Message( | ||
| message.getId(), | ||
| message.getRole(), | ||
| referencesJson, | ||
| message.getContent(), | ||
| message.getCreatedAt() | ||
| ); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| public enum MentorChatStatus { | ||
| ACTIVE, | ||
| GENERATING, | ||
| CLOSED, | ||
| DISABLED | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.mr.domain.mentor.repository; | ||
|
|
||
| import com.mr.domain.mentor.entity.MentorChatSession; | ||
| import jakarta.persistence.LockModeType; | ||
| import java.util.Optional; | ||
| 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; | ||
|
|
||
| public interface MentorChatSessionRepository extends JpaRepository<MentorChatSession, Long> { | ||
|
|
||
| Optional<MentorChatSession> findByAnalysisId(Long analysisId); | ||
|
|
||
| @Lock(LockModeType.PESSIMISTIC_WRITE) | ||
| @Query("select session from MentorChatSession session where session.id = :sessionId") | ||
| Optional<MentorChatSession> findByIdForUpdate(@Param("sessionId") Long sessionId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| package com.mr.domain.mentor.service; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonProcessingException; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.mr.domain.analysis.entity.Analysis; | ||
| import com.mr.domain.analysis.entity.AnalysisReport; | ||
| import com.mr.domain.analysis.entity.enums.AnalysisStatus; | ||
| import com.mr.domain.analysis.entity.enums.LlmStatus; | ||
| import com.mr.domain.analysis.exception.AnalysisErrorStatus; | ||
| import com.mr.domain.analysis.repository.AnalysisReportRepository; | ||
| import com.mr.domain.analysis.repository.AnalysisRepository; | ||
| import com.mr.domain.mentor.dto.res.MentorStreamEventDTO; | ||
| import com.mr.domain.mentor.entity.MentorChatSession; | ||
| import com.mr.domain.mentor.entity.MentorMessage; | ||
| import com.mr.domain.mentor.exception.MentorErrorStatus; | ||
| import com.mr.domain.mentor.repository.MentorChatSessionRepository; | ||
| import com.mr.domain.mentor.repository.MentorMessageRepository; | ||
| import com.mr.global.apipayload.exception.GeneralException; | ||
| import java.time.Duration; | ||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Objects; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class MentorQuestionService { | ||
|
|
||
| private static final Duration STALE_GENERATION_AFTER = Duration.ofMinutes(2); | ||
| private static final String REFERENCES_JSON = | ||
| "{\"sourceFields\":[\"analysis.raw_result_json\",\"analysis_report.content\"]}"; | ||
|
|
||
| private final AnalysisRepository analysisRepository; | ||
| private final AnalysisReportRepository analysisReportRepository; | ||
| private final MentorChatSessionRepository sessionRepository; | ||
| private final MentorMessageRepository messageRepository; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @Transactional | ||
| public PreparedQuestion prepare(Long userId, Long analysisId, String content) { | ||
| validateContent(content); | ||
|
|
||
| Analysis analysis = analysisRepository.findByIdForUpdate(analysisId) | ||
| .orElseThrow(() -> new GeneralException(AnalysisErrorStatus.ANALYSIS_NOT_FOUND)); | ||
| validateOwner(analysis, userId); | ||
| if (analysis.getStatus() != AnalysisStatus.COMPLETED) { | ||
| throw new GeneralException(MentorErrorStatus.MENTOR_ANALYSIS_NOT_COMPLETED); | ||
| } | ||
|
|
||
| MentorChatSession session = sessionRepository.findByAnalysisId(analysisId) | ||
| .orElseGet(() -> sessionRepository.save( | ||
| MentorChatSession.createActive(analysis, analysis.getUser()))); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 레포지토리에 findByIdForUpdate에는 비관적 락 정의 되어있는데 prepare()에는 일부러 findByAnalysisId 사용하신걸까요??
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 기존에는 Analysis를 findByIdForUpdate()로 먼저 조회하고 있어 동일 분석에 대한 요청이 직렬화된다고 판단해 세션은 일반 조회를 사용했습니다. 분석 락은 세션이 아직 없는 최초 요청에서 중복 세션 생성을 막기 위해 유지할 필요가 있었습니당... 다만 리뷰 덕분에 기존 세션은 스트리밍 완료/실패 처리에서 findByIdForUpdate()로 직접 갱신되므로, prepare()에서도 동일 세션을 잠그지 않으면 세션 상태 변경이 동시에 진행될 여지가 있다는 걸 확인했습니다. 이에 분석 락은 유지하고, 세션 조회는 findByAnalysisIdForUpdate()로 변경해 생성 경쟁과 세션 상태 경쟁을 모두 방지하도록 수정했습니다! 감사합니닿!! |
||
| String generationToken = session.startGenerating(STALE_GENERATION_AFTER); | ||
|
|
||
| String prompt = buildPrompt(analysis, content.trim()); | ||
| MentorMessage userMessage = messageRepository.saveAndFlush( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] 질문 저장 및 현재 사용자 질문은 스트리밍 시작 전에 DB에 저장되지만, 우려 사항
개선안
개인적으로 실패 시 보정을 추가 도입하면 프롬프트 이력 관리도 함께 해결할 수 있어 더 적절한 방향이라고 생각하는데 은우님, 다른 팀원분들 의견도 궁금해요~
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 핵심 기능의 동작을 막는 문제는 아니므로 필수 수정 사항은 아닌 것 같습니다.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 안녕하세요! 좋은 리뷰 감사합니다. 서브 이슈로 관리하면 좋을 것 같아요! 저는 아래와 같은 로직 + 논의할 사안이라고 생각했는데 어떠실지요...
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네 좋습니다! 추후 자세히 정책 세우고 디벨롭하면 좋을 것 같아요. |
||
| MentorMessage.createUserMessage(session, content.trim())); | ||
| return new PreparedQuestion( | ||
| session.getId(), | ||
| generationToken, | ||
| prompt, | ||
| new MentorStreamEventDTO.Start( | ||
| analysisId, | ||
| session.getId(), | ||
| MentorStreamEventDTO.Message.user(userMessage) | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| @Transactional | ||
| public MentorStreamEventDTO.Complete complete(Long sessionId, String generationToken, String answer) { | ||
| MentorChatSession session = findSessionForUpdate(sessionId); | ||
| session.completeGenerating(generationToken); | ||
| MentorMessage assistantMessage = messageRepository.saveAndFlush( | ||
| MentorMessage.createAssistantMessage(session, REFERENCES_JSON, answer)); | ||
| try { | ||
| return new MentorStreamEventDTO.Complete(MentorStreamEventDTO.Message.assistant( | ||
| assistantMessage, | ||
| objectMapper.readTree(REFERENCES_JSON) | ||
| )); | ||
| } catch (JsonProcessingException exception) { | ||
| throw new IllegalStateException("Invalid server-managed mentor references.", exception); | ||
| } | ||
| } | ||
|
|
||
| @Transactional | ||
| public void fail(Long sessionId, String generationToken) { | ||
| findSessionForUpdate(sessionId).failGenerating(generationToken); | ||
| } | ||
|
|
||
| private MentorChatSession findSessionForUpdate(Long sessionId) { | ||
| return sessionRepository.findByIdForUpdate(sessionId) | ||
| .orElseThrow(() -> new GeneralException(MentorErrorStatus.MENTOR_INVALID_REQUEST)); | ||
| } | ||
|
|
||
| private String buildPrompt(Analysis analysis, String question) { | ||
| AnalysisReport report = analysisReportRepository | ||
| .findFirstByAnalysisIdAndLlmStatusOrderByCreatedAtDesc(analysis.getId(), LlmStatus.SUCCESS) | ||
| .orElse(null); | ||
| List<MentorMessage> recentMessages = new ArrayList<>( | ||
| messageRepository.findTop10ByMentorChatSessionAnalysisIdOrderByCreatedAtDescIdDesc( | ||
| analysis.getId())); | ||
| Collections.reverse(recentMessages); | ||
|
|
||
| Map<String, Object> context = new LinkedHashMap<>(); | ||
| context.put("analysis", analysis.getRawResultJson()); | ||
| context.put("report", report == null ? null : report.getContent()); | ||
| context.put("history", recentMessages.stream() | ||
| .map(message -> Map.of( | ||
| "role", message.getRole().name(), | ||
| "content", message.getContent() | ||
| )) | ||
| .toList()); | ||
| context.put("question", question); | ||
| try { | ||
| return objectMapper.writeValueAsString(context); | ||
| } catch (JsonProcessingException exception) { | ||
| throw new GeneralException(MentorErrorStatus.MENTOR_INVALID_REQUEST); | ||
| } | ||
| } | ||
|
|
||
| private void validateOwner(Analysis analysis, Long userId) { | ||
| if (!Objects.equals(analysis.getUser().getUserId(), userId)) { | ||
| throw new GeneralException(MentorErrorStatus.MENTOR_ACCESS_DENIED); | ||
| } | ||
| } | ||
|
|
||
| private void validateContent(String content) { | ||
| if (content == null || content.isBlank()) { | ||
| throw new GeneralException(MentorErrorStatus.MENTOR_QUESTION_REQUIRED); | ||
| } | ||
| if (content.trim().length() > 500) { | ||
| throw new GeneralException(MentorErrorStatus.MENTOR_QUESTION_TOO_LONG); | ||
| } | ||
| } | ||
|
|
||
| public record PreparedQuestion( | ||
| Long sessionId, | ||
| String generationToken, | ||
| String prompt, | ||
| MentorStreamEventDTO.Start startEvent | ||
| ) { | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.