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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ->
Comment on lines +92 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve successful candidates when a later query fails

Isolate failures for each supplemental text query. flatMap executes every generated query inside the single outer fetch runCatching, 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 and fetch returns null. The multi-query fallback therefore makes success depend on every Google request succeeding instead of retaining any usable response.

Useful? React with 👍 / 👎.

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

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 Rank match quality before photo availability

Make the match score the primary ordering key rather than photoCount > 0. As written, any candidate above the low 45-point threshold with one photo beats an exact-name, exact-address, nearby candidate without photos, even if its score is much lower. That can persist the wrong Google Place ID and unrelated photos for same-city places with similar names; the photo bonus already included in matchScore can break ties without overriding substantially stronger identity evidence.

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)
Expand All @@ -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,
Expand All @@ -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) =
Expand All @@ -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? {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
Loading