Skip to content

Commit 0914ea4

Browse files
authored
feat: AI 요약 및 영상 타임라인 기능 추가 (#53)
* feat: VideoTimeline 도메인 모델 추가 및 VideoAnalysisResult/Task에 summary, timeline 필드 추가 (#52) * feat: VideoTimelinePersistencePort 추가 및 기존 포트에 summary, timelines 반영 (#52) * feat: video_timeline 엔티티, Querydsl 레포지토리 및 어댑터 추가 (#52) * feat: 영상 분석 결과 저장 및 조회 시 summary, timeline 처리 추가 (#52) * refactor: Gemini 프롬프트 토큰 최적화 및 summary, timeline 추출 추가 (#52) * feat: 영상 조회 응답에 summary, timelines(timestamp, timestampUrl) 반환 추가 (#52) * test: 영상 타임라인 및 AI 요약 기능 테스트 보강 * feat: VideoAnalysisTask, VideoTimeline 캐싱 데코레이터 적용 (#52) * refactor: ktlintformat (#52) * test: 타임라인 두 번째 항목 description 검증 추가 (#52)
1 parent 2178b79 commit 0914ea4

25 files changed

Lines changed: 573 additions & 218 deletions

File tree

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisResult.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ data class VideoAnalysisResult(
44
val valid: Boolean,
55
val destination: String?,
66
val title: String?,
7+
val summary: String?,
78
val estimatedMinCost: Long?,
89
val estimatedMaxCost: Long?,
910
val costBasis: CostBasis?,
1011
val hashtags: List<String>,
1112
val days: List<DaySchedule>,
13+
val timeline: List<TimelineItem>,
1214
) {
1315
data class DaySchedule(
1416
val day: Int,
@@ -22,4 +24,9 @@ data class VideoAnalysisResult(
2224
val description: String?,
2325
val tips: String?,
2426
)
27+
28+
data class TimelineItem(
29+
val timestampSeconds: Int,
30+
val description: String,
31+
)
2532
}

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaver.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,35 @@ package com.linktrip.application.domain.video
33
import com.linktrip.application.port.output.persistence.HashtagPersistencePort
44
import com.linktrip.application.port.output.persistence.TravelItineraryItemPersistencePort
55
import com.linktrip.application.port.output.persistence.VideoAnalysisTaskPersistencePort
6+
import com.linktrip.application.port.output.persistence.VideoTimelinePersistencePort
67
import org.springframework.stereotype.Component
78
import org.springframework.transaction.annotation.Transactional
89

910
@Component
1011
class VideoAnalysisResultSaver(
1112
private val travelItineraryItemPersistencePort: TravelItineraryItemPersistencePort,
1213
private val videoAnalysisTaskPersistencePort: VideoAnalysisTaskPersistencePort,
14+
private val videoTimelinePersistencePort: VideoTimelinePersistencePort,
1315
private val hashtagPersistencePort: HashtagPersistencePort,
1416
) {
1517
@Transactional
1618
fun save(
1719
videoAnalysisTaskId: String,
1820
itineraryItems: List<TravelItineraryItem>,
21+
summary: String? = null,
1922
estimatedMinCost: Long? = null,
2023
estimatedMaxCost: Long? = null,
2124
costBasis: CostBasis? = null,
2225
hashtags: List<String> = emptyList(),
26+
timelines: List<VideoTimeline> = emptyList(),
2327
) {
2428
travelItineraryItemPersistencePort.saveAll(itineraryItems)
29+
videoTimelinePersistencePort.saveAll(timelines)
2530
videoAnalysisTaskPersistencePort.updateValidAndStatus(
2631
videoAnalysisTaskId,
2732
valid = true,
2833
VideoAnalysisTaskStatus.COMPLETED,
34+
summary = summary,
2935
estimatedMinCost = estimatedMinCost,
3036
estimatedMaxCost = estimatedMaxCost,
3137
costBasis = costBasis,

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ data class VideoAnalysisTask(
1010
val youtubeUrl: String,
1111
val valid: Boolean,
1212
val status: VideoAnalysisTaskStatus,
13+
val summary: String? = null,
1314
val estimatedMinCost: Long? = null,
1415
val estimatedMaxCost: Long? = null,
1516
val costBasis: CostBasis? = null,

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListener.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,16 @@ class VideoAnalyzeEventListener(
4848
destination = result.destination
4949
title = result.title
5050
val itineraryItems = toItineraryItems(event.videoAnalysisTaskId, result)
51+
val timelines = toTimelines(event.videoAnalysisTaskId, result)
5152
videoAnalysisResultSaver.save(
5253
event.videoAnalysisTaskId,
5354
itineraryItems,
55+
summary = result.summary,
5456
estimatedMinCost = result.estimatedMinCost,
5557
estimatedMaxCost = result.estimatedMaxCost,
5658
costBasis = result.costBasis,
5759
hashtags = result.hashtags,
60+
timelines = timelines,
5861
)
5962

6063
val analyzeElapsed = System.currentTimeMillis() - startTime
@@ -127,4 +130,16 @@ class VideoAnalyzeEventListener(
127130
TravelItineraryItem.from(videoAnalysisTaskId, daySchedule, item)
128131
}
129132
}
133+
134+
private fun toTimelines(
135+
videoAnalysisTaskId: String,
136+
result: VideoAnalysisResult,
137+
): List<VideoTimeline> =
138+
result.timeline.map { timelineItem ->
139+
VideoTimeline.create(
140+
videoAnalysisTaskId = videoAnalysisTaskId,
141+
timestampSeconds = timelineItem.timestampSeconds,
142+
description = timelineItem.description,
143+
)
144+
}
130145
}

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoScheduleService.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.linktrip.application.port.input.VideoScheduleResult
44
import com.linktrip.application.port.input.VideoScheduleUseCase
55
import com.linktrip.application.port.output.persistence.TravelItineraryItemPersistencePort
66
import com.linktrip.application.port.output.persistence.VideoAnalysisTaskPersistencePort
7+
import com.linktrip.application.port.output.persistence.VideoTimelinePersistencePort
78
import com.linktrip.common.exception.ExceptionCode
89
import com.linktrip.common.exception.LinktripException
910
import org.springframework.stereotype.Service
@@ -14,14 +15,16 @@ import org.springframework.transaction.annotation.Transactional
1415
class VideoScheduleService(
1516
private val videoAnalysisTaskPersistencePort: VideoAnalysisTaskPersistencePort,
1617
private val travelItineraryItemPersistencePort: TravelItineraryItemPersistencePort,
18+
private val videoTimelinePersistencePort: VideoTimelinePersistencePort,
1719
) : VideoScheduleUseCase {
1820
override fun getVideoSchedule(videoAnalysisTaskId: String): VideoScheduleResult {
1921
val videoAnalysisTask =
2022
videoAnalysisTaskPersistencePort.findById(videoAnalysisTaskId)
2123
?: throw LinktripException(ExceptionCode.NOT_FOUND_VIDEO_ANALYSIS_TASK)
2224

2325
val items = travelItineraryItemPersistencePort.findByVideoAnalysisTaskIdWithPlace(videoAnalysisTaskId)
26+
val timelines = videoTimelinePersistencePort.findByVideoAnalysisTaskId(videoAnalysisTaskId)
2427

25-
return VideoScheduleResult(videoAnalysisTask, items)
28+
return VideoScheduleResult(videoAnalysisTask, items, timelines)
2629
}
2730
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.linktrip.application.domain.video
2+
3+
import com.linktrip.application.domain.common.IdGenerator
4+
import java.time.LocalDateTime
5+
6+
data class VideoTimeline(
7+
val id: String,
8+
val videoAnalysisTaskId: String,
9+
val timestampSeconds: Int,
10+
val description: String,
11+
val createdAt: LocalDateTime = LocalDateTime.now(),
12+
val updatedAt: LocalDateTime = LocalDateTime.now(),
13+
) {
14+
companion object {
15+
fun create(
16+
videoAnalysisTaskId: String,
17+
timestampSeconds: Int,
18+
description: String,
19+
): VideoTimeline =
20+
VideoTimeline(
21+
id = IdGenerator.generate(),
22+
videoAnalysisTaskId = videoAnalysisTaskId,
23+
timestampSeconds = timestampSeconds,
24+
description = description,
25+
)
26+
}
27+
}

linktrip-application/src/main/kotlin/com/linktrip/application/port/input/VideoScheduleUseCase.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.linktrip.application.port.input
22

33
import com.linktrip.application.domain.video.TravelItineraryItem
44
import com.linktrip.application.domain.video.VideoAnalysisTask
5+
import com.linktrip.application.domain.video.VideoTimeline
56

67
interface VideoScheduleUseCase {
78
fun getVideoSchedule(videoAnalysisTaskId: String): VideoScheduleResult
@@ -10,4 +11,5 @@ interface VideoScheduleUseCase {
1011
data class VideoScheduleResult(
1112
val videoAnalysisTask: VideoAnalysisTask,
1213
val items: List<TravelItineraryItem>,
14+
val timelines: List<VideoTimeline>,
1315
)

linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/VideoAnalysisTaskPersistencePort.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface VideoAnalysisTaskPersistencePort {
2020
id: String,
2121
valid: Boolean,
2222
status: VideoAnalysisTaskStatus,
23+
summary: String? = null,
2324
estimatedMinCost: Long? = null,
2425
estimatedMaxCost: Long? = null,
2526
costBasis: CostBasis? = null,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.linktrip.application.port.output.persistence
2+
3+
import com.linktrip.application.domain.video.VideoTimeline
4+
5+
interface VideoTimelinePersistencePort {
6+
fun saveAll(timelines: List<VideoTimeline>)
7+
8+
fun findByVideoAnalysisTaskId(videoAnalysisTaskId: String): List<VideoTimeline>
9+
}

linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaverTest.kt

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ package com.linktrip.application.domain.video
33
import com.linktrip.application.port.output.persistence.HashtagPersistencePort
44
import com.linktrip.application.port.output.persistence.TravelItineraryItemPersistencePort
55
import com.linktrip.application.port.output.persistence.VideoAnalysisTaskPersistencePort
6+
import com.linktrip.application.port.output.persistence.VideoTimelinePersistencePort
67
import org.junit.jupiter.api.Test
78
import org.junit.jupiter.api.extension.ExtendWith
89
import org.mockito.InjectMocks
910
import org.mockito.Mock
1011
import org.mockito.junit.jupiter.MockitoExtension
12+
import org.mockito.kotlin.anyOrNull
13+
import org.mockito.kotlin.eq
1114
import org.mockito.kotlin.verify
1215

1316
@ExtendWith(MockitoExtension::class)
@@ -18,6 +21,9 @@ class VideoAnalysisResultSaverTest {
1821
@Mock
1922
lateinit var videoAnalysisTaskPersistencePort: VideoAnalysisTaskPersistencePort
2023

24+
@Mock
25+
lateinit var videoTimelinePersistencePort: VideoTimelinePersistencePort
26+
2127
@Mock
2228
lateinit var hashtagPersistencePort: HashtagPersistencePort
2329

@@ -31,7 +37,7 @@ class VideoAnalysisResultSaverTest {
3137
listOf(
3238
TravelItineraryItem(
3339
id = "item-1",
34-
videoAnalysisTaskId = "summary-1",
40+
videoAnalysisTaskId = "task-1",
3541
day = 1,
3642
itemOrder = 1,
3743
category = Category.EAT,
@@ -42,14 +48,43 @@ class VideoAnalysisResultSaverTest {
4248
)
4349

4450
// when - 분석 결과를 저장한다
45-
saver.save("summary-1", items)
51+
saver.save("task-1", items)
4652

4753
// then - 일정 항목이 저장되고, VideoAnalysisTask가 COMPLETED로 변경된다
4854
verify(travelItineraryItemPersistencePort).saveAll(items)
4955
verify(videoAnalysisTaskPersistencePort).updateValidAndStatus(
50-
"summary-1",
51-
valid = true,
52-
VideoAnalysisTaskStatus.COMPLETED,
56+
eq("task-1"),
57+
eq(true),
58+
eq(VideoAnalysisTaskStatus.COMPLETED),
59+
anyOrNull(),
60+
anyOrNull(),
61+
anyOrNull(),
62+
anyOrNull(),
5363
)
5464
}
65+
66+
@Test
67+
fun `타임라인이 있는 분석 결과를 저장하면_타임라인도 DB에 함께 저장된다`() {
68+
// given - 타임라인이 포함된 저장 요청
69+
val timelines =
70+
listOf(
71+
VideoTimeline.create("task-1", 0, "인트로"),
72+
VideoTimeline.create("task-1", 135, "시부야 스크램블 교차로"),
73+
)
74+
75+
// when - 타임라인과 함께 저장한다
76+
saver.save("task-1", emptyList(), timelines = timelines)
77+
78+
// then - 타임라인이 DB에 저장된다
79+
verify(videoTimelinePersistencePort).saveAll(timelines)
80+
}
81+
82+
@Test
83+
fun `타임라인이 없는 분석 결과를 저장하면_빈 리스트로 타임라인 저장이 호출된다`() {
84+
// when - 타임라인 없이 저장한다
85+
saver.save("task-1", emptyList())
86+
87+
// then - 빈 리스트로 타임라인 저장이 호출된다
88+
verify(videoTimelinePersistencePort).saveAll(emptyList())
89+
}
5590
}

0 commit comments

Comments
 (0)