diff --git a/docs/tasks/NOOK-191/README.md b/docs/tasks/NOOK-191/README.md new file mode 100644 index 00000000..6dbd2c50 --- /dev/null +++ b/docs/tasks/NOOK-191/README.md @@ -0,0 +1,30 @@ +# NOOK-191 게시물 상세 위치 정보 보존 및 장소 매칭 정확도 개선 + +## 목적 + +Instagram 게시물 원문이나 이미지에 명시된 층·호 정보가 장소 단서 추출과 후보 선택 과정에서 +유실되지 않게 하고, 같은 건물 내 다른 매장이 연결되는 문제를 수정합니다. + +## 범위 + +- 본문·해시태그·Instagram 장소 태그·이미지 전사에서 명시적인 상세 주소를 장소 단서로 보존합니다. +- `1층`, `4층`, `B1`, `지하 1층`, `201호` 등의 상세 위치를 검색어와 후보 선택 근거에 전달합니다. +- 도로명·건물 번호 또는 층·호가 충돌하는 후보를 자동 확정하지 않습니다. +- 같은 주소의 다른 상호는 주소 일치만으로 연결하지 않습니다. +- 재현 게시물과 유사한 주소·층·호 조합의 회귀 테스트를 추가합니다. + +## 제외 범위 + +- 지도 provider 교체 +- 장소·게시물 공개 API 응답 필드 변경 +- 장소 테이블의 주소 체계 또는 DB 스키마 변경 +- 기존 저장 게시물 전체 일괄 재파싱 + +## 성공 기준 + +- 명시적인 주소와 층·호 정보가 `PlaceClue.addressHint`에 원문 그대로 보존됩니다. +- 상세 주소가 포함된 검색어가 일반 검색어보다 먼저 사용됩니다. +- 같은 건물의 다른 상호와 다른 도로명·건물 번호 후보가 자동 연결되지 않습니다. +- provider 주소에서 상세 위치가 생략되어도 상호와 기본 주소가 일치하면 정상 연결됩니다. +- 기존에 저장된 장소 단서 JSON을 계속 읽을 수 있습니다. +- `./gradlew check`가 통과합니다. diff --git a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceAddressMatcher.kt b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceAddressMatcher.kt new file mode 100644 index 00000000..a58b13c1 --- /dev/null +++ b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceAddressMatcher.kt @@ -0,0 +1,95 @@ +package org.every.nook.api.application.place + +internal object PlaceAddressMatcher { + fun isCompatible(addressHint: String?, candidateAddress: String): Boolean { + val hint = addressHint?.trim()?.takeIf(String::isNotEmpty) ?: return true + val hintAddressKeys = addressKeys(hint) + val candidateAddressKeys = addressKeys(candidateAddress) + if ( + hintAddressKeys.isNotEmpty() && + candidateAddressKeys.isNotEmpty() && + hintAddressKeys.intersect(candidateAddressKeys).isEmpty() + ) { + return false + } + + val hintDetails = locationDetails(hint).groupBy(LocationDetail::type) + val candidateDetails = locationDetails(candidateAddress).groupBy(LocationDetail::type) + return hintDetails.all { (type, details) -> + val candidateValues = candidateDetails[type]?.mapTo(mutableSetOf(), LocationDetail::value) + ?: return@all true + details.any { it.value in candidateValues } + } + } + + fun addressKeys(value: String): Set = BASE_ADDRESS_PATTERN.findAll(value).flatMap { match -> + val roadName = match.groupValues[1].groundingKey() + val buildingNumber = match.groupValues[2].groundingKey() + val buildingNumbers = buildSet { + add(buildingNumber) + if (buildingNumber.length >= MIN_COMPACT_BUILDING_FLOOR_LENGTH && match.isFollowedByFloorSuffix(value)) { + add(buildingNumber.dropLast(1)) + } + } + buildingNumbers.asSequence().flatMap { number -> + sequenceOf( + roadName + number, + roadName.removeSuffix(ROAD_SUFFIX) + number, + ) + } + }.toSet() + + fun hasLocationDetail(value: String?): Boolean = value != null && locationDetails(value).isNotEmpty() + + private fun locationDetails(value: String): Set { + val basementDetails = BASEMENT_PATTERN.findAll(value).map { match -> + LocationDetail(LocationDetailType.FLOOR, "-${match.firstCapturedValue()}") + }.toSet() + val aboveGroundSource = BASEMENT_PATTERN.replace(value, " ") + val floorDetails = FLOOR_PATTERN.findAll(aboveGroundSource).map { match -> + LocationDetail(LocationDetailType.FLOOR, match.groupValues[1]) + } + val compactFloorDetails = COMPACT_BUILDING_FLOOR_PATTERN.findAll(aboveGroundSource).map { match -> + LocationDetail(LocationDetailType.FLOOR, match.groupValues[1]) + } + val roomDetails = ROOM_PATTERN.findAll(value).map { match -> + LocationDetail(LocationDetailType.ROOM, match.groupValues[1]) + } + return basementDetails + floorDetails + compactFloorDetails + roomDetails + } + + private fun MatchResult.isFollowedByFloorSuffix(source: String): Boolean = + source.substring(range.last + 1).trimStart().startsWith(FLOOR_SUFFIX) + + private fun MatchResult.firstCapturedValue(): String = groupValues.drop(1).first(String::isNotEmpty) + + private fun String.groundingKey(): String = lowercase().filter(Char::isLetterOrDigit) + + private enum class LocationDetailType { + FLOOR, + ROOM, + } + + private data class LocationDetail(val type: LocationDetailType, val value: String) + + private val BASE_ADDRESS_PATTERN = Regex( + "([가-힣A-Za-z]+(?:대로|로|길)(?:\\d+[가-힣]?(?:길)?)?|" + + "[가-힣A-Za-z0-9]+(?:동|읍|면|리))\\s+(\\d+(?:-\\d+)?)", + ) + private val BASEMENT_PATTERN = Regex( + "(?i)(?:\\bB\\s*-?\\s*(\\d+)|지(?:하)?\\s*(\\d+)\\s*층)", + ) + private val FLOOR_PATTERN = Regex( + "(?i)(?.distinctLogicalPlaces(): List = + fold(mutableListOf()) { places, candidate -> + val samePlaceIndex = places.indexOfFirst { existing -> existing.isSameLogicalPlace(candidate) } + when { + samePlaceIndex < 0 -> places += candidate + candidate.isMoreDetailedThan(places[samePlaceIndex]) -> places[samePlaceIndex] = candidate + } + places + } + +private fun PlaceCandidate.isSameLogicalPlace(other: PlaceCandidate): Boolean { + if (name.groundingKey() != other.name.groundingKey()) { + return false + } + val addressKeys = PlaceAddressMatcher.addressKeys(address) + val otherAddressKeys = PlaceAddressMatcher.addressKeys(other.address) + return addressKeys.isNotEmpty() && + otherAddressKeys.isNotEmpty() && + addressKeys.intersect(otherAddressKeys).isNotEmpty() +} + +private fun PlaceCandidate.isMoreDetailedThan(other: PlaceCandidate): Boolean = + PlaceAddressMatcher.hasLocationDetail(address) && !PlaceAddressMatcher.hasLocationDetail(other.address) + +private fun String.groundingKey(): String = lowercase().filter(Char::isLetterOrDigit) diff --git a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceClue.kt b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceClue.kt index cd52fa1a..76103946 100644 --- a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceClue.kt +++ b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceClue.kt @@ -5,6 +5,7 @@ data class PlaceClue( val region: String?, val queries: List, val evidence: List = emptyList(), + val addressHint: String? = null, ) data class PlaceClueEvidence(val imageIndex: Int, val evidenceText: String) diff --git a/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceClueCandidateMatcher.kt b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceClueCandidateMatcher.kt new file mode 100644 index 00000000..4f6409c9 --- /dev/null +++ b/nook-api-application/src/main/kotlin/org/every/nook/api/application/place/PlaceClueCandidateMatcher.kt @@ -0,0 +1,74 @@ +package org.every.nook.api.application.place + +internal fun Collection.compatibleWith( + clue: PlaceClue, +): List { + val addressHint = clue.addressHint?.trim()?.takeIf(String::isNotEmpty) ?: return toList() + return filter { candidate -> PlaceAddressMatcher.isCompatible(addressHint, candidate.place.address) } +} + +internal fun PlaceClue.isSupportedBy(candidate: PlaceCandidate): Boolean { + val explicitAddressHint = addressHint?.trim()?.takeIf(String::isNotEmpty) + if (explicitAddressHint != null && !PlaceAddressMatcher.isCompatible(explicitAddressHint, candidate.address)) { + return false + } + + val hasCompatibleIdentity = hasCompatibleName(candidate) || hasNameEvidence(candidate) + if (explicitAddressHint != null && !hasCompatibleIdentity) { + return false + } + return evidence.isEmpty() || hasCompatibleIdentity || hasCompatibleEvidence(candidate) +} + +internal fun Collection.descriptions(limit: Int): List = + take(limit).map { candidate -> "${candidate.place.name}|${candidate.place.address}" } + +private fun PlaceClue.hasCompatibleName(candidate: PlaceCandidate): Boolean { + val candidateName = candidate.name.groundingKey() + val candidateAddress = candidate.address.groundingKey() + return (sequenceOf(name) + queries.asSequence()) + .map(String::groundingKey) + .any { queryName -> + candidateName == queryName || + candidateName.isCompatibleName(queryName) || + candidateName.isFuzzyNameMatch(queryName) || + (queryName.length >= MIN_NAME_COMPATIBILITY_KEY_LENGTH && candidateAddress.contains(queryName)) + } +} + +private fun PlaceClue.hasNameEvidence(candidate: PlaceCandidate): Boolean { + val candidateName = candidate.name.groundingKey() + return candidateName.length >= MIN_GROUNDING_KEY_LENGTH && evidence.any { clueEvidence -> + clueEvidence.evidenceText.groundingKey().contains(candidateName) + } +} + +private fun PlaceClue.hasCompatibleEvidence(candidate: PlaceCandidate): Boolean { + val candidateName = candidate.name.groundingKey() + val candidateAddress = candidate.address.groundingKey() + val candidateAddressKeys = PlaceAddressMatcher.addressKeys(candidate.address) + return evidence.asSequence().map(PlaceClueEvidence::evidenceText).any { evidenceText -> + val normalizedEvidence = evidenceText.groundingKey() + val containsName = candidateName.length >= MIN_GROUNDING_KEY_LENGTH && + normalizedEvidence.contains(candidateName) + val containsAddress = candidateAddress.length >= MIN_ADDRESS_GROUNDING_KEY_LENGTH && + normalizedEvidence.contains(candidateAddress) + containsName || containsAddress || + candidateAddressKeys.any { it in PlaceAddressMatcher.addressKeys(evidenceText) } + } +} + +private fun String.isCompatibleName(other: String): Boolean = length >= MIN_NAME_COMPATIBILITY_KEY_LENGTH && + other.length >= MIN_NAME_COMPATIBILITY_KEY_LENGTH && + (contains(other) || other.contains(this)) + +private fun String.isFuzzyNameMatch(other: String): Boolean = length >= MIN_FUZZY_NAME_LENGTH && + length == other.length && zip(other).count { (left, right) -> left != right } <= MAX_NAME_CHARACTER_DIFFERENCE + +private fun String.groundingKey(): String = lowercase().filter(Char::isLetterOrDigit) + +private const val MIN_GROUNDING_KEY_LENGTH = 2 +private const val MIN_NAME_COMPATIBILITY_KEY_LENGTH = 3 +private const val MIN_FUZZY_NAME_LENGTH = 4 +private const val MAX_NAME_CHARACTER_DIFFERENCE = 1 +private const val MIN_ADDRESS_GROUNDING_KEY_LENGTH = 6 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 aea0dc20..07bf7ac8 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 @@ -52,6 +52,7 @@ class ProcessPlaceParsingJobUseCase( val imageResolution = resolveImageClues(job, textResolution.places.size, expectedPlaceCount) val places = (textResolution.places + imageResolution?.places.orEmpty()) .distinctBy { it.provider to it.externalPlaceId } + .distinctLogicalPlaces() if (places.isEmpty()) { val failure = imageResolution?.failure ?: textResolution.failure terminalFailure( @@ -183,33 +184,31 @@ class ProcessPlaceParsingJobUseCase( val candidates = searchCandidates(job, clue) logger.info { "Place candidates searched: placeName=${clue.name}, region=${clue.region}, " + - "queries=${clue.queries}, candidateCount=${candidates.size}" + "addressHint=${clue.addressHint}, queries=${clue.searchQueries()}, candidateCount=${candidates.size}" } - val matches = strictMatches(clue, candidates) + val selectionCandidates = candidates.compatibleWith(clue) + val matches = strictMatches(clue, selectionCandidates) + val groundedMatches = selectionCandidates.filter { candidate -> clue.isSupportedBy(candidate.place) } + val candidateDescriptions = selectionCandidates.descriptions(CANDIDATE_LOG_LIMIT) logger.info { "Place candidate matching completed: placeName=${clue.name}, region=${clue.region}, " + - "candidateCount=${candidates.size}, matchCount=${matches.size}, " + - "candidates=${candidates.take(CANDIDATE_LOG_LIMIT).map { "${it.place.name}|${it.place.address}" }}" + "candidateCount=${candidates.size}, addressMatchCount=${selectionCandidates.size}, " + + "strictMatchCount=${matches.size}, groundedMatchCount=${groundedMatches.size}, " + + "candidates=$candidateDescriptions" } - val resolved = if (matches.size == 1) { - matches.single().place - } else { - if (candidates.isEmpty()) { + val selection = uniqueCandidate(matches, groundedMatches) ?: run { + if (selectionCandidates.isEmpty()) { failResolution("No place candidate found: ${clue.name}") } - measure(job, SELECT_STAGE) { - candidateSelector.select( - PlaceCandidateSelector.Request( - clue = clue, - candidates = candidates, - ), - ) + val selected = measure(job, SELECT_STAGE) { + candidateSelector.select(PlaceCandidateSelector.Request(clue = clue, candidates = selectionCandidates)) } ?: failResolution( "No place candidate selected: ${clue.name}, strictMatchCount=${matches.size}", ) + CandidateSelection(selected, "openai") } - if (!clue.isSupportedBy(resolved)) { + if (!clue.isSupportedBy(selection.place)) { failResolution("Selected place is not grounded in image evidence: ${clue.name}") } eventLogger.info( @@ -218,19 +217,21 @@ class ProcessPlaceParsingJobUseCase( SELECT_STAGE, SUCCESS_OUTCOME, fields = mapOf( - "provider.name" to resolved.provider, - "place.external_id" to resolved.externalPlaceId, - "place.selection_method" to if (matches.size == 1) "strict_match" else "openai", + "provider.name" to selection.place.provider, + "place.external_id" to selection.place.externalPlaceId, + "place.selection_method" to selection.method, "place.candidate_count" to candidates.size, "place.strict_match_count" to matches.size, + "place.grounded_match_count" to groundedMatches.size, ), ), ) logger.info { - "Place resolved: provider=${resolved.provider}, externalPlaceId=${resolved.externalPlaceId}, " + - "name=${resolved.name}, address=${resolved.address}" + "Place resolved: provider=${selection.place.provider}, " + + "externalPlaceId=${selection.place.externalPlaceId}, " + + "name=${selection.place.name}, address=${selection.place.address}" } - return resolved + return selection.place } private fun searchCandidates( @@ -238,7 +239,7 @@ class ProcessPlaceParsingJobUseCase( clue: PlaceClue, ): List { val candidatesById = linkedMapOf, PlaceCandidateSelector.Candidate>() - clue.queries.asSequence() + clue.searchQueries().asSequence() .map(String::trim) .filter(String::isNotEmpty) .distinct() @@ -364,6 +365,17 @@ class ProcessPlaceParsingJobUseCase( private data class ClueResolution(val places: List, val failure: PlaceResolutionException?) } +private fun uniqueCandidate( + strictMatches: List, + groundedMatches: List, +): CandidateSelection? = when { + strictMatches.size == 1 -> CandidateSelection(strictMatches.single().place, "strict_match") + groundedMatches.size == 1 -> CandidateSelection(groundedMatches.single().place, "grounded_match") + else -> null +} + +private data class CandidateSelection(val place: PlaceCandidate, val method: String) + private fun logOcrDecision( logger: org.slf4j.Logger, job: ClaimedPlaceParsingJob, @@ -424,52 +436,30 @@ private fun strictMatches( val normalizedRegion = clue.region?.normalize()?.takeIf(String::isNotEmpty) return candidates.filter { candidate -> candidate.place.name.normalize() == normalizedName && - (normalizedRegion == null || candidate.place.address.normalize().contains(normalizedRegion)) + (normalizedRegion == null || candidate.place.address.normalize().contains(normalizedRegion)) && + PlaceAddressMatcher.isCompatible(clue.addressHint, candidate.place.address) } } +internal fun PlaceClue.searchQueries(): List = buildList { + addressHint?.trim()?.takeIf(String::isNotEmpty)?.let { address -> add("$name $address") } + add(name) + region?.trim()?.takeIf(String::isNotEmpty)?.let { placeRegion -> + name.split(Regex("\\s+")) + .map(String::trim) + .filter { it.length >= MIN_SEARCH_ALIAS_LENGTH } + .forEach { alias -> add("$placeRegion $alias") } + } + addAll(queries) +}.map(String::trim).filter(String::isNotEmpty).distinct().take(MAX_PLACE_QUERY_COUNT) + private fun String.normalize(): String = lowercase().filterNot(Char::isWhitespace) private fun String.groundingKey(): String = lowercase().filter(Char::isLetterOrDigit) private const val MIN_GROUNDING_KEY_LENGTH = 2 +private const val MIN_SEARCH_ALIAS_LENGTH = 2 private const val MIN_EXPECTED_PLACE_COUNT = 2 private const val MAX_EXPECTED_PLACE_COUNT = 80 +private const val MAX_PLACE_QUERY_COUNT = 4 private val EXPECTED_PLACE_COUNT_PATTERN = Regex("(? - val normalizedEvidence = evidenceText.groundingKey() - val containsName = candidateName.length >= MIN_GROUNDING_KEY_LENGTH && - normalizedEvidence.contains(candidateName) - val containsAddress = candidateAddress.length >= MIN_ADDRESS_GROUNDING_KEY_LENGTH && - normalizedEvidence.contains(candidateAddress) - containsName || containsAddress || - candidateAddressKeys.any { it in evidenceText.addressKeys() } - } - return hasCompatibleName || hasCompatibleEvidence -} - -private fun String.isCompatibleName(other: String): Boolean = length >= MIN_NAME_COMPATIBILITY_KEY_LENGTH && - other.length >= MIN_NAME_COMPATIBILITY_KEY_LENGTH && - (contains(other) || other.contains(this)) - -private fun String.addressKeys(): Set = ADDRESS_KEY_PATTERN.findAll(this).map { match -> - match.groupValues.drop(1).joinToString(separator = "").groundingKey() -}.toSet() - -private const val MIN_NAME_COMPATIBILITY_KEY_LENGTH = 3 -private const val MIN_ADDRESS_GROUNDING_KEY_LENGTH = 6 -private val ADDRESS_KEY_PATTERN = Regex( - "([가-힣A-Za-z0-9]+(?:대로|로|길|동|읍|면|리))\\s*(\\d+(?:-\\d+)?)", -) diff --git a/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/PlaceAddressMatcherTest.kt b/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/PlaceAddressMatcherTest.kt new file mode 100644 index 00000000..3fd1d7bb --- /dev/null +++ b/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/PlaceAddressMatcherTest.kt @@ -0,0 +1,243 @@ +package org.every.nook.api.application.place + +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PlaceAddressMatcherTest { + @Test + fun `accepts the same base address when provider omits floor and room details`() { + val candidateAddress = "서울 중구 마른내로 55" + + listOf( + "서울 중구 마른내로 55 1층", + "서울 중구 마른내로 55 4F", + "서울 중구 마른내로 55 B1", + "서울 중구 마른내로 55 지하 1층", + "서울 중구 마른내로 55 201호", + ).forEach { addressHint -> + assertTrue(PlaceAddressMatcher.isCompatible(addressHint, candidateAddress)) + } + } + + @Test + fun `rejects a different road or building number`() { + assertFalse( + PlaceAddressMatcher.isCompatible( + "서울 마포구 동교로38길 27-19 지1층 좌측", + "서울 마포구 월드컵북로5가길 34", + ), + ) + } + + @Test + fun `accepts a road address when the source omits the final gil suffix`() { + assertTrue( + PlaceAddressMatcher.isCompatible( + "서울 용산구 이태원로20가 11 4층", + "서울특별시 용산구 이태원로20가길 11 4층", + ), + ) + assertTrue( + PlaceAddressMatcher.isCompatible( + "서울 용산구 이태원로20가 11 4층", + "서울 용산구 이태원로20가길 11", + ), + ) + } + + @Test + fun `accepts a building number and floor concatenated by OCR`() { + assertTrue( + PlaceAddressMatcher.isCompatible( + "서울 중구 삼일대로 3018층", + "서울특별시 중구 삼일대로 301 8층", + ), + ) + assertTrue(PlaceAddressMatcher.hasLocationDetail("서울 중구 삼일대로 3018층")) + } + + @Test + fun `rejects conflicting floor and room details when both addresses provide them`() { + assertFalse( + PlaceAddressMatcher.isCompatible( + "서울 중구 마른내로 55 4층 201호", + "서울 중구 마른내로 55 3층 201호", + ), + ) + assertFalse( + PlaceAddressMatcher.isCompatible( + "서울 중구 마른내로 55 B1", + "서울 중구 마른내로 55 1층", + ), + ) + assertFalse( + PlaceAddressMatcher.isCompatible( + "서울 중구 마른내로 55 201호", + "서울 중구 마른내로 55 202호", + ), + ) + } + + @Test + fun `puts the full detailed address first without exceeding the query limit`() { + val clue = PlaceClue( + name = "파티오피즈", + region = "서울 용산구", + queries = listOf("파티오피즈", "이태원 파티오피즈", "patio fizz", "파티오 피즈"), + addressHint = "서울 용산구 이태원로20가길 11 4층", + ) + + assertEquals( + listOf( + "파티오피즈 서울 용산구 이태원로20가길 11 4층", + "파티오피즈", + "서울 용산구 파티오피즈", + "이태원 파티오피즈", + ), + clue.searchQueries(), + ) + } + + @Test + fun `keeps a bare store name after the detailed address query`() { + val clue = PlaceClue( + name = "도원", + region = "홍대입구역", + queries = listOf( + "dowon.kr 홍대입구역", + "도원 동교로38길 27-19 지1층", + "도원 홍대입구 1층", + "Dowon 홍대입구", + ), + addressHint = "서울 마포구 동교로38길 27-19 지1층 좌측", + ) + + assertEquals( + listOf( + "도원 서울 마포구 동교로38길 27-19 지1층 좌측", + "도원", + "홍대입구역 도원", + "dowon.kr 홍대입구역", + ), + clue.searchQueries(), + ) + } + + @Test + fun `uses regional aliases before inferred queries for multilingual store names`() { + val clue = PlaceClue( + name = "라벤다 lavender", + region = "중곡역", + queries = listOf("lavender lavender_seoul", "라벤다 능동로50길 24 1층"), + addressHint = "서울 광진구 능동로50길 24 1층", + ) + + assertEquals( + listOf( + "라벤다 lavender 서울 광진구 능동로50길 24 1층", + "라벤다 lavender", + "중곡역 라벤다", + "중곡역 lavender", + ), + clue.searchQueries(), + ) + } + + @Test + fun `limits candidate selection to addresses compatible with the explicit clue`() { + val clue = PlaceClue( + name = "도원", + region = "서울 마포구", + queries = listOf("도원", "홍대 도원"), + addressHint = "서울 마포구 동교로38길 27-19 지1층 좌측", + ) + val correct = candidate("도원", "서울 마포구 동교로38길 27-19") + val wrong = candidate("도원", "서울 마포구 월드컵북로5가길 34") + + assertEquals( + listOf(correct), + listOf(correct, wrong) + .map { PlaceCandidateSelector.Candidate(it, listOf("도원")) } + .compatibleWith(clue) + .map(PlaceCandidateSelector.Candidate::place), + ) + } + + @Test + fun `accepts matching store when provider omits floor but rejects wrong address or store`() { + val clue = PlaceClue( + name = "SHEET", + region = "서울 중구", + queries = listOf("SHEET", "을지로 SHEET"), + addressHint = "서울 중구 마른내로 55 4F", + ) + + assertTrue(clue.isSupportedBy(candidate("SHEET", "서울 중구 마른내로 55"))) + assertFalse(clue.isSupportedBy(candidate("SHEET", "서울 중구 마른내로 51-4"))) + assertFalse(clue.isSupportedBy(candidate("고은손카드", "서울 중구 마른내로 55"))) + assertFalse(clue.isSupportedBy(candidate("SHEET", "서울 중구 마른내로 55 3층"))) + assertTrue(clue.isSupportedBy(candidate("SHEEP", "서울 중구 마른내로 55"))) + + val transliteratedClue = PlaceClue( + name = "noob.store", + region = "서울 중구", + queries = listOf("noobstore", "을지로 noobstore"), + addressHint = "서울 중구 을지로18길 25-2 4층", + ) + assertTrue( + transliteratedClue.isSupportedBy( + candidate("눕스토어", "서울 중구 을지로18길 25-2 4층 흰색 문 noobstore"), + ), + ) + + val differentlyNamedDetailedAddressClue = PlaceClue( + name = "TOOL 3", + region = "서울 중구", + queries = listOf("TOOL 3", "마른내로6길 18-1 2층"), + addressHint = "서울 중구 마른내로6길 18-1 2층", + ) + assertFalse( + differentlyNamedDetailedAddressClue.isSupportedBy( + candidate("툴3", "서울 중구 마른내로6길 18-1"), + ), + ) + + val roomClue = PlaceClue( + name = "KOZELNANT", + region = "서울 종로구", + queries = listOf("KOZELNANT", "삼일대로 437 인사관 407호"), + addressHint = "서울 종로구 삼일대로 437 인사관 407호", + ) + assertFalse(roomClue.isSupportedBy(candidate("세보가", "서울 종로구 삼일대로 437"))) + + val basementClue = PlaceClue( + name = "도원 Dowon", + region = "서울 마포구", + queries = listOf("Dowon dowon.kr", "홍대입구역 Dowon"), + evidence = listOf( + PlaceClueEvidence( + imageIndex = 1, + evidenceText = "도원 @dowon.kr 홍대입구역 서울 마포구 동교로38길 27-19 지1층 좌측", + ), + ), + addressHint = "서울 마포구 동교로38길 27-19 지1층 좌측", + ) + assertTrue(basementClue.isSupportedBy(candidate("도원", "서울 마포구 동교로38길 27-19"))) + assertFalse(basementClue.isSupportedBy(candidate("도원", "서울 마포구 월드컵북로5가길 34"))) + } + + private fun candidate(name: String, address: String): PlaceCandidate = PlaceCandidate( + provider = "KAKAO", + externalPlaceId = "1", + name = name, + address = address, + latitude = BigDecimal("37.0"), + longitude = BigDecimal("127.0"), + category = null, + phoneNumber = null, + providerUrl = null, + ) +} diff --git a/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/PlaceCandidateDeduplicatorTest.kt b/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/PlaceCandidateDeduplicatorTest.kt new file mode 100644 index 00000000..31c6a368 --- /dev/null +++ b/nook-api-application/src/test/kotlin/org/every/nook/api/application/place/PlaceCandidateDeduplicatorTest.kt @@ -0,0 +1,37 @@ +package org.every.nook.api.application.place + +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals + +class PlaceCandidateDeduplicatorTest { + @Test + fun `merges the same logical place across providers and keeps the detailed address`() { + val kakao = candidate("KAKAO", "kakao-1", "파티오피즈", "서울 용산구 이태원로20가길 11") + val naver = candidate("NAVER", "naver-1", "파티오 피즈", "서울 용산구 이태원로20가길 11 4층") + + val result = listOf(kakao, naver).distinctLogicalPlaces() + + assertEquals(listOf(naver), result) + } + + @Test + fun `does not merge different stores at the same address`() { + val first = candidate("KAKAO", "1", "파티오피즈", "서울 용산구 이태원로20가길 11") + val second = candidate("NAVER", "2", "다른가게", "서울 용산구 이태원로20가길 11 4층") + + assertEquals(listOf(first, second), listOf(first, second).distinctLogicalPlaces()) + } + + private fun candidate(provider: String, id: String, name: String, address: String) = PlaceCandidate( + provider = provider, + externalPlaceId = id, + name = name, + address = address, + latitude = BigDecimal("37.0"), + longitude = BigDecimal("127.0"), + category = null, + phoneNumber = null, + providerUrl = null, + ) +} 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 720814cd..959d6ec5 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 @@ -67,6 +67,11 @@ class OpenAiContentInferenceAdapter( evidenceText = evidence.path("evidenceText").asText().trim(), ) }, + addressHint = place.path("addressHint") + .takeUnless { it.isNull || it.isMissingNode } + ?.asText() + ?.trim() + ?.ifBlank { null }, ) } @@ -182,6 +187,7 @@ class OpenAiContentInferenceAdapter( "placeClue" to mapOf( "name" to clue.name, "region" to clue.region, + "addressHint" to clue.addressHint, "queries" to clue.queries, "evidence" to clue.evidence.map { evidence -> mapOf( @@ -238,6 +244,7 @@ class OpenAiContentInferenceAdapter( "properties" to mapOf( "name" to mapOf("type" to "string"), "region" to mapOf("type" to listOf("string", "null")), + "addressHint" to mapOf("type" to listOf("string", "null")), "queries" to mapOf( "type" to "array", "minItems" to 1, @@ -262,7 +269,7 @@ class OpenAiContentInferenceAdapter( ), ), ), - "required" to listOf("name", "region", "queries", "evidence"), + "required" to listOf("name", "region", "addressHint", "queries", "evidence"), "additionalProperties" to false, ), ) @@ -296,7 +303,10 @@ class OpenAiContentInferenceAdapter( "실제 영업 장소만 추출한다. " + "가게는 음식점, 카페, 술집, 상점, 숙박업소처럼 상호명이 있는 영업 장소를 뜻한다. " + "도시, 구, 동, 거리, 역, 공원, 관광지는 가게로 반환하지 말고 가게 검색을 위한 region과 query 단서로만 사용한다. " + - "상호명이 확인되지 않으면 추측하거나 일반 업종명으로 만들지 않는다. 좌표와 주소도 만들지 않는다. " + + "상호명이 확인되지 않으면 추측하거나 일반 업종명으로 만들지 않는다. 좌표는 만들지 않는다. " + + "본문이나 imageTranscripts에 주소가 명시된 경우에만 addressHint에 주소 원문 전체를 그대로 담고, " + + "주소가 없으면 null을 반환한다. 주소를 축약하거나 보정하거나 추측하지 않는다. " + + "특히 1층, 4층, B1, 지하 1층, 201호, 건물명, 출입구 같은 상세 위치를 절대 생략하지 않는다. " + "sourceLocationTag가 상호명인 경우 Instagram이 제공한 명시적 장소 정보이므로 본문과 해시태그보다 우선한다. " + "이때 name은 sourceLocationTag 원문을 그대로 사용하고, 본문의 수식어나 별칭을 name에 붙이지 않는다. " + "sourceLocationTag와 본문이 같은 가게를 가리키면 하나의 장소로 합친다. " + @@ -310,9 +320,10 @@ class OpenAiContentInferenceAdapter( "이미지 근거가 있는 장소는 imageTranscripts의 imageIndex와 상호명 또는 주소가 포함된 실제 전사 문구를 " + "evidenceText로 evidence에 담는다. 이미지가 없거나 이미지 근거가 아니면 evidence는 빈 배열이다. " + "읽을 수 없는 글씨나 로고를 추측하지 않는다. " + - "장소별 상호명 name, 확인 가능한 region, 카카오 장소 검색용 queries를 반환한다. " + + "장소별 상호명 name, 확인 가능한 region, 명시된 전체 주소 addressHint, 장소 검색용 queries를 반환한다. " + "queries의 첫 항목은 sourceLocationTag가 상호명이면 원문 그대로 사용하고, 이후에는 본문에서 확인되는 " + "한글·영문 표기와 지역 조합을 우선해 서로 다른 검색어 3~4개를 만든다. " + + "addressHint가 있으면 상호명과 전체 주소를 조합한 검색어를 포함하고 층·호 정보를 그대로 유지한다. " + "예를 들어 sourceLocationTag가 Lodge190이고 본문이 '연희동 사랑방 롯지190'이면 name은 Lodge190이고 " + "queries는 원문 Lodge190, 한글 음차 롯지190, 띄어쓰기 변형 롯지 190, " + "지역을 붙인 축약형 연희동 Lodge 순서로 반환한다. " + @@ -321,8 +332,11 @@ class OpenAiContentInferenceAdapter( "title과 places를 하나의 응답으로 함께 반환한다. " + TITLE_INSTRUCTIONS + " " + PLACE_INSTRUCTIONS const val CANDIDATE_SELECTION_INSTRUCTIONS = "placeClue는 Instagram 게시물에서 추출한 장소 단서이고 candidates는 실제 장소 검색 결과다. " + - "상호명의 한글·영문 표기, 숫자와 띄어쓰기 변형, 업종, 주소, region, 이미지 evidence, matchedQueries를 함께 비교해 " + + "상호명의 한글·영문 표기, 숫자와 띄어쓰기 변형, 업종, addressHint, 후보 주소, region, " + + "이미지 evidence, matchedQueries를 함께 비교해 " + "게시물이 가리키는 장소와 가장 일치하는 candidateIndex 하나를 선택한다. " + + "도로명과 건물 번호가 다르거나 양쪽에 명시된 층·호가 충돌하면 선택하지 않는다. " + + "도로명 주소만 같고 상호명이 다른 후보를 같은 건물이라는 이유로 선택하지 않는다. " + "후보에 없는 장소를 만들거나 후보 정보를 수정하지 않는다. " + "명확한 근거가 없거나 서로 다른 후보를 하나로 확정할 수 없으면 candidateIndex를 null로 반환한다." const val PLACE_TAG_INSTRUCTIONS = 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 f6bc89b5..e9a483b5 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 @@ -188,6 +188,8 @@ 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("층·호 정보를 그대로 유지"))) + .andExpect(content().string(containsString("\"addressHint\""))) .andExpect(content().string(containsString("\"max_output_tokens\":12000"))) .andRespond( withSuccess( @@ -196,6 +198,7 @@ class OpenAiContentInferenceAdapterTest { {"places":[{ "name":"빈브라더스 커피하우스 서울", "region":"서울 마포구 상수동", + "addressHint":"서울 마포구 상수동 354-12 지하 1층 201호", "queries":["빈브라더스 커피하우스 서울","상수동 빈브라더스"], "evidence":[{"imageIndex":2,"evidenceText":"빈브라더스 커피하우스 서울"}] }]} @@ -211,12 +214,16 @@ class OpenAiContentInferenceAdapterTest { hashtags = emptyList(), sourceLocationTag = null, imageTranscripts = listOf( - ImageTranscript(2, listOf("빈브라더스 커피하우스 서울", "서울 마포구 상수동 354-12")), + ImageTranscript( + 2, + listOf("빈브라더스 커피하우스 서울", "서울 마포구 상수동 354-12 지하 1층 201호"), + ), ), ), ) assertEquals("빈브라더스 커피하우스 서울", places.single().name) + assertEquals("서울 마포구 상수동 354-12 지하 1층 201호", places.single().addressHint) assertEquals(2, places.single().evidence.single().imageIndex) fixture.server.verify() } @@ -228,6 +235,8 @@ class OpenAiContentInferenceAdapterTest { .andExpect(content().string(containsString("place_candidate_selection"))) .andExpect(content().string(containsString("matchedQueries"))) .andExpect(content().string(containsString("evidenceText"))) + .andExpect(content().string(containsString("addressHint"))) + .andExpect(content().string(containsString("서울 마포구 양화로6길 99-9 4층"))) .andExpect(content().string(containsString("상수동 이츠야"))) .andRespond(withSuccess(response("""{"candidateIndex":0}"""), MediaType.APPLICATION_JSON)) val candidate = PlaceCandidate( @@ -249,6 +258,7 @@ class OpenAiContentInferenceAdapterTest { region = "서울특별시 서초구 상수역 인근", queries = listOf("이츠야", "상수동 이츠야"), evidence = listOf(PlaceClueEvidence(2, "이츠야 / 서울 마포구 양화로6길 99-9")), + addressHint = "서울 마포구 양화로6길 99-9 4층", ), candidates = listOf( PlaceCandidateSelector.Candidate( diff --git a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceParsingPersistenceAdapterTest.kt b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceParsingPersistenceAdapterTest.kt index bacb9d29..775398ce 100644 --- a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceParsingPersistenceAdapterTest.kt +++ b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/persistence/place/PlaceParsingPersistenceAdapterTest.kt @@ -116,6 +116,7 @@ class PlaceParsingPersistenceAdapterTest { val claimed = requireNotNull(adapter.claim(11, Duration.ofMinutes(1))) assertEquals(listOf("성수 식당"), claimed.textClues?.map(PlaceClue::name)) + assertEquals(null, claimed.textClues?.single()?.addressHint) } @Test diff --git a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/persistence/post/PostContentParsingPersistenceAdapterTest.kt b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/persistence/post/PostContentParsingPersistenceAdapterTest.kt index 3cb20ad3..58c79aac 100644 --- a/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/persistence/post/PostContentParsingPersistenceAdapterTest.kt +++ b/nook-api-infrastructure/src/test/kotlin/org/every/nook/api/infrastructure/persistence/post/PostContentParsingPersistenceAdapterTest.kt @@ -88,7 +88,14 @@ class PostContentParsingPersistenceAdapterTest { adapter.complete( postId = 101, post = post, - textPlaceClues = listOf(PlaceClue("성수 식당", "성수", listOf("성수 식당"))), + textPlaceClues = listOf( + PlaceClue( + name = "성수 식당", + region = "성수", + queries = listOf("성수 식당"), + addressHint = "서울 성동구 성수이로 11 4층", + ), + ), ) assertEquals(PostContentParsingStatus.COMPLETED, job.status) @@ -99,7 +106,8 @@ class PostContentParsingPersistenceAdapterTest { verify(placeJobRepository).save(placeJobCaptor.capture()) assertEquals(PlaceParsingStatus.PENDING, placeJobCaptor.value.status) assertEquals( - """[{"name":"성수 식당","region":"성수","queries":["성수 식당"],"evidence":[]}]""", + """[{"name":"성수 식당","region":"성수","queries":["성수 식당"],"evidence":[],""" + + """"addressHint":"서울 성동구 성수이로 11 4층"}]""", placeJobCaptor.value.textPlaceClues, ) val eventCaptor = ArgumentCaptor.forClass(Any::class.java)