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
22 changes: 22 additions & 0 deletions docs/tasks/NOOK-176/README.md
Original file line number Diff line number Diff line change
@@ -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`
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the processing lease valid for the enlarged workload

When a post now yields dozens of clues, resolveClues can perform up to four sequential provider searches plus candidate selection per clue, so processing can exceed the default one-minute lease. The presentation worker still dispatches outstanding jobs every 10 seconds, while PlaceParsingPersistenceAdapter.findOutstanding marks a PROCESSING job available solely from its last updatedAt; there is no heartbeat during resolution. A second worker can therefore reclaim the same high-count job while the first is still running, causing duplicate provider work and competing complete transactions that can hit post_places uniqueness constraints or overwrite the job status. Increase/renew the lease or otherwise prevent reclamation while processing the newly supported workload.

Useful? React with 👍 / 👎.

const val MAX_QUERY_COUNT = 4
const val MAX_IMAGE_COUNT = 20
const val CANDIDATE_LOG_LIMIT = 5
Expand Down Expand Up @@ -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("(?<!\\d)(\\d{1,2})\\s*(?:곳|선|군데)")

private fun PlaceClue.isSupportedBy(candidate: PlaceCandidate): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,79 @@ class ProcessPlaceParsingJobUseCaseTest {
assertEquals(listOf(1, 2), port.storedImageTranscripts?.map(ImageTranscript::imageIndex))
}

@Test
fun `uses image fallback when text results do not meet a high expected count`() {
val port = FakeJobPort(
body = "압구정 맛집 52곳: 텍스트 장소",
imageUrls = listOf("https://cdn.test/1.jpg"),
)
val requests = mutableListOf<PlaceClueExtractor.Request>()
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<ProcessPlaceParsingJobUseCase.Result.Completed>(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<ProcessPlaceParsingJobUseCase.Result.Completed>(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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 게시물"
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
Expand All @@ -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())
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down