Skip to content
Open
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
@@ -0,0 +1,6 @@
package com.learner.language.domain.chat

enum class ChatContextType(val code: String) {
GENERAL("GENERAL"),
VIDEO_TRANSCRIPT("VIDEO_TRANSCRIPT");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.learner.language.domain.chat

import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter

@Converter(autoApply = true)
class ChatContextTypeConverter : AttributeConverter<ChatContextType, String> {
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
}
}
15 changes: 13 additions & 2 deletions src/main/kotlin/com/learner/language/domain/chat/ChatRoom.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()) {
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -209,7 +222,7 @@ class ChatServiceImpl(
message = command.message,
sequence = nextSequence
)
val savedChatMessage = chatWriter.save(chatMessage)
chatWriter.save(chatMessage)
chatRoom.updateLastMessageDateTime()
chatRoomRepository.save(chatRoom)

Expand All @@ -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)
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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<com.learner.language.domain.chat.ChatWriter>()
val chatReader = mockk<com.learner.language.domain.chat.ChatReader>()
val chatRoomRepository = mockk<ChatRoomRepository>()
val userReader = mockk<UserReader>()
val aiChatService = mockk<AiChatService>()
val aiAudioService = mockk<AiAudioService>()
val audioTranscribeRepository = mockk<AudioTranscribeRepository>()
val audioSpeechRepository = mockk<AudioSpeechRepository>()
val chatAudioSpeechMatchRepository = mockk<ChatAudioSpeechMatchRepository>()
val clipLearningTranscriptReader = mockk<ClipLearningTranscriptReader>()
val passwordEncoder = mockk<CustomPasswordEncoder>()

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<BadRequestException> {
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<BadRequestException> {
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()) }
}
}
}
})
Loading