Skip to content
Merged

Dev #90

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
28 changes: 28 additions & 0 deletions api/src/docs/asciidoc/Ai-API.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[[Ai-API]]
= Ai API

== 1. Ai 채팅방 생성

=== 요청(Request)
include::{snippets}/ai-controller-test/create-ai-chat-room/http-request.adoc[]

=== 응답(Response)
include::{snippets}/ai-controller-test/create-ai-chat-room/http-response.adoc[]

=== 응답 필드 설명(Response Fields)
include::{snippets}/ai-controller-test/create-ai-chat-room/response-fields.adoc[]

== 2. AI 클론 채팅

=== 요청(Request)
include::{snippets}/ai-controller-test/clone-ai-chat-room/http-request.adoc[]

=== 요청 필드 설명(Request Fields)
include::{snippets}/ai-controller-test/clone-ai-chat-room/request-fields.adoc[]

=== 응답(Response)
include::{snippets}/ai-controller-test/clone-ai-chat-room/http-response.adoc[]

=== 응답 필드 설명(Response Fields)
include::{snippets}/ai-controller-test/clone-ai-chat-room/response-fields.adoc[]

2 changes: 2 additions & 0 deletions api/src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,5 @@ include::Chat-Log-API.adoc[]
include::Notification-API.adoc[]

include::Report-API.adoc[]

include::Ai-API.adoc[]
23 changes: 19 additions & 4 deletions api/src/main/kotlin/org/chewing/v1/controller/ai/AiController.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package org.chewing.v1.controller.ai

import org.chewing.v1.dto.request.chat.ChatRequest
import org.chewing.v1.dto.request.chat.ClonePromptRequest
import org.chewing.v1.dto.response.chat.AiChatMessageResponse
import org.chewing.v1.dto.response.chat.ChatRoomIdResponse
import org.chewing.v1.dto.response.chat.PairAiChatMessageResponse
import org.chewing.v1.facade.AiFacade
import org.chewing.v1.model.chat.room.ChatRoomId
import org.chewing.v1.model.user.UserId
import org.chewing.v1.service.chat.AiChatRoomService
import org.chewing.v1.util.aliases.SuccessResponseEntity
import org.chewing.v1.util.helper.ResponseHelper
import org.chewing.v1.util.security.CurrentUser
Expand All @@ -16,14 +18,13 @@ import org.springframework.web.bind.annotation.RequestBody
@Controller
class AiController(
private val aiFacade: AiFacade,
private val aiChatRoomService: AiChatRoomService,
) {
@PostMapping("/ai/chat/room")
fun createAiChatRoom(
@CurrentUser userId: UserId,
): SuccessResponseEntity<ChatRoomId> {
): SuccessResponseEntity<ChatRoomIdResponse> {
val chatRoomId = aiFacade.produceAiChatRoom(userId)
return ResponseHelper.success(chatRoomId)
return ResponseHelper.successCreate(ChatRoomIdResponse.of(chatRoomId))
}

@PostMapping("/ai/chat/room/prompt")
Expand All @@ -34,4 +35,18 @@ class AiController(
val prompt = aiFacade.processAiMessage(userId, request.toChatRoomId(), request.toMessage())
return ResponseHelper.success(AiChatMessageResponse.of(prompt))
}

@PostMapping("/ai/chat/clone")
fun cloneDirectChatRoom(
@CurrentUser userId: UserId,
@RequestBody request: ClonePromptRequest,
): SuccessResponseEntity<PairAiChatMessageResponse> {
val (userMessage, aiMessage) = aiFacade.cloneChatAsUserFromChatRoom(
requestingUserId = userId,
sourceChatRoomId = ChatRoomId.of(request.sourceChatRoomId),
targetAiChatRoomId = ChatRoomId.of(request.aiChatRoomId),
userPrompt = request.prompt,
)
return ResponseHelper.success(PairAiChatMessageResponse.of(userMessage, aiMessage))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.chewing.v1.dto.request.chat

data class ClonePromptRequest(
val prompt: String,
val sourceChatRoomId: String,
val aiChatRoomId: String,
)
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package org.chewing.v1.dto.response.chat

import org.chewing.v1.model.chat.room.ChatRoomId

class ChatRoomIdResponse(
val chatRoomId: String,
) {
companion object {
fun from(chatRoomId: String): ChatRoomIdResponse {
return ChatRoomIdResponse(chatRoomId)
fun of(chatRoomId: ChatRoomId): ChatRoomIdResponse {
return ChatRoomIdResponse(chatRoomId.id)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.chewing.v1.dto.response.chat

import org.chewing.v1.model.chat.message.ChatAiMessage

data class PairAiChatMessageResponse(
val userMessage: AiChatMessageResponse,
val aiMessage: AiChatMessageResponse,
) {
companion object {
fun of(
userMessage: ChatAiMessage,
aiMessage: ChatAiMessage,
): PairAiChatMessageResponse {
return PairAiChatMessageResponse(
userMessage = AiChatMessageResponse.of(
userMessage,
),
aiMessage = AiChatMessageResponse.of(
aiMessage,
),
)
}
}
}
171 changes: 171 additions & 0 deletions api/src/test/kotlin/org/chewing/v1/controller/AiControllerTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@

package org.chewing.v1.controller

import io.mockk.every
import io.mockk.mockk
import org.chewing.v1.RestDocsTest
import org.chewing.v1.RestDocsUtils.requestAccessTokenFields
import org.chewing.v1.RestDocsUtils.requestPreprocessor
import org.chewing.v1.RestDocsUtils.responsePreprocessor
import org.chewing.v1.controller.ai.AiController
import org.chewing.v1.dto.request.chat.ClonePromptRequest
import org.chewing.v1.facade.AiFacade
import org.chewing.v1.model.chat.member.SenderType
import org.chewing.v1.model.chat.message.ChatAiMessage
import org.chewing.v1.model.chat.room.ChatRoomId
import org.chewing.v1.model.chat.room.ChatRoomSequence
import org.chewing.v1.model.chat.room.ChatRoomType
import org.chewing.v1.model.user.UserId
import org.chewing.v1.util.handler.GlobalExceptionHandler
import org.chewing.v1.util.security.UserArgumentResolver
import org.hamcrest.CoreMatchers.equalTo
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
import org.springframework.restdocs.payload.PayloadDocumentation.*
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.test.context.ActiveProfiles
import java.time.LocalDateTime

@ActiveProfiles("test")
class AiControllerTest : RestDocsTest() {

private lateinit var aiFacade: AiFacade
private lateinit var aiController: AiController
private lateinit var exceptionHandler: GlobalExceptionHandler
private lateinit var userArgumentResolver: UserArgumentResolver

@BeforeEach
fun setUp() {
aiFacade = mockk()
exceptionHandler = GlobalExceptionHandler()
userArgumentResolver = UserArgumentResolver()
aiController = AiController(aiFacade)
mockMvc = mockController(aiController, exceptionHandler, userArgumentResolver)

val userId = UserId.of("testUserId")
val authentication = UsernamePasswordAuthenticationToken(userId, null)
SecurityContextHolder.getContext().authentication = authentication
}

@Test
@DisplayName("AI 채팅방 생성")
fun createAiChatRoom() {
val chatRoomId = ChatRoomId.of("ai-room-001")
every { aiFacade.produceAiChatRoom(any()) } returns chatRoomId

given()
.setupAuthenticatedJsonRequest()
.post("/ai/chat/room")
.then()
.statusCode(HttpStatus.CREATED.value())
.apply {
body("status", equalTo(201))
body("data.chatRoomId", equalTo(chatRoomId.id))
}
.apply(
document(
"{class-name}/{method-name}",
requestPreprocessor(),
responsePreprocessor(),
responseFields(
fieldWithPath("status").description("응답 상태"),
fieldWithPath("data.chatRoomId").description("AI 채팅방 ID"),
),
requestAccessTokenFields(),
),
)
}

@Test
@DisplayName("일반 채팅방 복제 프롬프트 요청")
fun cloneAiChatRoom() {
val chatRoomId = ChatRoomId.of("ai-room-001")
val request = ClonePromptRequest(
prompt = "안녕",
sourceChatRoomId = "chatroom-001",
aiChatRoomId = "ai-room-001",
)
val aiMessage = ChatAiMessage.of(
messageId = "msg-002",
chatRoomId = chatRoomId,
chatRoomType = ChatRoomType.AI,
senderId = UserId.of("ai-user"),
timestamp = LocalDateTime.now(),
roomSequence = ChatRoomSequence.of(chatRoomId, 2),
text = "안녕하세요!",
senderType = SenderType.AI,
)
val userMessage = ChatAiMessage.of(
messageId = "msg-001",
chatRoomId = chatRoomId,
chatRoomType = ChatRoomType.AI,
senderId = UserId.of("testUserId"),
timestamp = LocalDateTime.now(),
roomSequence = ChatRoomSequence.of(chatRoomId, 1),
text = request.prompt,
senderType = SenderType.USER,
)
every {
aiFacade.cloneChatAsUserFromChatRoom(any(), any(), any(), any())
} returns Pair(userMessage, aiMessage)

given()
.setupAuthenticatedJsonRequest()
.body(request)
.post("/ai/chat/clone")
.then()
.statusCode(HttpStatus.OK.value())
.body("status", equalTo(200))
.apply {
body("data.userMessage.messageId", equalTo(userMessage.messageId))
body("data.userMessage.type", equalTo(userMessage.type.name.lowercase()))
body("data.userMessage.senderId", equalTo(userMessage.senderId.id))
body("data.userMessage.timestamp", equalTo(userMessage.timestamp.toString()))
body("data.userMessage.seqNumber", equalTo(userMessage.roomSequence.sequence))
body("data.userMessage.text", equalTo(userMessage.text))
body("data.userMessage.senderType", equalTo(userMessage.senderType.name.lowercase()))

body("data.aiMessage.messageId", equalTo(aiMessage.messageId))
body("data.aiMessage.type", equalTo(aiMessage.type.name.lowercase()))
body("data.aiMessage.senderId", equalTo(aiMessage.senderId.id))
body("data.aiMessage.timestamp", equalTo(aiMessage.timestamp.toString()))
body("data.aiMessage.seqNumber", equalTo(aiMessage.roomSequence.sequence))
body("data.aiMessage.text", equalTo(aiMessage.text))
body("data.aiMessage.senderType", equalTo(aiMessage.senderType.name.lowercase()))
}
.apply(
document(
"{class-name}/{method-name}",
requestPreprocessor(),
responsePreprocessor(),
requestFields(
fieldWithPath("prompt").description("사용자 입력 프롬프트"),
fieldWithPath("sourceChatRoomId").description("복제할 원본 채팅방 ID"),
fieldWithPath("aiChatRoomId").description("AI 채팅방 ID"),
),
requestAccessTokenFields(),
responseFields(
fieldWithPath("status").description("응답 상태"),
fieldWithPath("data.userMessage.messageId").description("AI 메시지 ID"),
fieldWithPath("data.userMessage.type").description("메시지 타입"),
fieldWithPath("data.userMessage.senderId").description("메시지 발신자 ID"),
fieldWithPath("data.userMessage.timestamp").description("메시지 타임스탬프"),
fieldWithPath("data.userMessage.seqNumber").description("채팅방 내 메시지 순서"),
fieldWithPath("data.userMessage.text").description("메시지 내용"),
fieldWithPath("data.userMessage.senderType").description("메시지 발신자 타입"),
fieldWithPath("data.aiMessage.messageId").description("AI 메시지 ID"),
fieldWithPath("data.aiMessage.type").description("메시지 타입"),
fieldWithPath("data.aiMessage.senderId").description("메시지 발신자 ID"),
fieldWithPath("data.aiMessage.timestamp").description("메시지 타임스탬프"),
fieldWithPath("data.aiMessage.seqNumber").description("채팅방 내 메시지 순서"),
fieldWithPath("data.aiMessage.text").description("메시지 내용"),
fieldWithPath("data.aiMessage.senderType").description("메시지 발신자 타입"),
),
),
)
}
}
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ subprojects {

//env
implementation("me.paulschwarz:spring-dotenv:4.0.0")
implementation("org.springframework.boot:spring-boot-starter-web")
}

tasks {
Expand Down
32 changes: 32 additions & 0 deletions domain/src/main/kotlin/org/chewing/v1/facade/AiFacade.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.chewing.v1.model.user.UserId
import org.chewing.v1.service.ai.AiPromptService
import org.chewing.v1.service.chat.AiChatRoomService
import org.chewing.v1.service.chat.ChatLogService
import org.chewing.v1.service.chat.DirectChatRoomService
import org.springframework.stereotype.Service

@Service
Expand All @@ -17,6 +18,7 @@ class AiFacade(
private val aiPromptService: AiPromptService,
private val chatLogService: ChatLogService,
private val aiUserGenerator: AiUserGenerator,
private val directChatRoomService: DirectChatRoomService,
) {
fun processAiMessage(
userId: UserId,
Expand All @@ -38,4 +40,34 @@ class AiFacade(
): ChatRoomId {
return aiChatRoomService.createAiChatRoom(userId)
}

fun cloneChatAsUserFromChatRoom(
requestingUserId: UserId,
sourceChatRoomId: ChatRoomId,
targetAiChatRoomId: ChatRoomId,
userPrompt: String,
): Pair<ChatAiMessage, ChatAiMessage> {
// 1. 현재 채팅방에서 상대방 ID 추출
val directChatRoom = directChatRoomService.getDirectChatRoom(requestingUserId, sourceChatRoomId)
val friendUserId = directChatRoom.roomInfo.friendId

// 2. 해당 채팅방 로그 중, 상대방이 작성한 메시지만 추출
val friendMessages = chatLogService.getChatLogsBySender(sourceChatRoomId, friendUserId)

// 3. 사용자 입력 메시지를 AI 채팅방에 저장
val aiChatRoom = aiChatRoomService.getAiChatRoom(sourceChatRoomId, requestingUserId)
val userMessageSeq = aiChatRoomService.increaseDirectChatRoomSequence(aiChatRoom.chatRoomId)
val userMessage = chatLogService.aiMessage(aiChatRoom.chatRoomId, requestingUserId, userMessageSeq, userPrompt, ChatRoomType.AI, SenderType.USER)

// 4. 클론용 프롬프트 생성
val aiGeneratedPrompt = aiPromptService.promptClone(friendMessages, userPrompt)

// 5. AI 응답을 실제 채팅방에 저장
val aiResponseSeq = aiChatRoomService.increaseDirectChatRoomSequence(targetAiChatRoomId)
val aiUserId = aiUserGenerator.getAiUserId()

val aiMessage = chatLogService.aiMessage(sourceChatRoomId, aiUserId, aiResponseSeq, aiGeneratedPrompt, ChatRoomType.AI, SenderType.AI)

return Pair(userMessage, aiMessage)
}
}
Loading