From df7f1151edea5d4da52548fa229d5469c28905c3 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 20 May 2026 22:23:34 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=A4=91=EB=B3=B5=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleGenerationPersistenceService.kt | 55 ++++++++++++++- .../schedule/ScheduleServiceTest.kt | 68 +++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt index 2e34183..a15fa2d 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt @@ -4,6 +4,7 @@ import com.tripsync.domain.entity.AxisScores import com.tripsync.application.consensus.MemberSnapshot import com.tripsync.application.consensus.PlaceCandidate import com.tripsync.application.consensus.ScheduleOptionDraft +import com.tripsync.application.consensus.ScheduleSlotDraft import com.tripsync.common.exception.DomainException import com.tripsync.domain.entity.Place import com.tripsync.domain.entity.SatisfactionScore @@ -105,7 +106,9 @@ class ScheduleGenerationPersistenceService( val room = tripRoomRepository.findActiveByIdForUpdate(roomId, YnFlag.N) ?: throw DomainException(HttpStatus.NOT_FOUND, "ROOM_NOT_FOUND", "존재하지 않는 방입니다.") val version = (scheduleRepository.findTopByRoomIdAndDelYnOrderByVersionDesc(room.id, YnFlag.N)?.version ?: 0) + 1 - val saved = options.map { option -> + val replacementCandidates = placeQueryRepository.findScheduleCandidates(dto.destination) + val saved = options.map { rawOption -> + val option = rawOption.copy(slots = ensureUniqueSlots(rawOption.slots, replacementCandidates)) val personaValidation = personaValidationByType[option.optionType] val schedule = scheduleRepository.save( Schedule( @@ -180,6 +183,56 @@ class ScheduleGenerationPersistenceService( } return SavedScheduleGeneration(version = version, options = saved) } + private fun ensureUniqueSlots(slots: List, replacementCandidates: List): List { + if (slots.isEmpty()) return slots + + val candidates = replacementCandidates + .filter { it.delYn == YnFlag.N } + .distinctBy { it.id } + val usedPlaceIds = mutableSetOf() + val usedPlaceKeys = mutableSetOf() + + return slots.map { slot -> + val slotKeys = placeKeys(slot.placeName, slot.placeAddress) + val isDuplicate = slot.placeId in usedPlaceIds || slotKeys.any { it in usedPlaceKeys } + val uniqueSlot = if (isDuplicate) { + candidates.firstOrNull { candidate -> + candidate.id !in usedPlaceIds && placeKeys(candidate.name, candidate.address).none { it in usedPlaceKeys } + }?.let { replacement -> + slot.copy( + placeId = replacement.id, + placeName = replacement.name, + placeAddress = replacement.address, + isHiddenGem = isHiddenGem(replacement), + ) + } ?: slot + } else { + slot + } + + usedPlaceIds.add(uniqueSlot.placeId) + usedPlaceKeys.addAll(placeKeys(uniqueSlot.placeName, uniqueSlot.placeAddress)) + uniqueSlot + } + } + + private fun isHiddenGem(place: Place): Boolean { + return place.metadataTags?.get("hiddenGem") == true || + place.metadataTags?.get("populationDeclineArea") == true || + place.metadataTags?.get("regionalBenefit") == true || + place.metadataTags?.get("regionType") == "population_decline" + } + + private fun placeKeys(name: String, address: String): Set { + val normalizedName = normalizePlaceText(name) + val normalizedAddress = normalizePlaceText(address) + return setOf(normalizedName, "$normalizedName|$normalizedAddress").filter { it.isNotBlank() }.toSet() + } + + private fun normalizePlaceText(value: String): String { + return value.trim().lowercase().replace(Regex("[\\s\\p{Punct}]+"), "") + } + @Transactional(readOnly = true) fun getRoomIdForRegeneration(scheduleId: Long, userId: Long): Long { diff --git a/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt b/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt index b247aa1..7472cf7 100644 --- a/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt @@ -134,6 +134,36 @@ class ScheduleServiceTest( assertEquals("INVALID_REQUEST", error.code) } + @Test + fun `generated schedule save replaces duplicated places before persistence`() { + val fixture = createFixture(isConfirmed = false) + val dto = GenerateScheduleDto( + destination = "충남", + tripDate = "2026-06-01", + startTime = "09:00", + endTime = "12:00", + ) + + val saved = generationPersistenceService.saveGeneratedOptions( + roomId = fixture.schedule.room.id, + dto = dto, + options = listOf(duplicatedGeneratedOption(fixture.host.id, fixture.newPlace.id)), + personaValidationByType = emptyMap(), + ) + + val persistedSlots = scheduleSlotRepository.findAll() + .filter { it.schedule.id == saved.options.first().scheduleId } + .sortedBy { it.orderIndex } + + assertEquals(2, persistedSlots.size) + assertEquals(2, persistedSlots.map { it.place.id }.toSet().size) + assertEquals( + 2, + persistedSlots.map { normalizePlaceName(it.place.name) }.toSet().size, + "schedule save must not persist repeated visible place names", + ) + } + @Test fun `concurrent generated schedule saves allocate different versions`() { val fixture = createFixture(isConfirmed = false) @@ -280,6 +310,44 @@ class ScheduleServiceTest( ) } + private fun duplicatedGeneratedOption(userId: Long, placeId: Long): ScheduleOptionDraft { + val start = Instant.parse("2026-06-01T00:00:00Z") + return generatedOption(userId, placeId).copy( + slots = listOf( + ScheduleSlotDraft( + orderIndex = 1, + slotType = SlotType.COMMON, + targetUserId = null, + reasonAxis = ReasonAxis.COMMON, + reasonText = "첫 장소", + startTime = start, + endTime = start.plusSeconds(3600), + placeId = placeId, + placeName = "태안 안면도 꽃지해수욕장", + placeAddress = "충청남도 태안군 안면읍 승언리", + isHiddenGem = false, + ), + ScheduleSlotDraft( + orderIndex = 2, + slotType = SlotType.COMMON, + targetUserId = null, + reasonAxis = ReasonAxis.COMMON, + reasonText = "중복 장소", + startTime = start.plusSeconds(3600), + endTime = start.plusSeconds(7200), + placeId = placeId, + placeName = "태안 안면도 꽃지해수욕장", + placeAddress = "충청남도 태안군 안면읍 승언리", + isHiddenGem = false, + ), + ), + ) + } + + private fun normalizePlaceName(name: String): String { + return name.trim().lowercase().replace(Regex("[\\s\\p{Punct}]+"), "") + } + private fun place(tourApiId: String, name: String, address: String): Place { return Place( tourApiId = tourApiId, From d3db507d7e9593c5eb2c5e877e5cdd5bcf652b29 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 20 May 2026 22:53:13 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=B6=94=EC=B2=9C=20=EB=8B=A4=EC=96=91?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/consensus/ConsensusDtos.kt | 2 ++ .../application/consensus/ConsensusService.kt | 17 ++++++++++++++++- .../application/schedule/ScheduleService.kt | 13 +++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusDtos.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusDtos.kt index e7334f9..66eac4d 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusDtos.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusDtos.kt @@ -91,4 +91,6 @@ data class OptionContext( val endTime: String, val members: List, val places: List, + val recentPlaceIds: Set = emptySet(), + val diversitySalt: Long = 0L, ) diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt index a511e04..6b2144c 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt @@ -570,6 +570,8 @@ class ConsensusService( mustBeHiddenGem = index == forcedHiddenGemIndex, tripDate = tripDate, profile = profile, + recentPlaceIds = context.recentPlaceIds, + diversitySalt = context.diversitySalt, ) val place = rankedPlaces.firstOrNull() ?: throw DomainException(HttpStatus.UNPROCESSABLE_ENTITY, "PLACE_CANDIDATE_EMPTY", "일정 생성 후보 장소가 부족합니다.") @@ -745,6 +747,8 @@ class ConsensusService( mustBeHiddenGem: Boolean, tripDate: String, profile: SlotSelectionProfile, + recentPlaceIds: Set, + diversitySalt: Long, ): List { val source = if (mustBeHiddenGem) places.filter { isHiddenGem(it) } else places var pool = if (source.isNotEmpty()) source else places @@ -793,7 +797,7 @@ class ConsensusService( } return pool.distinctBy { normalizedPlaceName(it) }.sortedByDescending { - placeRankingScore(it, targetVector, previousPlace, preferHiddenGem, usedPlaceIds + avoidPlaceIds, tripDate, profile) + placeRankingScore(it, targetVector, previousPlace, preferHiddenGem, usedPlaceIds + avoidPlaceIds, recentPlaceIds, tripDate, profile, diversitySalt) } } @@ -823,11 +827,14 @@ class ConsensusService( previousPlace: PlaceCandidate?, preferHiddenGem: Boolean, usedPlaceIds: Set, + recentPlaceIds: Set, tripDate: String, profile: SlotSelectionProfile, + diversitySalt: Long, ): Double { var score = calculateVectorMatch(targetVector, placeScores(place)) if (usedPlaceIds.contains(place.id)) score -= 0.2 + if (recentPlaceIds.contains(place.id)) score -= 0.28 if (previousPlace != null && previousPlace.category == place.category) score -= 0.08 if (previousPlace != null) { val distance = distanceKm(previousPlace, place) @@ -850,9 +857,17 @@ class ConsensusService( } score += placeCategoryModifier(place, tripDate, profile) + score += diversityJitter(place.id, diversitySalt) return score } + private fun diversityJitter(placeId: Long, salt: Long): Double { + if (salt == 0L) return 0.0 + val mixed = placeId * 1103515245L + salt * 12345L + val bucket = Math.floorMod(mixed, 1000L) + return bucket / 1000.0 * 0.04 + } + private fun distanceKm(from: PlaceCandidate, to: PlaceCandidate): Double? { val fromLat = from.latitude ?: return null val fromLon = from.longitude ?: return null diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt index 5a293ef..0f6fa4a 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt @@ -38,6 +38,7 @@ class ScheduleService( val members = generationContext.members val placesById = generationContext.placesById + val recentPlaceIds = latestGeneratedPlaceIds(roomId) val context = OptionContext( roomId = roomId, destination = dto.destination, @@ -47,6 +48,8 @@ class ScheduleService( endTime = dto.endTime, members = members, places = generationContext.places, + recentPlaceIds = recentPlaceIds, + diversitySalt = System.nanoTime(), ) val options = runBlocking { consensusService.buildScheduleOptions(context) } @@ -76,6 +79,16 @@ class ScheduleService( ) } + + private fun latestGeneratedPlaceIds(roomId: Long): Set { + val schedules = scheduleRepository.findByRoomIdAndDelYn(roomId, YnFlag.N) + val latestVersion = schedules.maxOfOrNull { it.version } ?: return emptySet() + return schedules + .filter { it.version == latestVersion } + .flatMap { scheduleSlotRepository.findActivePlaceIdsByScheduleId(it.id, YnFlag.N) } + .toSet() + } + @Transactional(readOnly = true) fun getSchedule(scheduleId: Long, userId: Long): ApiResponse> { val schedule = accessPolicy.getActiveSchedule(scheduleId) From 2c9d65ea08f5991d80fb9edf05e5441ce10839ab Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Thu, 21 May 2026 03:54:24 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20LLM=20=EB=B3=B4=EC=A0=95=20=EB=B3=91=EB=A0=AC?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI 보정은 일정 생성의 보조 단계라서 옵션별 호출을 순차 대기하면 클라이언트 타임아웃을 유발할 수 있다. 기본 일정 후보 생성은 기존 순서를 유지하고, LLM 보정만 병렬 실행해 응답 대기 시간을 단일 호출 상한에 가깝게 제한한다. Constraint: 일정 생성 HTTP 요청이 약 30초 부근에서 클라이언트에 의해 중단됨\nRejected: OpenAI timeout만 15초로 확대 | 순차 호출 구조에서는 다시 30초 이상 지연 가능\nConfidence: high\nScope-risk: moderate\nDirective: deterministic 후보 생성 순서는 중복 회피에 영향을 주므로 병렬화하지 말고 LLM 보정 단계만 병렬화할 것\nTested: ./gradlew test --tests 'com.tripsync.infrastructure.llm.OpenAiClientTest' --tests 'com.tripsync.application.consensus.ConsensusServiceTest' --tests 'com.tripsync.application.schedule.ScheduleServiceTest' --no-daemon --max-workers=1\nTested: docker compose up -d --build server; tripsync-server health=healthy\nNot-tested: 실제 OpenAI 네트워크 성공 응답의 운영 latency 분포 --- .../application/consensus/ConsensusService.kt | 86 ++++++++++++------- .../infrastructure/llm/OpenAiClient.kt | 4 + src/main/resources/application-local.yml | 1 + src/main/resources/application.yml | 1 + .../consensus/ConsensusServiceTest.kt | 42 +++++++++ .../infrastructure/llm/OpenAiClientTest.kt | 39 +++++++++ 6 files changed, 144 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt index 6b2144c..4d00c10 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt @@ -7,6 +7,8 @@ import com.tripsync.domain.enums.ReasonAxis import com.tripsync.domain.enums.ScheduleOptionType import com.tripsync.domain.enums.ScoreAxis import com.tripsync.domain.enums.SlotType +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import mu.KotlinLogging import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -99,7 +101,7 @@ class ConsensusService( val usedPlaceIdsByOrder = mutableMapOf>() val usedPlaceKeysByOrder = mutableMapOf>() - val balanced = materializeOption( + val balanced = prepareOption( optionType = ScheduleOptionType.BALANCED, label = "균형형", summary = "모두가 조금씩 만족하는 안전한 선택", @@ -117,15 +119,13 @@ class ConsensusService( places = optionPlaceScopes.getValue(ScheduleOptionType.BALANCED), threshold = 65, preferHiddenGem = false, - members = context.members, tripDate = context.tripDate, - analysis = analysis, context = context, avoidPlaceIdsByOrder = usedPlaceIdsByOrder, avoidPlaceKeysByOrder = usedPlaceKeysByOrder, - ).also { rememberUsedPlacesByOrder(usedPlaceIdsByOrder, usedPlaceKeysByOrder, it) } + ).also { rememberUsedPlacesByOrder(usedPlaceIdsByOrder, usedPlaceKeysByOrder, it.slots) } - val individual = materializeOption( + val individual = prepareOption( optionType = ScheduleOptionType.INDIVIDUAL, label = "개성형", summary = "각자의 취향이 살아있는 교대 배분 일정", @@ -133,15 +133,13 @@ class ConsensusService( places = optionPlaceScopes.getValue(ScheduleOptionType.INDIVIDUAL), threshold = 60, preferHiddenGem = false, - members = context.members, tripDate = context.tripDate, - analysis = analysis, context = context, avoidPlaceIdsByOrder = usedPlaceIdsByOrder, avoidPlaceKeysByOrder = usedPlaceKeysByOrder, - ).also { rememberUsedPlacesByOrder(usedPlaceIdsByOrder, usedPlaceKeysByOrder, it) } + ).also { rememberUsedPlacesByOrder(usedPlaceIdsByOrder, usedPlaceKeysByOrder, it.slots) } - val discovery = materializeOption( + val discovery = prepareOption( optionType = ScheduleOptionType.DISCOVERY, label = "지역 발굴형", summary = "${destinationLabel(context.destination)} 숨은 명소 중심 탐험 일정", @@ -149,15 +147,18 @@ class ConsensusService( places = optionPlaceScopes.getValue(ScheduleOptionType.DISCOVERY), threshold = 55, preferHiddenGem = true, - members = context.members, tripDate = context.tripDate, - analysis = analysis, context = context, avoidPlaceIdsByOrder = usedPlaceIdsByOrder, avoidPlaceKeysByOrder = usedPlaceKeysByOrder, ) - return listOf(balanced, individual, discovery) + return coroutineScope { + val balancedDeferred = async { refinePreparedOption(balanced, context.members, analysis, context) } + val individualDeferred = async { refinePreparedOption(individual, context.members, analysis, context) } + val discoveryDeferred = async { refinePreparedOption(discovery, context.members, analysis, context) } + listOf(balancedDeferred.await(), individualDeferred.await(), discoveryDeferred.await()) + } } private fun parseScheduleWindows(tripStartDate: String, tripEndDate: String?, startTime: String, endTime: String): List { @@ -534,7 +535,7 @@ class ConsensusService( ) } - private suspend fun materializeOption( + private fun prepareOption( optionType: ScheduleOptionType, label: String, summary: String, @@ -542,13 +543,11 @@ class ConsensusService( places: List, threshold: Int, preferHiddenGem: Boolean, - members: List, tripDate: String, - analysis: GroupAnalysis, context: OptionContext, avoidPlaceIdsByOrder: Map>, avoidPlaceKeysByOrder: Map>, - ): ScheduleOptionDraft { + ): PreparedScheduleOption { val chosenPlaces = mutableListOf() val forcedHiddenGemIndex = if (preferHiddenGem) pickForcedHiddenGemSlot(targets) else -1 val shortlistedPerSlot = mutableListOf() @@ -608,27 +607,45 @@ class ConsensusService( ) } - val llmAttempt = llmService.refineScheduleOption( + return PreparedScheduleOption( optionType = optionType, label = label, summary = summary, + threshold = threshold, + places = places, + slots = slots, + chosenPlaces = chosenPlaces, + shortlistedPerSlot = shortlistedPerSlot, + ) + } + + private suspend fun refinePreparedOption( + prepared: PreparedScheduleOption, + members: List, + analysis: GroupAnalysis, + context: OptionContext, + ): ScheduleOptionDraft { + val llmAttempt = llmService.refineScheduleOption( + optionType = prepared.optionType, + label = prepared.label, + summary = prepared.summary, room = RoomRef(roomId = context.roomId, destination = context.destination, tripDate = context.tripDate), commonAxes = analysis.commonAxes, priorityAxes = analysis.priorityAxes, members = members.map { MemberRef(it.userId, it.nickname) }, - slotPlan = shortlistedPerSlot, + slotPlan = prepared.shortlistedPerSlot, ) val llmRefined = llmAttempt.result - val finalSummary = llmRefined?.summary ?: summary - val placesById = places.associateBy { it.id } + val finalSummary = llmRefined?.summary ?: prepared.summary + val placesById = prepared.places.associateBy { it.id } val finalPlacesByOrder = mutableMapOf() llmRefined?.slots?.forEach { slot -> placesById[slot.placeId]?.let { finalPlacesByOrder[slot.orderIndex] = it } } val refinedByOrder = llmRefined?.slots?.associateBy { it.orderIndex } ?: emptyMap() - val finalSlots = slots.map { slot -> + val finalSlots = prepared.slots.map { slot -> val refined = refinedByOrder[slot.orderIndex] val refinedPlace = refined?.let { finalPlacesByOrder[it.orderIndex] } if (refined == null || refinedPlace == null) { @@ -645,19 +662,19 @@ class ConsensusService( } val finalPlaces = finalSlots.mapIndexed { index, slot -> - finalPlacesByOrder[slot.orderIndex] ?: chosenPlaces[index] + finalPlacesByOrder[slot.orderIndex] ?: prepared.chosenPlaces[index] } - val satisfactionByUser = buildSatisfaction(optionType, finalSlots, finalPlaces, members) - val groupSatisfaction = maxOf(threshold, satisfactionByUser.minOf { it.score }) + val satisfactionByUser = buildSatisfaction(prepared.optionType, finalSlots, finalPlaces, members) + val groupSatisfaction = maxOf(prepared.threshold, satisfactionByUser.minOf { it.score }) logger.info { - "schedule_option optionType=$optionType roomId=${context.roomId} provider=${llmRefined?.provider ?: DETERMINISTIC_PROVIDER} attemptedProvider=${llmAttempt.attemptedProvider} latencyMs=${llmAttempt.latencyMs ?: 0} fallbackUsed=${llmAttempt.fallbackUsed} fallbackReason=${llmAttempt.fallbackReason?.code ?: "none"} groupSatisfaction=$groupSatisfaction" + "schedule_option optionType=${prepared.optionType} roomId=${context.roomId} provider=${llmRefined?.provider ?: DETERMINISTIC_PROVIDER} attemptedProvider=${llmAttempt.attemptedProvider} latencyMs=${llmAttempt.latencyMs ?: 0} fallbackUsed=${llmAttempt.fallbackUsed} fallbackReason=${llmAttempt.fallbackReason?.code ?: "none"} groupSatisfaction=$groupSatisfaction" } return ScheduleOptionDraft( - optionType = optionType, - label = label, + optionType = prepared.optionType, + label = prepared.label, summary = finalSummary, groupSatisfaction = groupSatisfaction, slots = finalSlots, @@ -670,12 +687,23 @@ class ConsensusService( ) } + private data class PreparedScheduleOption( + val optionType: ScheduleOptionType, + val label: String, + val summary: String, + val threshold: Int, + val places: List, + val slots: List, + val chosenPlaces: List, + val shortlistedPerSlot: List, + ) + private fun rememberUsedPlacesByOrder( usedPlaceIdsByOrder: MutableMap>, usedPlaceKeysByOrder: MutableMap>, - option: ScheduleOptionDraft, + slots: List, ) { - option.slots.forEach { slot -> + slots.forEach { slot -> usedPlaceIdsByOrder.getOrPut(slot.orderIndex) { mutableSetOf() }.add(slot.placeId) usedPlaceKeysByOrder.getOrPut(slot.orderIndex) { mutableSetOf() }.addAll(scheduleSlotPlaceKeys(slot)) } diff --git a/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt b/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt index 248afdc..044eb83 100644 --- a/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt +++ b/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt @@ -16,6 +16,7 @@ import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClientResponseException import org.springframework.web.reactive.function.client.bodyToMono +import java.time.Duration import java.util.concurrent.TimeUnit @Component @@ -26,6 +27,8 @@ class OpenAiClient( private val apiKey: String, @Value("\${openai.model:gpt-4o-mini}") private val model: String, + @Value("\${openai.timeout-seconds:10}") + private val timeoutSeconds: Long = 10, private val meterRegistry: MeterRegistry, ) { private val logger = KotlinLogging.logger {} @@ -77,6 +80,7 @@ class OpenAiClient( .bodyValue(requestBody) .retrieve() .bodyToMono() + .timeout(Duration.ofSeconds(timeoutSeconds.coerceAtLeast(1))) .awaitSingle() val latencyMs = elapsedMillis(startNanos) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 10a82b7..084d5e9 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -64,6 +64,7 @@ jwt: openai: api-key: ${OPENAI_API_KEY:} model: ${OPENAI_MODEL:gpt-4o-mini} + timeout-seconds: ${OPENAI_TIMEOUT_SECONDS:10} tourapi: key: ${TOURAPI_KEY:${TOUR_API_SERVICE_KEY:}} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2510211..03afe0f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -67,6 +67,7 @@ jwt: openai: api-key: ${OPENAI_API_KEY:} model: ${OPENAI_MODEL:gpt-4o-mini} + timeout-seconds: ${OPENAI_TIMEOUT_SECONDS:10} tourapi: key: ${TOURAPI_KEY:${TOUR_API_SERVICE_KEY:}} diff --git a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt index 7418e7d..51843e6 100644 --- a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt @@ -10,7 +10,11 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.web.reactive.function.client.ClientResponse import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import java.time.Duration import java.time.ZoneId class ConsensusServiceTest { @@ -54,6 +58,44 @@ class ConsensusServiceTest { } } + @Test + fun `llm refinement runs recommendation options in parallel`() = runBlocking { + val slowWebClient = WebClient.builder() + .exchangeFunction { + Mono.delay(Duration.ofSeconds(3)) + .thenReturn(ClientResponse.create(HttpStatus.OK).body("{}").build()) + } + .build() + val parallelConsensusService = ConsensusService( + LlmService( + OpenAiClient( + webClient = slowWebClient, + objectMapper = ObjectMapper(), + apiKey = "test-key", + model = "gpt-test", + timeoutSeconds = 1, + meterRegistry = SimpleMeterRegistry(), + ) + ) + ) + + val startNanos = System.nanoTime() + val options = parallelConsensusService.buildScheduleOptions( + context( + destination = "공주시", + startTime = "09:00", + endTime = "12:00", + members = members(2), + places = places("충청남도 공주시"), + ) + ) + val elapsedMillis = Duration.ofNanos(System.nanoTime() - startNanos).toMillis() + + assertEquals(3, options.size) + assertTrue(options.all { it.fallbackUsed }) + assertTrue(elapsedMillis < 2_500, "three LLM refinements should wait once in parallel instead of timing out sequentially") + } + @Test fun `schedule generation uses requested time window instead of fixed nine to twenty one`() = runBlocking { val options = consensusService.buildScheduleOptions( diff --git a/src/test/kotlin/com/tripsync/infrastructure/llm/OpenAiClientTest.kt b/src/test/kotlin/com/tripsync/infrastructure/llm/OpenAiClientTest.kt index e731394..87edd0d 100644 --- a/src/test/kotlin/com/tripsync/infrastructure/llm/OpenAiClientTest.kt +++ b/src/test/kotlin/com/tripsync/infrastructure/llm/OpenAiClientTest.kt @@ -10,7 +10,11 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.web.reactive.function.client.ClientResponse import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import java.time.Duration class OpenAiClientTest { private val client = OpenAiClient( @@ -54,6 +58,41 @@ class OpenAiClientTest { ).count()) } + @Test + fun `refine schedule falls back quickly when openai call exceeds configured timeout`() = runBlocking { + val slowWebClient = WebClient.builder() + .exchangeFunction { + Mono.delay(Duration.ofSeconds(3)) + .thenReturn(ClientResponse.create(HttpStatus.OK).body("{}").build()) + } + .build() + val timeoutClient = OpenAiClient( + webClient = slowWebClient, + objectMapper = ObjectMapper(), + apiKey = "test-key", + model = "gpt-test", + timeoutSeconds = 1, + meterRegistry = SimpleMeterRegistry(), + ) + + val startNanos = System.nanoTime() + val attempt = timeoutClient.refineSchedule( + optionType = ScheduleOptionType.BALANCED, + label = "균형형", + summary = "요약", + room = ConsensusService.RoomRef(roomId = 1, destination = "충남", tripDate = "2026-06-01"), + commonAxes = emptyList(), + priorityAxes = emptyList(), + members = emptyList(), + slotPlan = emptyList(), + ) + val elapsedMillis = Duration.ofNanos(System.nanoTime() - startNanos).toMillis() + + assertTrue(attempt.fallbackUsed) + assertEquals(LlmService.FallbackReason.API_CALL_FAILED, attempt.fallbackReason) + assertTrue(elapsedMillis < 2_500, "OpenAI timeout fallback should happen near the configured timeout") + } + @Test fun `parse response returns refinement result with latency and provider`() { val response = """ From ca94c9d05276fe421d4b77d996a989982c23c3ca Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Thu, 21 May 2026 04:13:48 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20LLM=20=EB=B3=B4=EC=A0=95=20=ED=9B=84?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EA=B0=84=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 병렬 LLM 보정은 deterministic 준비 단계의 avoid set 이후에 최종 장소를 바꿀 수 있어 같은 순번의 추천 옵션이 같은 장소로 수렴할 수 있다. 병렬화는 유지하되 최종 보정 결과를 order별로 한 번 더 검사하고 대체 후보로 조정한다. Constraint: LLM 보정은 옵션별 병렬 실행되어 이전 옵션의 최종 LLM 선택을 다음 옵션 준비 단계에서 알 수 없음\nRejected: 다시 전체 옵션 생성을 순차화 | 타임아웃 개선 효과 상실\nConfidence: high\nScope-risk: moderate\nDirective: 옵션 간 중복 회피는 deterministic 준비 단계와 최종 LLM 후처리 양쪽에서 유지할 것\nTested: ./gradlew test --tests 'com.tripsync.infrastructure.llm.OpenAiClientTest' --tests 'com.tripsync.application.consensus.ConsensusServiceTest' --tests 'com.tripsync.application.schedule.ScheduleServiceTest' --no-daemon --max-workers=1\nTested: docker compose up -d --build server; tripsync-server health=healthy\nNot-tested: 실제 운영 OpenAI 응답 분포에서의 대체 빈도 --- .../application/consensus/ConsensusService.kt | 81 +++++++++++++++- .../application/consensus/LlmService.kt | 4 +- .../consensus/ConsensusServiceTest.kt | 93 +++++++++++++++++++ 3 files changed, 175 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt index 4d00c10..767212a 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt @@ -153,12 +153,14 @@ class ConsensusService( avoidPlaceKeysByOrder = usedPlaceKeysByOrder, ) - return coroutineScope { + val preparedOptions = listOf(balanced, individual, discovery) + val refinedOptions = coroutineScope { val balancedDeferred = async { refinePreparedOption(balanced, context.members, analysis, context) } val individualDeferred = async { refinePreparedOption(individual, context.members, analysis, context) } val discoveryDeferred = async { refinePreparedOption(discovery, context.members, analysis, context) } listOf(balancedDeferred.await(), individualDeferred.await(), discoveryDeferred.await()) } + return resolveCrossOptionPlaceCollisions(refinedOptions, preparedOptions, context.members) } private fun parseScheduleWindows(tripStartDate: String, tripEndDate: String?, startTime: String, endTime: String): List { @@ -687,6 +689,83 @@ class ConsensusService( ) } + private fun resolveCrossOptionPlaceCollisions( + options: List, + preparedOptions: List, + members: List, + ): List { + val usedPlaceIdsByOrder = mutableMapOf>() + val usedPlaceKeysByOrder = mutableMapOf>() + + return options.zip(preparedOptions).map { (option, prepared) -> + val placesById = prepared.places.associateBy { it.id } + val currentOptionPlaceIds = mutableSetOf() + val currentOptionPlaceKeys = mutableSetOf() + val adjustedSlots = option.slots.map { slot -> + val slotKeys = scheduleSlotPlaceKeys(slot) + val duplicateByOrder = slot.placeId in usedPlaceIdsByOrder[slot.orderIndex].orEmpty() || + slotKeys.any { it in usedPlaceKeysByOrder[slot.orderIndex].orEmpty() } + val duplicateInOption = slot.placeId in currentOptionPlaceIds || slotKeys.any { it in currentOptionPlaceKeys } + val adjusted = if (duplicateByOrder || duplicateInOption) { + replacementSlot(slot, prepared, usedPlaceIdsByOrder[slot.orderIndex].orEmpty(), usedPlaceKeysByOrder[slot.orderIndex].orEmpty(), currentOptionPlaceIds, currentOptionPlaceKeys) + } else { + slot + } + currentOptionPlaceIds.add(adjusted.placeId) + currentOptionPlaceKeys.addAll(scheduleSlotPlaceKeys(adjusted)) + usedPlaceIdsByOrder.getOrPut(adjusted.orderIndex) { mutableSetOf() }.add(adjusted.placeId) + usedPlaceKeysByOrder.getOrPut(adjusted.orderIndex) { mutableSetOf() }.addAll(scheduleSlotPlaceKeys(adjusted)) + adjusted + } + + val adjustedPlaces = adjustedSlots.mapNotNull { placesById[it.placeId] } + if (adjustedPlaces.size != adjustedSlots.size) { + option.copy(slots = adjustedSlots) + } else { + val satisfactionByUser = buildSatisfaction(option.optionType, adjustedSlots, adjustedPlaces, members) + option.copy( + slots = adjustedSlots, + satisfactionByUser = satisfactionByUser, + groupSatisfaction = maxOf(prepared.threshold, satisfactionByUser.minOf { it.score }), + ) + } + } + } + + private fun replacementSlot( + slot: ScheduleSlotDraft, + prepared: PreparedScheduleOption, + usedPlaceIdsForOrder: Set, + usedPlaceKeysForOrder: Set, + currentOptionPlaceIds: Set, + currentOptionPlaceKeys: Set, + ): ScheduleSlotDraft { + val placesById = prepared.places.associateBy { it.id } + val shortlistedIds = prepared.shortlistedPerSlot + .firstOrNull { it.orderIndex == slot.orderIndex } + ?.candidatePlaces + ?.map { it.id } + .orEmpty() + val candidateIds = (shortlistedIds + prepared.slots.map { it.placeId } + prepared.places.map { it.id }).distinct() + val replacement = candidateIds + .asSequence() + .mapNotNull { placesById[it] } + .firstOrNull { candidate -> + val keys = placeCandidateKeys(candidate) + candidate.id !in usedPlaceIdsForOrder && + candidate.id !in currentOptionPlaceIds && + keys.none { it in usedPlaceKeysForOrder } && + keys.none { it in currentOptionPlaceKeys } + } ?: return slot + + return slot.copy( + placeId = replacement.id, + placeName = replacement.name, + placeAddress = replacement.address, + isHiddenGem = isHiddenGem(replacement), + ) + } + private data class PreparedScheduleOption( val optionType: ScheduleOptionType, val label: String, diff --git a/src/main/kotlin/com/tripsync/application/consensus/LlmService.kt b/src/main/kotlin/com/tripsync/application/consensus/LlmService.kt index 099c8e1..db1430f 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/LlmService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/LlmService.kt @@ -5,7 +5,7 @@ import com.tripsync.infrastructure.llm.OpenAiClient import org.springframework.stereotype.Service @Service -class LlmService( +open class LlmService( private val openAiClient: OpenAiClient, ) { @@ -39,7 +39,7 @@ class LlmService( val failureDetail: String? = null, ) - suspend fun refineScheduleOption( + open suspend fun refineScheduleOption( optionType: ScheduleOptionType, label: String, summary: String, diff --git a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt index 51843e6..17c659e 100644 --- a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper import io.micrometer.core.instrument.simple.SimpleMeterRegistry import com.tripsync.common.exception.DomainException import com.tripsync.domain.entity.AxisScores +import com.tripsync.domain.enums.ScheduleOptionType import com.tripsync.infrastructure.llm.OpenAiClient +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -96,6 +98,37 @@ class ConsensusServiceTest { assertTrue(elapsedMillis < 2_500, "three LLM refinements should wait once in parallel instead of timing out sequentially") } + @Test + fun `llm refined places are deduplicated across recommendation options by same order`() = runBlocking { + val llmConsensusService = ConsensusService(coordinatedDuplicateLlmService()) + + val options = llmConsensusService.buildScheduleOptions( + context( + destination = "공주시", + startTime = "09:00", + endTime = "12:00", + members = members(2), + places = places("충청남도 공주시"), + ) + ) + + assertEquals(3, options.size) + assertTrue(options.all { it.llmProvider == "test/duplicate-llm" }, "test must exercise successful LLM-refined slots before deduplication") + val slotsByOrder = options.flatMap { it.slots }.groupBy { it.orderIndex } + slotsByOrder.forEach { (orderIndex, slots) -> + assertEquals( + slots.size, + slots.map { it.placeId }.toSet().size, + "slot $orderIndex repeated the same LLM-refined place across recommendation options", + ) + assertEquals( + slots.size, + slots.map { normalizePlaceName(it.placeName) }.toSet().size, + "slot $orderIndex repeated the same LLM-refined visible place across recommendation options", + ) + } + } + @Test fun `schedule generation uses requested time window instead of fixed nine to twenty one`() = runBlocking { val options = consensusService.buildScheduleOptions( @@ -291,6 +324,66 @@ class ConsensusServiceTest { assertEquals("PLACE_CANDIDATE_EMPTY", error.code) } + private fun coordinatedDuplicateLlmService(): LlmService { + val client = OpenAiClient( + webClient = WebClient.create(), + objectMapper = ObjectMapper(), + apiKey = "", + model = "gpt-test", + meterRegistry = SimpleMeterRegistry(), + ) + return object : LlmService(client) { + private val lock = Any() + private val requests = mutableListOf>>() + private val ready = CompletableDeferred>() + + override suspend fun refineScheduleOption( + optionType: ScheduleOptionType, + label: String, + summary: String, + room: ConsensusService.RoomRef, + commonAxes: List, + priorityAxes: List, + members: List, + slotPlan: List, + ): RefinementAttempt { + synchronized(lock) { + requests.add(optionType to slotPlan) + if (requests.size == 3 && !ready.isCompleted) { + val sharedPlaceByOrder = slotPlan.map { it.orderIndex }.associateWith { orderIndex -> + val candidateSets = requests.map { (_, plan) -> + plan.first { it.orderIndex == orderIndex }.candidatePlaces.map { candidate -> candidate.id }.toSet() + } + candidateSets.reduce { acc, ids -> acc.intersect(ids) }.firstOrNull() + ?: candidateSets.first().first() + } + ready.complete(sharedPlaceByOrder) + } + } + val sharedPlaceByOrder = ready.await() + val refinedSlots = slotPlan.map { slot -> + RefinedSlot( + orderIndex = slot.orderIndex, + placeId = sharedPlaceByOrder.getValue(slot.orderIndex), + reason = "LLM 중복 선택", + ) + } + return RefinementAttempt( + result = RefinementResult( + summary = "LLM 보정 요약", + provider = "test/duplicate-llm", + latencyMs = 1, + slots = refinedSlots, + ), + attemptedProvider = "test/duplicate-llm", + latencyMs = 1, + fallbackUsed = false, + fallbackReason = null, + ) + } + } + } + private fun context( destination: String, startTime: String,