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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ application-prod.yml
*~

node_modules/

.kotlin/
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ 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.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeout
import mu.KotlinLogging
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.time.Instant
Expand All @@ -19,6 +22,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
Expand All @@ -29,6 +33,8 @@ import kotlin.math.sqrt
@Service
class ConsensusService(
private val llmService: LlmService,
@Value("\${openai.refinement-timeout-seconds:3}")
private val refinementTimeoutSeconds: Long = 3,
) {
private val logger = KotlinLogging.logger {}

Expand Down Expand Up @@ -627,16 +633,34 @@ class ConsensusService(
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 = prepared.shortlistedPerSlot,
)
val startNanos = System.nanoTime()
val llmAttempt = try {
withTimeout(refinementTimeoutMillis()) {
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 = prepared.shortlistedPerSlot,
)
}
} catch (e: TimeoutCancellationException) {
val latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos).coerceAtLeast(0)
logger.warn(e) {
"schedule_option_refinement_timeout roomId=${context.roomId} optionType=${prepared.optionType} budgetMs=${refinementTimeoutMillis()} latencyMs=$latencyMs fallbackReason=${LlmService.FallbackReason.API_CALL_FAILED.code}"
}
LlmService.RefinementAttempt(
result = null,
attemptedProvider = llmService.attemptedProvider,
latencyMs = latencyMs.toLong(),
fallbackUsed = true,
fallbackReason = LlmService.FallbackReason.API_CALL_FAILED,
failureDetail = "refinement budget exceeded (${refinementTimeoutMillis()}ms)",
)
}
val llmRefined = llmAttempt.result

val finalSummary = llmRefined?.summary ?: prepared.summary
Expand Down Expand Up @@ -689,6 +713,8 @@ class ConsensusService(
)
}

private fun refinementTimeoutMillis(): Long = refinementTimeoutSeconds.coerceAtLeast(1) * 1_000

private fun resolveCrossOptionPlaceCollisions(
options: List<ScheduleOptionDraft>,
preparedOptions: List<PreparedScheduleOption>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import org.springframework.stereotype.Service
open class LlmService(
private val openAiClient: OpenAiClient,
) {
open val attemptedProvider: String
get() = openAiClient.providerName


data class RefinedSlot(
val orderIndex: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.tripsync.infrastructure.popularity.GooglePlacesClient
import com.tripsync.infrastructure.popularity.NaverDataLabClient
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
Expand Down Expand Up @@ -61,7 +62,7 @@ class ExternalPopularityBatchService(
private fun syncInternal(triggeredBy: String, operatorUserId: Long?, limitOverride: Int? = null): ExternalPopularitySyncReport {
val startedAt = Instant.now()
val limit = (limitOverride ?: properties.sync.batchLimit).coerceAtLeast(1)
val places = placeRepository.findByDelYn(YnFlag.N).take(limit)
val places = placeRepository.findByDelYn(YnFlag.N, PageRequest.of(0, limit))
val rawResults = mutableListOf<ExternalPopularityRawResult>()

places.forEachIndexed { index, place ->
Expand Down
13 changes: 5 additions & 8 deletions src/main/kotlin/com/tripsync/application/room/RoomService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import com.tripsync.common.dto.ApiResponse
import com.tripsync.common.exception.DomainException
import com.tripsync.domain.entity.RoomMember
import com.tripsync.domain.entity.RoomMemberProfile
import com.tripsync.domain.entity.Schedule
import com.tripsync.domain.entity.TripRoom
import com.tripsync.domain.entity.User
import com.tripsync.domain.enums.RoomMemberRole
Expand Down Expand Up @@ -345,16 +344,18 @@ class RoomService(
?.let { version -> scheduleRepository.findAllByRoomIdAndDelYnAndVersion(room.id, YnFlag.N, version) }
?.sortedBy { it.optionType.ordinal }
.orEmpty()
val schedulesToFormat = (listOfNotNull(confirmed) + latestOptions).distinctBy { it.id }
val formattedSchedules = scheduleReadAssembler.formatStoredSchedules(schedulesToFormat)
val scheduleState = when {
confirmed != null -> mapOf(
"status" to "confirmed",
"confirmedSchedule" to formatSchedule(confirmed),
"options" to latestOptions.map { formatSchedule(it) },
"confirmedSchedule" to formattedSchedules.getValue(confirmed.id),
"options" to latestOptions.map { formattedSchedules.getValue(it.id) },
)
latestOptions.isNotEmpty() -> mapOf(
"status" to "generated",
"confirmedSchedule" to null,
"options" to latestOptions.map { formatSchedule(it) },
"options" to latestOptions.map { formattedSchedules.getValue(it.id) },
)
else -> mapOf(
"status" to "empty",
Expand Down Expand Up @@ -384,10 +385,6 @@ class RoomService(
}
}

private fun formatSchedule(schedule: Schedule): Map<String, Any?> {
return scheduleReadAssembler.formatStoredSchedule(schedule)
}

private fun normalizeRoomName(roomName: String?, destination: String): String {
val normalized = roomName?.trim()?.takeIf { it.isNotBlank() } ?: defaultRoomName(destination)
if (normalized.length > ROOM_NAME_MAX_LENGTH) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.tripsync.domain.entity.Place
import com.tripsync.domain.entity.SatisfactionScore
import com.tripsync.domain.entity.Schedule
import com.tripsync.domain.entity.ScheduleSlot
import com.tripsync.domain.entity.User
import com.tripsync.domain.enums.ScheduleOptionType
import com.tripsync.domain.enums.TripRoomStatus
import com.tripsync.domain.enums.YnFlag
Expand Down Expand Up @@ -107,8 +108,17 @@ class ScheduleGenerationPersistenceService(
?: throw DomainException(HttpStatus.NOT_FOUND, "ROOM_NOT_FOUND", "존재하지 않는 방입니다.")
val version = (scheduleRepository.findTopByRoomIdAndDelYnOrderByVersionDescIdDesc(room.id, YnFlag.N)?.version ?: 0) + 1
val replacementCandidates = placeQueryRepository.findScheduleCandidates(dto.destination)
val saved = options.map { rawOption ->
val option = rawOption.copy(slots = ensureUniqueSlots(rawOption.slots, replacementCandidates))
val repairedOptions = options.map { rawOption ->
rawOption.copy(slots = ensureUniqueSlots(rawOption.slots, replacementCandidates))
}
val placesById = loadActivePlacesById(repairedOptions.flatMap { option -> option.slots.map { it.placeId } })
val usersById = loadUsersById(
repairedOptions.flatMap { option ->
option.slots.mapNotNull { it.targetUserId } + option.satisfactionByUser.map { it.userId }
}
)

val saved = repairedOptions.map { option ->
val personaValidation = personaValidationByType[option.optionType]
val schedule = scheduleRepository.save(
Schedule(
Expand Down Expand Up @@ -137,32 +147,28 @@ class ScheduleGenerationPersistenceService(
)
)

option.slots.forEach { slot ->
val place = placeRepository.findById(slot.placeId)
.orElseThrow { DomainException(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다.") }
if (place.delYn != YnFlag.N) {
throw DomainException(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다.")
}
val targetUser = slot.targetUserId?.let { userRepository.findById(it).orElse(null) }
scheduleSlotRepository.save(
scheduleSlotRepository.saveAll(
option.slots.map { slot ->
val place = placesById[slot.placeId]
?: throw DomainException(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다.")
ScheduleSlot(
schedule = schedule,
startTime = slot.startTime,
endTime = slot.endTime,
place = place,
slotType = slot.slotType,
targetUser = targetUser,
targetUser = slot.targetUserId?.let { usersById[it] },
reasonAxis = slot.reasonAxis,
reasonText = slot.reasonText,
orderIndex = slot.orderIndex,
)
)
}
}
)

option.satisfactionByUser.forEach { sat ->
val user = userRepository.findById(sat.userId)
.orElseThrow { DomainException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다: ${sat.userId}") }
satisfactionScoreRepository.save(
satisfactionScoreRepository.saveAll(
option.satisfactionByUser.map { sat ->
val user = usersById[sat.userId]
?: throw DomainException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다: ${sat.userId}")
SatisfactionScore(
schedule = schedule,
user = user,
Expand All @@ -172,8 +178,8 @@ class ScheduleGenerationPersistenceService(
"byAxis" to sat.breakdown.byAxis.mapKeys { it.key.name.lowercase() },
),
)
)
}
}
)

SavedScheduleOption(
scheduleId = schedule.id,
Expand All @@ -183,6 +189,24 @@ class ScheduleGenerationPersistenceService(
}
return SavedScheduleGeneration(version = version, options = saved)
}
private fun loadActivePlacesById(placeIds: List<Long>): Map<Long, Place> {
val uniqueIds = placeIds.distinct()
if (uniqueIds.isEmpty()) return emptyMap()
val placesById = placeRepository.findAllById(uniqueIds)
.filter { it.delYn == YnFlag.N }
.associateBy { it.id }
if (placesById.size != uniqueIds.size) {
throw DomainException(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다.")
}
return placesById
}

private fun loadUsersById(userIds: List<Long>): Map<Long, User> {
val uniqueIds = userIds.distinct()
if (uniqueIds.isEmpty()) return emptyMap()
return userRepository.findAllById(uniqueIds).associateBy { it.id }
}

private fun ensureUniqueSlots(slots: List<ScheduleSlotDraft>, replacementCandidates: List<Place>): List<ScheduleSlotDraft> {
if (slots.isEmpty()) return slots

Expand All @@ -205,7 +229,11 @@ class ScheduleGenerationPersistenceService(
placeAddress = replacement.address,
isHiddenGem = isHiddenGem(replacement),
)
} ?: slot
} ?: throw DomainException(
HttpStatus.UNPROCESSABLE_ENTITY,
"INSUFFICIENT_UNIQUE_PLACES",
"중복 장소를 대체할 수 있는 후보가 부족합니다.",
)
} else {
slot
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ class ScheduleReadAssembler(
)
}

fun formatStoredSchedules(schedules: List<Schedule>): Map<Long, Map<String, Any?>> {
if (schedules.isEmpty()) return emptyMap()

val memberNicknamesByRoomId = schedules
.map { it.room.id }
.distinct()
.associateWith { roomId ->
roomMemberProfileRepository.findMemberNicknamesByRoomId(roomId, YnFlag.N)
.associate { it.getUserId() to it.getNickname() }
}
val scheduleIds = schedules.map { it.id }
val slotsByScheduleId = scheduleSlotRepository
.findAllByScheduleIdInAndDelYnOrderByScheduleIdAscOrderIndexAsc(scheduleIds, YnFlag.N)
.groupBy { it.schedule.id }
val scoresByScheduleId = satisfactionScoreRepository
.findAllByScheduleIdInAndDelYn(scheduleIds, YnFlag.N)
.groupBy { it.schedule.id }

val memberNicknamesByScheduleId = schedules.associate { schedule ->
schedule.id to memberNicknamesByRoomId[schedule.room.id].orEmpty()
}
return responseMapper.formatStoredSchedules(
schedules = schedules,
memberNicknamesByScheduleId = memberNicknamesByScheduleId,
slotsByScheduleId = slotsByScheduleId,
satisfactionScoresByScheduleId = scoresByScheduleId,
)
}

fun formatPublicShareSchedule(schedule: Schedule): Map<String, Any?> {
val details = loadDetails(schedule)
return responseMapper.formatPublicShareSchedule(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,47 @@ class ScheduleResponseMapper(
slots: List<ScheduleSlot>,
satisfactionScores: List<SatisfactionScore>,
): Map<String, Any?> {
val llmMetadata = formatLlmMetadata(schedule)
val activeSlots = slots.filter { it.delYn == YnFlag.N }.sortedBy { slot -> slot.orderIndex }
val metricsByPlaceId = loadMetricsByPlaceId(activeSlots.map { it.place.id })
return formatStoredSchedule(
schedule = schedule,
memberNicknames = memberNicknames,
activeSlots = activeSlots,
satisfactionScores = satisfactionScores,
metricsByPlaceId = loadMetricsByPlaceId(activeSlots.map { it.place.id }),
)
}

fun formatStoredSchedules(
schedules: List<Schedule>,
memberNicknamesByScheduleId: Map<Long, Map<Long, String>>,
slotsByScheduleId: Map<Long, List<ScheduleSlot>>,
satisfactionScoresByScheduleId: Map<Long, List<SatisfactionScore>>,
): Map<Long, Map<String, Any?>> {
val activeSlotsByScheduleId = schedules.associate { schedule ->
schedule.id to slotsByScheduleId[schedule.id].orEmpty()
.filter { it.delYn == YnFlag.N }
.sortedBy { slot -> slot.orderIndex }
}
val metricsByPlaceId = loadMetricsByPlaceId(activeSlotsByScheduleId.values.flatten().map { it.place.id })
return schedules.associate { schedule ->
schedule.id to formatStoredSchedule(
schedule = schedule,
memberNicknames = memberNicknamesByScheduleId[schedule.id].orEmpty(),
activeSlots = activeSlotsByScheduleId[schedule.id].orEmpty(),
satisfactionScores = satisfactionScoresByScheduleId[schedule.id].orEmpty(),
metricsByPlaceId = metricsByPlaceId,
)
}
}

private fun formatStoredSchedule(
schedule: Schedule,
memberNicknames: Map<Long, String>,
activeSlots: List<ScheduleSlot>,
satisfactionScores: List<SatisfactionScore>,
metricsByPlaceId: Map<Long, ExternalPopularityMetric>,
): Map<String, Any?> {
val llmMetadata = formatLlmMetadata(schedule)
return mapOf(
"id" to schedule.id,
"shareToken" to schedule.shareToken,
Expand Down
Loading
Loading