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 @@ -91,4 +91,6 @@ data class OptionContext(
val endTime: String,
val members: List<MemberSnapshot>,
val places: List<PlaceCandidate>,
val recentPlaceIds: Set<Long> = emptySet(),
val diversitySalt: Long = 0L,
)
182 changes: 152 additions & 30 deletions src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {

Expand Down Expand Up @@ -39,7 +39,7 @@ class LlmService(
val failureDetail: String? = null,
)

suspend fun refineScheduleOption(
open suspend fun refineScheduleOption(
optionType: ScheduleOptionType,
label: String,
summary: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -180,6 +183,56 @@ class ScheduleGenerationPersistenceService(
}
return SavedScheduleGeneration(version = version, options = saved)
}
private fun ensureUniqueSlots(slots: List<ScheduleSlotDraft>, replacementCandidates: List<Place>): List<ScheduleSlotDraft> {
if (slots.isEmpty()) return slots

val candidates = replacementCandidates
.filter { it.delYn == YnFlag.N }
.distinctBy { it.id }
val usedPlaceIds = mutableSetOf<Long>()
val usedPlaceKeys = mutableSetOf<String>()

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<String> {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) }
Expand Down Expand Up @@ -76,6 +79,16 @@ class ScheduleService(
)
}


private fun latestGeneratedPlaceIds(roomId: Long): Set<Long> {
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<Map<String, Any?>> {
val schedule = accessPolicy.getActiveSchedule(scheduleId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {}
Expand Down Expand Up @@ -77,6 +80,7 @@ class OpenAiClient(
.bodyValue(requestBody)
.retrieve()
.bodyToMono<String>()
.timeout(Duration.ofSeconds(timeoutSeconds.coerceAtLeast(1)))
.awaitSingle()

val latencyMs = elapsedMillis(startNanos)
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:}}
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ 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
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 {
Expand Down Expand Up @@ -54,6 +60,75 @@ 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 `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(
Expand Down Expand Up @@ -249,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<Pair<ScheduleOptionType, List<ConsensusService.SlotShortlist>>>()
private val ready = CompletableDeferred<Map<Int, Long>>()

override suspend fun refineScheduleOption(
optionType: ScheduleOptionType,
label: String,
summary: String,
room: ConsensusService.RoomRef,
commonAxes: List<com.tripsync.domain.enums.ScoreAxis>,
priorityAxes: List<com.tripsync.domain.enums.ScoreAxis>,
members: List<ConsensusService.MemberRef>,
slotPlan: List<ConsensusService.SlotShortlist>,
): 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,
Expand Down
Loading
Loading