diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 84e561d..14f512f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/src/main/kotlin/me/misik/api/api/ReviewController.kt b/src/main/kotlin/me/misik/api/api/ReviewController.kt index feba672..86c7f6a 100644 --- a/src/main/kotlin/me/misik/api/api/ReviewController.kt +++ b/src/main/kotlin/me/misik/api/api/ReviewController.kt @@ -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 @@ -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}") diff --git a/src/main/kotlin/me/misik/api/api/response/ParsedOcrResponse.kt b/src/main/kotlin/me/misik/api/api/response/ParsedOcrResponse.kt index 65bed02..3cf5e81 100644 --- a/src/main/kotlin/me/misik/api/api/response/ParsedOcrResponse.kt +++ b/src/main/kotlin/me/misik/api/api/response/ParsedOcrResponse.kt @@ -1,11 +1,24 @@ package me.misik.api.api.response data class ParsedOcrResponse( - val status: Boolean = true, val parsed: List, ) { data class KeyValuePair( val key: String, val value: String, ) + + companion object { + fun from(parsed: List) : ParsedOcrResponse { + return ParsedOcrResponse( + parsed + ) + } + + fun from(key: String, value: String) : ParsedOcrResponse { + return ParsedOcrResponse( + listOf(KeyValuePair(key, value)) + ) + } + } } diff --git a/src/main/kotlin/me/misik/api/app/CreateReviewFacade.kt b/src/main/kotlin/me/misik/api/app/CreateReviewFacade.kt index 8c46dff..36186ae 100644 --- a/src/main/kotlin/me/misik/api/app/CreateReviewFacade.kt +++ b/src/main/kotlin/me/misik/api/app/CreateReviewFacade.kt @@ -1,7 +1,7 @@ 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 @@ -9,13 +9,12 @@ 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 @@ -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) @@ -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) } @@ -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 { + 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 { + 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" } } diff --git a/src/main/kotlin/me/misik/api/core/OcrParser.kt b/src/main/kotlin/me/misik/api/core/OcrParser.kt index b26574f..3606cf7 100644 --- a/src/main/kotlin/me/misik/api/core/OcrParser.kt +++ b/src/main/kotlin/me/misik/api/core/OcrParser.kt @@ -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), diff --git a/src/main/kotlin/me/misik/api/core/OpenAIOcrParser.kt b/src/main/kotlin/me/misik/api/core/OpenAIOcrParser.kt new file mode 100644 index 0000000..ced49a0 --- /dev/null +++ b/src/main/kotlin/me/misik/api/core/OpenAIOcrParser.kt @@ -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, + 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? + ) { + data class Choice( + val message: Message, + val finish_reason: String + ) + + data class Message( + val role: String, + val content: String + ) + } + +} diff --git a/src/main/kotlin/me/misik/api/domain/RequestPrompt.kt b/src/main/kotlin/me/misik/api/domain/RequestPrompt.kt index 5de0f2e..8dafe4e 100644 --- a/src/main/kotlin/me/misik/api/domain/RequestPrompt.kt +++ b/src/main/kotlin/me/misik/api/domain/RequestPrompt.kt @@ -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, diff --git a/src/main/kotlin/me/misik/api/domain/ReviewStyle.kt b/src/main/kotlin/me/misik/api/domain/ReviewStyle.kt deleted file mode 100644 index ad979d5..0000000 --- a/src/main/kotlin/me/misik/api/domain/ReviewStyle.kt +++ /dev/null @@ -1,9 +0,0 @@ -package me.misik.api.domain - -enum class ReviewStyle(val iconUrl: String) { - PROFESSIONAL("https://kr.object.ncloudstorage.com/misik/review-style/professional-icon.png"), - FRIENDLY("https://kr.object.ncloudstorage.com/misik/review-style/friendly-icon.png"), - CUTE("https://kr.object.ncloudstorage.com/misik/review-style/cute-icon.png"), - OCR("NOT_USE"), - ; -} diff --git a/src/main/kotlin/me/misik/api/domain/Style.kt b/src/main/kotlin/me/misik/api/domain/Style.kt new file mode 100644 index 0000000..2c94e5e --- /dev/null +++ b/src/main/kotlin/me/misik/api/domain/Style.kt @@ -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","품명"), + ; +} diff --git a/src/main/kotlin/me/misik/api/domain/prompt/Prompt.kt b/src/main/kotlin/me/misik/api/domain/prompt/Prompt.kt index 9744f08..a4f8422 100644 --- a/src/main/kotlin/me/misik/api/domain/prompt/Prompt.kt +++ b/src/main/kotlin/me/misik/api/domain/prompt/Prompt.kt @@ -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( @@ -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, diff --git a/src/main/kotlin/me/misik/api/domain/prompt/PromptRepository.kt b/src/main/kotlin/me/misik/api/domain/prompt/PromptRepository.kt index d7c536b..38c8ba3 100644 --- a/src/main/kotlin/me/misik/api/domain/prompt/PromptRepository.kt +++ b/src/main/kotlin/me/misik/api/domain/prompt/PromptRepository.kt @@ -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 { - @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 diff --git a/src/main/kotlin/me/misik/api/domain/prompt/PromptService.kt b/src/main/kotlin/me/misik/api/domain/prompt/PromptService.kt index cdc210e..e2061c9 100644 --- a/src/main/kotlin/me/misik/api/domain/prompt/PromptService.kt +++ b/src/main/kotlin/me/misik/api/domain/prompt/PromptService.kt @@ -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 @@ -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 = promptRepository.findAllByType(promptType) } diff --git a/src/main/kotlin/me/misik/api/domain/request/CreateReviewRequest.kt b/src/main/kotlin/me/misik/api/domain/request/CreateReviewRequest.kt index 0d48254..7927a4b 100644 --- a/src/main/kotlin/me/misik/api/domain/request/CreateReviewRequest.kt +++ b/src/main/kotlin/me/misik/api/domain/request/CreateReviewRequest.kt @@ -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, - val reviewStyle: ReviewStyle = ReviewStyle.FRIENDLY, + val reviewStyle: Style = Style.FRIENDLY, ) diff --git a/src/main/kotlin/me/misik/api/domain/response/ReviewStyleResponse.kt b/src/main/kotlin/me/misik/api/domain/response/ReviewStyleResponse.kt index c86b99c..669c358 100644 --- a/src/main/kotlin/me/misik/api/domain/response/ReviewStyleResponse.kt +++ b/src/main/kotlin/me/misik/api/domain/response/ReviewStyleResponse.kt @@ -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, ) } } diff --git a/src/main/kotlin/me/misik/api/domain/response/ReviewStylesResponse.kt b/src/main/kotlin/me/misik/api/domain/response/ReviewStylesResponse.kt index 5526306..89068fc 100644 --- a/src/main/kotlin/me/misik/api/domain/response/ReviewStylesResponse.kt +++ b/src/main/kotlin/me/misik/api/domain/response/ReviewStylesResponse.kt @@ -1,14 +1,14 @@ package me.misik.api.domain.response -import me.misik.api.domain.ReviewStyle +import me.misik.api.domain.Style data class ReviewStylesResponse( val reviewStyles: List ) { companion object { - fun from(reviewStyles: List): ReviewStylesResponse { + fun from(styles: List