diff --git a/docs/tasks/NOOK-176/README.md b/docs/tasks/NOOK-176/README.md new file mode 100644 index 0000000..c4ba7b8 --- /dev/null +++ b/docs/tasks/NOOK-176/README.md @@ -0,0 +1,22 @@ +# NOOK-176 OCR 장소 후보 탈락률 개선 + +## 목적 + +OCR transcript에 다수의 장소명이 포함된 게시물에서 장소 clue 추출 단계의 개수 제한 때문에 후보가 과도하게 탈락하지 않도록 한다. + +## 범위 + +- 장소 clue 반환 상한을 20개에서 60개로 확대한다. +- 본문에 `52곳`처럼 20개를 초과하는 기대 장소 개수가 있어도 OCR fallback과 recall recovery 판단에 사용한다. +- OpenAI structured output schema, 출력 토큰 한도, 프롬프트의 장소 개수 상한을 함께 조정한다. + +## 제외 범위 + +- OCR provider 교체 +- 게시물 이미지 저장 흐름 변경 +- DB 스키마 변경 + +## 검증 + +- `./gradlew :nook-api-application:test --tests org.every.nook.api.application.place.ProcessPlaceParsingJobUseCaseTest` +- `./gradlew check` diff --git a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/ProcessPlaceParsingJobUseCase.kt b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/ProcessPlaceParsingJobUseCase.kt index 7946b1b..13dc7a8 100644 --- a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/ProcessPlaceParsingJobUseCase.kt +++ b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/ProcessPlaceParsingJobUseCase.kt @@ -329,7 +329,7 @@ class ProcessPlaceParsingJobUseCase( val logger = KotlinLogging.logger {} val eventLogger = LoggerFactory.getLogger(ProcessPlaceParsingJobUseCase::class.java) - const val MAX_PLACE_COUNT = 20 + const val MAX_PLACE_COUNT = 60 const val MAX_QUERY_COUNT = 4 const val MAX_IMAGE_COUNT = 20 const val CANDIDATE_LOG_LIMIT = 5 @@ -425,7 +425,7 @@ private fun String.groundingKey(): String = lowercase().filter(Char::isLetterOrD private const val MIN_GROUNDING_KEY_LENGTH = 2 private const val MIN_EXPECTED_PLACE_COUNT = 2 -private const val MAX_EXPECTED_PLACE_COUNT = 20 +private const val MAX_EXPECTED_PLACE_COUNT = 80 private val EXPECTED_PLACE_COUNT_PATTERN = Regex("(?() + val extractor = PlaceClueExtractor { request -> + requests += request + if (request.imageTranscripts.isEmpty()) { + listOf(PlaceClue("텍스트 장소", "압구정", listOf("텍스트 장소"))) + } else { + listOf( + PlaceClue( + "이미지 장소", + "압구정", + listOf("이미지 장소"), + listOf(PlaceClueEvidence(1, "이미지 장소 / 압구정")), + ), + ) + } + } + val useCase = useCase( + port, + extractor, + SearchPlaceCandidatesUseCase { request -> + listOf(candidate(request.query, request.query, "서울 강남구 압구정")) + }, + ) + + assertIs(useCase(1)) + assertEquals(2, requests.size) + assertEquals(listOf("텍스트 장소", "이미지 장소"), port.completed.map(PlaceCandidate::name)) + } + + @Test + fun `resolves more than twenty image place clues`() { + val placeCount = 25 + val port = FakeJobPort( + body = "압구정 맛집 ${placeCount}곳", + imageUrls = (1..20).map { "https://cdn.test/$it.jpg" }, + textClues = emptyList(), + imageTranscripts = (1..20).map { imageIndex -> + ImageTranscript(imageIndex, listOf("이미지 장소 $imageIndex")) + }, + ) + val useCase = useCase( + port = port, + extractor = PlaceClueExtractor { request -> + if (request.imageTranscripts.isEmpty()) { + emptyList() + } else { + (1..placeCount).map { index -> + val imageIndex = ((index - 1) % 20) + 1 + PlaceClue( + name = "이미지 장소 $index", + region = "압구정", + queries = listOf("이미지 장소 $index"), + evidence = listOf(PlaceClueEvidence(imageIndex, "이미지 장소 $index / 압구정")), + ) + } + } + }, + search = SearchPlaceCandidatesUseCase { request -> + listOf(candidate(request.query, request.query, "서울 강남구 압구정")) + }, + ) + + assertIs(useCase(1)) + assertEquals(placeCount, port.completed.size) + assertEquals("이미지 장소 25", port.completed.last().name) + } + @Test fun `transcribes at most twenty images in batches of five`() { val imageUrls = (0 until 25).map { "https://cdn.test/$it.jpg" } diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/openai/OpenAiContentInferenceAdapter.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/openai/OpenAiContentInferenceAdapter.kt index a4c1e75..720814c 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/openai/OpenAiContentInferenceAdapter.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/openai/OpenAiContentInferenceAdapter.kt @@ -222,11 +222,11 @@ class OpenAiContentInferenceAdapter( const val NANOS_PER_MILLISECOND = 1_000_000 const val MAX_TITLE_LENGTH = 25 - const val MAX_PLACE_COUNT = 20 + const val MAX_PLACE_COUNT = 60 const val MAX_QUERY_COUNT = 4 - const val CONTENT_INFERENCE_MAX_OUTPUT_TOKENS = 3000 - const val PLACE_MAX_OUTPUT_TOKENS = 800 - const val IMAGE_PLACE_MAX_OUTPUT_TOKENS = 4000 + const val CONTENT_INFERENCE_MAX_OUTPUT_TOKENS = 8000 + const val PLACE_MAX_OUTPUT_TOKENS = 2500 + const val IMAGE_PLACE_MAX_OUTPUT_TOKENS = 12000 const val CANDIDATE_SELECTION_MAX_OUTPUT_TOKENS = 100 const val PLACE_TAG_MAX_OUTPUT_TOKENS = 600 const val DEFAULT_TITLE = "Instagram 게시물" @@ -316,7 +316,7 @@ class OpenAiContentInferenceAdapter( "예를 들어 sourceLocationTag가 Lodge190이고 본문이 '연희동 사랑방 롯지190'이면 name은 Lodge190이고 " + "queries는 원문 Lodge190, 한글 음차 롯지190, 띄어쓰기 변형 롯지 190, " + "지역을 붙인 축약형 연희동 Lodge 순서로 반환한다. " + - "가게 근거가 없으면 places를 빈 배열로 반환한다. 최대 20개 가게와 가게당 최대 4개 검색어만 반환한다." + "가게 근거가 없으면 places를 빈 배열로 반환한다. 최대 60개 가게와 가게당 최대 4개 검색어만 반환한다." const val CONTENT_INFERENCE_INSTRUCTIONS = "title과 places를 하나의 응답으로 함께 반환한다. " + TITLE_INSTRUCTIONS + " " + PLACE_INSTRUCTIONS const val CANDIDATE_SELECTION_INSTRUCTIONS = diff --git a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapper.kt b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapper.kt index 0d43c6d..b12799d 100644 --- a/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapper.kt +++ b/nook-api-infrastructure/src/main/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapper.kt @@ -25,7 +25,7 @@ class NaverPlaceMapper { address = displayAddress, latitude = latitude, longitude = longitude, - category = item.category.toPlainText(), + category = item.category.toTopLevelCategory(), phoneNumber = item.telephone.toNullableValue(), providerUrl = item.link.toNullableValue(), ) @@ -51,6 +51,10 @@ class NaverPlaceMapper { ?.trim() ?.takeIf(String::isNotEmpty) + private fun String?.toTopLevelCategory(): String? = toPlainText() + ?.substringBefore(CATEGORY_DELIMITER) + ?.toNullableValue() + private fun stableId(name: String, address: String, longitude: BigDecimal, latitude: BigDecimal): String = MessageDigest.getInstance("SHA-256") .digest("$name|$address|${longitude.toPlainString()}|${latitude.toPlainString()}".toByteArray()) @@ -59,6 +63,7 @@ class NaverPlaceMapper { private companion object { const val PROVIDER = "NAVER" const val WGS84_SCALE = 7 + const val CATEGORY_DELIMITER = ">" val HTML_TAG = Regex("<[^>]+>") val MIN_LONGITUDE = BigDecimal("-180") val MAX_LONGITUDE = BigDecimal("180") diff --git a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/openai/OpenAiContentInferenceAdapterTest.kt b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/openai/OpenAiContentInferenceAdapterTest.kt index a858615..f6bc89b 100644 --- a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/openai/OpenAiContentInferenceAdapterTest.kt +++ b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/openai/OpenAiContentInferenceAdapterTest.kt @@ -70,7 +70,7 @@ class OpenAiContentInferenceAdapterTest { .andExpect(content().string(containsString("post_content_inference"))) .andExpect(content().string(containsString("홍보성"))) .andExpect(content().string(containsString("\"maxLength\":25"))) - .andExpect(content().string(containsString("\"maxItems\":20"))) + .andExpect(content().string(containsString("\"maxItems\":60"))) .andRespond( withSuccess( response( @@ -104,7 +104,7 @@ class OpenAiContentInferenceAdapterTest { val fixture = adapterFixture() fixture.server.expect(requestTo("https://api.openai.test/v1/responses")) .andExpect(content().string(containsString("place_clues"))) - .andExpect(content().string(containsString("\"maxItems\":20"))) + .andExpect(content().string(containsString("\"maxItems\":60"))) .andRespond( withSuccess( response( @@ -188,7 +188,7 @@ class OpenAiContentInferenceAdapterTest { fixture.server.expect(requestTo("https://api.openai.test/v1/responses")) .andExpect(content().string(not(containsString("\"type\":\"input_image\"")))) .andExpect(content().string(containsString("빈브라더스 커피하우스 서울"))) - .andExpect(content().string(containsString("\"max_output_tokens\":4000"))) + .andExpect(content().string(containsString("\"max_output_tokens\":12000"))) .andRespond( withSuccess( response( diff --git a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapperTest.kt b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapperTest.kt index 0c44acf..e344836 100644 --- a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapperTest.kt +++ b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/place/NaverPlaceMapperTest.kt @@ -25,6 +25,7 @@ class NaverPlaceMapperTest { assertEquals("서울특별시 용산구 한강대로77길 4-1", candidate.address) assertEquals("126.972332".toBigDecimal(), candidate.longitude) assertEquals("37.543123".toBigDecimal(), candidate.latitude) + assertEquals("음식점", candidate.category) } @Test