diff --git a/src/main/kotlin/com/learner/language/domain/chat/ChatContextType.kt b/src/main/kotlin/com/learner/language/domain/chat/ChatContextType.kt new file mode 100644 index 0000000..da2ddf3 --- /dev/null +++ b/src/main/kotlin/com/learner/language/domain/chat/ChatContextType.kt @@ -0,0 +1,6 @@ +package com.learner.language.domain.chat + +enum class ChatContextType(val code: String) { + GENERAL("GENERAL"), + VIDEO_TRANSCRIPT("VIDEO_TRANSCRIPT"); +} diff --git a/src/main/kotlin/com/learner/language/domain/chat/ChatContextTypeConverter.kt b/src/main/kotlin/com/learner/language/domain/chat/ChatContextTypeConverter.kt new file mode 100644 index 0000000..d677f64 --- /dev/null +++ b/src/main/kotlin/com/learner/language/domain/chat/ChatContextTypeConverter.kt @@ -0,0 +1,15 @@ +package com.learner.language.domain.chat + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter(autoApply = true) +class ChatContextTypeConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: ChatContextType?): String { + return attribute?.code ?: ChatContextType.GENERAL.code + } + + override fun convertToEntityAttribute(dbData: String?): ChatContextType { + return ChatContextType.entries.firstOrNull { it.code == dbData } ?: ChatContextType.GENERAL + } +} diff --git a/src/main/kotlin/com/learner/language/domain/chat/ChatRoom.kt b/src/main/kotlin/com/learner/language/domain/chat/ChatRoom.kt index b8e1c2b..af737d9 100644 --- a/src/main/kotlin/com/learner/language/domain/chat/ChatRoom.kt +++ b/src/main/kotlin/com/learner/language/domain/chat/ChatRoom.kt @@ -27,6 +27,13 @@ class ChatRoom( @Column var personaType: PersonaType, + @Convert(converter = ChatContextTypeConverter::class) + @Column(name = "context_type", nullable = false) + var contextType: ChatContextType = ChatContextType.GENERAL, + + @Column(name = "video_id") + var videoId: String? = null, + @Column(name = "last_message_date_time") var lastMessageDateTime: LocalDateTime = LocalDateTime.now() @@ -35,7 +42,11 @@ class ChatRoom( this.lastMessageDateTime = LocalDateTime.now() } - constructor(user: User, personaType: PersonaType) : this(user, "name", personaType, LocalDateTime.now()) { + constructor( + user: User, + personaType: PersonaType, + contextType: ChatContextType = ChatContextType.GENERAL, + videoId: String? = null + ) : this(user, "name", personaType, contextType, videoId, LocalDateTime.now()) { } } - diff --git a/src/main/kotlin/com/learner/language/domain/chat/ChatRoomCommand.kt b/src/main/kotlin/com/learner/language/domain/chat/ChatRoomCommand.kt index 51f642c..4659234 100644 --- a/src/main/kotlin/com/learner/language/domain/chat/ChatRoomCommand.kt +++ b/src/main/kotlin/com/learner/language/domain/chat/ChatRoomCommand.kt @@ -6,12 +6,25 @@ import com.learner.language.domain.user.User class ChatRoomCommand { data class Register( val personaType: PersonaType, + val contextType: ChatContextType = ChatContextType.GENERAL, + val youtubeVideoId: String? = null, + val name: String? = null, ) { fun toEntity(user: User): ChatRoom { return ChatRoom( user = user, + name = name ?: defaultRoomName(), personaType = personaType, + contextType = contextType, + videoId = youtubeVideoId, ) } + + private fun defaultRoomName(): String { + return when (contextType) { + ChatContextType.GENERAL -> "새 대화" + ChatContextType.VIDEO_TRANSCRIPT -> "영상 대화 ${youtubeVideoId.orEmpty()}".trim() + } + } } } diff --git a/src/main/kotlin/com/learner/language/domain/chat/ChatRoomInfo.kt b/src/main/kotlin/com/learner/language/domain/chat/ChatRoomInfo.kt index b617d0d..8d48ec6 100644 --- a/src/main/kotlin/com/learner/language/domain/chat/ChatRoomInfo.kt +++ b/src/main/kotlin/com/learner/language/domain/chat/ChatRoomInfo.kt @@ -4,12 +4,16 @@ class ChatRoomInfo( val chatRoomId: Long, val name: String, val personaType: String, + val contextType: String, + val youtubeVideoId: String?, val lastMessageDateTime: String ) { constructor(chatRoom: ChatRoom): this( chatRoomId = chatRoom.id, name = chatRoom.name, personaType = chatRoom.personaType.name, + contextType = chatRoom.contextType.name, + youtubeVideoId = chatRoom.videoId, lastMessageDateTime = chatRoom.lastMessageDateTime.toString() ) diff --git a/src/main/kotlin/com/learner/language/domain/chat/ChatServiceImpl.kt b/src/main/kotlin/com/learner/language/domain/chat/ChatServiceImpl.kt index 6f8ce76..5ce9f5c 100644 --- a/src/main/kotlin/com/learner/language/domain/chat/ChatServiceImpl.kt +++ b/src/main/kotlin/com/learner/language/domain/chat/ChatServiceImpl.kt @@ -6,6 +6,8 @@ import com.learner.language.domain.audio.AudioSpeech import com.learner.language.domain.audio.AudioSpeechInfo import com.learner.language.domain.audio.AudioTranscribe import com.learner.language.domain.audio.AudioTranscribeInfo +import com.learner.language.domain.cliplearning.ClipLearningTranscriptInfo +import com.learner.language.domain.cliplearning.ClipLearningTranscriptReader import com.learner.language.domain.event.ChatEvent import com.learner.language.domain.prompt.PersonaType import com.learner.language.domain.user.User @@ -30,6 +32,7 @@ class ChatServiceImpl( private val audioTranscribeRepository: AudioTranscribeRepository, private val audioSpeechRepository: AudioSpeechRepository, private val chatAudioSpeechMatchRepository: ChatAudioSpeechMatchRepository, + private val clipLearningTranscriptReader: ClipLearningTranscriptReader, ): ChatService { override fun hello(): String { return "hello" @@ -44,6 +47,11 @@ class ChatServiceImpl( val chatHistory = toHistory(chatMessageList) val nextSequence = getNextSequence(chatMessageList) + if (chatRoom.contextType == ChatContextType.VIDEO_TRANSCRIPT) { + val transcriptContext = buildTranscriptContext(chatRoom) + return aiChatService.greetingTranscriptChat(personaType, user, chatRoom, chatHistory, transcriptContext, nextSequence) + } + return aiChatService.greetingChat(personaType, user, chatRoom, chatHistory, nextSequence) } @@ -101,7 +109,12 @@ class ChatServiceImpl( val chatMessageList = chatReader.getChatMessageListByChatRoomId(command.chatRoomId) val chatHistory = toHistory(chatMessageList) val nextSequence = getNextSequence(command.chatRoomId) - val chatMessage = aiChatService.generateChat(command, user, chatRoom, chatHistory, nextSequence) + val chatMessage = if (chatRoom.contextType == ChatContextType.VIDEO_TRANSCRIPT) { + val transcriptContext = buildTranscriptContext(chatRoom) + aiChatService.generateTranscriptChat(command, user, chatRoom, chatHistory, transcriptContext, nextSequence) + } else { + aiChatService.generateChat(command, user, chatRoom, chatHistory, nextSequence) + } val savedChatMessage = chatWriter.save(chatMessage) val chatMessageInfo = ChatMessageInfo(savedChatMessage) @@ -209,7 +222,7 @@ class ChatServiceImpl( message = command.message, sequence = nextSequence ) - val savedChatMessage = chatWriter.save(chatMessage) + chatWriter.save(chatMessage) chatRoom.updateLastMessageDateTime() chatRoomRepository.save(chatRoom) @@ -236,6 +249,7 @@ class ChatServiceImpl( userId: Long, command: ChatRoomCommand.Register ): ChatRoomInfo { + validateChatRoomCommand(command) val user = userReader.getUserById(userId) val chatRoom = command.toEntity(user) val savedChatRoom = chatRoomRepository.save(chatRoom) @@ -260,10 +274,58 @@ class ChatServiceImpl( val chatMessageList = chatReader.getChatMessageListByChatRoomId(chatRoomId) val chatHistory = toHistory(chatMessageList) val nextSequence = getNextSequence(chatRoomId) - val chatMessage = aiChatService.greetingChat(command.personaType, user, chatRoom, chatHistory, nextSequence) + val chatMessage = if (chatRoom.contextType == ChatContextType.VIDEO_TRANSCRIPT) { + val transcriptContext = buildTranscriptContext(chatRoom) + aiChatService.greetingTranscriptChat(command.personaType, user, chatRoom, chatHistory, transcriptContext, nextSequence) + } else { + aiChatService.greetingChat(command.personaType, user, chatRoom, chatHistory, nextSequence) + } val savedChatMessage = chatWriter.save(chatMessage) val chatMessageInfo = ChatMessageInfo(savedChatMessage) return chatMessageInfo } + + private fun validateChatRoomCommand(command: ChatRoomCommand.Register) { + when (command.contextType) { + ChatContextType.GENERAL -> { + if (!command.youtubeVideoId.isNullOrBlank()) { + throw BadRequestException(ErrorCode.BAD_REQUEST, "GENERAL chat room does not accept youtubeVideoId") + } + } + ChatContextType.VIDEO_TRANSCRIPT -> { + val youtubeVideoId = command.youtubeVideoId?.trim() + ?: throw BadRequestException(ErrorCode.BAD_REQUEST, "VIDEO_TRANSCRIPT chat room requires youtubeVideoId") + if (youtubeVideoId.isBlank()) { + throw BadRequestException(ErrorCode.BAD_REQUEST, "VIDEO_TRANSCRIPT chat room requires youtubeVideoId") + } + clipLearningTranscriptReader.retrieveTranscript(youtubeVideoId) + } + } + } + + private fun buildTranscriptContext(chatRoom: ChatRoom): String { + val youtubeVideoId = chatRoom.videoId + ?: throw BadRequestException(ErrorCode.BAD_REQUEST, "VIDEO_TRANSCRIPT chat room requires youtubeVideoId") + val transcript = clipLearningTranscriptReader.retrieveTranscript(youtubeVideoId) + return transcript.toPromptContext() + } + + private fun ClipLearningTranscriptInfo.toPromptContext(): String { + val header = buildString { + appendLine("videoId: $videoId") + appendLine("languagePriority: ${languagePriority.joinToString(", ")}") + appendLine("count: $count") + appendLine("items:") + } + val lines = items.joinToString("\n") { item -> + "[${formatSeconds(item.start)} +${"%.3f".format(item.duration)}s] ${item.text}" + } + + return header + lines + } + + private fun formatSeconds(seconds: Double): String { + return "%.3f".format(seconds) + } } diff --git a/src/main/kotlin/com/learner/language/interfaces/chat/ChatRoomDto.kt b/src/main/kotlin/com/learner/language/interfaces/chat/ChatRoomDto.kt index 24f3bb8..72a12eb 100644 --- a/src/main/kotlin/com/learner/language/interfaces/chat/ChatRoomDto.kt +++ b/src/main/kotlin/com/learner/language/interfaces/chat/ChatRoomDto.kt @@ -1,18 +1,24 @@ package com.learner.language.interfaces.chat import com.learner.language.domain.chat.ChatRoomCommand +import com.learner.language.domain.chat.ChatContextType import com.learner.language.domain.chat.ChatRoomInfo import com.learner.language.domain.prompt.PersonaType import jakarta.validation.constraints.NotEmpty class ChatRoomDto { data class RegisterRequest( - @NotEmpty(message = "personaType is empty") val personaType: PersonaType, + val contextType: ChatContextType = ChatContextType.GENERAL, + val youtubeVideoId: String? = null, + val name: String? = null, ) { fun toCommand(): ChatRoomCommand.Register { return ChatRoomCommand.Register( - personaType = personaType + personaType = personaType, + contextType = contextType, + youtubeVideoId = youtubeVideoId, + name = name ) } } diff --git a/src/main/kotlin/com/learner/language/system/db/migration/202603300001_add_chat_room_context.sql b/src/main/kotlin/com/learner/language/system/db/migration/202603300001_add_chat_room_context.sql new file mode 100644 index 0000000..eecdb5d --- /dev/null +++ b/src/main/kotlin/com/learner/language/system/db/migration/202603300001_add_chat_room_context.sql @@ -0,0 +1,5 @@ +ALTER TABLE chat_room + ADD COLUMN context_type VARCHAR(50) NOT NULL DEFAULT 'GENERAL' AFTER persona_type, + ADD COLUMN video_id VARCHAR(100) NULL AFTER context_type; + +CREATE INDEX idx_chat_room_user_context ON chat_room (user_id, context_type); diff --git a/src/test/kotlin/com/learner/language/service/chat/ChatRoomContextServiceTest.kt b/src/test/kotlin/com/learner/language/service/chat/ChatRoomContextServiceTest.kt new file mode 100644 index 0000000..acee316 --- /dev/null +++ b/src/test/kotlin/com/learner/language/service/chat/ChatRoomContextServiceTest.kt @@ -0,0 +1,138 @@ +package com.learner.language.service.chat + +import com.learner.language.domain.ai.AiAudioService +import com.learner.language.domain.ai.AiChatService +import com.learner.language.domain.chat.ChatContextType +import com.learner.language.domain.chat.ChatRoomCommand +import com.learner.language.domain.chat.ChatServiceImpl +import com.learner.language.domain.cliplearning.ClipLearningTranscriptInfo +import com.learner.language.domain.cliplearning.ClipLearningTranscriptItemInfo +import com.learner.language.domain.cliplearning.ClipLearningTranscriptReader +import com.learner.language.domain.prompt.PersonaType +import com.learner.language.domain.user.UserReader +import com.learner.language.infrastructure.audio.AudioSpeechRepository +import com.learner.language.infrastructure.audio.AudioTranscribeRepository +import com.learner.language.infrastructure.chat.ChatAudioSpeechMatchRepository +import com.learner.language.infrastructure.chat.ChatRoomRepository +import com.learner.language.system.exception.BadRequestException +import com.learner.language.system.security.CustomPasswordEncoder +import com.learner.language.testutils.fixture.UserFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class ChatRoomContextServiceTest : BehaviorSpec({ + val chatWriter = mockk() + val chatReader = mockk() + val chatRoomRepository = mockk() + val userReader = mockk() + val aiChatService = mockk() + val aiAudioService = mockk() + val audioTranscribeRepository = mockk() + val audioSpeechRepository = mockk() + val chatAudioSpeechMatchRepository = mockk() + val clipLearningTranscriptReader = mockk() + val passwordEncoder = mockk() + + val chatService = ChatServiceImpl( + chatWriter = chatWriter, + chatReader = chatReader, + chatRoomRepository = chatRoomRepository, + userReader = userReader, + aiChatService = aiChatService, + aiAudioService = aiAudioService, + audioTranscribeRepository = audioTranscribeRepository, + audioSpeechRepository = audioSpeechRepository, + chatAudioSpeechMatchRepository = chatAudioSpeechMatchRepository, + clipLearningTranscriptReader = clipLearningTranscriptReader + ) + + afterTest { + clearMocks( + chatWriter, + chatReader, + chatRoomRepository, + userReader, + aiChatService, + aiAudioService, + audioTranscribeRepository, + audioSpeechRepository, + chatAudioSpeechMatchRepository, + clipLearningTranscriptReader + ) + } + + Given("saveChatRoom 호출 시") { + val userId = 1L + every { passwordEncoder.encodePassword(any()) } returns "encoded-password" + val user = UserFixture.createUser(passwordEncoder = passwordEncoder) + + When("VIDEO_TRANSCRIPT 타입인데 youtubeVideoId가 없으면") { + every { userReader.getUserById(userId) } returns user + every { chatRoomRepository.save(any()) } answers { firstArg() } + val command = ChatRoomCommand.Register( + personaType = PersonaType.TEACHER, + contextType = ChatContextType.VIDEO_TRANSCRIPT, + youtubeVideoId = null + ) + + Then("BadRequestException이 발생해야 한다") { + shouldThrow { + chatService.saveChatRoom(userId, command) + } + } + } + + When("GENERAL 타입인데 youtubeVideoId가 들어오면") { + every { userReader.getUserById(userId) } returns user + every { chatRoomRepository.save(any()) } answers { firstArg() } + val command = ChatRoomCommand.Register( + personaType = PersonaType.CHILD, + contextType = ChatContextType.GENERAL, + youtubeVideoId = "Kkx6-9AJTY0" + ) + + Then("BadRequestException이 발생해야 한다") { + shouldThrow { + chatService.saveChatRoom(userId, command) + } + } + } + + When("VIDEO_TRANSCRIPT 타입과 유효한 youtubeVideoId가 들어오면") { + every { userReader.getUserById(userId) } returns user + every { chatRoomRepository.save(any()) } answers { firstArg() } + val command = ChatRoomCommand.Register( + personaType = PersonaType.TEACHER, + contextType = ChatContextType.VIDEO_TRANSCRIPT, + youtubeVideoId = "Kkx6-9AJTY0", + name = "카페 표현 연습" + ) + every { clipLearningTranscriptReader.retrieveTranscript("Kkx6-9AJTY0") } returns ClipLearningTranscriptInfo( + videoId = "Kkx6-9AJTY0", + languagePriority = listOf("ko", "en"), + count = 1, + items = listOf( + ClipLearningTranscriptItemInfo( + text = "아이스 아메리카노 한 잔 주세요.", + start = 7.632, + duration = 5.031 + ) + ) + ) + + val result = chatService.saveChatRoom(userId, command) + + Then("transcript를 검증하고 youtubeVideoId를 가진 방을 생성해야 한다") { + result.contextType shouldBe ChatContextType.VIDEO_TRANSCRIPT.name + result.youtubeVideoId shouldBe "Kkx6-9AJTY0" + verify(exactly = 1) { clipLearningTranscriptReader.retrieveTranscript("Kkx6-9AJTY0") } + verify(exactly = 1) { chatRoomRepository.save(any()) } + } + } + } +}) diff --git a/src/test/kotlin/com/learner/language/service/chat/ChatServiceTest.kt b/src/test/kotlin/com/learner/language/service/chat/ChatServiceTest.kt index 012488e..8644753 100644 --- a/src/test/kotlin/com/learner/language/service/chat/ChatServiceTest.kt +++ b/src/test/kotlin/com/learner/language/service/chat/ChatServiceTest.kt @@ -2,7 +2,11 @@ package com.learner.language.service.chat import com.learner.language.domain.ai.AiAudioService import com.learner.language.domain.ai.AiChatService +import com.learner.language.domain.chat.ChatContextType import com.learner.language.domain.chat.* +import com.learner.language.domain.cliplearning.ClipLearningTranscriptInfo +import com.learner.language.domain.cliplearning.ClipLearningTranscriptItemInfo +import com.learner.language.domain.cliplearning.ClipLearningTranscriptReader import com.learner.language.domain.prompt.PersonaType import com.learner.language.domain.user.UserReader import com.learner.language.infrastructure.audio.AudioSpeechRepository @@ -35,18 +39,19 @@ class ChatServiceTest : BehaviorSpec({ val audioTranscribeRepository = mockk() val audioSpeechRepository = mockk() val chatAudioSpeechMatchRepository = mockk() + val clipLearningTranscriptReader = mockk() val passwordEncoder = mockk() // 테스트 대상 클래스 생성 val chatServiceImpl = ChatServiceImpl( chatWriter, chatReader, chatRoomRepository, userReader, aiChatService, aiAudioService, audioTranscribeRepository, - audioSpeechRepository, chatAudioSpeechMatchRepository + audioSpeechRepository, chatAudioSpeechMatchRepository, clipLearningTranscriptReader ) // 테스트가 끝날 때마다 Mock 초기화 (권장) afterTest { - clearMocks(chatWriter, chatReader, chatRoomRepository, userReader, aiChatService) + clearMocks(chatWriter, chatReader, chatRoomRepository, userReader, aiChatService, clipLearningTranscriptReader) } Given("AI 채팅 시작(greetingChat) 시나리오에서") { @@ -129,6 +134,61 @@ class ChatServiceTest : BehaviorSpec({ } } + Given("영상 transcript 컨텍스트 채팅방에서 generateChat 메서드는") { + val userId = 1L + val chatRoomId = 200L + val chatId = 10L + val personaType = PersonaType.TEACHER + val videoId = "Kkx6-9AJTY0" + + every { passwordEncoder.encodePassword(any()) } returns "123456789" + val user = UserFixture.createUser(passwordEncoder = passwordEncoder) + val chatRoom = ChatFixture.createChatRoom( + id = chatRoomId, + user = user, + personaType = personaType, + contextType = ChatContextType.VIDEO_TRANSCRIPT, + videoId = videoId + ) + + val command = ChatCommand.Generate(chatRoomId = chatRoomId, personaType = personaType) + val aiChatMessage = ChatFixture.createChatMessage(chatId, user, chatRoom, SenderType.AI) + val transcript = ClipLearningTranscriptInfo( + videoId = videoId, + languagePriority = listOf("ko", "en"), + count = 1, + items = listOf( + ClipLearningTranscriptItemInfo( + text = "안녕하세요. 오늘은 카페에서 주문하는 표현을 배워요.", + start = 7.632, + duration = 5.031 + ) + ) + ) + + When("채팅방이 VIDEO_TRANSCRIPT 타입이면") { + every { userReader.getUserById(userId) } returns user + every { chatRoomRepository.findById(chatRoomId) } returns Optional.of(chatRoom) + every { chatReader.getChatMessageListByChatRoomId(chatRoomId) } returns emptyList() + every { chatReader.getLastChatMessageByChatRoomId(chatRoomId) } returns null + every { clipLearningTranscriptReader.retrieveTranscript(videoId) } returns transcript + every { + aiChatService.generateTranscriptChat(eq(command), eq(user), eq(chatRoom), any(), any(), any()) + } returns aiChatMessage + every { chatWriter.save(any()) } returns aiChatMessage + + val result = chatServiceImpl.generateChat(command, userId) + + Then("transcript 컨텍스트를 조회한 뒤 transcript-aware 응답을 생성해야 한다") { + result.message shouldBe "Test Message" + verify(exactly = 1) { clipLearningTranscriptReader.retrieveTranscript(videoId) } + verify(exactly = 1) { + aiChatService.generateTranscriptChat(eq(command), eq(user), eq(chatRoom), any(), any(), any()) + } + } + } + } + Given("createParaphraseChatMessage 메서드는") { val userId = 1L val chatRoomId = 100L diff --git a/src/test/kotlin/com/learner/language/testutils/fixture/ChatFixture.kt b/src/test/kotlin/com/learner/language/testutils/fixture/ChatFixture.kt index 70f4de5..10e6ebb 100644 --- a/src/test/kotlin/com/learner/language/testutils/fixture/ChatFixture.kt +++ b/src/test/kotlin/com/learner/language/testutils/fixture/ChatFixture.kt @@ -1,6 +1,7 @@ package com.learner.language.testutils.fixture import com.learner.language.domain.chat.ChatMessage +import com.learner.language.domain.chat.ChatContextType import com.learner.language.domain.chat.ChatRoom import com.learner.language.domain.chat.SenderType import com.learner.language.domain.prompt.PersonaType // Corrected import @@ -14,12 +15,16 @@ object ChatFixture { id: Long = 1L, user: User, // User is now a required parameter personaType: PersonaType = PersonaType.CHILD, + contextType: ChatContextType = ChatContextType.GENERAL, + videoId: String? = null, name: String = "Test Chat Room", lastMessageDateTime: LocalDateTime = LocalDateTime.now() ): ChatRoom { return ChatRoom( user = user, personaType = personaType, + contextType = contextType, + videoId = videoId, name = name, lastMessageDateTime = lastMessageDateTime ).apply { setId(id) } @@ -51,4 +56,4 @@ fun ChatRoom.setId(id: Long) { fun ChatMessage.setId(id: Long) { ReflectionTestUtils.setField(this, "id", id) -} \ No newline at end of file +}