diff --git a/archive-api/build.gradle.kts b/archive-api/build.gradle.kts index f46d9fb..4bc00c8 100644 --- a/archive-api/build.gradle.kts +++ b/archive-api/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(project(":archive-common")) implementation(project(":archive-domain")) implementation(project(":archive-auth")) + implementation(project(":archive-chatbot")) // Spring Boot Actuator implementation("org.springframework.boot:spring-boot-starter-actuator") @@ -22,6 +23,7 @@ dependencies { testImplementation("com.h2database:h2") testImplementation("org.mockito:mockito-core") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + } springBoot { diff --git a/archive-api/src/main/kotlin/com/alpha/archive/chatbot/ChatbotController.kt b/archive-api/src/main/kotlin/com/alpha/archive/chatbot/ChatbotController.kt new file mode 100644 index 0000000..6efa2cd --- /dev/null +++ b/archive-api/src/main/kotlin/com/alpha/archive/chatbot/ChatbotController.kt @@ -0,0 +1,49 @@ +package com.alpha.archive.chatbot + +import com.alpha.archive.auth.security.service.ArchiveUserDetails +import com.alpha.archive.chatbot.dto.ChatbotMessageRequest +import com.alpha.archive.chatbot.dto.ChatbotMessageResponse +import com.alpha.archive.chatbot.service.ChatbotService +import com.alpha.archive.common.annotations.ArchivePostMapping +import com.alpha.archive.exception.ErrorTitle +import com.alpha.archive.exception.annotation.CustomFailResponseAnnotation +import com.alpha.archive.util.ResponseUtil +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import com.alpha.archive.common.dto.ApiResponse + +@RestController +@RequestMapping("/api/chatbot") +@Tag(name = "챗봇 관련 API", description = "사용자 경험 기록을 돕는 챗봇 API 입니다.") +@SecurityRequirement(name = "bearerAuth") +class ChatbotController( + private val chatbotService: ChatbotService +) { + @ArchivePostMapping("/message", authenticated = true) + @SwaggerApiResponse(responseCode = "200", description = "챗봇 메시지 처리 성공") + @Operation(summary = "챗봇과 대화하는 API", description = "사용자의 메시지를 받아 챗봇의 응답을 반환합니다. 대화를 통해 활동 기록을 완성합니다.") + @CustomFailResponseAnnotation(ErrorTitle.BadRequest) + @CustomFailResponseAnnotation(ErrorTitle.NotFoundUser) + suspend fun handleMessage( + @AuthenticationPrincipal userDetails: ArchiveUserDetails, + @RequestBody @Valid request: ChatbotMessageRequest + ): ResponseEntity> { + // 인증된 사용자 정보와 메시지를 서비스에 전달. + val replyMessage = chatbotService.continueConversation(userDetails.getUserId(), request.message) + + // 서비스의 응답을 DTO에 저장. + val response = ChatbotMessageResponse(reply = replyMessage) + + // 기존 프로젝트의 + // 응답 형식에 맞춰 성공 응답 반환. + return ResponseUtil.success("메시지가 성공적으로 처리되었습니다.", response) + } +} \ No newline at end of file diff --git a/archive-api/src/main/resources/application.yaml b/archive-api/src/main/resources/application.yaml index 9be0457..3db2bf4 100644 --- a/archive-api/src/main/resources/application.yaml +++ b/archive-api/src/main/resources/application.yaml @@ -64,3 +64,7 @@ logging: org.springframework.web.bind: DEBUG com.alpha.archive: DEBUG root: INFO + +openai: + api: + key: '채우세용' # 오픈 api 시크릿 키 \ No newline at end of file diff --git a/archive-auth/src/main/kotlin/com/alpha/archive/auth/security/config/SecurityConfig.kt b/archive-auth/src/main/kotlin/com/alpha/archive/auth/security/config/SecurityConfig.kt index 6f1488d..6f452f7 100644 --- a/archive-auth/src/main/kotlin/com/alpha/archive/auth/security/config/SecurityConfig.kt +++ b/archive-auth/src/main/kotlin/com/alpha/archive/auth/security/config/SecurityConfig.kt @@ -31,6 +31,8 @@ import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource import org.springframework.web.cors.UrlBasedCorsConfigurationSource import java.lang.reflect.Method +import jakarta.servlet.DispatcherType +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer @Configuration @EnableWebSecurity @@ -56,12 +58,17 @@ class SecurityConfig( .csrf { it.disable() } .cors { it.configurationSource(corsConfigurationSource()) } .headers { it.frameOptions { fo -> fo.sameOrigin() } } - .applyDynamicUrlSecurity(applicationContext) - .authorizeHttpRequests { - it - .requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - .anyRequest().permitAll() + .authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry -> + // 비동기 해결 ASYNC 타입 요청을 맨 먼저 허용(chatbot 인증문제) + auth.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() + + auth.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() + auth.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + + applyDynamicUrlSecurity(applicationContext, auth) + + // 여기 이렇게 수정하는게 좋다해서 일단 했는데 확인 부탁 + auth.anyRequest().authenticated() } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .addFilterBefore(JwtAuthenticationFilter(jwtService), BasicAuthenticationFilter::class.java) @@ -87,28 +94,32 @@ class SecurityConfig( return source } - fun HttpSecurity.applyDynamicUrlSecurity(applicationContext: ApplicationContext): HttpSecurity { + fun applyDynamicUrlSecurity( + applicationContext: ApplicationContext, + auth: AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry + ) { val controllers = applicationContext.getBeansWithAnnotation(Controller::class.java) controllers.values.forEach { controller -> val parentPath = controller.javaClass.getAnnotation(RequestMapping::class.java)?.value?.firstOrNull() controller.javaClass.declaredMethods.forEach { method -> - handleMapping(method, HttpMethod.GET, parentPath) - handleMapping(method, HttpMethod.POST, parentPath) - handleMapping(method, HttpMethod.PATCH, parentPath) - handleMapping(method, HttpMethod.PUT, parentPath) - handleMapping(method, HttpMethod.DELETE, parentPath) + // 'auth' 객체를 하위 함수로 전달합니다. + handleMapping(method, HttpMethod.GET, parentPath, auth) + handleMapping(method, HttpMethod.POST, parentPath, auth) + handleMapping(method, HttpMethod.PATCH, parentPath, auth) + handleMapping(method, HttpMethod.PUT, parentPath, auth) + handleMapping(method, HttpMethod.DELETE, parentPath, auth) } } - return this } @Suppress("UNCHECKED_CAST") - private inline fun HttpSecurity.handleMapping( + private inline fun handleMapping( method: Method, httpMethod: HttpMethod, - parentPath: String? + parentPath: String?, + auth: AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry ) { method.getAnnotation(T::class.java)?.let { mapping -> val paths = mapping.javaClass.getMethod("value").invoke(mapping) as Array @@ -116,40 +127,38 @@ class SecurityConfig( val authenticated = mapping.javaClass.getMethod("authenticated").invoke(mapping) as Boolean when { - hasRole.isNotEmpty() -> configureHasRole(httpMethod, parentPath, paths, hasRole) - authenticated -> configureAuthenticated(httpMethod, parentPath, paths) + hasRole.isNotEmpty() -> configureHasRole(httpMethod, parentPath, paths, hasRole, auth) + authenticated -> configureAuthenticated(httpMethod, parentPath, paths, auth) } } } - private fun HttpSecurity.configureHasRole( + private fun configureHasRole( httpMethod: HttpMethod, parentPath: String?, paths: Array, - roles: Array + roles: Array, + auth: AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry ) { - authorizeHttpRequests { - if (paths.isEmpty()) { - it.requestMatchers(httpMethod, parentPath).hasAnyRole(*roles) - } - paths.forEach { p -> - it.requestMatchers(httpMethod, formatPath(p, parentPath)).hasAnyRole(*roles) - } + if (paths.isEmpty()) { + auth.requestMatchers(httpMethod, parentPath).hasAnyRole(*roles) + } + paths.forEach { p -> + auth.requestMatchers(httpMethod, formatPath(p, parentPath)).hasAnyRole(*roles) } } - private fun HttpSecurity.configureAuthenticated( + private fun configureAuthenticated( httpMethod: HttpMethod, parentPath: String?, - paths: Array + paths: Array, + auth: AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry ) { - authorizeHttpRequests { - if (paths.isEmpty()) { - it.requestMatchers(httpMethod, parentPath).authenticated() - } - paths.forEach { p -> - it.requestMatchers(httpMethod, formatPath(p, parentPath)).authenticated() - } + if (paths.isEmpty()) { + auth.requestMatchers(httpMethod, parentPath).authenticated() + } + paths.forEach { p -> + auth.requestMatchers(httpMethod, formatPath(p, parentPath)).authenticated() } } @@ -157,4 +166,4 @@ class SecurityConfig( val formattedPath = if (!path.startsWith("/")) "/$path" else path return if (parentPath == null || parentPath == "null") path else parentPath + formattedPath } -} +} \ No newline at end of file diff --git a/archive-chatbot/build.gradle.kts b/archive-chatbot/build.gradle.kts new file mode 100644 index 0000000..0f19b2d --- /dev/null +++ b/archive-chatbot/build.gradle.kts @@ -0,0 +1,35 @@ +import org.springframework.boot.gradle.tasks.bundling.BootJar + +plugins { + kotlin("jvm") +} + +version = "0.0.1-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + api(project(":archive-domain")) + api("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + testImplementation(kotlin("test")) + implementation("org.springframework.boot:spring-boot-starter-data-redis") +} + +tasks.named("bootJar") { + enabled = false +} + +tasks.getByName("jar") { + enabled = true +} + +tasks.test { + useJUnitPlatform() +} + +kotlin { + jvmToolchain(17) +} \ No newline at end of file diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/SystemPrompt.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/SystemPrompt.kt new file mode 100644 index 0000000..faee062 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/SystemPrompt.kt @@ -0,0 +1,76 @@ +package com.alpha.archive.chatbot + +import com.alpha.archive.domain.event.enums.EventCategory + +object SystemPrompt { + // EventCategory enum 값을 동적으로 가져와 프롬프트에 포함 + private val categories = EventCategory.entries.joinToString(", ") + + val PROMPT: String = """ + 너는 사용자의 경험을 기록하고 정리해주는 매우 꼼꼼하고 친절한 챗봇 '알파'야. + 너의 임무는 사용자와의 대화를 통해 다음 JSON 객체를 '완벽하게' 채우는 거야. + + { + "activityDate": "YYYY-MM-DDTHH:MM:SS", + "customTitle": "활동 제목", + "customCategory": "${categories} 중 하나", + "customLocation": "활동 장소", + "rating": "1-5 정수", + "memo": "메모 내용" + } + + --- + **[매우 엄격한 대화 흐름(Flow)]** + 너는 반드시 다음 3단계를 순서대로 따라야 해. + + **[1단계: 정보 수집 (응답: 텍스트)]** + 1. 사용자의 첫 메시지가 오면, **절대로** JSON을 반환하지 말고 **항상** 추가 질문으로 응답해야 해. + 2. 대화를 통해 위의 JSON 객체에 있는 모든 항목을 채우기 위해 노력해야 해. + 3. `activityDate`, `customTitle`, `customCategory` 3개는 **필수 항목**이야. + 4. `customLocation`, `rating`, `memo` 3개는 **선택 항목**이지만, 사용자가 "없어" 또는 "넘어가"라고 말하기 전까지 **최소 한 번은 꼭 물어봐야 해.** (예: "활동한 장소는 어디인가요?", "이번 경험에 1~5점 사이의 평점을 주시겠어요?") + 5. 이 단계에서 너의 모든 응답은 **JSON이 아닌 일반 텍스트 질문**이어야 해. + + **[2단계: 정보 확인 (응답: 텍스트)]** + 1. 모든 필수 항목이 채워지고, 선택 항목에 대해서도 질문을 마쳤다고 판단되면, **절대로 JSON을 출력하면 안 돼.** + 2. 대신, 지금까지 수집한 모든 정보를 **요약 리스트 (일반 텍스트)**로 만들어서 사용자에게 보여줘야 해. + 3. 요약 리스트의 마지막에는 **"이 내용으로 기록해 드릴까요? 수정하고 싶은 부분이 있으신가요?"**라고 **반드시** 질문해야 해. + + **[3단계: 저장 또는 수정]** + 1. [2단계]의 질문에 사용자가 **"응", "맞아", "저장해 줘"** 등 긍정적인 답변을 하면: + * 너의 다음 응답은 **오직 최종 JSON 객체 하나**여야 해. 다른 설명이나 텍스트 없이 JSON만 출력해야 해. + 2. [2단계]의 질문에 사용자가 **"아니", "수정할래", "제목 바꿔 줘"** 등 수정 요청을 하면: + * **절대로** JSON을 출력하지 말고, [1단계]로 돌아가서 수정할 내용을 다시 질문해야 해. (예: "알겠습니다. 제목을 무엇으로 수정할까요?") + --- + + **[대화 예시]** + 사용자: 오늘 영화 봤어. + 너: (1단계) 오, 좋은 시간 보내셨네요! 어떤 영화 보셨어요? + 사용자: 범죄도시4. + 너: (1단계) 재미있으셨어요? 그 경험은 'CULTURE' 카테고리로 기록할까요? + 사용자: 응. + 너: (1단계) 알겠습니다. 오늘 날짜로 기록해 드릴게요. 혹시 영화 본 장소(customLocation)도 알려주실 수 있나요? + 사용자: 강남 CGV. + 너: (1단계) 좋습니다. 이번 경험에 평점(rating)을 주신다면 몇 점이신가요? + 사용자: 5점. + 너: (1단계) 마지막으로, 이 경험에 대해 남기고 싶은 메모(memo)가 있으신가요? + 사용자: 아니 없어. + 너: (2단계) 알겠습니다! 수집된 정보를 요약해 드릴게요. + * 날짜: 2025-10-21T18:30:00 (AI가 계산) + * 제목: 범죄도시4 관람 + * 카테고리: CULTURE + * 장소: 강남 CGV + * 평점: 5 + * 메모: (없음) + 이 내용으로 기록해 드릴까요? 수정하고 싶은 부분이 있으신가요? + 사용자: 응 저장해 줘. + 너: (3단계 - JSON만 출력) + { + "activityDate": "2025-10-21T18:30:00", + "customTitle": "범죄도시4 관람", + "customCategory": "CULTURE", + "customLocation": "강남 CGV", + "rating": 5, + "memo": null + } + """ +} \ No newline at end of file diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/config/ChatbotRedisConfig.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/config/ChatbotRedisConfig.kt new file mode 100644 index 0000000..dac3bf9 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/config/ChatbotRedisConfig.kt @@ -0,0 +1,36 @@ +package com.alpha.archive.chatbot.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +class ChatbotRedisConfig { + + /** + * 챗봇 모듈 전용 RedisTemplate Bean 생성. + * Value를 JSON으로 직렬화하여 List 같은 객체를 저장. + * * @param connectionFactory - 이 Bean은 'auth' 모듈의 RedisConfig가 + * 이미 만들어 둔 것을 Spring이 찾아서 자동으로 주입해 줍니다. + */ + @Bean + fun chatbotRedisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate { + val template = RedisTemplate() + template.connectionFactory = connectionFactory + + // Key는 String을 사용 + template.keySerializer = StringRedisSerializer() + + // Value는 객체를 JSON으로 변환하는 Serializer를 사용 + template.valueSerializer = GenericJackson2JsonRedisSerializer() + + // Hash용 Serializer도 동일하게 설정 + template.hashKeySerializer = StringRedisSerializer() + template.hashValueSerializer = GenericJackson2JsonRedisSerializer() + + return template + } +} \ No newline at end of file diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/config/OpenAPIConfig.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/config/OpenAPIConfig.kt new file mode 100644 index 0000000..17ae01f --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/config/OpenAPIConfig.kt @@ -0,0 +1,22 @@ +package com.alpha.archive.chatbot.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenAPIConfig { + + @Value("\${openai.api.key}") + lateinit var apiKey: String + + companion object { + const val AUTHORIZATION = "Authorization" + const val BEARER = "Bearer" + const val MODEL = "gpt-4o" + const val MEDIA_TYPE = "application/json; charset=UTF-8" + const val URL = "https://api.openai.com/v1/chat/completions" + const val MAX_TOKENS = 300 + const val TEMPERATURE = 0.7 + const val TOP_P = 1.0 + } +} \ No newline at end of file diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/ChatbotMessageRequest.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/ChatbotMessageRequest.kt new file mode 100644 index 0000000..f09c873 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/ChatbotMessageRequest.kt @@ -0,0 +1,11 @@ +package com.alpha.archive.chatbot.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +@Schema(description = "챗봇 메시지 요청 DTO") +data class ChatbotMessageRequest( + @Schema(description = "사용자가 챗봇에게 보내는 메시지", example = "오늘 친구랑 영화 봤어!") + @field:NotBlank(message = "메시지는 비어 있을 수 없습니다.") + val message: String +) diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/ChatbotMessageResponse.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/ChatbotMessageResponse.kt new file mode 100644 index 0000000..45fad04 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/ChatbotMessageResponse.kt @@ -0,0 +1,9 @@ +package com.alpha.archive.chatbot.dto + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "챗봇 메시지 응답 DTO") +data class ChatbotMessageResponse( + @Schema(description = "챗봇의 응답 메시지", example = "오, 좋은 시간 보내셨네요! 어떤 영화 보셨어요?") + val reply: String +) diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/ChatbotRequestDto.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/ChatbotRequestDto.kt new file mode 100644 index 0000000..8c70403 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/ChatbotRequestDto.kt @@ -0,0 +1,6 @@ +package com.alpha.archive.chatbot.dto + +data class ChatbotRequestDto( + val userId: Long, + val message: String +) diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/Choice.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/Choice.kt new file mode 100644 index 0000000..aa806f9 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/Choice.kt @@ -0,0 +1,11 @@ +package com.alpha.archive.chatbot.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class Choice( + val index: Int? = null, + val message: Message? = null, + + @JsonProperty("finish_reason") + val finishReason: String? = null +) \ No newline at end of file diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/EventDataDto.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/EventDataDto.kt new file mode 100644 index 0000000..6543d7a --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/EventDataDto.kt @@ -0,0 +1,13 @@ +package com.alpha.archive.chatbot.dto + +import com.alpha.archive.domain.event.enums.EventCategory +import java.time.LocalDateTime + +data class EventDataDto( + val activityDate: LocalDateTime, + val customTitle: String, + val customCategory: EventCategory, + val customLocation: String? = null, + val rating: Int? = null, + val memo: String? = null +) diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/Message.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/Message.kt new file mode 100644 index 0000000..73f78d0 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/Message.kt @@ -0,0 +1,6 @@ +package com.alpha.archive.chatbot.dto + +data class Message( + val role: String = "", + val content: String = "", +) diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/OpenAPIRequest.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/OpenAPIRequest.kt new file mode 100644 index 0000000..f913fb2 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/OpenAPIRequest.kt @@ -0,0 +1,18 @@ +package com.alpha.archive.chatbot.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class OpenAPIRequest ( + val model: String, + val messages: List, + // JSON 모드를 활성화하여 AI가 더 안정적으로 JSON을 반환하도록 합니다. + val response_format: Map? = null, + + @JsonProperty(value = "max_tokens") + val maxTokens: Int = 300, + + val temperature: Double = 0.7, + + @JsonProperty("top_p") + val topP: Double = 0.5 +) \ No newline at end of file diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/OpenAPIResponse.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/OpenAPIResponse.kt new file mode 100644 index 0000000..d6a2645 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/OpenAPIResponse.kt @@ -0,0 +1,10 @@ +package com.alpha.archive.chatbot.dto + +data class OpenAPIResponse ( + val id: String? = null, + val `object`: String? = null, + val created: Long? = null, + val model: String? = null, + val choices: List? = null, + val usage: Usage? = null +) \ No newline at end of file diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/QuestionRequest.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/QuestionRequest.kt new file mode 100644 index 0000000..4c6963e --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/QuestionRequest.kt @@ -0,0 +1,5 @@ +package com.alpha.archive.chatbot.dto + +data class QuestionRequest ( + val question: String +) diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/Usage.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/Usage.kt new file mode 100644 index 0000000..3d2768f --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/dto/Usage.kt @@ -0,0 +1,14 @@ +package com.alpha.archive.chatbot.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class Usage( + @JsonProperty("prompt_tokens") + val promptTokens: Int = 0, + + @JsonProperty("completion_tokens") + val completionTokens: Int = 0, + + @JsonProperty("total_tokens") + val totalTokens: Int = 0, +) diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/service/ChatbotService.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/service/ChatbotService.kt new file mode 100644 index 0000000..1bb5d70 --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/service/ChatbotService.kt @@ -0,0 +1,92 @@ +package com.alpha.archive.chatbot.service + +import com.alpha.archive.chatbot.SystemPrompt +import com.alpha.archive.chatbot.dto.EventDataDto +import com.alpha.archive.chatbot.dto.Message +import com.alpha.archive.domain.event.UserEvent +import com.alpha.archive.domain.event.embeddable.ActivityInfo +import com.alpha.archive.domain.event.repository.UserEventRepository +import com.alpha.archive.domain.user.UserRepository +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.util.concurrent.TimeUnit + +@Service +class ChatbotService( + private val openAPIService: OpenAPIService, + private val userEventRepository: UserEventRepository, + private val userRepository: UserRepository, + private val redisTemplate: RedisTemplate +) { + private val objectMapper = jacksonObjectMapper().findAndRegisterModules() + + private fun getHistoryKey(userId: String) = "chatbot:history:$userId" + + suspend fun continueConversation(userId: String, userMessage: String): String { + val historyKey = getHistoryKey(userId) + + val rawHistory: Any? = redisTemplate.opsForValue().get(historyKey) + + val history: MutableList = if (rawHistory == null) { + // 'if'가 참일 때 이 값을 반환하여 history에 할당. + mutableListOf(Message(role = "system", content = SystemPrompt.PROMPT)) + } else { + // 'else'일 경우, 'try/catch' 표현식의 결과를 반환하여 history에 할당. + try { + objectMapper.convertValue( + rawHistory, + object : TypeReference>() {} + ) + } catch (e: Exception) { + mutableListOf(Message(role = "system", content = SystemPrompt.PROMPT)) + } + } + + history.add(Message(role = "user", content = userMessage)) + // OpenAI API 호출 + val aiResponse = openAPIService.askWithHistory(history) + val aiMessageContent = aiResponse.choices?.firstOrNull()?.message?.content + ?: return "죄송해요, 답변을 생성하는 데 문제가 생겼어요. 다시 시도해 주세요." + + // AI 응답이 JSON인지 파싱 시도 + try { + val eventData = objectMapper.readValue(aiMessageContent) + saveUserEvent(userId, eventData) + // 성공 시 Redis에서 대화 기록 삭제 + redisTemplate.delete(historyKey) + return "경험이 성공적으로 기록되었어요! 또 다른 이야기를 들려주세요." + } catch (e: Exception) { + history.add(Message(role = "assistant", content = aiMessageContent)) + // 실패 시 (대화 지속) 업데이트된 기록을 Redis에 다시 저장 (1시간 뒤 자동 만료되도록 설정) + redisTemplate.opsForValue().set(historyKey, history, 1, TimeUnit.HOURS) + return aiMessageContent + } + } + + @Transactional + private fun saveUserEvent(userId: String, data: EventDataDto) { + val user = userRepository.findUserById(userId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "해당 ID의 사용자를 찾을 수 없습니다: $userId") + + val activityInfo = ActivityInfo( + customTitle = data.customTitle, + customCategory = data.customCategory, + customLocation = data.customLocation, + rating = data.rating, + memo = data.memo + ) + val userEvent = UserEvent( + user = user, + activityDate = data.activityDate, + activityInfo = activityInfo + ) + + userEventRepository.save(userEvent) + } +} \ No newline at end of file diff --git a/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/service/OpenAPIService.kt b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/service/OpenAPIService.kt new file mode 100644 index 0000000..57409ce --- /dev/null +++ b/archive-chatbot/src/main/kotlin/com/alpha/archive/chatbot/service/OpenAPIService.kt @@ -0,0 +1,73 @@ +package com.alpha.archive.chatbot.service + +import com.alpha.archive.chatbot.config.OpenAPIConfig +import com.alpha.archive.chatbot.dto.Message +import com.alpha.archive.chatbot.dto.OpenAPIRequest +import com.alpha.archive.chatbot.dto.OpenAPIResponse +import com.alpha.archive.chatbot.dto.QuestionRequest +import kotlinx.coroutines.reactor.awaitSingle +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.server.ResponseStatusException + +@Service +class OpenAPIService( + private val openAPIConfig: OpenAPIConfig, + private val webClientBuilder: WebClient.Builder +) { + private val logger = LoggerFactory.getLogger(OpenAPIService::class.java) + private val webClient = webClientBuilder + .baseUrl("https://api.openai.com/v1/") + .build() + + suspend fun askQuestion(requestDto: QuestionRequest): OpenAPIResponse? { + if (requestDto.question.isBlank()) { + // 비어있다면, OpenAI에 요청을 보내지 않고 즉시 400 에러를 클라이언트에게 반환합니다. + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "질문 내용은 비어 있을 수 없습니다.") + } + val messages = listOf(Message("user", requestDto.question)) + val openAPIRequest = OpenAPIRequest( + model = OpenAPIConfig.MODEL, + messages = messages, + maxTokens = OpenAPIConfig.MAX_TOKENS, + temperature = OpenAPIConfig.TEMPERATURE, + topP = OpenAPIConfig.TOP_P + ) + + logger.info("requestBody: $openAPIRequest") + + return webClient.post() + .uri("chat/completions") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${openAPIConfig.apiKey}") + .bodyValue(openAPIRequest) + .retrieve() + .bodyToMono(OpenAPIResponse::class.java) + .doOnSuccess { logger.info("✅ OpenAI 응답 성공") } + .doOnError { e -> logger.error("❌ OpenAI 호출 실패: ${e.message}") } + .awaitSingle() + } + + suspend fun askWithHistory(messages: List): OpenAPIResponse { + logger.info("✉️ OpenAI에 대화 기록 전송 (메시지 ${messages.size}개)") + val openAPIRequest = OpenAPIRequest( + model = "gpt-4o", + messages = messages, + maxTokens = OpenAPIConfig.MAX_TOKENS, + temperature = OpenAPIConfig.TEMPERATURE, + topP = OpenAPIConfig.TOP_P, + ) + + return webClient.post() + .uri("/chat/completions") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${openAPIConfig.apiKey}") + .bodyValue(openAPIRequest) + .retrieve() + .bodyToMono(OpenAPIResponse::class.java) + .doOnSuccess { logger.info("✅ OpenAI 응답 성공") } + .doOnError { e -> logger.error("❌ OpenAI 호출 실패: ${e.message}", e) } + .awaitSingle() + } +} \ No newline at end of file diff --git a/archive-domain/src/main/kotlin/com/alpha/archive/domain/user/UserRepository.kt b/archive-domain/src/main/kotlin/com/alpha/archive/domain/user/UserRepository.kt index f6b7216..a0d3da8 100644 --- a/archive-domain/src/main/kotlin/com/alpha/archive/domain/user/UserRepository.kt +++ b/archive-domain/src/main/kotlin/com/alpha/archive/domain/user/UserRepository.kt @@ -7,4 +7,5 @@ interface UserRepository : JpaRepository { fun findByKakaoId(kakaoId: Long): User? fun existsByEmail(email: String): Boolean fun findByEmail(email: String): User? + fun findUserById(userId: String): User? } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c8223b..9f4d495 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,4 +3,5 @@ rootProject.name = "archive" include("archive-auth") include("archive-common") include("archive-domain") -include("archive-api") \ No newline at end of file +include("archive-api") +include("archive-chatbot") \ No newline at end of file