Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ jobs:
"REDIS_HOST=${{ secrets.REDIS_HOST }}"
"REDIS_PORT=${{ secrets.REDIS_PORT }}"
"CLOVA_AUTHORIZATION=${{ secrets.CLOVA_AUTHORIZATION }}"
"OPENAI_AUTHORIZATION=${{ secrets.OPENAI_AUTHORIZATION }}"

deploy:
needs: build
Expand Down
6 changes: 3 additions & 3 deletions src/main/kotlin/me/misik/api/api/ReviewController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import me.misik.api.app.ReCreateReviewFacade
import me.misik.api.app.GetReviewFacade
import me.misik.api.domain.request.CreateReviewRequest
import me.misik.api.domain.request.OcrTextRequest
import me.misik.api.domain.ReviewStyle
import me.misik.api.domain.Style
import me.misik.api.domain.response.ReviewStylesResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestBody
Expand Down Expand Up @@ -37,9 +37,9 @@ class ReviewController(

@GetMapping("reviews/styles")
fun getReviewStyles() : ReviewStylesResponse {
val reviewStyles = ReviewStyle.entries.toList()
val styles = Style.entries.toList()

return ReviewStylesResponse.from(reviewStyles)
return ReviewStylesResponse.from(styles)
}

@GetMapping("reviews/{id}")
Expand Down
15 changes: 14 additions & 1 deletion src/main/kotlin/me/misik/api/api/response/ParsedOcrResponse.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
package me.misik.api.api.response

data class ParsedOcrResponse(
val status: Boolean = true,
val parsed: List<KeyValuePair>,
) {
data class KeyValuePair(
val key: String,
val value: String,
)

companion object {
fun from(parsed: List<KeyValuePair>) : ParsedOcrResponse {
return ParsedOcrResponse(
parsed
)
}

fun from(key: String, value: String) : ParsedOcrResponse {
return ParsedOcrResponse(
listOf(KeyValuePair(key, value))
)
}
}
}
92 changes: 70 additions & 22 deletions src/main/kotlin/me/misik/api/app/CreateReviewFacade.kt
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
package me.misik.api.app;

import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import me.misik.api.api.response.ParsedOcrResponse
import me.misik.api.core.Chatbot
import me.misik.api.core.GracefulShutdownDispatcher
import me.misik.api.core.OcrParser
import me.misik.api.core.OpenAIOcrParser
import me.misik.api.domain.CreateReviewCache
import me.misik.api.domain.Review
import me.misik.api.domain.ReviewService
import me.misik.api.domain.prompt.Prompt
import me.misik.api.domain.Style
import me.misik.api.domain.prompt.PromptService
import me.misik.api.domain.prompt.PromptType
import me.misik.api.domain.request.CreateReviewRequest
import me.misik.api.domain.request.OcrTextRequest
import org.slf4j.LoggerFactory
Expand All @@ -25,11 +24,10 @@ import kotlin.time.Duration.Companion.milliseconds
@Service
class CreateReviewFacade(
private val chatbot: Chatbot,
private val ocrParser: OcrParser,
private val openAiOcrParser: OpenAIOcrParser,
private val reviewService: ReviewService,
private val promptService: PromptService,
private val createReviewCache: CreateReviewCache,
private val objectMapper: ObjectMapper,
) {

private val logger = LoggerFactory.getLogger(this::class.simpleName)
Expand Down Expand Up @@ -58,6 +56,7 @@ class CreateReviewFacade(
}
}.invokeOnCompletion {
if (it == null) {
logger.info("Review created successfully. ${review.text}")
createReviewCache.get(review.id).let {
reviewService.updateAndCompleteReview(it.id, it.text)
}
Expand All @@ -79,43 +78,92 @@ class CreateReviewFacade(

fun parseOcrText(ocrText: OcrTextRequest): ParsedOcrResponse {
return runBlocking(GracefulShutdownDispatcher.dispatcher) {
withTimeout(10.milliseconds) {
val prompt = promptService.findAllByType(PromptType.OCR).first()
parseOcrWithRetry(prompt, ocrText, 0)
withTimeout(5000.milliseconds) {
val shopNamePrompt = promptService.getByStyle(Style.OCR_SHOP_NAME)
val itemNamePrompt = promptService.getByStyle(Style.OCR_ITEM_NAME)

val shopNameOcrRequest = OpenAIOcrParser.Request.from(shopNamePrompt, ocrText.text)
logger.info("ocr request $shopNameOcrRequest")

val itemNameOcrRequest = OpenAIOcrParser.Request.from(itemNamePrompt, ocrText.text)
logger.info("ocr request $itemNameOcrRequest")

parseOcrWithRetry(shopNameOcrRequest, itemNameOcrRequest, 0)
}
}
}

private fun parseOcrWithRetry(
prompt: Prompt,
ocrText: OcrTextRequest,
private suspend fun parseOcrWithRetry(
shopNameOcrRequest: OpenAIOcrParser.Request,
itemNameOcrRequest: OpenAIOcrParser.Request,
retryCount: Int,
): ParsedOcrResponse {
return runCatching {
val response = ocrParser.createParsedOcr(OcrParser.Request.of(prompt, ocrText.text))
logger.info("ocr response before parsing $response")

val responseContent = response.result?.message?.content ?: ""
logger.info("ocr responseContent $responseContent")
val shopNameDeferred = CoroutineScope(GracefulShutdownDispatcher.dispatcher).async {
parseShopName(shopNameOcrRequest)
}
val itemNameDeferred = CoroutineScope(GracefulShutdownDispatcher.dispatcher).async {
parseItemNames(itemNameOcrRequest)
}

val parsedOcr = objectMapper.readValue(responseContent, ParsedOcrResponse::class.java)
?: throw IllegalStateException("Invalid OCR text format")
val shopNameKeyValuePair = shopNameDeferred.await()
val itemNameKeyValuePairs = itemNameDeferred.await()

require(parsedOcr.status) { "Wrong ocr request \"$ocrText\"" }
require(parsedOcr.parsed.isEmpty().not()) { "Parsed OCR content is empty" }
val parsedOcr = ParsedOcrResponse(shopNameKeyValuePair + itemNameKeyValuePairs)

require(parsedOcr.parsed.isNotEmpty()) { "Parsed OCR content is empty" }
parsedOcr
}.getOrElse {
logger.error("OCR Parsing fail", it)
if (it is IllegalArgumentException) {
throw it
}

if (retryCount < MAX_RETRY_COUNT) {
return@getOrElse parseOcrWithRetry(prompt, ocrText, retryCount + 1)
return@getOrElse parseOcrWithRetry(shopNameOcrRequest, itemNameOcrRequest, retryCount + 1)
}
throw it
}
}

private fun parseItemNames(itemNameOcrRequest: OpenAIOcrParser.Request): List<ParsedOcrResponse.KeyValuePair> {
val itemNameOcrResponse = openAiOcrParser.createParsedOcr(itemNameOcrRequest)
logger.info("item name ocr response before parsing $itemNameOcrResponse")

val itemNameResponseContent =
itemNameOcrResponse.choices?.firstOrNull()?.message?.content ?: ""
logger.info("item name ocr responseContent $itemNameResponseContent")

if (isParsable(itemNameResponseContent)) return emptyList()

val itemNameKeyValuePairs = itemNameResponseContent.split(',').map {
ParsedOcrResponse.KeyValuePair(Style.OCR_ITEM_NAME.key, it.trim())
}
return itemNameKeyValuePairs
}

private fun parseShopName(shopNameOcrRequest: OpenAIOcrParser.Request): List<ParsedOcrResponse.KeyValuePair> {
val shopNameOcrResponse = openAiOcrParser.createParsedOcr(shopNameOcrRequest)
logger.info("shop name ocr response before parsing $shopNameOcrResponse")

val shopNameResponseContent =
shopNameOcrResponse.choices?.firstOrNull()?.message?.content ?: ""
logger.info("shop name ocr responseContent $shopNameResponseContent")

if (isParsable(shopNameResponseContent)) return emptyList()

val shopNameKeyValuePair =
ParsedOcrResponse.KeyValuePair(Style.OCR_SHOP_NAME.key, shopNameResponseContent)
return listOf(shopNameKeyValuePair)
}

private fun isParsable(shopNameResponseContent: String): Boolean {
return shopNameResponseContent.equals(UNPARSABLE, ignoreCase = true)
}

private companion object {
private const val MAX_RETRY_COUNT = 3
private const val ALREADY_COMPLETED = "stop_before"
private const val UNPARSABLE = "X"
}
}
2 changes: 1 addition & 1 deletion src/main/kotlin/me/misik/api/core/OcrParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ fun interface OcrParser {

companion object {

fun of(prompt: Prompt, ocrText: String): Request {
fun from(prompt: Prompt, ocrText: String): Request {
return Request(
messages = listOf(
Message.createSystem(prompt.command),
Expand Down
63 changes: 63 additions & 0 deletions src/main/kotlin/me/misik/api/core/OpenAIOcrParser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package me.misik.api.core

import me.misik.api.domain.prompt.Prompt
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.service.annotation.PostExchange

fun interface OpenAIOcrParser {

@PostExchange("/v1/chat/completions")
fun createParsedOcr(@RequestBody request: Request): Response

data class Request(
val model: String = "gpt-4o-mini",
val messages: List<Message>,
val max_tokens: Int = 1000,
) {
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 {
fun from(prompt: Prompt, ocrText: String): Request {
return Request(
messages = listOf(
Message.createSystem(prompt.command),
Message.createUser(ocrText),
)
)
}
}
}

data class Response(
val id: String?,
val created: Long?,
val model: String?,
val choices: List<Choice>?
) {
data class Choice(
val message: Message,
val finish_reason: String
)

data class Message(
val role: String,
val content: String
)
}

}
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 @@ -7,7 +7,7 @@ import me.misik.api.domain.converter.ListToStringConverter
class RequestPrompt(
@Enumerated(EnumType.STRING)
@Column(name = "style", nullable = false, columnDefinition = "VARCHAR(20)")
val style: ReviewStyle,
val style: Style,

@Column(name = "ocr_text", columnDefinition = "TEXT", nullable = false)
val ocrText: String,
Expand Down
9 changes: 0 additions & 9 deletions src/main/kotlin/me/misik/api/domain/ReviewStyle.kt

This file was deleted.

10 changes: 10 additions & 0 deletions src/main/kotlin/me/misik/api/domain/Style.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package me.misik.api.domain

enum class Style(val iconUrl: String, val key: String) {
PROFESSIONAL("https://kr.object.ncloudstorage.com/misik/review-style/professional-icon.png", "NOT_USE"),
FRIENDLY("https://kr.object.ncloudstorage.com/misik/review-style/friendly-icon.png", "NOT_USE"),
CUTE("https://kr.object.ncloudstorage.com/misik/review-style/cute-icon.png", "NOT_USE"),
OCR_SHOP_NAME("NOT_USE","가게명"),
OCR_ITEM_NAME("NOT_USE","품명"),
;
}
4 changes: 2 additions & 2 deletions src/main/kotlin/me/misik/api/domain/prompt/Prompt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package me.misik.api.domain.prompt

import jakarta.persistence.*
import me.misik.api.domain.AbstractTime
import me.misik.api.domain.ReviewStyle
import me.misik.api.domain.Style

@Entity
@Table(
Expand All @@ -16,7 +16,7 @@ class Prompt(

@Enumerated(EnumType.STRING)
@Column(name = "style", columnDefinition = "VARCHAR(20)", nullable = false)
val style: ReviewStyle,
val style: Style,

@Column(name = "command", columnDefinition = "TEXT", nullable = false)
val command: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package me.misik.api.domain.prompt

import me.misik.api.domain.ReviewStyle
import me.misik.api.domain.Style
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param

interface PromptRepository : JpaRepository<Prompt, Long> {
@Query("SELECT p FROM Prompt p WHERE p.style = :reviewStyle")
fun findByReviewStyleOrNull(@Param("reviewStyle") reviewStyle: ReviewStyle?): Prompt?
@Query("SELECT p FROM Prompt p WHERE p.style = :style")
fun findByReviewStyleOrNull(@Param("style") style: Style?): Prompt?

@Query("SELECt p FROM Prompt p WHERE p.type = :promptType")
fun findAllByType(@Param("promptType") promptType: PromptType): List<Prompt>
Expand Down
6 changes: 3 additions & 3 deletions src/main/kotlin/me/misik/api/domain/prompt/PromptService.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package me.misik.api.domain.prompt

import me.misik.api.domain.ReviewStyle
import me.misik.api.domain.Style
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

Expand All @@ -10,8 +10,8 @@ class PromptService(
private val promptRepository: PromptRepository,
) {

fun getByStyle(reviewStyle: ReviewStyle): Prompt = promptRepository.findByReviewStyleOrNull(reviewStyle)
?: throw IllegalArgumentException("Cannot find prompt by review style \"$reviewStyle\"")
fun getByStyle(style: Style): Prompt = promptRepository.findByReviewStyleOrNull(style)
?: throw IllegalArgumentException("Cannot find prompt by review style \"$style\"")

fun findAllByType(promptType: PromptType): List<Prompt> = promptRepository.findAllByType(promptType)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package me.misik.api.domain.request

import me.misik.api.domain.ReviewStyle
import me.misik.api.domain.Style

data class CreateReviewRequest(
val ocrText: String,
val hashTag: List<String>,
val reviewStyle: ReviewStyle = ReviewStyle.FRIENDLY,
val reviewStyle: Style = Style.FRIENDLY,
)
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package me.misik.api.domain.response

import me.misik.api.domain.ReviewStyle
import me.misik.api.domain.Style

data class ReviewStyleResponse(
val icon: String,
val style: String
) {
companion object {
fun from(reviewStyle: ReviewStyle): ReviewStyleResponse {
fun from(style: Style): ReviewStyleResponse {
return ReviewStyleResponse(
icon = reviewStyle.iconUrl,
style = reviewStyle.name,
icon = style.iconUrl,
style = style.name,
)
}
}
Expand Down
Loading
Loading