diff --git a/.gitignore b/.gitignore index 58ce205..403c2ab 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ out/ ## Agents AGENTS.md +CLAUDE.md diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 45c1504..160b3ec 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -190,11 +190,13 @@ public ApiResponse getMeetingDetail( @ApiErrorCodeExamples({ @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_END"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_UNNORMAL_END") }) public ApiResponse getHistory( @PathVariable Long meetingId) { - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(meetingQueryService.getDialogueHistory(meetingId)); } @GetMapping("/meetings/{meetingId}/analysis") diff --git a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index d5d77e7..97e9951 100644 --- a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java +++ b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java @@ -1,13 +1,17 @@ package com.whylog.server.domain.meeting.dto; +import com.whylog.server.domain.meeting.entity.Dialogue; +import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.meeting.socket.MeetingParticipant; +import com.whylog.server.domain.user.entity.Member; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -191,6 +195,16 @@ public static class ParticipantDTO { @Schema(description = "참여자 프로필 사진 URL", example = "https://example.com/profile/user-1.jpg") private String profileImage; + + public static List create(List members) { + return members.stream().map(member -> ParticipantDTO.builder() + .memberId(member.getId()) + .name(member.getName()) + .profileImage(member.getProfileImage()) + .build() + ).toList(); + } + } @Getter @@ -208,7 +222,47 @@ public static class DialogueDTO { @Schema(description = "말한 시간", example = "00:22") private String timestamp; + + public static List create(Meeting meeting, List dialogues) { + LocalDateTime startDateTime = meeting.getStartDateTime(); + + return dialogues.stream().map(dialogue -> DialogueDTO.builder() + .memberId(dialogue.getMember().getId()) + .content(dialogue.getContent()) + .timestamp(formatElapsed(startDateTime, dialogue.getSpeechDateTime())) + .build() + ).toList(); + } + + private static String formatElapsed(LocalDateTime startDateTime, LocalDateTime speechDateTime) { + if (startDateTime == null || speechDateTime == null) { + return null; + } + + Duration elapsed = Duration.between(startDateTime, speechDateTime); + if (elapsed.isNegative()) { + return "00:00"; + } + + long totalSeconds = elapsed.getSeconds(); + long minutes = totalSeconds / 60; + long seconds = totalSeconds % 60; + return String.format("%02d:%02d", minutes, seconds); + } + + } + + public static HistoryListDTO create(Meeting meeting, List dialogues, List participants ) { + + List participantDtoList = ParticipantDTO.create(participants); + List dialogueDtoList = DialogueDTO.create(meeting, dialogues); + return HistoryListDTO.builder() + .participants(participantDtoList) + .dialogues(dialogueDtoList) + .build(); + } + } @Getter diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java index 31b10ff..470e677 100644 --- a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java @@ -16,6 +16,8 @@ public enum MeetingErrorCode implements BaseErrorCode { MEETING_NOT_OWNER(HttpStatus.FORBIDDEN, "MEETING_403", "회의 삭제 권한이 없습니다."), MEETING_AUDIO_NOT_READY(HttpStatus.CONFLICT, "MEETING_411", "회의 녹음본이 아직 생성되지 않았거나 업로드가 완료되지 않았습니다."), MEETING_ALREADY_PARTICIPATING(HttpStatus.CONFLICT, "MEETING_412", "이미 실시간으로 참여 중인 회의입니다."), + MEETING_NOT_END(HttpStatus.CONFLICT, "MEETING413", "아직 종료되지 않은 회의입니다."), + MEETING_UNNORMAL_END(HttpStatus.CONFLICT, "MEETING414", "비정상 종료된 회의입니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java index 3b3d6fc..19db016 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java @@ -1,6 +1,7 @@ package com.whylog.server.domain.meeting.repository; import com.whylog.server.domain.meeting.entity.MeetingAnalysis; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -15,4 +16,7 @@ public interface MeetingAnalysisRepository extends JpaRepository findByMeetingId(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index 7982b49..9eeefa0 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -59,5 +59,13 @@ public interface MeetingRepository extends JpaRepository { """) int markAllOngoingMeetingsAsEnded(@Param("endedAt") LocalDateTime endedAt); + @Query(""" + SELECT m FROM Meeting m + LEFT JOIN FETCH m.dialogues d + LEFT JOIN FETCH m.meetingMembers mm + JOIN FETCH d.member dm + WHERE m.id = :meetingId + """) + Optional findWithDialogue(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index c69984a..59665ab 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -21,12 +21,14 @@ import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; import com.whylog.server.domain.meeting.exception.MeetingAudioNotReadyException; -import com.whylog.server.domain.meeting.repository.DialogueRepository; import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; +import com.whylog.server.global.external.fast.client.FastApiMeetingAnalysisClient; import com.whylog.server.global.external.fast.dto.FastApiResponse; +import com.whylog.server.global.external.fast.dto.request.ApplicationEmbeddingsRequest; +import com.whylog.server.global.external.fast.dto.response.ApplicationEmbeddingsResponse; import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunCreateResponse; import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; import com.whylog.server.global.external.fast.exception.FastApiErrorCode; @@ -34,13 +36,12 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Comparator; +import java.util.Map; import java.util.List; -import java.util.Locale; +import java.util.function.Function; import java.util.Optional; import java.util.concurrent.TimeUnit; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.UrlResource; @@ -59,12 +60,12 @@ public class MeetingAnalysisService { private static final int MAX_AUDIO_READY_ATTEMPTS = 20; private static final Duration RUN_POLL_WAIT_INTERVAL = Duration.ofSeconds(3); private static final int MAX_RUN_POLL_ATTEMPTS = 120; - private static final Pattern SPEAKER_INDEX_PATTERN = Pattern.compile("(\\d+)"); private final MeetingUseCase meetingUseCase; private final MeetingAudioReplayService meetingAudioReplayService; private final MeetingAudioFileService meetingAudioFileService; private final FastApiTranscribeClient fastApiTranscribeClient; + private final FastApiMeetingAnalysisClient fastApiMeetingAnalysisClient; private final ApplicationRepository applicationRepository; private final ApplicationBaseRepository applicationBaseRepository; private final ApplicationTimelineRepository applicationTimelineRepository; @@ -72,8 +73,8 @@ public class MeetingAnalysisService { private final DecisionTimelineRepository decisionTimelineRepository; private final DecisionRepository decisionRepository; private final MeetingAnalysisRepository meetingAnalysisRepository; - private final DialogueRepository dialogueRepository; private final MeetingMemberRepository meetingMemberRepository; + private final MeetingLiveMessageBundleService meetingLiveMessageBundleService; private final TransactionTemplate transactionTemplate; private final ObjectMapper objectMapper; @@ -129,6 +130,7 @@ private String createTranscribeApplicationRun(Meeting meeting, MeetingResponse.A String audioUrl = audioResponse.getAudioUrl(); String audioFilename = meetingAudioFileService.extractFileName(audioKey); String contentType = meetingAudioFileService.resolveResponseContentType(audioKey); + String liveMessagesJson = meetingLiveMessageBundleService.buildLiveMessagesJson(meeting); FastApiResponse createResponse = fastApiTranscribeClient.createTranscribeApplicationRun( buildAudioResource(audioUrl), @@ -136,7 +138,8 @@ private String createTranscribeApplicationRun(Meeting meeting, MeetingResponse.A contentType, null, String.valueOf(meeting.getId()), - null + null, + liveMessagesJson ); String runId = requireResult(createResponse).runId(); @@ -221,25 +224,31 @@ private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunRes analysisResult != null && analysisResult.applications() != null ? analysisResult.applications() : List.of(); MeetingAnalysis.MeetingAnalysisPayload payload = buildMeetingAnalysisPayload(overallAnalysis); - transactionTemplate.executeWithoutResult(status -> { + SavedApplications savedApplications = transactionTemplate.execute(status -> { Meeting managedMeeting = meetingUseCase.findMeetingWithMembersById(meeting.getId()); - - MeetingAnalysis meetingAnalysis = MeetingAnalysis.create(managedMeeting, payload); + MeetingAnalysis meetingAnalysis = meetingAnalysisRepository.findByMeetingId(managedMeeting.getId()) + .map(existingMeetingAnalysis -> { + existingMeetingAnalysis.updateAnalysis(payload); + return existingMeetingAnalysis; + }) + .orElseGet(() -> MeetingAnalysis.create(managedMeeting, payload)); meetingAnalysisRepository.save(meetingAnalysis); managedMeeting.attachMeetingAnalysis(meetingAnalysis); List dialogues = buildDialogues(managedMeeting, transcriptSegments); - if (!dialogues.isEmpty()) { - dialogueRepository.saveAll(dialogues); - dialogues.forEach(managedMeeting::addDialogue); - } + managedMeeting.getDialogues().clear(); + managedMeeting.getDialogues().addAll(dialogues); Decision decision = createDecisionIfAbsent(managedMeeting); - replaceApplications(managedMeeting.getId(), decision, applications); + return replaceApplications(managedMeeting.getId(), decision, applications); }); + if (savedApplications == null) { + savedApplications = SavedApplications.empty(); + } log.info("회의 오디오 분석 저장 완료: meetingId={}, transcriptSegmentCount={}", meeting.getId(), transcriptSegments.size()); - // TODO: applications 저장 후 applicationId를 발급해 /api/meeting-analysis/embeddings로 전달한다. + sendApplicationEmbeddingsSafely(meeting, response, savedApplications); + } // Decision이 없을 때 새로 생성한다. @@ -253,9 +262,15 @@ private Decision createDecisionIfAbsent(Meeting meeting) { } // 분석 결과의 적용사항 제목 목록을 저장한다. - private void replaceApplications(Long meetingId, - Decision decision, - List applications) { + private SavedApplications replaceApplications(Long meetingId, + Decision decision, + List applications) { + applicationBaseRepository.deleteByMeetingId(meetingId); + applicationTimelineRepository.deleteByMeetingId(meetingId); + decisionBaseRepository.deleteByMeetingId(meetingId); + decisionTimelineRepository.deleteByMeetingId(meetingId); + applicationRepository.deleteByMeetingId(meetingId); + List validApplications = applications.stream() .filter(application -> application != null && application.applicationTitle() != null @@ -274,7 +289,10 @@ private void replaceApplications(Long meetingId, persistApplicationDetails(savedApplications, validApplications); log.info("적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", meetingId, decision.getId(), newApplications.size()); + return new SavedApplications(savedApplications, validApplications); } + + return SavedApplications.empty(); } // 저장된 적용사항 엔티티에 reason/timeline 세부 정보를 순서대로 연결 저장한다. @@ -320,7 +338,7 @@ private void persistApplicationTimelines(Application application, timeline.timestamp(), timeline.step(), timeline.content(), - meetingUseCase.resolveMemberIdBySpeakerId(application.getDecision().getMeeting().getId(), timeline.speakerId()), + timeline.memberId(), timeline.utterance() )) .toList(); @@ -336,6 +354,82 @@ private void persistApplicationTimelines(Application application, applicationTimelineRepository.saveAllAndFlush(applicationTimelines); } + // 저장된 적용사항을 FastAPI 임베딩 요청으로 전달한다. + private void sendApplicationEmbeddingsSafely(Meeting meeting, + TranscribeApplicationRunResponse runResponse, + SavedApplications savedApplications) { + if (savedApplications.savedApplications().isEmpty()) { + log.info("저장된 적용사항이 없어 임베딩 호출을 생략한다: meetingId={}", meeting.getId()); + return; + } + + try { + ApplicationEmbeddingsRequest request = buildEmbeddingsRequest(meeting, runResponse, savedApplications); + FastApiResponse response = fastApiMeetingAnalysisClient.createApplicationEmbeddings(request); + Integer totalDocuments = response != null && response.result() != null ? response.result().totalDocuments() : null; + log.info("적용사항 임베딩 저장 완료: meetingId={}, totalDocuments={}", meeting.getId(), totalDocuments); + } catch (Exception exception) { + log.error("적용사항 임베딩 호출 실패: meetingId={}", meeting.getId(), exception); + } + } + + // FastAPI 임베딩 요청을 생성한다. + private ApplicationEmbeddingsRequest buildEmbeddingsRequest(Meeting meeting, + TranscribeApplicationRunResponse runResponse, + SavedApplications savedApplications) { + TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = runResponse.result(); + TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = + runResult != null ? runResult.analysisResult() : null; + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis = + analysisResult != null ? analysisResult.overallAnalysis() : null; + List otherMentions = + analysisResult != null && analysisResult.otherMentions() != null ? analysisResult.otherMentions() : List.of(); + + List applicationPayloads = new ArrayList<>(); + List applications = savedApplications.savedApplications(); + List sourceApplications = savedApplications.sourceApplications(); + for (int index = 0; index < applications.size(); index++) { + Application savedApplication = applications.get(index); + TranscribeApplicationRunResponse.ApplicationResponse sourceApplication = sourceApplications.get(index); + applicationPayloads.add(new ApplicationEmbeddingsRequest.ApplicationPayload( + savedApplication.getId(), + sourceApplication.applicationTitle(), + sourceApplication.applicationReasons(), + mapTimelinePayloads(sourceApplication.timeline()) + )); + } + + return new ApplicationEmbeddingsRequest( + String.valueOf(meeting.getId()), + null, + new ApplicationEmbeddingsRequest.AnalysisResultPayload( + overallAnalysis, + applicationPayloads, + otherMentions + ) + ); + } + + // FastAPI 응답 타임라인을 임베딩 요청 payload로 변환한다. + private List mapTimelinePayloads( + List timelines + ) { + if (timelines == null || timelines.isEmpty()) { + return List.of(); + } + + return timelines.stream() + .filter(timeline -> timeline != null) + .map(timeline -> new ApplicationEmbeddingsRequest.TimelinePayload( + timeline.timestamp(), + timeline.step(), + timeline.memberId(), + timeline.content(), + timeline.utterance() + )) + .toList(); + } + // null 이거나 비어 있는 문자열을 제외한 값만 반환한다. private List safeStrings(List values) { if (values == null || values.isEmpty()) { @@ -407,12 +501,11 @@ private List buildDialogues(Meeting meeting, return List.of(); } - List members = meeting.getMeetingMembers().stream() + Map membersById = meeting.getMeetingMembers().stream() .map(MeetingMember::getMember) - .sorted(Comparator.comparing(Member::getId)) - .toList(); + .collect(Collectors.toMap(Member::getId, Function.identity())); - if (members.isEmpty()) { + if (membersById.isEmpty()) { return List.of(); } @@ -423,7 +516,11 @@ private List buildDialogues(Meeting meeting, continue; } - Member member = resolveMemberForSegment(members, segment.speaker(), index); + Member member = segment.memberId() != null ? membersById.get(segment.memberId()) : null; + if (member == null) { + continue; + } + LocalDateTime speechDateTime = resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); dialogues.add(Dialogue.create(meeting, member, segment.text().trim(), speechDateTime)); } @@ -431,38 +528,6 @@ private List buildDialogues(Meeting meeting, return dialogues; } - // 세그먼트의 화자 정보를 기반으로 회의 참여자를 선택한다. - private Member resolveMemberForSegment(List members, String speaker, int index) { - if (speaker != null && !speaker.isBlank()) { - String normalizedSpeaker = speaker.trim().toLowerCase(Locale.ROOT); - for (Member member : members) { - if (member.getName() != null && member.getName().trim().toLowerCase(Locale.ROOT).equals(normalizedSpeaker)) { - return member; - } - } - - Integer speakerIndex = extractSpeakerIndex(speaker); - if (speakerIndex != null && speakerIndex >= 0 && speakerIndex < members.size()) { - return members.get(speakerIndex); - } - } - - return members.get(Math.min(index, members.size() - 1)); - } - - // 화자 문자열에서 숫자 인덱스를 추출한다. - private Integer extractSpeakerIndex(String speaker) { - Matcher matcher = SPEAKER_INDEX_PATTERN.matcher(speaker); - if (matcher.find()) { - try { - return Integer.parseInt(matcher.group(1)); - } catch (NumberFormatException ignored) { - return null; - } - } - return null; - } - // 전사 시작 시각 offset을 회의 시작 시각 기준 LocalDateTime으로 변환한다. private LocalDateTime resolveSpeechDateTime(LocalDateTime meetingStartDateTime, String offset, int fallbackIndex) { if (meetingStartDateTime == null) { @@ -500,6 +565,14 @@ private Duration parseDuration(String offset) { } } + private record SavedApplications(List savedApplications, + List sourceApplications) { + + private static SavedApplications empty() { + return new SavedApplications(List.of(), List.of()); + } + } + // FastAPI 응답의 result가 비어 있으면 예외를 던진다. private T requireResult(FastApiResponse response) { if (response == null || response.result() == null) { diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java index b0d9910..be2bf1b 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -12,6 +12,7 @@ import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; +import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; import com.whylog.server.domain.meeting.service.MeetingAnalysisService; import com.whylog.server.domain.meeting.service.MeetingCleanupService; import com.whylog.server.domain.meeting.service.LiveKitTokenService; @@ -42,6 +43,7 @@ public class MeetingCommandService { private final MeetingCleanupService meetingCleanupService; private final MeetingAudioFileService meetingAudioFileService; private final MeetingAnalysisService meetingAnalysisService; + private final MeetingLiveMessageRepository meetingLiveMessageRepository; private final LiveKitTokenService liveKitTokenService; private final LiveKitEgressClient liveKitEgressClient; @@ -136,6 +138,7 @@ public MeetingResponse.MeetingDeleteResponseDTO deleteMeeting(Long memberId, Lon stopRecording(meeting); meetingCleanupService.deleteByMeetingId(meetingId); + meetingLiveMessageRepository.clear(meetingId); scheduleAfterCommit(() -> meetingSocketRoomService.closeRoom(meetingId)); return MeetingResponse.MeetingDeleteResponseDTO.builder() diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleService.java new file mode 100644 index 0000000..828374b --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleService.java @@ -0,0 +1,40 @@ +package com.whylog.server.domain.meeting.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; +import com.whylog.server.global.external.fast.dto.request.LiveMessagePayload; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +// 회의 종료 시 실시간 발화를 drain해서 FastAPI 전송용 JSON으로 묶습니다. +@Service +@Slf4j +@RequiredArgsConstructor +public class MeetingLiveMessageBundleService { + + private final MeetingLiveMessageRepository meetingLiveMessageRepository; + private final ObjectMapper objectMapper; + + public String buildLiveMessagesJson(Meeting meeting) { + List liveMessageEntries = meetingLiveMessageRepository.drain(meeting.getId()); + if (liveMessageEntries.isEmpty()) { + return null; + } + + List payloads = liveMessageEntries.stream() + .map(entry -> LiveMessagePayload.from(entry, meeting.getStartDateTime())) + .toList(); + + try { + return objectMapper.writeValueAsString(payloads); + } catch (JsonProcessingException exception) { + log.warn("실시간 발화 직렬화 실패: meetingId={}", meeting.getId(), exception); + return null; + } + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java index f2a1495..528460f 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java @@ -1,8 +1,10 @@ package com.whylog.server.domain.meeting.service; import com.whylog.server.domain.meeting.dto.MeetingResponse; +import com.whylog.server.domain.meeting.entity.Dialogue; import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingAnalysis; +import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; @@ -96,4 +98,18 @@ public MeetingResponse.AnalysisResultDTO getAnalysis(Long meetingId) { return MeetingResponse.AnalysisResultDTO.create(meetingAnalysis, audioDuration); } + @Transactional(readOnly = true) + public MeetingResponse.HistoryListDTO getDialogueHistory(Long meetingId) { + + // 회의 정보 조회 -> 대화 정보, 참여자 같이 조회 + Meeting meeting = meetingUseCase.findWithDialogue(meetingId); + List dialogues = meeting.getDialogues(); + List members = meeting.getMeetingMembers().stream() + .map(MeetingMember::getMember) + .toList(); + + // dto 생성 및 반환 + return MeetingResponse.HistoryListDTO.create(meeting, dialogues, members); + } + } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java deleted file mode 100644 index 14b9fb5..0000000 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.whylog.server.domain.meeting.service; - -public interface MeetingSpeakerResolver { - - Long resolveMemberIdBySpeakerId(Long meetingId, String speakerId); -} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java index 02bcf54..6b9a383 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java @@ -2,9 +2,11 @@ import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingMember; +import com.whylog.server.domain.meeting.exception.MeetingErrorCode; import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -12,7 +14,7 @@ @Service @RequiredArgsConstructor -public class MeetingUseCase implements MeetingSpeakerResolver { +public class MeetingUseCase{ private final MeetingRepository meetingRepository; @@ -46,9 +48,11 @@ public Meeting findWithAnalysisByMeetingId(Long meetingId) { return meetingRepository.findByMeetingId(meetingId) .orElseThrow(MeetingNotFoundException::new); } - @Override - public Long resolveMemberIdBySpeakerId(Long meetingId, String speakerId) { - // TODO: 대화 내역을 기준으로 speakerId와 실제 memberId를 매칭한다. - return 1L; + + public Meeting findWithDialogue(Long meetingId) { + return meetingRepository.findByMeetingId(meetingId) + .orElseThrow(MeetingNotFoundException::new); } + + } diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java index b1c37b2..53a5d28 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java @@ -3,10 +3,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.whylog.server.domain.meeting.socket.message.*; import com.whylog.server.domain.meeting.service.MeetingCommandService; +import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; import com.whylog.server.global.util.json.JsonConverter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; +import com.fasterxml.jackson.databind.JsonNode; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.socket.BinaryMessage; @@ -16,6 +18,7 @@ import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; import org.springframework.web.socket.handler.BinaryWebSocketHandler; +import java.time.LocalDateTime; import java.time.Instant; import java.util.List; import java.util.Optional; @@ -31,6 +34,7 @@ public class MeetingSocketHandler extends BinaryWebSocketHandler { private final MeetingSocketRoomService meetingSocketRoomService; private final MeetingCommandService meetingCommandService; + private final MeetingLiveMessageRepository meetingLiveMessageRepository; // 웹소켓 연결 직후 참가자를 방에 등록하고 현재 참여자 목록과 입장 이벤트를 전파합니다. @Override @@ -202,6 +206,31 @@ private void broadcastTextMessage(MeetingParticipant participant, MeetingMessage incoming.payload() )) ); + if (type == MeetingMessageType.AUDIO_TEXT + && StringUtils.hasText(incoming.text()) + && !isInterim(incoming.payload())) { + meetingLiveMessageRepository.append( + participant.meetingId(), + new LiveMessageEntry( + participant.meetingId(), + participant.memberId(), + participant.name(), + incoming.targetMemberId(), + incoming.text(), + incoming.payload(), + LocalDateTime.now() + ) + ); + } + } + + private static boolean isInterim(JsonNode payload) { + if (payload == null) { + return false; + } + + JsonNode isFinal = payload.get("is_final"); + return isFinal != null && isFinal.isBoolean() && !isFinal.booleanValue(); } private void forwardSignal( diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/LiveMessageEntry.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/LiveMessageEntry.java new file mode 100644 index 0000000..d14b47b --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/LiveMessageEntry.java @@ -0,0 +1,16 @@ +package com.whylog.server.domain.meeting.socket.message; + +import com.fasterxml.jackson.databind.JsonNode; +import java.time.LocalDateTime; + +// 실시간 회의 발화 한 건을 메모리에 임시 저장하는 단위입니다. +public record LiveMessageEntry( + Long meetingId, + Long fromMemberId, + String fromName, + Long targetMemberId, + String text, + JsonNode payload, + LocalDateTime receivedAt +) { +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepository.java b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepository.java new file mode 100644 index 0000000..3827675 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepository.java @@ -0,0 +1,49 @@ +package com.whylog.server.domain.meeting.socket.repository; + +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.stereotype.Repository; + +// meetingId 기준으로 실시간 발화를 메모리에 임시 저장합니다. +@Repository +public class MeetingLiveMessageRepository { + + private final Map> liveMessagesByMeetingId = new ConcurrentHashMap<>(); + + public void append(Long meetingId, LiveMessageEntry entry) { + liveMessagesByMeetingId.compute(meetingId, (id, list) -> { + CopyOnWriteArrayList target = list != null ? list : new CopyOnWriteArrayList<>(); + if (!target.isEmpty()) { + LiveMessageEntry last = target.get(target.size() - 1); + if (isSameUtterance(last, entry)) { + return target; + } + } + + target.add(entry); + return target; + }); + } + + public List drain(Long meetingId) { + CopyOnWriteArrayList entries = liveMessagesByMeetingId.remove(meetingId); + if (entries == null) { + return List.of(); + } + + return List.copyOf(entries); + } + + public void clear(Long meetingId) { + liveMessagesByMeetingId.remove(meetingId); + } + + private static boolean isSameUtterance(LiveMessageEntry left, LiveMessageEntry right) { + return Objects.equals(left.fromMemberId(), right.fromMemberId()) + && Objects.equals(left.text(), right.text()); + } +} diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java index 10f0003..68b08ac 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java @@ -1,11 +1,11 @@ package com.whylog.server.global.external.fast.client; import com.fasterxml.jackson.databind.JsonNode; -import java.util.Map; - import com.whylog.server.global.external.fast.FastApiInfo; import com.whylog.server.global.external.fast.dto.FastApiResponse; +import com.whylog.server.global.external.fast.dto.request.ApplicationEmbeddingsRequest; import com.whylog.server.global.external.fast.dto.request.MeetingAnalysisRequest; +import com.whylog.server.global.external.fast.dto.response.ApplicationEmbeddingsResponse; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -26,7 +26,7 @@ public FastApiResponse extractMeetingAnalysis(MeetingAnalysisRequest r ); } - public FastApiResponse createApplicationEmbeddings(Map request) { + public FastApiResponse createApplicationEmbeddings(ApplicationEmbeddingsRequest request) { return postJson( FastApiInfo.MEETING_ANALYSIS_EMBEDDINGS, request, diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java index 5243d3c..95fc688 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java @@ -10,6 +10,7 @@ import java.util.Map; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.Resource; +import org.springframework.util.StringUtils; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -46,7 +47,7 @@ public FastApiResponse transcribeAudio(Resource audio, String projectId) { return postMultipart( FastApiInfo.TRANSCRIBE_AUDIO, - buildTextParts(numSpeakers, meetingId, projectId), + buildTextParts(numSpeakers, meetingId, projectId, null), new FastApiBinaryPart("audio", audio, filename, contentType), new ParameterizedTypeReference<>() { } @@ -61,7 +62,7 @@ public FastApiResponse transcribeApplications(Resource audio, String projectId) { return postMultipart( FastApiInfo.TRANSCRIBE_APPLICATIONS, - buildTextParts(numSpeakers, meetingId, projectId), + buildTextParts(numSpeakers, meetingId, projectId, null), new FastApiBinaryPart("audio", audio, filename, contentType), new ParameterizedTypeReference<>() { } @@ -85,9 +86,19 @@ public FastApiResponse createTranscribeA Integer numSpeakers, String meetingId, String projectId) { + return createTranscribeApplicationRun(audio, filename, contentType, numSpeakers, meetingId, projectId, null); + } + + public FastApiResponse createTranscribeApplicationRun(Resource audio, + String filename, + String contentType, + Integer numSpeakers, + String meetingId, + String projectId, + String liveMessages) { return postMultipart( FastApiInfo.TRANSCRIBE_APPLICATION_RUNS, - buildTextParts(numSpeakers, meetingId, projectId), + buildTextParts(numSpeakers, meetingId, projectId, liveMessages), new FastApiBinaryPart("audio", audio, filename, contentType), new ParameterizedTypeReference<>() { } @@ -101,7 +112,8 @@ public FastApiResponse createTranscribeA request.audio().contentType(), request.numSpeakers(), request.meetingId(), - request.projectId() + request.projectId(), + null ); } @@ -114,7 +126,7 @@ public FastApiResponse getTranscribeApplicatio ); } - private Map buildTextParts(Integer numSpeakers, String meetingId, String projectId) { + private Map buildTextParts(Integer numSpeakers, String meetingId, String projectId, String liveMessages) { Map parts = new LinkedHashMap<>(); if (numSpeakers != null) { parts.put("num_speakers", numSpeakers); @@ -125,6 +137,9 @@ private Map buildTextParts(Integer numSpeakers, String meetingId if (projectId != null) { parts.put("project_id", projectId); } + if (StringUtils.hasText(liveMessages)) { + parts.put("live_messages", liveMessages); + } return parts; } } diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/request/ApplicationEmbeddingsRequest.java b/src/main/java/com/whylog/server/global/external/fast/dto/request/ApplicationEmbeddingsRequest.java new file mode 100644 index 0000000..ac7d584 --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/fast/dto/request/ApplicationEmbeddingsRequest.java @@ -0,0 +1,66 @@ +package com.whylog.server.global.external.fast.dto.request; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "FastAPI 회의 분석 임베딩 요청") +public record ApplicationEmbeddingsRequest( + @Schema(description = "회의 ID", nullable = true) + String meetingId, + @Schema(description = "프로젝트 ID", nullable = true) + String projectId, + @Schema(description = "회의 분석 결과", nullable = true) + AnalysisResultPayload analysisResult +) { + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + @Schema(description = "회의 분석 결과 payload") + public record AnalysisResultPayload( + @Schema(description = "전체 분석 결과", nullable = true) + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis, + @Schema(description = "적용사항 목록", nullable = true) + List applications, + @Schema(description = "기타 언급 목록", nullable = true) + List otherMentions + ) { + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + @Schema(description = "적용사항 payload") + public record ApplicationPayload( + @Schema(description = "적용사항 ID", nullable = true) + Long applicationId, + @Schema(description = "적용사항 제목", nullable = true) + String applicationTitle, + @Schema(description = "적용사항 사유 목록", nullable = true) + List applicationReasons, + @Schema(description = "적용사항 타임라인", nullable = true) + List timeline + ) { + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + @Schema(description = "적용사항 타임라인 payload") + public record TimelinePayload( + @Schema(description = "타임스탬프", nullable = true) + String timestamp, + @Schema(description = "단계", nullable = true) + String step, + @Schema(description = "발화자 멤버 ID", nullable = true) + Long memberId, + @Schema(description = "내용", nullable = true) + String content, + @Schema(description = "원문 발화", nullable = true) + String utterance + ) { + } +} diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java b/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java new file mode 100644 index 0000000..258bc89 --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java @@ -0,0 +1,58 @@ +package com.whylog.server.global.external.fast.dto.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import java.time.Duration; +import java.time.LocalDateTime; + +// FastAPI로 전달할 실시간 발화 항목입니다. +@JsonInclude(JsonInclude.Include.NON_NULL) +public record LiveMessagePayload( + Long meetingId, + Long fromMemberId, + String fromName, + String timestamp, + Long targetMemberId, + String text, + JsonNode payload +) { + + @JsonProperty("type") + public String type() { + return "TEXT"; + } + + public static LiveMessagePayload from(LiveMessageEntry entry, LocalDateTime meetingStartDateTime) { + return new LiveMessagePayload( + entry.meetingId(), + entry.fromMemberId(), + entry.fromName(), + formatElapsed(meetingStartDateTime, entry.receivedAt()), + entry.targetMemberId(), + entry.text(), + entry.payload() + ); + } + + private static String formatElapsed(LocalDateTime startDateTime, LocalDateTime receivedAt) { + try { + if (startDateTime == null || receivedAt == null) { + return "00:00:00"; + } + + long elapsedSeconds = Duration.between(startDateTime, receivedAt).getSeconds(); + if (elapsedSeconds < 0) { + return "00:00:00"; + } + + long hours = elapsedSeconds / 3600; + long minutes = (elapsedSeconds % 3600) / 60; + long seconds = elapsedSeconds % 60; + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } catch (RuntimeException exception) { + return "00:00:00"; + } + } +} diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/response/ApplicationEmbeddingsResponse.java b/src/main/java/com/whylog/server/global/external/fast/dto/response/ApplicationEmbeddingsResponse.java new file mode 100644 index 0000000..070436a --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/fast/dto/response/ApplicationEmbeddingsResponse.java @@ -0,0 +1,39 @@ +package com.whylog.server.global.external.fast.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "FastAPI 회의 분석 임베딩 응답") +public record ApplicationEmbeddingsResponse( + @Schema(description = "회의 ID", nullable = true) + String meetingId, + @Schema(description = "프로젝트 ID", nullable = true) + String projectId, + @Schema(description = "문서 수", nullable = true) + Integer totalDocuments, + @Schema(description = "문서 ID 목록", nullable = true) + List documentIds, + @Schema(description = "문서 목록", nullable = true) + List documents +) { + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + @Schema(description = "임베딩 문서") + public record DocumentResponse( + @Schema(description = "문서 ID", nullable = true) + String documentId, + @Schema(description = "문서 본문", nullable = true) + String text, + @Schema(description = "적용사항 ID", nullable = true) + Long applicationId, + @Schema(description = "적용사항 제목", nullable = true) + String applicationTitle + ) { + } +} diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java b/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java index d75cc33..53192b3 100644 --- a/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java +++ b/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java @@ -53,8 +53,8 @@ public record TranscribeApplicationRunResult( public record TranscriptSegmentResponse( @Schema(description = "메시지 ID", example = "1", nullable = true) Long messageId, - @Schema(description = "화자명", example = "Speaker 0", nullable = true) - String speaker, + @Schema(description = "발화자 멤버 ID", example = "1", nullable = true) + Long memberId, @Schema(description = "시작 시각", example = "00:00:00", nullable = true) String startTime, @Schema(description = "종료 시각", example = "00:00:04", nullable = true) @@ -132,8 +132,8 @@ public record TimelineResponse( String timestamp, @Schema(description = "단계", example = "이슈제기", nullable = true) String step, - @Schema(description = "화자 ID", example = "Speaker 0", nullable = true) - String speakerId, + @Schema(description = "발화자 멤버 ID", example = "1", nullable = true) + Long memberId, @Schema(description = "내용", nullable = true) String content, @Schema(description = "원문 발화", nullable = true)