-
Notifications
You must be signed in to change notification settings - Fork 0
[NOOK-171] 장소 매칭 지역 보강 #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ import kotlin.math.cos | |
| import kotlin.math.sin | ||
| import kotlin.math.sqrt | ||
|
|
||
| @Suppress("TooManyFunctions") | ||
| class GooglePlacePhotoProvider( | ||
| private val restClient: RestClient, | ||
| private val properties: GooglePlacePhotoProperties, | ||
|
|
@@ -79,53 +80,97 @@ class GooglePlacePhotoProvider( | |
| private fun searchPlace(place: PlaceCandidate): GooglePlace? { | ||
| place.googlePlaceId?.let { return getPlace(it) } | ||
| val startedAt = System.nanoTime() | ||
| val response = restClient.post() | ||
| .uri("/v1/places:searchText") | ||
| val queries = place.searchQueries() | ||
| val nearbyCandidates = searchNearby(place).map { candidate -> | ||
| RankedGooglePlace( | ||
| place = candidate, | ||
| query = NEARBY_QUERY_LABEL, | ||
| score = candidate.matchScore(place), | ||
| photoCount = candidate.photos.orEmpty().size, | ||
| ) | ||
| }.filter { it.place.isEligibleNearbyCandidate(place) } | ||
| val textCandidates = queries.flatMap { query -> | ||
| searchText(query, place).map { candidate -> | ||
| RankedGooglePlace( | ||
| place = candidate, | ||
| query = query, | ||
| score = candidate.matchScore(place), | ||
| photoCount = candidate.photos.orEmpty().size, | ||
| ) | ||
| } | ||
| } | ||
| val allCandidates = nearbyCandidates + textCandidates | ||
| val matched = allCandidates | ||
| .filter { it.score >= MIN_MATCH_SCORE } | ||
| .maxWithOrNull( | ||
| compareBy<RankedGooglePlace> { it.photoCount > 0 } | ||
| .thenBy { it.score } | ||
| .thenBy { it.photoCount }, | ||
|
Comment on lines
+106
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Make the match score the primary ordering key rather than Useful? React with 👍 / 👎. |
||
| ) | ||
| logger.debug { | ||
| "Google place candidates evaluated: provider=${place.provider}, " + | ||
| "externalPlaceId=${place.externalPlaceId}, nearbyCandidateCount=${nearbyCandidates.size}, " + | ||
| "queryCount=${queries.size}, candidateCount=${allCandidates.size}, " + | ||
| "matchedPlaceId=${matched?.place?.placeId()}, selectedQuery=${matched?.query}, " + | ||
| "durationNs=${System.nanoTime() - startedAt}" | ||
| } | ||
| logger.info { | ||
| "Google place photo search completed: provider=${place.provider}, " + | ||
| "externalPlaceId=${place.externalPlaceId}, googlePlaceCount=${allCandidates.size}, " + | ||
| "matched=${matched != null}" | ||
| } | ||
| return matched?.place | ||
| } | ||
|
|
||
| private fun searchNearby(place: PlaceCandidate): List<GooglePlace> { | ||
| val includedTypes = place.nearbyTypes() | ||
| if (includedTypes.isEmpty()) return emptyList() | ||
| return restClient.post() | ||
| .uri("/v1/places:searchNearby") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .header(API_KEY_HEADER, properties.apiKey) | ||
| .header(FIELD_MASK_HEADER, SEARCH_FIELD_MASK) | ||
| .body( | ||
| TextSearchRequest( | ||
| textQuery = "${place.name} ${place.address}".trim(), | ||
| languageCode = "ko", | ||
| regionCode = "KR", | ||
| pageSize = SEARCH_PAGE_SIZE, | ||
| locationBias = LocationBias( | ||
| NearbySearchRequest( | ||
| includedTypes = includedTypes, | ||
| maxResultCount = NEARBY_SEARCH_RESULT_SIZE, | ||
| locationRestriction = LocationRestriction( | ||
| Circle( | ||
| center = GoogleLocation(place.latitude.toDouble(), place.longitude.toDouble()), | ||
| radius = LOCATION_BIAS_RADIUS_METERS, | ||
| radius = NEARBY_SEARCH_RADIUS_METERS, | ||
| ), | ||
| ), | ||
| ), | ||
| ) | ||
| .retrieve() | ||
| .body(TextSearchResponse::class.java) | ||
| val scored = response?.places.orEmpty().map { it to it.matchScore(place) } | ||
| val matched = scored.maxByOrNull { it.second } | ||
| ?.takeIf { it.second >= MIN_MATCH_SCORE } | ||
| ?.first | ||
| eventLogger.info( | ||
| place.event( | ||
| "google.place.match.completed", | ||
| SEARCH_STAGE, | ||
| if (matched == null) "empty" else "success", | ||
| mapOf( | ||
| "event.duration_ms" to elapsedMillis(startedAt), | ||
| "google.place_candidate_count" to response?.places.orEmpty().size, | ||
| "google.place_candidate_scores" to scored.map { "${it.first.placeId()}:${it.second}" }, | ||
| "google.place_selected_id" to matched?.placeId(), | ||
| "google.place_matched" to (matched != null), | ||
| "empty.reason" to if (matched == null) "place_not_matched" else null, | ||
| ?.places | ||
| .orEmpty() | ||
| } | ||
|
|
||
| private fun searchText(query: String, place: PlaceCandidate): List<GooglePlace> = restClient.post() | ||
| .uri("/v1/places:searchText") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .header(API_KEY_HEADER, properties.apiKey) | ||
| .header(FIELD_MASK_HEADER, SEARCH_FIELD_MASK) | ||
| .body( | ||
| TextSearchRequest( | ||
| textQuery = query, | ||
| languageCode = "ko", | ||
| regionCode = "KR", | ||
| pageSize = SEARCH_PAGE_SIZE, | ||
| locationBias = LocationBias( | ||
| Circle( | ||
| center = GoogleLocation(place.latitude.toDouble(), place.longitude.toDouble()), | ||
| radius = LOCATION_BIAS_RADIUS_METERS, | ||
| ), | ||
| ), | ||
| ), | ||
| ) | ||
| logger.info { | ||
| "Google place photo search completed: provider=${place.provider}, " + | ||
| "externalPlaceId=${place.externalPlaceId}, googlePlaceCount=${response?.places.orEmpty().size}, " + | ||
| "matched=${matched != null}" | ||
| } | ||
| return matched | ||
| } | ||
| .retrieve() | ||
| .body(TextSearchResponse::class.java) | ||
| ?.places | ||
| .orEmpty() | ||
|
|
||
| private fun getPlace(googlePlaceId: String): GooglePlace? = restClient.get() | ||
| .uri("/v1/places/{placeId}", googlePlaceId) | ||
|
|
@@ -134,14 +179,17 @@ class GooglePlacePhotoProvider( | |
| .retrieve() | ||
| .body(GooglePlace::class.java) | ||
|
|
||
| @Suppress("CyclomaticComplexMethod") | ||
| private fun GooglePlace.matchScore(candidate: PlaceCandidate): Int { | ||
| val googleName = displayName?.text?.normalize() ?: return 0 | ||
| val candidateName = candidate.name.normalize() | ||
| val nameScore = nameScore(googleName, candidateName, displayName.text.orEmpty(), candidate.name) | ||
| val categoryScore = categoryScore(candidate) | ||
| val addressMatches = formattedAddress?.normalize()?.let { googleAddress -> | ||
| val candidateAddress = candidate.address.normalize() | ||
| googleAddress.contains(candidateAddress) || candidateAddress.contains(googleAddress) | ||
| } ?: false | ||
| val cityMatches = formattedAddress?.containsCity(candidate.city) ?: false | ||
| val distance = location?.let { googleLocation -> | ||
| distanceMeters( | ||
| candidate.latitude, | ||
|
|
@@ -150,15 +198,55 @@ class GooglePlacePhotoProvider( | |
| BigDecimal.valueOf(googleLocation.longitude), | ||
| ) | ||
| } | ||
| val addressScore = if (addressMatches) 30 else 0 | ||
| val addressScore = when { | ||
| addressMatches -> 30 | ||
| cityMatches -> 10 | ||
| else -> 0 | ||
| } | ||
| val distanceScore = when { | ||
| distance == null -> 0 | ||
| distance <= CLOSE_MATCH_DISTANCE_METERS -> 25 | ||
| distance <= MAX_MATCH_DISTANCE_METERS -> 15 | ||
| distance <= FAR_MATCH_DISTANCE_METERS -> 5 | ||
| else -> 0 | ||
| } | ||
| return nameScore + addressScore + distanceScore | ||
| val photoScore = if (photos.isNullOrEmpty()) NO_PHOTO_PENALTY else PHOTO_BONUS_SCORE | ||
| val regionPenalty = if (distance != null && distance > REGION_MISMATCH_DISTANCE_METERS && !cityMatches) { | ||
| REGION_MISMATCH_PENALTY | ||
| } else { | ||
| 0 | ||
| } | ||
| return nameScore + categoryScore + addressScore + distanceScore + photoScore - regionPenalty | ||
| } | ||
|
|
||
| private fun GooglePlace.isEligibleNearbyCandidate(candidate: PlaceCandidate): Boolean { | ||
| val googleName = displayName?.text | ||
| val googleAddress = formattedAddress | ||
| if (googleName == null || googleAddress == null) { | ||
| return false | ||
| } | ||
|
|
||
| val candidateNameScore = nameScore( | ||
| googleName.normalize(), | ||
| candidate.name.normalize(), | ||
| googleName, | ||
| candidate.name, | ||
| ) | ||
| val addressMatches = googleAddress.normalize().let { normalizedGoogleAddress -> | ||
| val normalizedCandidateAddress = candidate.address.normalize() | ||
| normalizedGoogleAddress.contains(normalizedCandidateAddress) || | ||
| normalizedCandidateAddress.contains(normalizedGoogleAddress) | ||
| } | ||
| val distance = location?.let { googleLocation -> | ||
| distanceMeters( | ||
| candidate.latitude, | ||
| candidate.longitude, | ||
| BigDecimal.valueOf(googleLocation.latitude), | ||
| BigDecimal.valueOf(googleLocation.longitude), | ||
| ) | ||
| } | ||
| val isStrictAddressMatch = addressMatches && distance != null && distance <= STRICT_NEARBY_DISTANCE_METERS | ||
| return candidateNameScore >= TOKEN_NAME_SCORE || isStrictAddressMatch | ||
| } | ||
|
|
||
| private fun nameScore(googleName: String, candidateName: String, rawGoogleName: String, rawCandidateName: String) = | ||
|
|
@@ -174,6 +262,85 @@ class GooglePlacePhotoProvider( | |
| return if (leftTokens.intersect(rightTokens).isEmpty()) 0 else 1 | ||
| } | ||
|
|
||
| private fun GooglePlace.categoryScore(candidate: PlaceCandidate): Int { | ||
| val categoryKeyword = candidate.categoryKeyword() ?: return 0 | ||
| val googleName = displayName?.text.orEmpty() | ||
| val googleAddress = formattedAddress.orEmpty() | ||
| return if ( | ||
| googleName.contains(categoryKeyword, ignoreCase = true) || | ||
| googleAddress.contains(categoryKeyword, ignoreCase = true) || | ||
| primaryType.matchesCategory(candidate) | ||
| ) { | ||
| CATEGORY_MATCH_SCORE | ||
| } else { | ||
| 0 | ||
| } | ||
| } | ||
|
|
||
| private fun PlaceCandidate.searchQueries(): List<String> { | ||
| val queries = buildList { | ||
| add("$name $address") | ||
| categoryKeyword()?.let { add("$name $it") } | ||
| city?.let { add("$name $it") } | ||
| addressTokens().take(2).joinToString(" ").takeIf(String::isNotBlank)?.let { add("$name $it") } | ||
| add(name) | ||
| } | ||
| return queries.map(String::trim).filter(String::isNotBlank).distinct() | ||
| } | ||
|
|
||
| private fun PlaceCandidate.categoryKeyword(): String? = category | ||
| ?.substringAfterLast(">") | ||
| ?.trim() | ||
| ?.takeIf(String::isNotEmpty) | ||
| ?: category?.trim()?.takeIf(String::isNotEmpty) | ||
|
|
||
| private fun PlaceCandidate.nearbyTypes(): List<String> { | ||
| val categoryValue = categoryKeyword().orEmpty() | ||
| return buildList { | ||
| when { | ||
| categoryValue.contains("카페") -> add("cafe") | ||
|
|
||
| categoryValue.contains("베이커리") || categoryValue.contains("빵") -> add("bakery") | ||
|
|
||
| categoryValue.contains("술") || categoryValue.contains("바") || categoryValue.contains("주점") -> add("bar") | ||
|
|
||
| categoryValue.contains("숙소") || categoryValue.contains("호텔") -> add("lodging") | ||
|
|
||
| categoryValue.contains("음식") || categoryValue.contains("식당") || categoryValue.contains("맛집") -> { | ||
| add("restaurant") | ||
| } | ||
| } | ||
| if (isEmpty() && provider == "KAKAO") add("restaurant") | ||
| }.distinct() | ||
| } | ||
|
|
||
| private fun PlaceCandidate.addressTokens(): List<String> = address | ||
| .split(Regex("\\s+")) | ||
| .filter { token -> token.length >= MIN_ADDRESS_TOKEN_LENGTH } | ||
|
|
||
| private fun String.containsCity(city: String?): Boolean = city != null && contains(city, ignoreCase = true) | ||
| private fun String?.matchesCategory(candidate: PlaceCandidate): Boolean { | ||
| val primaryTypeValue = this ?: return false | ||
| val categoryValue = candidate.categoryKeyword().orEmpty() | ||
| return when { | ||
| categoryValue.contains("카페") -> primaryTypeValue == "cafe" | ||
|
|
||
| categoryValue.contains("베이커리") || categoryValue.contains("빵") -> primaryTypeValue == "bakery" | ||
|
|
||
| categoryValue.contains("술") || categoryValue.contains("바") || categoryValue.contains("주점") -> { | ||
| primaryTypeValue == "bar" | ||
| } | ||
|
|
||
| categoryValue.contains("숙소") || categoryValue.contains("호텔") -> primaryTypeValue == "lodging" | ||
|
|
||
| categoryValue.contains("음식") || categoryValue.contains("식당") || categoryValue.contains("맛집") -> { | ||
| primaryTypeValue == "restaurant" | ||
| } | ||
|
|
||
| else -> false | ||
| } | ||
| } | ||
|
|
||
| private fun String.normalize(): String = lowercase().filter(Char::isLetterOrDigit) | ||
|
|
||
| private fun storePhoto(photoName: String, sequence: Int, place: PlaceCandidate): String? { | ||
|
|
@@ -266,6 +433,7 @@ class GooglePlacePhotoProvider( | |
| val name: String? = null, | ||
| val displayName: LocalizedText? = null, | ||
| val formattedAddress: String? = null, | ||
| val primaryType: String? = null, | ||
| val location: GoogleLocation? = null, | ||
| val timeZone: GoogleTimeZone? = null, | ||
| val regularOpeningHours: GoogleOpeningHours? = null, | ||
|
|
@@ -313,33 +481,57 @@ class GooglePlacePhotoProvider( | |
| val pageSize: Int, | ||
| val locationBias: LocationBias, | ||
| ) | ||
| private data class NearbySearchRequest( | ||
| val includedTypes: List<String>, | ||
| val maxResultCount: Int, | ||
| val locationRestriction: LocationRestriction, | ||
| ) | ||
| private data class LocationBias(val circle: Circle) | ||
| private data class LocationRestriction(val circle: Circle) | ||
| private data class Circle(val center: GoogleLocation, val radius: Double) | ||
|
|
||
| @JsonIgnoreProperties(ignoreUnknown = true) | ||
| private data class PhotoMediaResponse(val photoUri: String? = null) | ||
|
|
||
| private data class RankedGooglePlace( | ||
| val place: GooglePlace, | ||
| val query: String, | ||
| val score: Int, | ||
| val photoCount: Int, | ||
| ) | ||
|
|
||
| private companion object { | ||
| val logger = KotlinLogging.logger {} | ||
| val eventLogger = LoggerFactory.getLogger(GooglePlacePhotoProvider::class.java) | ||
| const val API_KEY_HEADER = "X-Goog-Api-Key" | ||
| const val FIELD_MASK_HEADER = "X-Goog-FieldMask" | ||
| const val DETAIL_FIELD_MASK = | ||
| "id,name,displayName,formattedAddress,location,timeZone,regularOpeningHours,photos.name" | ||
| "id,name,displayName,formattedAddress,primaryType,location,timeZone,regularOpeningHours,photos.name" | ||
| const val SEARCH_FIELD_MASK = | ||
| "places.id,places.name,places.displayName,places.formattedAddress,places.location,places.timeZone," + | ||
| "places.id,places.name,places.displayName,places.formattedAddress,places.primaryType," + | ||
| "places.location,places.timeZone," + | ||
| "places.regularOpeningHours,places.photos.name" | ||
| const val PLACE_RESOURCE_PREFIX = "places/" | ||
| const val PHOTO_RESOURCE_SEPARATOR = "/photos/" | ||
| const val SEARCH_PAGE_SIZE = 5 | ||
| const val LOCATION_BIAS_RADIUS_METERS = 1_000.0 | ||
| const val NEARBY_SEARCH_RADIUS_METERS = 120.0 | ||
| const val NEARBY_SEARCH_RESULT_SIZE = 10 | ||
| const val STRICT_NEARBY_DISTANCE_METERS = 30.0 | ||
| const val MIN_MATCH_SCORE = 45 | ||
| const val EXACT_NAME_SCORE = 60 | ||
| const val CONTAINS_NAME_SCORE = 45 | ||
| const val TOKEN_NAME_SCORE = 30 | ||
| const val CATEGORY_MATCH_SCORE = 20 | ||
| const val PHOTO_BONUS_SCORE = 8 | ||
| const val NO_PHOTO_PENALTY = -12 | ||
| const val MAX_MATCH_DISTANCE_METERS = 500.0 | ||
| const val FAR_MATCH_DISTANCE_METERS = 2_000.0 | ||
| const val CLOSE_MATCH_DISTANCE_METERS = 100.0 | ||
| const val REGION_MISMATCH_DISTANCE_METERS = 20_000.0 | ||
| const val REGION_MISMATCH_PENALTY = 60 | ||
| const val MIN_ADDRESS_TOKEN_LENGTH = 2 | ||
| const val NEARBY_QUERY_LABEL = "__nearby__" | ||
| const val EARTH_RADIUS_METERS = 6_371_000.0 | ||
| const val SEARCH_STAGE = "google-place-match" | ||
| const val PHOTO_LIST_STAGE = "google-photo-list" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isolate failures for each supplemental text query.
flatMapexecutes every generated query inside the single outerfetchrunCatching, so if the first query returns the correct place but any later category, city, address-token, or name-only request gets a transient 5xx, the exception discards all previously collected candidates andfetchreturns null. The multi-query fallback therefore makes success depend on every Google request succeeding instead of retaining any usable response.Useful? React with 👍 / 👎.