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 @@ -38,6 +38,7 @@ class ConsensusService(
6 to listOf(120, 120, 120, 120, 120, 120),
7 to listOf(90, 120, 90, 120, 90, 120, 90),
)
private const val NEARBY_LOCALITY_RADIUS_KM = 45.0
private val SCORE_AXES = ScoreAxis.entries
}

Expand Down Expand Up @@ -297,10 +298,11 @@ class ConsensusService(
val sameIds = sameLocality.map { place -> place.id }.toSet()
val nearby = places
.filter { it.id !in sameLocality.map { place -> place.id }.toSet() }
.filter { distanceKm(anchor, it)?.let { distance -> distance <= 12.0 } == true }
.filter { distanceKm(anchor, it)?.let { distance -> distance <= NEARBY_LOCALITY_RADIUS_KM } == true }
.sortedBy { distanceKm(anchor, it) ?: Double.MAX_VALUE }

return (sameLocality + nearby.filter { it.id !in sameIds }).distinctBy { it.id }
val scoped = (sameLocality + nearby.filter { it.id !in sameIds }).distinctBy { it.id }
return if (scoped.size >= targetSlotCount) scoped else places
}

private fun localityScore(group: List<PlaceCandidate>, optionType: ScheduleOptionType, targetSlotCount: Int): Double {
Expand Down Expand Up @@ -563,7 +565,7 @@ class ConsensusService(
usedPlaceKeys = currentOptionPlaceKeys,
avoidPlaceIds = avoidPlaceIdsByOrder[orderIndex].orEmpty(),
avoidPlaceKeys = avoidPlaceKeysByOrder[orderIndex].orEmpty(),
previousPlace = chosenPlaces.lastOrNull(),
previousPlace = previousPlaceInSameTripDay(targets, index, chosenPlaces),
preferHiddenGem = preferHiddenGem,
mustBeHiddenGem = index == forcedHiddenGemIndex,
tripDate = tripDate,
Expand Down Expand Up @@ -718,6 +720,19 @@ class ConsensusService(
return if (personalIndex >= 0) personalIndex else targets.size / 2
}

private fun previousPlaceInSameTripDay(
targets: List<TargetVector>,
currentIndex: Int,
chosenPlaces: List<PlaceCandidate>,
): PlaceCandidate? {
if (currentIndex <= 0) return null
val previousTarget = targets.getOrNull(currentIndex - 1) ?: return null
val currentTarget = targets.getOrNull(currentIndex) ?: return null
val sameTripDay = previousTarget.startTime.atZone(ZoneId.of("Asia/Seoul")).toLocalDate() ==
currentTarget.startTime.atZone(ZoneId.of("Asia/Seoul")).toLocalDate()
return chosenPlaces.lastOrNull()?.takeIf { sameTripDay }
}

private fun rankPlaces(
targetVector: AxisScores,
places: List<PlaceCandidate>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,34 @@ class ConsensusServiceTest {
}
}

@Test
fun `multi day schedule can expand to nearby localities when one locality lacks enough unique places`() = runBlocking {
val options = consensusService.buildScheduleOptions(
context(
destination = "충남",
startTime = "09:00",
endTime = "12:00",
members = members(2),
places = nearbyMultiLocalityPlaces(),
tripDate = "2026-06-01",
tripEndDate = "2026-06-03",
)
)

options.forEach { option ->
assertEquals(9, option.slots.size)
assertEquals(
option.slots.size,
option.slots.map { it.placeId }.toSet().size,
"option ${option.optionType} repeated a place instead of expanding to nearby localities",
)
assertTrue(
option.slots.map { primaryLocality(it.placeAddress) }.toSet().size > 1,
"option ${option.optionType} should be allowed to cross nearby localities across trip days",
)
}
}

@Test
fun `schedule generation reports missing candidates instead of rejecting non Chungnam destination`() {
val error = assertThrows(DomainException::class.java) {
Expand Down Expand Up @@ -297,6 +325,36 @@ class ConsensusServiceTest {
)
}

private fun nearbyMultiLocalityPlaces(): List<PlaceCandidate> {
val localities = listOf(
Triple("충청남도 예산군 예산읍", 36.68, 126.85),
Triple("충청남도 아산시 온천동", 36.78, 127.00),
Triple("충청남도 천안시 동남구", 36.81, 127.15),
)
val categories = listOf("tourist_attraction", "restaurant", "cultural_facility")
return localities.flatMapIndexed { localityIndex, (address, baseLat, baseLon) ->
(1..3).map { index ->
PlaceCandidate(
id = (localityIndex * 100 + index).toLong(),
name = "nearby-$localityIndex-$index",
address = "$address 테스트로 $index",
latitude = baseLat + index * 0.001,
longitude = baseLon + index * 0.001,
category = categories[(index - 1) % categories.size],
mobilityScore = 60 + index,
photoScore = 60 + index,
budgetScore = 60 + index,
themeScore = 60 + index,
metadataTags = mapOf("hiddenGem" to (index == 2)),
operatingHours = mapOf("status" to "always"),
externalPopularityScore = if (index == 1) 80 else 35,
externalSignalConfidence = 80,
isRegionalBenefit = index != 1,
)
}
}
}

private fun mixedLocalityPlaces(): List<PlaceCandidate> {
val localities = listOf(
"충청남도 서천군 장항읍",
Expand Down
Loading