Skip to content

Commit

Permalink
feat: 리뷰 재생성 기능 추가 (#7)
Browse files Browse the repository at this point in the history
* feat: 리뷰 재생성 기능을 추가한다

* refactor: 컨트롤러의 url path 오타를 수정한다

* refactor: Qualifer 오타를 -> Value로 수정한다

* fix: 리뷰 스트리밍 생성 기능을 테스트하고, 오류를 수정한다
  • Loading branch information
devxb authored Jan 28, 2025
1 parent 086839b commit cae07ed
Show file tree
Hide file tree
Showing 20 changed files with 397 additions and 27 deletions.
7 changes: 0 additions & 7 deletions docs/api/리뷰_생성_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,3 @@
### Response

#### `Response Status 200 OK`

```json
{
"id": "123456789",
"review": "카야토스는 숨겨져 있는 카야잼과 버터가 확실히 가득합니다. 또한, 가게의 분위기는 아늑하고 편안하고 바깥쪽에 있고 사랑하는 시간을 보낼 수 있는 공간입니다. 무엇보다 가격에 비해 음식의 품질이 정말 훌륭해서, 마음에 들었습니다."
}
```
7 changes: 0 additions & 7 deletions docs/api/리뷰_재생성_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,3 @@
### Response

#### `Response Status 200 OK`

```json
{
"id": "123456789",
"review": "가득합니다....."
}
```
24 changes: 24 additions & 0 deletions docs/api/리뷰_조회_API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 리뷰 생성

리뷰를 생성합니다.

## Request

### HTTP METHOD : `GET`

### url : `https://api.misik.me/reviews/{id}`
### Http Headers
- device-id: 식별할 수 있는 값
`미래에도 변하지 않아야함 앱을 삭제했다 다시 깔아도 안변하는 값으로 줄 수 있는지`

### Response

#### `Response Status 200 OK`

```json
{
"isSuccess": true, // 리뷰가 생성되지 않았으면 false, 전체가 성공되면 true
"id": "123456789",
"review": "카야토스는 숨겨져 있는 카야잼과 버터가 확실히 가득합니다. 또한, 가게의 분위기는 아늑하고 편안하고 바깥쪽에 있고 사랑하는 시간을 보낼 수 있는 공간입니다. 무엇보다 가격에 비해 음식의 품질이 정말 훌륭해서, 마음에 들었습니다."
}
```
1 change: 1 addition & 0 deletions gradle/jetbrains.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ compileTestKotlin {

dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-reactor"

testImplementation "org.jetbrains.kotlin:kotlin-test"
}
1 change: 1 addition & 0 deletions gradle/spring.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ jar {
dependencies {
implementation "org.springframework.boot:spring-boot-starter"
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-webflux"
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation 'org.springframework.retry:spring-retry'

Expand Down
Empty file.
17 changes: 17 additions & 0 deletions src/main/kotlin/me/misik/api/api/ReviewController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package me.misik.api.api

import me.misik.api.app.ReCreateReviewFacade
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class ReviewController(
private val reCreateReviewFacade: ReCreateReviewFacade,
) {

@PostMapping("reviews/{id}/re-create")
fun reCreateReview(
@PathVariable("id") id: Long,
) = reCreateReviewFacade.reCreateReviewInBackground(id)
}
Empty file.
52 changes: 52 additions & 0 deletions src/main/kotlin/me/misik/api/app/ReCreateReviewFacade.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package me.misik.api.app

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.launch
import me.misik.api.core.Chatbot
import me.misik.api.core.GracefulShutdownDispatcher
import me.misik.api.domain.ReviewService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

@Service
class ReCreateReviewFacade(
private val chatbot: Chatbot,
private val reviewService: ReviewService,
) {

private val logger = LoggerFactory.getLogger(this::class.simpleName)

fun reCreateReviewInBackground(id: Long) {
reviewService.clearReview(id)

reCreateReviewWithRetry(id, retryCount = 0)
}

private fun reCreateReviewWithRetry(id: Long, retryCount: Int) {
CoroutineScope(GracefulShutdownDispatcher.dispatcher).launch {
val review = reviewService.getById(id)

chatbot.createReviewWithModelName(Chatbot.Request.from(review))
.filterNot { it.stopReason == ALREADY_COMPLETED }
.collect {
reviewService.updateReview(id, it.message?.content ?: "")
}
}.invokeOnCompletion {
if (it == null) {
return@invokeOnCompletion reviewService.completeReview(id)
}
if (retryCount == MAX_RETRY_COUNT) {
logger.error("Failed to create review.", it)
throw it
}
logger.warn("Failed to create review. retrying... retryCount: \"${retryCount + 1}\"", it)
reCreateReviewWithRetry(id, retryCount + 1)
}
}

private companion object {
private const val MAX_RETRY_COUNT = 3
private const val ALREADY_COMPLETED = "stop_before"
}
}
65 changes: 65 additions & 0 deletions src/main/kotlin/me/misik/api/core/Chatbot.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package me.misik.api.core

import kotlinx.coroutines.flow.Flow
import me.misik.api.domain.Review
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.service.annotation.PostExchange

fun interface Chatbot {

@PostExchange("/testapp/v1/chat-completions/HCX-003")
fun createReviewWithModelName(@RequestBody request: Request): Flow<Response>

data class Request(
val messages: List<Message>,
val includeAiFilters: Boolean = true,
) {
data class Message(
val role: String,
val content: String,
) {

companion object {

fun createSystem(content: String) = Message(
role = "system",
content = content,
)

fun createUser(content: String) = Message(
role = "user",
content = content,
)
}
}

companion object {
private val cachedSystemMessage = Message.createSystem(
"""
너는 지금부터 음식에 대한 리뷰를 하는 고독한 미식가야.
답변에는 리뷰에 대한 내용만 포함해.
또한, 응답 메시지는 공백을 포함해서 300자가 넘으면 절대로 안돼.
"""
)

fun from(review: Review): Request {
return Request(
messages = listOf(
cachedSystemMessage,
Message.createUser(review.requestPrompt.text)
)
)
}
}
}

data class Response(
val stopReason: String?,
val message: Message?,
) {
data class Message(
val role: String,
val content: String,
)
}
}
45 changes: 45 additions & 0 deletions src/main/kotlin/me/misik/api/core/CoroutineScope.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package me.misik.api.core

import jakarta.annotation.PreDestroy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import me.misik.api.core.GracefulShutdownDispatcher.executorService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit

object GracefulShutdownDispatcher {

val executorService = Executors.newFixedThreadPool(10) { runnable ->
Thread(runnable, "misik-gracefulshutdown").apply { isDaemon = false }
}

val dispatcher: CoroutineDispatcher = executorService.asCoroutineDispatcher()
}

@Component
class GracefulShutdownHook {

private val logger = LoggerFactory.getLogger(this::class.simpleName)

@PreDestroy
fun tryGracefulShutdown() {
logger.info("Shutting down dispatcher...")
executorService.shutdown()
runCatching {
if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
logger.warn("Forcing shutdown...")
executorService.shutdownNow()
} else {
logger.info("Shutdown completed gracefully.")
}
}.onFailure {
if (it is InterruptedException) {
logger.warn("Shutdown interrupted. Forcing shutdown...")
executorService.shutdownNow()
Thread.currentThread().interrupt()
}
}
}
}
2 changes: 1 addition & 1 deletion src/main/kotlin/me/misik/api/domain/RequestPrompt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class RequestPrompt(
@Column(name = "style", nullable = false, columnDefinition = "VARCHAR(20)")
val style: ReviewStyle,

@Column(name = "text", columnDefinition = "TEXT", nullable = false)
@Column(name = "prompt_text", columnDefinition = "TEXT", nullable = false)
val text: String,

@Column(name = "hash_tags", columnDefinition = "TEXT", nullable = false)
Expand Down
18 changes: 7 additions & 11 deletions src/main/kotlin/me/misik/api/domain/Review.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package me.misik.api.domain

import jakarta.persistence.Column
import jakarta.persistence.Embedded
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.persistence.*
import me.misik.api.core.AggregateRoot


Expand All @@ -16,15 +12,15 @@ class Review(
@Column(name = "id")
val id: Long,

@Column(name = "text", length = 300, columnDefinition = "VARCHAR(300)", nullable = false)
val text: String,
@Column(name = "is_completed")
var isCompleted: Boolean,

@Column(name = "text", length = 1000, columnDefinition = "VARCHAR(1000)", nullable = false)
var text: String,

@Column(name = "device_id", nullable = false, columnDefinition = "VARCHAR(100)")
val deviceId: String,

@Embedded
val requestPrompt: RequestPrompt,
): AbstractTime() {


}
) : AbstractTime()
20 changes: 19 additions & 1 deletion src/main/kotlin/me/misik/api/domain/ReviewRepository.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
package me.misik.api.domain

import jakarta.persistence.LockModeType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param

interface ReviewRepository : JpaRepository<Review, Long>
interface ReviewRepository : JpaRepository<Review, Long> {

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update review as r set r.text = concat(r.text, :text) where r.id = :id")
fun addText(@Param("id") id: Long, @Param("text") text: String)

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update review as r set r.isCompleted = :completedStatus where r.id = :id")
fun setReviewCompletedStatus(@Param("id") id: Long, @Param("completedStatus") completedStatus: Boolean)

@Lock(LockModeType.PESSIMISTIC_READ)
@Query("select r from review as r where id = :id")
fun getReviewWithReadLock(@Param("id") id: Long): Review
}
17 changes: 17 additions & 0 deletions src/main/kotlin/me/misik/api/domain/ReviewService.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package me.misik.api.domain

import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

Expand All @@ -8,4 +9,20 @@ import org.springframework.transaction.annotation.Transactional
class ReviewService(
private val reviewRepository: ReviewRepository,
) {

@Transactional
fun clearReview(id: Long) {
val review = reviewRepository.getReviewWithReadLock(id)
review.text = ""
review.isCompleted = false
}

@Transactional
fun completeReview(id: Long) = reviewRepository.setReviewCompletedStatus(id, true)

@Transactional
fun updateReview(id: Long, text: String) = reviewRepository.addText(id, text)

fun getById(id: Long): Review = reviewRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("Cannot find review by id \"$id\"")
}
Empty file.
36 changes: 36 additions & 0 deletions src/main/kotlin/me/misik/api/infra/ClovaChatbotConfiguration.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package me.misik.api.infra

import me.misik.api.core.Chatbot
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.support.WebClientAdapter
import org.springframework.web.service.invoker.HttpServiceProxyFactory

@Configuration
class ClovaChatbotConfiguration(
@Value("\${me.misik.chatbot.clova.url:https://clovastudio.stream.ntruss.com/}") private val chatbotUrl: String,
@Value("\${me.misik.chatbot.clova.authorization}") private val authorization: String,
) {

@Bean
fun clovaChatbot(): Chatbot {
val webClient = WebClient.builder()
.baseUrl(chatbotUrl)
.defaultHeaders { headers ->
headers.add(HttpHeaders.AUTHORIZATION, "Bearer $authorization")
headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
headers.add(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
}
.build()

val httpServiceProxyFactory = HttpServiceProxyFactory
.builderFor(WebClientAdapter.create(webClient))
.build()

return httpServiceProxyFactory.createClient(Chatbot::class.java)
}
}
Loading

0 comments on commit cae07ed

Please sign in to comment.