Skip to content

Commit

Permalink
feat: 리뷰를 생성한다 (#13)
Browse files Browse the repository at this point in the history
* doc: 리뷰 조회, OCR 파싱 문서 수정

* feat: 리뷰를 생성한다

* test: 리뷰 생성 테스트 오류 수정

* doc: OCR 파싱 문서 추가

* feat: 리뷰 생성, 재생성 시 인메모리 캐싱

* feat: LLM 리뷰 생성 시 해시태그를 반영한다

* feat: 불필요한 쿼리를 수정하고, 재시도 실패 시에도 캐시를 삭제한다
  • Loading branch information
inpink authored Jan 30, 2025
1 parent 0e05b21 commit 2a8bfb6
Show file tree
Hide file tree
Showing 20 changed files with 366 additions and 31 deletions.
50 changes: 50 additions & 0 deletions docs/api/OCR_파싱_API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# OCR 파싱 API

OCR로 추출한 텍스트를 요청으로 받아, LLM을 이용해 영수증 내용을 파싱하여 응답합니다.
key:value는 요청 text에 따라 다르게 응답합니다.
파싱 결과에 유효한 key:value가 1개 이상 없다면 실패합니다.

## Request

### HTTP METHOD : `GET`

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

### RequestBody

```json
{
"ocrText": "영수증 내용. 품명 단가 수량 카야토스트+음료세트 3,000 ..."
}
```

### Response

#### `Response Status 200 OK`

```json
{
"parsed": [
{
"key": "품명",
"value": "카야토스트+음료세트"
},
{
"key": "품명",
"value": "카야토스트+음료세트"
},
...
]
}
```

#### `Response Status 400 Bad Request`

```json
{
"message": "영수증 내용이 없습니다."
}
```
12 changes: 10 additions & 2 deletions docs/api/리뷰_조회_API.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 리뷰 생성
# 리뷰 조회

리뷰를 생성합니다.
리뷰를 조회합니다.

## Request

Expand All @@ -22,3 +22,11 @@
"review": "카야토스는 숨겨져 있는 카야잼과 버터가 확실히 가득합니다. 또한, 가게의 분위기는 아늑하고 편안하고 바깥쪽에 있고 사랑하는 시간을 보낼 수 있는 공간입니다. 무엇보다 가격에 비해 음식의 품질이 정말 훌륭해서, 마음에 들었습니다."
}
```

#### `Response Status 400 Bad Request`

```json
{
"message": "리뷰가 존재하지 않습니다."
}
```
14 changes: 13 additions & 1 deletion src/main/kotlin/me/misik/api/api/ReviewController.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
package me.misik.api.api

import me.misik.api.domain.request.CreateReviewRequest
import me.misik.api.app.CreateReviewFacade
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.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RestController

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

@PostMapping("reviews")
fun createReview(
@RequestHeader("device-id") deviceId: String,
@RequestBody createReviewRequest: CreateReviewRequest,
) : Long = createReviewFacade.createReviewInBackground(deviceId, createReviewRequest)

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

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.launch
import me.misik.api.domain.request.CreateReviewRequest
import me.misik.api.core.Chatbot
import me.misik.api.core.GracefulShutdownDispatcher
import me.misik.api.domain.CreateReviewCache
import me.misik.api.domain.Review
import me.misik.api.domain.ReviewService
import me.misik.api.domain.prompt.PromptService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service


@Service
class CreateReviewFacade(
private val chatbot:Chatbot,
private val reviewService:ReviewService,
private val promptService: PromptService,
private val createReviewCache: CreateReviewCache
) {

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

fun createReviewInBackground(deviceId:String, createReviewRequest: CreateReviewRequest) : Long {
val prompt = promptService.getByStyle(createReviewRequest.reviewStyle)
val review = reviewService.createReview(deviceId, prompt.command, createReviewRequest)

createReviewCache.put(review.id, review)

createReviewWithRetry(review, retryCount = 0)

return review.id
}

private fun createReviewWithRetry(review: Review, retryCount: Int) {
CoroutineScope(GracefulShutdownDispatcher.dispatcher).launch {
chatbot.createReviewWithModelName(Chatbot.Request.from(review))
.filterNot { it.stopReason == ALREADY_COMPLETED }
.collect {
val newText = it.message?.content ?: ""
review.addText(newText)

val updatedReview = review.copy()
createReviewCache.put(review.id, updatedReview)
}
}.invokeOnCompletion {
if (it == null) {
createReviewCache.get(review.id)?.let {
reviewService.updateAndCompleteReview(it.id, it.text)
}
createReviewCache.remove(review.id)
return@invokeOnCompletion
}
if (retryCount == MAX_RETRY_COUNT) {
logger.error("Failed to create review.", it)
createReviewCache.remove(review.id)
throw it
}
logger.warn("Failed to create review. retrying... retryCount: \"${retryCount + 1}\"", it)
createReviewWithRetry(review, retryCount + 1)
}
}

private companion object {
private const val MAX_RETRY_COUNT = 3
private const val ALREADY_COMPLETED = "stop_before"
}
}
27 changes: 20 additions & 7 deletions src/main/kotlin/me/misik/api/app/ReCreateReviewFacade.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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.CreateReviewCache
import me.misik.api.domain.Review
import me.misik.api.domain.ReviewService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
Expand All @@ -13,35 +15,46 @@ import org.springframework.stereotype.Service
class ReCreateReviewFacade(
private val chatbot: Chatbot,
private val reviewService: ReviewService,
private val createReviewCache: CreateReviewCache
) {

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

fun reCreateReviewInBackground(id: Long) {
fun reCreateReviewInBackground(deviceId: String, id: Long) {
reviewService.clearReview(id)

reCreateReviewWithRetry(id, retryCount = 0)
val review = reviewService.getById(id)

reCreateReviewWithRetry(review, retryCount = 0)
}

private fun reCreateReviewWithRetry(id: Long, retryCount: Int) {
private fun reCreateReviewWithRetry(review: Review, 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 ?: "")
val newText = it.message?.content ?: ""
review.addText(newText)

val updatedReview = review.copy()
createReviewCache.put(review.id, updatedReview)
}
}.invokeOnCompletion {
if (it == null) {
return@invokeOnCompletion reviewService.completeReview(id)
createReviewCache.get(review.id)?.let {
reviewService.updateAndCompleteReview(it.id, it.text)
}
createReviewCache.remove(review.id)
return@invokeOnCompletion
}
if (retryCount == MAX_RETRY_COUNT) {
logger.error("Failed to create review.", it)
createReviewCache.remove(review.id)
throw it
}
logger.warn("Failed to create review. retrying... retryCount: \"${retryCount + 1}\"", it)
reCreateReviewWithRetry(id, retryCount + 1)
reCreateReviewWithRetry(review, retryCount + 1)
}
}

Expand Down
12 changes: 3 additions & 9 deletions src/main/kotlin/me/misik/api/core/Chatbot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ fun interface Chatbot {

data class Request(
val messages: List<Message>,
val maxTokens: Int = 100,
val includeAiFilters: Boolean = true,
) {
data class Message(
Expand All @@ -34,19 +35,12 @@ fun interface Chatbot {
}

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

fun from(review: Review): Request {
return Request(
messages = listOf(
cachedSystemMessage,
Message.createUser(review.requestPrompt.text)
Message.createSystem(review.requestPrompt.promptCommand + review.requestPrompt.hashTags.joinToString(", ") { "$it" }),
Message.createUser(review.requestPrompt.ocrText)
)
)
}
Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/me/misik/api/domain/CreateReviewCache.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package me.misik.api.domain

interface CreateReviewCache {

fun get(id: Long) : Review
fun put(id: Long, review: Review)
fun remove(id: Long)
}
7 changes: 5 additions & 2 deletions src/main/kotlin/me/misik/api/domain/RequestPrompt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ class RequestPrompt(
@Column(name = "style", nullable = false, columnDefinition = "VARCHAR(20)")
val style: ReviewStyle,

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

@Column(name = "prompt_command", columnDefinition = "TEXT", nullable = false)
val promptCommand: String,

@Column(name = "hash_tags", columnDefinition = "TEXT", nullable = false)
@Convert(converter = ListToStringConverter::class)
Expand Down
49 changes: 47 additions & 2 deletions src/main/kotlin/me/misik/api/domain/Review.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package me.misik.api.domain

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


@Entity(name = "review")
Expand All @@ -23,4 +28,44 @@ class Review(

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

companion object {

fun create(
text: String = "",
deviceId: String,
requestPrompt: RequestPrompt,
isCompleted: Boolean = false,
): Review {

return Review(
id = IdGenerator.generate(),
isCompleted = isCompleted,
text = text,
deviceId = deviceId,
requestPrompt = requestPrompt,
)
}
}

fun copy(
id: Long = this.id,
isCompleted: Boolean = this.isCompleted,
text: String = this.text,
deviceId: String = this.deviceId,
requestPrompt: RequestPrompt = this.requestPrompt,
): Review {
return Review(
id = id,
isCompleted = isCompleted,
text = text,
deviceId = deviceId,
requestPrompt = requestPrompt,
)
}

fun addText(newText: String) {
text += newText
}
}
6 changes: 5 additions & 1 deletion src/main/kotlin/me/misik/api/domain/ReviewRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ interface ReviewRepository : JpaRepository<Review, Long> {
fun setReviewCompletedStatus(@Param("id") id: Long, @Param("completedStatus") completedStatus: Boolean)

@Lock(LockModeType.PESSIMISTIC_READ)
@Query("select r from review as r where id = :id")
@Query("select r from review as r where r.id = :id")
fun getReviewWithReadLock(@Param("id") id: Long): Review

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update review as r set r.text = :text, r.isCompleted = true where r.id = :id")
fun updateTextAndComplete(id: Long, text: String): Any
}
Loading

0 comments on commit 2a8bfb6

Please sign in to comment.