From e29b977e30f8cafcceeef15c5f6ee9a98d8fa73d Mon Sep 17 00:00:00 2001 From: shane Date: Sun, 2 Mar 2025 14:21:49 +0900 Subject: [PATCH 1/7] refactor: spring boot 3.3-> 3.4 & openai dependency --- build.gradle.kts | 11 ++++++++++- .../dutypark/department/service/DepartmentService.kt | 3 +-- .../dutypark/duty/repository/DutyRepository.kt | 6 ++---- src/main/resources/application.yml | 9 +++++++++ .../department/service/DepartmentServiceTest.kt | 12 +++--------- .../dutypark/member/service/FriendServiceTest.kt | 2 ++ src/test/resources/application.yml | 10 ++++++++++ 7 files changed, 37 insertions(+), 16 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 45bf6134..67368ef8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ plugins { kotlin("plugin.spring") version "2.1.10" kotlin("plugin.jpa") version "2.1.10" id("io.spring.dependency-management") version "1.1.7" - id("org.springframework.boot") version "3.3.5" + id("org.springframework.boot") version "3.4.3" id("org.asciidoctor.jvm.convert") version "3.3.2" } @@ -20,6 +20,8 @@ repositories { mavenCentral() } +extra["springAiVersion"] = "1.0.0-M6" + val asciidoctorExt: Configuration by configurations.creating dependencies { // Kotlin @@ -36,6 +38,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-webflux") runtimeOnly("io.netty:netty-resolver-dns-native-macos:4.1.93.Final:osx-aarch_64") + implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter") // Monitoring implementation("io.micrometer:micrometer-registry-prometheus") @@ -68,6 +71,12 @@ dependencies { runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") } +dependencyManagement { + imports { + mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}") + } +} + tasks.withType { useJUnitPlatform() failFast = true diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/department/service/DepartmentService.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/department/service/DepartmentService.kt index 7430d2bf..858967cc 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/department/service/DepartmentService.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/department/service/DepartmentService.kt @@ -68,9 +68,8 @@ class DepartmentService( if (department.members.isNotEmpty()) { throw IllegalStateException("Department has members") } - val dutyTypes = department.dutyTypes - dutyRepository.setDutyTypeNullIfDutyTypeIn(dutyTypes) + dutyRepository.deleteAllByDutyTypeIn(department.dutyTypes) departmentRepository.deleteById(id) } diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/duty/repository/DutyRepository.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/duty/repository/DutyRepository.kt index e40c1213..5622d38f 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/duty/repository/DutyRepository.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/duty/repository/DutyRepository.kt @@ -20,12 +20,10 @@ interface DutyRepository : JpaRepository { @Query("update Duty d set d.dutyType = null where d.dutyType = :dutyType") fun setDutyTypeNullIfDutyTypeIs(dutyType: DutyType) - @Modifying - @Query("update Duty d set d.dutyType = null where d.dutyType in :dutyTypes") - fun setDutyTypeNullIfDutyTypeIn(dutyTypes: Iterable) - fun deleteDutiesByMemberAndDutyDateBetween(member: Member, start: LocalDate, end: LocalDate) fun findByDutyDateAndMemberIn(dutyDate: LocalDate, members: List): List + fun deleteAllByDutyTypeIn(dutyTypes: List) + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9fcdc9b6..5cbd33f3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,6 +21,15 @@ spring: devtools: restart: enabled: false + ai: + openai: + api-key: "API_KEY_HERE" + chat: + base-url: "https://generativelanguage.googleapis.com/v1beta/openai/" + options: + model: "gemini-2.0-flash-lite" + temperature: 0.0 + completions-path: "/chat/completions" server: port: 443 diff --git a/src/test/kotlin/com/tistory/shanepark/dutypark/department/service/DepartmentServiceTest.kt b/src/test/kotlin/com/tistory/shanepark/dutypark/department/service/DepartmentServiceTest.kt index 2bfadc5d..876da3b5 100644 --- a/src/test/kotlin/com/tistory/shanepark/dutypark/department/service/DepartmentServiceTest.kt +++ b/src/test/kotlin/com/tistory/shanepark/dutypark/department/service/DepartmentServiceTest.kt @@ -123,7 +123,7 @@ class DepartmentServiceTest : DutyparkIntegrationTest() { } @Test - fun `When Department is deleted, All related duties will have null dutyType`( + fun `When Department is deleted, All related duties will removed`( @Autowired dutyService: DutyService, @Autowired dutyRepository: DutyRepository ) { @@ -147,20 +147,14 @@ class DepartmentServiceTest : DutyparkIntegrationTest() { // When department.removeMember(member) - em.flush() - service.delete(department.id!!) // Then - em.flush() - em.clear() - assertThat(dutyTypeRepository.findById(dutyType1.id!!)).isEmpty assertThat(departmentRepository.findById(department.id!!)).isEmpty - val theDuty = dutyRepository.findById(duty?.id!!).orElseThrow() - assertThat(theDuty).isNotNull - assertThat(theDuty.dutyType).isNull() + val theDuty = dutyRepository.findById(duty?.id!!) + assertThat(theDuty).isEmpty } @Test diff --git a/src/test/kotlin/com/tistory/shanepark/dutypark/member/service/FriendServiceTest.kt b/src/test/kotlin/com/tistory/shanepark/dutypark/member/service/FriendServiceTest.kt index 17205f95..c243c443 100644 --- a/src/test/kotlin/com/tistory/shanepark/dutypark/member/service/FriendServiceTest.kt +++ b/src/test/kotlin/com/tistory/shanepark/dutypark/member/service/FriendServiceTest.kt @@ -236,6 +236,8 @@ class FriendServiceTest : DutyparkIntegrationTest() { assertThat(friendService.findAllFriends(member2)).isEmpty() setFriend(TestData.member, TestData.member2) + em.flush() + em.clear() // When val friends = friendService.findAllFriends(member1) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 06bba3a8..082b888b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -12,6 +12,16 @@ spring: flyway: enabled: false + ai: + openai: + api-key: "API_KEY_HERE" + chat: + base-url: "https://generativelanguage.googleapis.com/v1beta/openai/" + options: + model: "gemini-2.0-flash-lite" + temperature: 0.0 + completions-path: "/chat/completions" + decorator: datasource: p6spy: From 412d2a4ee26be6ee26f0705541559d34c7200f37 Mon Sep 17 00:00:00 2001 From: shane Date: Sun, 2 Mar 2025 15:37:56 +0900 Subject: [PATCH 2/7] feat: ScheduleTimeExtractionService --- .../shanepark/dutypark/DutyparkApplication.kt | 10 +++ .../domain/ScheduleTimeExtractionRequest.kt | 9 +++ .../domain/ScheduleTimeExtractionResponse.kt | 9 +++ .../service/ScheduleTimeExtractionService.kt | 70 +++++++++++++++++++ src/main/resources/application.yml | 2 +- .../dutypark/DutyparkIntegrationTest.kt | 6 +- .../tistory/shanepark/dutypark/TestUtils.kt | 17 +++++ .../ScheduleTimeExtractionServiceTest.kt | 57 +++++++++++++++ 8 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionRequest.kt create mode 100644 src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionResponse.kt create mode 100644 src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionService.kt create mode 100644 src/test/kotlin/com/tistory/shanepark/dutypark/TestUtils.kt create mode 100644 src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/DutyparkApplication.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/DutyparkApplication.kt index 85a7a171..b804e817 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/DutyparkApplication.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/DutyparkApplication.kt @@ -1,10 +1,13 @@ package com.tistory.shanepark.dutypark +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Bean import org.springframework.data.jpa.repository.config.EnableJpaAuditing import org.springframework.scheduling.annotation.EnableScheduling @@ -19,6 +22,13 @@ fun main(args: Array) { runApplication(*args) } +@Bean +fun objectMapper(): ObjectMapper { + return ObjectMapper().apply { + registerModule(JavaTimeModule()) + } +} + diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionRequest.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionRequest.kt new file mode 100644 index 00000000..342641ad --- /dev/null +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionRequest.kt @@ -0,0 +1,9 @@ +package com.tistory.shanepark.dutypark.schedule.timeextract.domain + +import java.time.LocalDate + +data class ScheduleTimeExtractionRequest( + val date: LocalDate, + val content: String, +) { +} diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionResponse.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionResponse.kt new file mode 100644 index 00000000..37d3885c --- /dev/null +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionResponse.kt @@ -0,0 +1,9 @@ +package com.tistory.shanepark.dutypark.schedule.timeextract.domain + +data class ScheduleTimeExtractionResponse( + val result: Boolean = false, + val hasTime: Boolean = false, + val dateTime: String? = null, + val content: String? = null, +) { +} diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionService.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionService.kt new file mode 100644 index 00000000..739e3ea0 --- /dev/null +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionService.kt @@ -0,0 +1,70 @@ +package com.tistory.shanepark.dutypark.schedule.timeextract.service + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.tistory.shanepark.dutypark.schedule.timeextract.domain.ScheduleTimeExtractionRequest +import com.tistory.shanepark.dutypark.schedule.timeextract.domain.ScheduleTimeExtractionResponse +import org.springframework.ai.chat.client.ChatClient +import org.springframework.ai.chat.model.ChatModel + +class ScheduleTimeExtractionService( + chatModel: ChatModel, + private val objectMapper: ObjectMapper +) { + private val chatClient = ChatClient.builder(chatModel).build() + private val log: org.slf4j.Logger = org.slf4j.LoggerFactory.getLogger(ScheduleTimeExtractionService::class.java) + + fun extractScheduleTime(request: ScheduleTimeExtractionRequest): ScheduleTimeExtractionResponse { + val prompt = generatePrompt(request) + val chatResponse = chatClient.prompt(prompt) + .call() + .chatResponse() + if (chatResponse == null) { + return ScheduleTimeExtractionResponse(result = false) + } + val chatAnswer = chatResponse.result.output.text + val response = parseChatAnswer(chatAnswer) + log.info("ScheduleTimeExtraction Request:\n $request \nResponse:\n $response") + return response + } + + private fun parseChatAnswer(chatAnswer: String): ScheduleTimeExtractionResponse { + val json = chatAnswer.lines() + .filter { !it.contains("```") } + .joinToString("\n") + return try { + objectMapper.readValue(json, ScheduleTimeExtractionResponse::class.java) + } catch (e: JsonProcessingException) { + log.warn("Failed to parse JSON: $json", e) + ScheduleTimeExtractionResponse(result = false) + } + } + + private fun generatePrompt(request: ScheduleTimeExtractionRequest): String { + val jsonRequest = objectMapper.writeValueAsString(request) + return """ + Task: Extract time from the text and return a JSON response. + + - Identify time and convert it to ISO 8601 (YYYY-MM-DDTHH:MM:SS). + - Remove the identified time from the text. The remaining text becomes `content`. + - If no time is found, return: + { "result": true, "hasTime": false} + - If multiple time exists, return: + { "result": false } + + Respond in JSON format only, with the following fields: + - result + - hasTime + - dateTime + - content + + No explanations. + + === + input: + + $jsonRequest + """.trimIndent() + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5cbd33f3..a0064628 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,7 +23,7 @@ spring: enabled: false ai: openai: - api-key: "API_KEY_HERE" + api-key: "GEMINI_API_KEY_HERE" chat: base-url: "https://generativelanguage.googleapis.com/v1beta/openai/" options: diff --git a/src/test/kotlin/com/tistory/shanepark/dutypark/DutyparkIntegrationTest.kt b/src/test/kotlin/com/tistory/shanepark/dutypark/DutyparkIntegrationTest.kt index 8ffab3f4..cf42947c 100644 --- a/src/test/kotlin/com/tistory/shanepark/dutypark/DutyparkIntegrationTest.kt +++ b/src/test/kotlin/com/tistory/shanepark/dutypark/DutyparkIntegrationTest.kt @@ -1,8 +1,6 @@ package com.tistory.shanepark.dutypark -import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.tistory.shanepark.dutypark.department.domain.entity.Department import com.tistory.shanepark.dutypark.department.repository.DepartmentRepository import com.tistory.shanepark.dutypark.duty.domain.entity.DutyType @@ -47,9 +45,7 @@ class DutyparkIntegrationTest { @Autowired lateinit var jwtProvider: JwtProvider - val objectMapper: ObjectMapper = ObjectMapper() - .setSerializationInclusion(JsonInclude.Include.NON_NULL) - .registerModule(JavaTimeModule()) + val objectMapper: ObjectMapper = TestUtils.jsr310ObjectMapper() @BeforeEach fun init() { diff --git a/src/test/kotlin/com/tistory/shanepark/dutypark/TestUtils.kt b/src/test/kotlin/com/tistory/shanepark/dutypark/TestUtils.kt new file mode 100644 index 00000000..e9ece17a --- /dev/null +++ b/src/test/kotlin/com/tistory/shanepark/dutypark/TestUtils.kt @@ -0,0 +1,17 @@ +package com.tistory.shanepark.dutypark + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule + +class TestUtils { + + companion object { + fun jsr310ObjectMapper(): ObjectMapper { + return ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .registerModule(JavaTimeModule()) + } + } + +} diff --git a/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt b/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt new file mode 100644 index 00000000..fbaec6d1 --- /dev/null +++ b/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt @@ -0,0 +1,57 @@ +package com.tistory.shanepark.dutypark.schedule.timeextract.service + +import com.tistory.shanepark.dutypark.TestUtils.Companion.jsr310ObjectMapper +import com.tistory.shanepark.dutypark.schedule.timeextract.domain.ScheduleTimeExtractionRequest +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.springframework.ai.openai.OpenAiChatModel +import org.springframework.ai.openai.OpenAiChatOptions +import org.springframework.ai.openai.api.OpenAiApi +import java.time.LocalDate + + +class ScheduleTimeExtractionServiceTest { + + @Test + @Disabled("External API test") + fun extractScheduleTime() { + val apiKey = "PUT_KEY_HERE_for_external_integration_test" + val openapi = OpenAiApi + .builder() + .apiKey(apiKey) + .baseUrl("https://generativelanguage.googleapis.com/v1beta/openai/") + .completionsPath("/chat/completions") + .build() + + val chatOption = OpenAiChatOptions + .builder() + .model("gemini-2.0-flash-lite") + .temperature(0.0) + .build() + + val chatModel = OpenAiChatModel + .builder() + .openAiApi(openapi) + .defaultOptions(chatOption) + .build() + + val service = ScheduleTimeExtractionService( + chatModel = chatModel, + objectMapper = jsr310ObjectMapper() + ) + + val request = ScheduleTimeExtractionRequest( + date = LocalDate.of(2025, 2, 28), + content = "친구들과 밤 11시에 만나기" + ) + val response = service.extractScheduleTime(request) + + Assertions.assertThat(response.result).isTrue() + Assertions.assertThat(response.hasTime).isTrue() + Assertions.assertThat(response.dateTime).isEqualTo("2025-02-28T23:00:00") + Assertions.assertThat(response.content).doesNotContain("11시") + } + + +} From 113d7f92f37dcaf98859c97900aa160eeaa83f1c Mon Sep 17 00:00:00 2001 From: shane Date: Sun, 2 Mar 2025 21:09:10 +0900 Subject: [PATCH 3/7] test: ScheduleTimeExtractionService --- .../ScheduleTimeExtractionServiceTest.kt | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt b/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt index fbaec6d1..87daa2f4 100644 --- a/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt +++ b/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt @@ -16,6 +16,7 @@ class ScheduleTimeExtractionServiceTest { @Test @Disabled("External API test") fun extractScheduleTime() { + // Given val apiKey = "PUT_KEY_HERE_for_external_integration_test" val openapi = OpenAiApi .builder() @@ -41,17 +42,52 @@ class ScheduleTimeExtractionServiceTest { objectMapper = jsr310ObjectMapper() ) - val request = ScheduleTimeExtractionRequest( - date = LocalDate.of(2025, 2, 28), - content = "친구들과 밤 11시에 만나기" + + // Then + val response = service.extractScheduleTime( + ScheduleTimeExtractionRequest( + date = LocalDate.of(2025, 2, 28), + content = "친구들과 밤 11시에 만나기" + ) ) - val response = service.extractScheduleTime(request) Assertions.assertThat(response.result).isTrue() Assertions.assertThat(response.hasTime).isTrue() Assertions.assertThat(response.dateTime).isEqualTo("2025-02-28T23:00:00") Assertions.assertThat(response.content).doesNotContain("11시") - } + val response2 = service.extractScheduleTime( + ScheduleTimeExtractionRequest( + date = LocalDate.of(2025, 2, 28), + content = "다섯시 저녁약속" + ) + ) + Assertions.assertThat(response2.result).isTrue() + Assertions.assertThat(response2.hasTime).isTrue() + Assertions.assertThat(response2.dateTime).isEqualTo("2025-02-28T17:00:00") + Assertions.assertThat(response2.content).doesNotContain("다섯시") + + val response3 = service.extractScheduleTime( + ScheduleTimeExtractionRequest( + date = LocalDate.of(2025, 2, 28), + content = "11:30세탁기설치" + ) + ) + Assertions.assertThat(response3.result).isTrue() + Assertions.assertThat(response3.hasTime).isTrue() + Assertions.assertThat(response3.dateTime).isEqualTo("2025-02-28T11:30:00") + Assertions.assertThat(response3.content).doesNotContain("11:30") + + val response4 = service.extractScheduleTime( + ScheduleTimeExtractionRequest( + date = LocalDate.of(2025, 2, 28), + content = "오사카 여행" + ) + ) + Assertions.assertThat(response4.result).isTrue() + Assertions.assertThat(response4.hasTime).isFalse() + Assertions.assertThat(response4.dateTime).isNull() + Assertions.assertThat(response4.content).isEqualTo("오사카 여행") + } } From 26f05e363bbfad0e30ba0f7e22a3bdc799fc244b Mon Sep 17 00:00:00 2001 From: shane Date: Sun, 2 Mar 2025 21:20:41 +0900 Subject: [PATCH 4/7] chores: time extraction -> time parsing --- .../domain/ScheduleTimeExtractionRequest.kt | 9 ------- .../domain/ScheduleTimeParsingRequest.kt | 8 ++++++ .../domain/ScheduleTimeParsingResponse.kt} | 7 +++-- .../service/ScheduleTimeParsingService.kt} | 24 ++++++++--------- .../ScheduleTimeParsingServiceTest.kt} | 26 +++++++++---------- 5 files changed, 36 insertions(+), 38 deletions(-) delete mode 100644 src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionRequest.kt create mode 100644 src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingRequest.kt rename src/main/kotlin/com/tistory/shanepark/dutypark/schedule/{timeextract/domain/ScheduleTimeExtractionResponse.kt => timeparsing/domain/ScheduleTimeParsingResponse.kt} (54%) rename src/main/kotlin/com/tistory/shanepark/dutypark/schedule/{timeextract/service/ScheduleTimeExtractionService.kt => timeparsing/service/ScheduleTimeParsingService.kt} (69%) rename src/test/kotlin/com/tistory/shanepark/dutypark/schedule/{timeextract/service/ScheduleTimeExtractionServiceTest.kt => timeparsing/service/ScheduleTimeParsingServiceTest.kt} (80%) diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionRequest.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionRequest.kt deleted file mode 100644 index 342641ad..00000000 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionRequest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tistory.shanepark.dutypark.schedule.timeextract.domain - -import java.time.LocalDate - -data class ScheduleTimeExtractionRequest( - val date: LocalDate, - val content: String, -) { -} diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingRequest.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingRequest.kt new file mode 100644 index 00000000..efd14ec6 --- /dev/null +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingRequest.kt @@ -0,0 +1,8 @@ +package com.tistory.shanepark.dutypark.schedule.timeparsing.domain + +import java.time.LocalDate + +data class ScheduleTimeParsingRequest( + val date: LocalDate, + val content: String, +) diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionResponse.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingResponse.kt similarity index 54% rename from src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionResponse.kt rename to src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingResponse.kt index 37d3885c..1ba07f15 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/domain/ScheduleTimeExtractionResponse.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingResponse.kt @@ -1,9 +1,8 @@ -package com.tistory.shanepark.dutypark.schedule.timeextract.domain +package com.tistory.shanepark.dutypark.schedule.timeparsing.domain -data class ScheduleTimeExtractionResponse( +data class ScheduleTimeParsingResponse( val result: Boolean = false, val hasTime: Boolean = false, val dateTime: String? = null, val content: String? = null, -) { -} +) diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionService.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt similarity index 69% rename from src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionService.kt rename to src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt index 739e3ea0..bd782fa7 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionService.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt @@ -1,46 +1,46 @@ -package com.tistory.shanepark.dutypark.schedule.timeextract.service +package com.tistory.shanepark.dutypark.schedule.timeparsing.service import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.databind.ObjectMapper -import com.tistory.shanepark.dutypark.schedule.timeextract.domain.ScheduleTimeExtractionRequest -import com.tistory.shanepark.dutypark.schedule.timeextract.domain.ScheduleTimeExtractionResponse +import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimeParsingRequest +import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimeParsingResponse import org.springframework.ai.chat.client.ChatClient import org.springframework.ai.chat.model.ChatModel -class ScheduleTimeExtractionService( +class ScheduleTimeParsingService( chatModel: ChatModel, private val objectMapper: ObjectMapper ) { private val chatClient = ChatClient.builder(chatModel).build() - private val log: org.slf4j.Logger = org.slf4j.LoggerFactory.getLogger(ScheduleTimeExtractionService::class.java) + private val log: org.slf4j.Logger = org.slf4j.LoggerFactory.getLogger(ScheduleTimeParsingService::class.java) - fun extractScheduleTime(request: ScheduleTimeExtractionRequest): ScheduleTimeExtractionResponse { + fun parseScheduleTime(request: ScheduleTimeParsingRequest): ScheduleTimeParsingResponse { val prompt = generatePrompt(request) val chatResponse = chatClient.prompt(prompt) .call() .chatResponse() if (chatResponse == null) { - return ScheduleTimeExtractionResponse(result = false) + return ScheduleTimeParsingResponse(result = false) } val chatAnswer = chatResponse.result.output.text val response = parseChatAnswer(chatAnswer) - log.info("ScheduleTimeExtraction Request:\n $request \nResponse:\n $response") + log.info("ScheduleTimeParsing Request:\n $request \nResponse:\n $response") return response } - private fun parseChatAnswer(chatAnswer: String): ScheduleTimeExtractionResponse { + private fun parseChatAnswer(chatAnswer: String): ScheduleTimeParsingResponse { val json = chatAnswer.lines() .filter { !it.contains("```") } .joinToString("\n") return try { - objectMapper.readValue(json, ScheduleTimeExtractionResponse::class.java) + objectMapper.readValue(json, ScheduleTimeParsingResponse::class.java) } catch (e: JsonProcessingException) { log.warn("Failed to parse JSON: $json", e) - ScheduleTimeExtractionResponse(result = false) + ScheduleTimeParsingResponse(result = false) } } - private fun generatePrompt(request: ScheduleTimeExtractionRequest): String { + private fun generatePrompt(request: ScheduleTimeParsingRequest): String { val jsonRequest = objectMapper.writeValueAsString(request) return """ Task: Extract time from the text and return a JSON response. diff --git a/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt b/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingServiceTest.kt similarity index 80% rename from src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt rename to src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingServiceTest.kt index 87daa2f4..3ee09db2 100644 --- a/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeextract/service/ScheduleTimeExtractionServiceTest.kt +++ b/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingServiceTest.kt @@ -1,7 +1,7 @@ -package com.tistory.shanepark.dutypark.schedule.timeextract.service +package com.tistory.shanepark.dutypark.schedule.timeparsing.service import com.tistory.shanepark.dutypark.TestUtils.Companion.jsr310ObjectMapper -import com.tistory.shanepark.dutypark.schedule.timeextract.domain.ScheduleTimeExtractionRequest +import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimeParsingRequest import org.assertj.core.api.Assertions import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test @@ -11,11 +11,11 @@ import org.springframework.ai.openai.api.OpenAiApi import java.time.LocalDate -class ScheduleTimeExtractionServiceTest { +class ScheduleTimeParsingServiceTest { @Test @Disabled("External API test") - fun extractScheduleTime() { + fun parseScheduleTime() { // Given val apiKey = "PUT_KEY_HERE_for_external_integration_test" val openapi = OpenAiApi @@ -37,15 +37,15 @@ class ScheduleTimeExtractionServiceTest { .defaultOptions(chatOption) .build() - val service = ScheduleTimeExtractionService( + val service = ScheduleTimeParsingService( chatModel = chatModel, objectMapper = jsr310ObjectMapper() ) // Then - val response = service.extractScheduleTime( - ScheduleTimeExtractionRequest( + val response = service.parseScheduleTime( + ScheduleTimeParsingRequest( date = LocalDate.of(2025, 2, 28), content = "친구들과 밤 11시에 만나기" ) @@ -56,8 +56,8 @@ class ScheduleTimeExtractionServiceTest { Assertions.assertThat(response.dateTime).isEqualTo("2025-02-28T23:00:00") Assertions.assertThat(response.content).doesNotContain("11시") - val response2 = service.extractScheduleTime( - ScheduleTimeExtractionRequest( + val response2 = service.parseScheduleTime( + ScheduleTimeParsingRequest( date = LocalDate.of(2025, 2, 28), content = "다섯시 저녁약속" ) @@ -67,8 +67,8 @@ class ScheduleTimeExtractionServiceTest { Assertions.assertThat(response2.dateTime).isEqualTo("2025-02-28T17:00:00") Assertions.assertThat(response2.content).doesNotContain("다섯시") - val response3 = service.extractScheduleTime( - ScheduleTimeExtractionRequest( + val response3 = service.parseScheduleTime( + ScheduleTimeParsingRequest( date = LocalDate.of(2025, 2, 28), content = "11:30세탁기설치" ) @@ -78,8 +78,8 @@ class ScheduleTimeExtractionServiceTest { Assertions.assertThat(response3.dateTime).isEqualTo("2025-02-28T11:30:00") Assertions.assertThat(response3.content).doesNotContain("11:30") - val response4 = service.extractScheduleTime( - ScheduleTimeExtractionRequest( + val response4 = service.parseScheduleTime( + ScheduleTimeParsingRequest( date = LocalDate.of(2025, 2, 28), content = "오사카 여행" ) From 6ea1aef3ef0582ec9a606cf677b6306002261ebd Mon Sep 17 00:00:00 2001 From: shane Date: Mon, 3 Mar 2025 00:00:24 +0900 Subject: [PATCH 5/7] feat: LLM time parsing worker --- .../common/domain/entity/BaseTimeEntity.kt | 4 +- .../common/domain/entity/EntityBase.kt | 13 ++ .../department/domain/dto/DepartmentDto.kt | 2 +- .../schedule/domain/entity/Schedule.kt | 16 ++- .../domain/enums/ParsingTimeStatus.kt | 9 ++ .../schedule/repository/ScheduleRepository.kt | 3 + .../schedule/service/ScheduleService.kt | 15 ++- .../domain/ScheduleTimeParsingTask.kt | 19 +++ .../ScheduleTimeParsingQueueManager.kt | 104 +++++++++++++++ .../service/ScheduleTimeParsingService.kt | 8 +- .../service/ScheduleTimeParsingWorker.kt | 99 +++++++++++++++ .../dutypark/todo/domain/entity/Todo.kt | 10 -- .../v2/V2.0.22__base_time_entities.sql | 17 +++ .../v2/V2.0.23__parsing_time_status.sql | 2 + ...V2.0.24__schedule_content_without_time.sql | 2 + .../common/domain/BaseTimeEntityTest.kt | 6 +- .../service/ScheduleTimeParsingServiceTest.kt | 118 +++++++++++------- 17 files changed, 378 insertions(+), 69 deletions(-) create mode 100644 src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/enums/ParsingTimeStatus.kt create mode 100644 src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingTask.kt create mode 100644 src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingQueueManager.kt create mode 100644 src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingWorker.kt create mode 100644 src/main/resources/db/migration/v2/V2.0.22__base_time_entities.sql create mode 100644 src/main/resources/db/migration/v2/V2.0.23__parsing_time_status.sql create mode 100644 src/main/resources/db/migration/v2/V2.0.24__schedule_content_without_time.sql diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/common/domain/entity/BaseTimeEntity.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/common/domain/entity/BaseTimeEntity.kt index 68f55cdf..61069736 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/common/domain/entity/BaseTimeEntity.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/common/domain/entity/BaseTimeEntity.kt @@ -14,10 +14,10 @@ abstract class BaseTimeEntity { @LastModifiedDate @Column(name = "modified_date", updatable = true) - var modifiedDate: LocalDateTime? = null + var lastModifiedDate: LocalDateTime = LocalDateTime.now() @CreatedDate @Column(name = "created_date", updatable = false) - var createdDate: LocalDateTime? = null + var createdDate: LocalDateTime = LocalDateTime.now() } diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/common/domain/entity/EntityBase.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/common/domain/entity/EntityBase.kt index 626a2fa7..2e755d31 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/common/domain/entity/EntityBase.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/common/domain/entity/EntityBase.kt @@ -5,11 +5,16 @@ import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode import org.hibernate.proxy.HibernateProxy import org.hibernate.type.SqlTypes +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.domain.Persistable +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime import java.util.* import kotlin.jvm.Transient @MappedSuperclass +@EntityListeners(AuditingEntityListener::class) class EntityBase : Persistable { @Id @@ -56,4 +61,12 @@ class EntityBase : Persistable { _isNew = false } + @LastModifiedDate + @Column(name = "modified_date", updatable = true) + var lastModifiedDate: LocalDateTime = LocalDateTime.now() + + @CreatedDate + @Column(name = "created_date", updatable = false) + var createdDate: LocalDateTime = LocalDateTime.now() + } diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/department/domain/dto/DepartmentDto.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/department/domain/dto/DepartmentDto.kt index 25fae385..3eb05a30 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/department/domain/dto/DepartmentDto.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/department/domain/dto/DepartmentDto.kt @@ -60,7 +60,7 @@ data class DepartmentDto( dutyTypes = sortedTypes, members = members.map { MemberDto.ofSimple(it) }, createdDate = department.createdDate.toString(), - lastModifiedDate = department.modifiedDate.toString(), + lastModifiedDate = department.lastModifiedDate.toString(), manager = department.manager?.name, dutyBatchTemplate = department.dutyBatchTemplate?.let { DutyBatchTemplateDto(it) } ) diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/entity/Schedule.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/entity/Schedule.kt index cbfd8ef9..2e8c4ebb 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/entity/Schedule.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/entity/Schedule.kt @@ -3,6 +3,7 @@ package com.tistory.shanepark.dutypark.schedule.domain.entity import com.tistory.shanepark.dutypark.common.domain.entity.EntityBase import com.tistory.shanepark.dutypark.member.domain.entity.Member import com.tistory.shanepark.dutypark.member.domain.enums.Visibility +import com.tistory.shanepark.dutypark.schedule.domain.enums.ParsingTimeStatus import jakarta.persistence.* import java.time.LocalDateTime @@ -29,9 +30,16 @@ class Schedule( @Column(name = "visibility", nullable = false) @Enumerated(EnumType.STRING) - var visibility: Visibility = Visibility.FRIENDS + var visibility: Visibility = Visibility.FRIENDS, -) : EntityBase() { + @Column(name = "parsing_time_status") + @Enumerated(EnumType.STRING) + var parsingTimeStatus: ParsingTimeStatus = ParsingTimeStatus.WAIT, + + ) : EntityBase() { + + @Column(name = "content_without_time") + var contentWithoutTime: String = "" @OneToMany(mappedBy = "schedule", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val tags: MutableList = mutableListOf() @@ -52,4 +60,8 @@ class Schedule( tags.remove(scheduleTag) } + fun hasTimeInfo(): Boolean { + return startDateTime.hour != 0 || startDateTime.minute != 0 + } + } diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/enums/ParsingTimeStatus.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/enums/ParsingTimeStatus.kt new file mode 100644 index 00000000..34e17f31 --- /dev/null +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/enums/ParsingTimeStatus.kt @@ -0,0 +1,9 @@ +package com.tistory.shanepark.dutypark.schedule.domain.enums + +enum class ParsingTimeStatus { + WAIT, + PARSED, + NO_TIME_INFO, + ALREADY_HAVE_TIME_INFO, + FAILED, +} diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/repository/ScheduleRepository.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/repository/ScheduleRepository.kt index 5cf2528c..eefb290d 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/repository/ScheduleRepository.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/repository/ScheduleRepository.kt @@ -3,6 +3,7 @@ package com.tistory.shanepark.dutypark.schedule.repository import com.tistory.shanepark.dutypark.member.domain.entity.Member import com.tistory.shanepark.dutypark.member.domain.enums.Visibility import com.tistory.shanepark.dutypark.schedule.domain.entity.Schedule +import com.tistory.shanepark.dutypark.schedule.domain.enums.ParsingTimeStatus import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository @@ -97,5 +98,7 @@ interface ScheduleRepository : JpaRepository { @Param("endOfDay") endOfDay: LocalDateTime = LocalDateTime.now().toLocalDate().atTime(23, 59, 59) ): List + fun findAllByParsingTimeStatus(parsingTimeStatus: ParsingTimeStatus): List + } diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/service/ScheduleService.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/service/ScheduleService.kt index 1715f777..b2b8454e 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/service/ScheduleService.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/service/ScheduleService.kt @@ -8,7 +8,9 @@ import com.tistory.shanepark.dutypark.member.service.FriendService import com.tistory.shanepark.dutypark.schedule.domain.dto.ScheduleDto import com.tistory.shanepark.dutypark.schedule.domain.dto.ScheduleUpdateDto import com.tistory.shanepark.dutypark.schedule.domain.entity.Schedule +import com.tistory.shanepark.dutypark.schedule.domain.enums.ParsingTimeStatus import com.tistory.shanepark.dutypark.schedule.repository.ScheduleRepository +import com.tistory.shanepark.dutypark.schedule.timeparsing.service.ScheduleTimeParsingQueueManager import com.tistory.shanepark.dutypark.security.domain.dto.LoginMember import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -21,7 +23,8 @@ import java.util.* class ScheduleService( private val scheduleRepository: ScheduleRepository, private val memberRepository: MemberRepository, - private val friendService: FriendService + private val friendService: FriendService, + private val scheduleTimeParsingQueueManager: ScheduleTimeParsingQueueManager, ) { private val log = org.slf4j.LoggerFactory.getLogger(this.javaClass) @@ -74,7 +77,6 @@ class ScheduleService( return array } - fun createSchedule(loginMember: LoginMember, scheduleUpdateDto: ScheduleUpdateDto): Schedule { val scheduleMember = memberRepository.findById(scheduleUpdateDto.memberId).orElseThrow() checkScheduleCreateAuthority(loginMember, scheduleMember) @@ -92,7 +94,9 @@ class ScheduleService( ) log.info("create schedule: $scheduleUpdateDto") - return scheduleRepository.save(schedule) + scheduleRepository.save(schedule) + scheduleTimeParsingQueueManager.addTask(schedule) + return schedule } private fun findNextPosition( @@ -109,9 +113,12 @@ class ScheduleService( schedule.content = scheduleUpdateDto.content schedule.description = scheduleUpdateDto.description schedule.visibility = scheduleUpdateDto.visibility + schedule.parsingTimeStatus = ParsingTimeStatus.WAIT log.info("update schedule: $scheduleUpdateDto") - return scheduleRepository.save(schedule) + scheduleRepository.save(schedule) + scheduleTimeParsingQueueManager.addTask(schedule) + return schedule } fun swapSchedulePosition(loginMember: LoginMember, schedule1Id: UUID, schedule2Id: UUID) { diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingTask.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingTask.kt new file mode 100644 index 00000000..b534d40f --- /dev/null +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingTask.kt @@ -0,0 +1,19 @@ +package com.tistory.shanepark.dutypark.schedule.timeparsing.domain + +import com.tistory.shanepark.dutypark.schedule.domain.entity.Schedule +import java.time.LocalDateTime +import java.util.* + +data class ScheduleTimeParsingTask( + val scheduleId: UUID, +) { + private val requestDateTime: LocalDateTime = LocalDateTime.now() + + fun isExpired(schedule: Schedule): Boolean { + if (schedule.id != scheduleId) { + throw IllegalArgumentException("Schedule ID does not match: ${schedule.id} != $scheduleId") + } + return schedule.lastModifiedDate.isAfter(requestDateTime) + } +} + diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingQueueManager.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingQueueManager.kt new file mode 100644 index 00000000..152a8d71 --- /dev/null +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingQueueManager.kt @@ -0,0 +1,104 @@ +package com.tistory.shanepark.dutypark.schedule.timeparsing.service + +import com.tistory.shanepark.dutypark.schedule.domain.entity.Schedule +import com.tistory.shanepark.dutypark.schedule.domain.enums.ParsingTimeStatus +import com.tistory.shanepark.dutypark.schedule.repository.ScheduleRepository +import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimeParsingTask +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +@Component +class ScheduleTimeParsingQueueManager( + private val worker: ScheduleTimeParsingWorker, + private val scheduleRepository: ScheduleRepository, +) { + private val log = LoggerFactory.getLogger(ScheduleTimeParsingQueueManager::class.java) + private val executorService = Executors.newSingleThreadExecutor() + private val queue = ConcurrentLinkedQueue() + private val isRunning = AtomicBoolean(false) + + private val completedTasks: Queue = ConcurrentLinkedQueue() + private val completedDailyTasks: Queue = ConcurrentLinkedQueue() + + private val rpmLimit = 30 + private val rpdLimit = 1500 + + @PostConstruct + fun init() { + val allWaitJobs = scheduleRepository.findAllByParsingTimeStatus(ParsingTimeStatus.WAIT) + allWaitJobs.forEach { schedule -> addTask(schedule) } + log.info("${allWaitJobs.size} schedules are added to the queue.") + } + + fun addTask(schedule: Schedule) { + queue.add(ScheduleTimeParsingTask(schedule.id)) + startWorkIfNeeded() + } + + private fun startWorkIfNeeded() { + if (queue.isEmpty() || !isRunning.compareAndSet(false, true)) return + + executorService.execute { + try { + // wait enough til schedule transaction is committed + TimeUnit.SECONDS.sleep(10) + run() + } finally { + isRunning.set(false) + } + } + } + + private fun run() { + while (queue.isNotEmpty()) { + while (shouldWait()) { + log.info("Waiting for API rate limit...") + TimeUnit.MINUTES.sleep(1) + } + + val task = queue.poll() ?: continue + worker.run(task) + recordCompletion() + } + } + + private fun shouldWait(): Boolean { + val now = LocalDateTime.now() + + completedTasks.peek()?.let { + if (completedTasks.size >= rpmLimit && it.isAfter(now.minusMinutes(1))) { + return true + } + } + + completedDailyTasks.peek()?.let { + if (completedDailyTasks.size >= rpdLimit && it.isAfter(now.minusDays(1))) { + return true + } + } + + return false + } + + private fun recordCompletion() { + val now = LocalDateTime.now() + + completedTasks.add(now) + while (completedTasks.size > rpmLimit) { + completedTasks.poll() + } + + completedDailyTasks.add(now) + while (completedDailyTasks.size > rpdLimit) { + completedDailyTasks.poll() + } + } + +} diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt index bd782fa7..e7c542c5 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt @@ -6,10 +6,12 @@ import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimePa import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimeParsingResponse import org.springframework.ai.chat.client.ChatClient import org.springframework.ai.chat.model.ChatModel +import org.springframework.stereotype.Service +@Service class ScheduleTimeParsingService( chatModel: ChatModel, - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, ) { private val chatClient = ChatClient.builder(chatModel).build() private val log: org.slf4j.Logger = org.slf4j.LoggerFactory.getLogger(ScheduleTimeParsingService::class.java) @@ -24,7 +26,7 @@ class ScheduleTimeParsingService( } val chatAnswer = chatResponse.result.output.text val response = parseChatAnswer(chatAnswer) - log.info("ScheduleTimeParsing Request:\n $request \nResponse:\n $response") + log.info("ScheduleTimeParsing==\n $request \nResponse:\n $response\n") return response } @@ -35,7 +37,7 @@ class ScheduleTimeParsingService( return try { objectMapper.readValue(json, ScheduleTimeParsingResponse::class.java) } catch (e: JsonProcessingException) { - log.warn("Failed to parse JSON: $json", e) + log.warn("Failed to parse JSON:\n$json", e) ScheduleTimeParsingResponse(result = false) } } diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingWorker.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingWorker.kt new file mode 100644 index 00000000..2b4d43f4 --- /dev/null +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingWorker.kt @@ -0,0 +1,99 @@ +package com.tistory.shanepark.dutypark.schedule.timeparsing.service + +import com.tistory.shanepark.dutypark.schedule.domain.entity.Schedule +import com.tistory.shanepark.dutypark.schedule.domain.enums.ParsingTimeStatus.* +import com.tistory.shanepark.dutypark.schedule.repository.ScheduleRepository +import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimeParsingRequest +import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimeParsingResponse +import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimeParsingTask +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeParseException + +/** + * Do not use @Transaction dirty checking since it will be on another thread. + */ +@Service +class ScheduleTimeParsingWorker( + private val scheduleTimeParsingService: ScheduleTimeParsingService, + private val scheduleRepository: ScheduleRepository, +) { + private val log: Logger = LoggerFactory.getLogger(ScheduleTimeParsingWorker::class.java) + + fun run(task: ScheduleTimeParsingTask) { + val scheduleOption = scheduleRepository.findById(task.scheduleId) + if (scheduleOption.isEmpty) { + log.info("Schedule not found. maybe already deleted. scheduleId: ${task.scheduleId}") + return + } + val schedule = scheduleOption.get() + if (task.isExpired(schedule)) { + log.info("Schedule is updated, skip parsing. scheduleId: ${task.scheduleId}") + return + } + + if (alreadyHaveTimeInfo(schedule)) return + + val request = ScheduleTimeParsingRequest( + date = LocalDate.of( + schedule.startDateTime.year, + schedule.startDateTime.monthValue, + schedule.startDateTime.dayOfMonth, + ), + content = schedule.content + ) + val response = scheduleTimeParsingService.parseScheduleTime(request) + + if (responseFail(response, schedule)) return + if (haveNoTimeInfo(response, schedule)) return + + try { + val parsed: LocalDateTime = LocalDateTime.parse(response.dateTime.toString()) + schedule.parsingTimeStatus = PARSED + schedule.startDateTime = parsed + schedule.contentWithoutTime = response.content ?: "" + scheduleRepository.save(schedule) + } catch (e: DateTimeParseException) { + log.warn("Failed to parse dateTime: $response") + schedule.parsingTimeStatus = FAILED + scheduleRepository.save(schedule) + } + } + + private fun haveNoTimeInfo( + response: ScheduleTimeParsingResponse, + schedule: Schedule + ): Boolean { + if (response.hasTime) { + return false + } + schedule.parsingTimeStatus = NO_TIME_INFO + scheduleRepository.save(schedule) + return true + } + + private fun responseFail( + response: ScheduleTimeParsingResponse, + schedule: Schedule + ): Boolean { + if (response.result) { + return false + } + schedule.parsingTimeStatus = FAILED + scheduleRepository.save(schedule) + return true + } + + private fun alreadyHaveTimeInfo(schedule: Schedule): Boolean { + if (schedule.hasTimeInfo()) { + schedule.parsingTimeStatus = ALREADY_HAVE_TIME_INFO + scheduleRepository.save(schedule) + return true + } + return false + } + +} diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/todo/domain/entity/Todo.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/todo/domain/entity/Todo.kt index 6bf9f40d..32d541ff 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/todo/domain/entity/Todo.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/todo/domain/entity/Todo.kt @@ -3,11 +3,8 @@ package com.tistory.shanepark.dutypark.todo.domain.entity import com.tistory.shanepark.dutypark.common.domain.entity.EntityBase import com.tistory.shanepark.dutypark.member.domain.entity.Member import jakarta.persistence.* -import org.springframework.data.jpa.domain.support.AuditingEntityListener -import java.time.LocalDateTime @Entity -@EntityListeners(AuditingEntityListener::class) class Todo( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) @@ -27,13 +24,6 @@ class Todo( fun update(title: String, content: String) { this.title = title this.content = content - this.modifiedDate = LocalDateTime.now() } - @Column(name = "modified_date", updatable = true) - var modifiedDate: LocalDateTime = LocalDateTime.now() - - @Column(name = "created_date", updatable = false) - val createdDate: LocalDateTime = LocalDateTime.now() - } diff --git a/src/main/resources/db/migration/v2/V2.0.22__base_time_entities.sql b/src/main/resources/db/migration/v2/V2.0.22__base_time_entities.sql new file mode 100644 index 00000000..ec0e833a --- /dev/null +++ b/src/main/resources/db/migration/v2/V2.0.22__base_time_entities.sql @@ -0,0 +1,17 @@ +alter table holiday + add column created_date datetime, + add column modified_date datetime; + +update holiday +set created_date = now(), + modified_date = now() +where created_date is null; + +alter table schedule + add column created_date datetime, + add column modified_date datetime; + +update schedule +set created_date = now(), + modified_date = now() +where created_date is null; diff --git a/src/main/resources/db/migration/v2/V2.0.23__parsing_time_status.sql b/src/main/resources/db/migration/v2/V2.0.23__parsing_time_status.sql new file mode 100644 index 00000000..4692ecb1 --- /dev/null +++ b/src/main/resources/db/migration/v2/V2.0.23__parsing_time_status.sql @@ -0,0 +1,2 @@ +alter table schedule + add column parsing_time_status varchar(255) default 'WAIT'; diff --git a/src/main/resources/db/migration/v2/V2.0.24__schedule_content_without_time.sql b/src/main/resources/db/migration/v2/V2.0.24__schedule_content_without_time.sql new file mode 100644 index 00000000..7fa81dda --- /dev/null +++ b/src/main/resources/db/migration/v2/V2.0.24__schedule_content_without_time.sql @@ -0,0 +1,2 @@ +alter table schedule + add column content_without_time varchar(50) default '' diff --git a/src/test/kotlin/com/tistory/shanepark/dutypark/common/domain/BaseTimeEntityTest.kt b/src/test/kotlin/com/tistory/shanepark/dutypark/common/domain/BaseTimeEntityTest.kt index 99f86a67..22e9d387 100644 --- a/src/test/kotlin/com/tistory/shanepark/dutypark/common/domain/BaseTimeEntityTest.kt +++ b/src/test/kotlin/com/tistory/shanepark/dutypark/common/domain/BaseTimeEntityTest.kt @@ -21,8 +21,8 @@ class BaseTimeEntityTest : DutyparkIntegrationTest() { refreshTokenRepository.save(refreshToken) assertThat(refreshToken.createdDate).isNotNull - assertThat(refreshToken.modifiedDate).isNotNull - assertThat(refreshToken.createdDate).isSameAs(refreshToken.modifiedDate) + assertThat(refreshToken.lastModifiedDate).isNotNull + assertThat(refreshToken.createdDate).isSameAs(refreshToken.lastModifiedDate) // When refreshToken.validUntil = LocalDateTime.now() @@ -30,7 +30,7 @@ class BaseTimeEntityTest : DutyparkIntegrationTest() { // Then val saved = refreshTokenRepository.save(refreshToken) - assertThat(saved.modifiedDate).isAfter(refreshToken.createdDate) + assertThat(saved.lastModifiedDate).isAfter(refreshToken.createdDate) } } diff --git a/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingServiceTest.kt b/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingServiceTest.kt index 3ee09db2..6e377127 100644 --- a/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingServiceTest.kt +++ b/src/test/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingServiceTest.kt @@ -13,37 +13,12 @@ import java.time.LocalDate class ScheduleTimeParsingServiceTest { + private val apiKey = "PUT_KEY_HERE_for_external_integration_test" + val service = makeService() + @Test @Disabled("External API test") - fun parseScheduleTime() { - // Given - val apiKey = "PUT_KEY_HERE_for_external_integration_test" - val openapi = OpenAiApi - .builder() - .apiKey(apiKey) - .baseUrl("https://generativelanguage.googleapis.com/v1beta/openai/") - .completionsPath("/chat/completions") - .build() - - val chatOption = OpenAiChatOptions - .builder() - .model("gemini-2.0-flash-lite") - .temperature(0.0) - .build() - - val chatModel = OpenAiChatModel - .builder() - .openAiApi(openapi) - .defaultOptions(chatOption) - .build() - - val service = ScheduleTimeParsingService( - chatModel = chatModel, - objectMapper = jsr310ObjectMapper() - ) - - - // Then + fun parseScheduleTime1() { val response = service.parseScheduleTime( ScheduleTimeParsingRequest( date = LocalDate.of(2025, 2, 28), @@ -55,39 +30,94 @@ class ScheduleTimeParsingServiceTest { Assertions.assertThat(response.hasTime).isTrue() Assertions.assertThat(response.dateTime).isEqualTo("2025-02-28T23:00:00") Assertions.assertThat(response.content).doesNotContain("11시") + } - val response2 = service.parseScheduleTime( + @Test + @Disabled("External API test") + fun parseScheduleTime2() { + val service = makeService() + val response = service.parseScheduleTime( ScheduleTimeParsingRequest( date = LocalDate.of(2025, 2, 28), content = "다섯시 저녁약속" ) ) - Assertions.assertThat(response2.result).isTrue() - Assertions.assertThat(response2.hasTime).isTrue() - Assertions.assertThat(response2.dateTime).isEqualTo("2025-02-28T17:00:00") - Assertions.assertThat(response2.content).doesNotContain("다섯시") + Assertions.assertThat(response.result).isTrue() + Assertions.assertThat(response.hasTime).isTrue() + Assertions.assertThat(response.dateTime).isEqualTo("2025-02-28T17:00:00") + Assertions.assertThat(response.content).doesNotContain("다섯시") + } - val response3 = service.parseScheduleTime( + @Test + @Disabled("External API test") + fun parseScheduleTime3() { + val response = service.parseScheduleTime( ScheduleTimeParsingRequest( date = LocalDate.of(2025, 2, 28), content = "11:30세탁기설치" ) ) - Assertions.assertThat(response3.result).isTrue() - Assertions.assertThat(response3.hasTime).isTrue() - Assertions.assertThat(response3.dateTime).isEqualTo("2025-02-28T11:30:00") - Assertions.assertThat(response3.content).doesNotContain("11:30") + Assertions.assertThat(response.result).isTrue() + Assertions.assertThat(response.hasTime).isTrue() + Assertions.assertThat(response.dateTime).isEqualTo("2025-02-28T11:30:00") + Assertions.assertThat(response.content).doesNotContain("11:30") + } - val response4 = service.parseScheduleTime( + @Test + @Disabled("External API test") + fun parseScheduleTime4() { + val response = service.parseScheduleTime( ScheduleTimeParsingRequest( date = LocalDate.of(2025, 2, 28), content = "오사카 여행" ) ) - Assertions.assertThat(response4.result).isTrue() - Assertions.assertThat(response4.hasTime).isFalse() - Assertions.assertThat(response4.dateTime).isNull() - Assertions.assertThat(response4.content).isEqualTo("오사카 여행") + Assertions.assertThat(response.result).isTrue() + Assertions.assertThat(response.hasTime).isFalse() + Assertions.assertThat(response.dateTime).isNull() + Assertions.assertThat(response.content).isEqualTo("오사카 여행") + } + + @Test + @Disabled("External API test") + fun parseScheduleTime5() { + val response = service.parseScheduleTime( + ScheduleTimeParsingRequest( + date = LocalDate.of(2025, 2, 28), + content = "테니스 2시~4시" + ) + ) + Assertions.assertThat(response.result).isTrue() + Assertions.assertThat(response.hasTime).isFalse() + Assertions.assertThat(response.dateTime).isNull() + Assertions.assertThat(response.content).isEqualTo("2시") + } + + private fun makeService(): ScheduleTimeParsingService { + val openapi = OpenAiApi + .builder() + .apiKey(apiKey) + .baseUrl("https://generativelanguage.googleapis.com/v1beta/openai/") + .completionsPath("/chat/completions") + .build() + + val chatOption = OpenAiChatOptions + .builder() + .model("gemini-2.0-flash-lite") + .temperature(0.0) + .build() + + val chatModel = OpenAiChatModel + .builder() + .openAiApi(openapi) + .defaultOptions(chatOption) + .build() + + val service = ScheduleTimeParsingService( + chatModel = chatModel, + objectMapper = jsr310ObjectMapper() + ) + return service } } From f2919d3660600286c0fb6be9558c551ca75c4614 Mon Sep 17 00:00:00 2001 From: shane Date: Mon, 3 Mar 2025 11:36:06 +0900 Subject: [PATCH 6/7] feat: LLM time parsing supports time range --- dutypark_secret | 2 +- .../schedule/domain/dto/ScheduleDto.kt | 4 +- .../schedule/domain/entity/Schedule.kt | 4 + .../domain/enums/ParsingTimeStatus.kt | 1 + .../domain/ScheduleTimeParsingResponse.kt | 3 +- .../domain/ScheduleTimeParsingTask.kt | 5 +- .../ScheduleTimeParsingQueueManager.kt | 6 +- .../service/ScheduleTimeParsingService.kt | 14 +- .../service/ScheduleTimeParsingWorker.kt | 10 +- .../v2/V2.0.25__skip_parse_old_schedules.sql | 3 + src/main/resources/static/css/base.css | 26 +-- src/main/resources/templates/duty/duty.html | 156 +++++++++++------- .../resources/templates/layout/layout.html | 11 +- .../service/ScheduleTimeParsingServiceTest.kt | 64 +++++-- 14 files changed, 202 insertions(+), 107 deletions(-) create mode 100644 src/main/resources/db/migration/v2/V2.0.25__skip_parse_old_schedules.sql diff --git a/dutypark_secret b/dutypark_secret index 5a71c824..fd28022c 160000 --- a/dutypark_secret +++ b/dutypark_secret @@ -1 +1 @@ -Subproject commit 5a71c824ffa2b8b3820be68e0ca7389b124cafb0 +Subproject commit fd28022cf9dcfc39856ea8e71d90eee2c6081506 diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/dto/ScheduleDto.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/dto/ScheduleDto.kt index 4110d884..320e3cba 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/dto/ScheduleDto.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/dto/ScheduleDto.kt @@ -32,7 +32,7 @@ data class ScheduleDto( fun ofSimple(member: Member, schedule: Schedule): ScheduleDto { return ScheduleDto( id = schedule.id, - content = schedule.content, + content = schedule.content(), position = schedule.position, year = schedule.startDateTime.year, month = schedule.startDateTime.monthValue, @@ -57,7 +57,7 @@ data class ScheduleDto( return validDays.map { ScheduleDto( id = schedule.id, - content = schedule.content, + content = schedule.content(), description = schedule.description, position = schedule.position, year = it.year, diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/entity/Schedule.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/entity/Schedule.kt index 2e8c4ebb..334991b8 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/entity/Schedule.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/entity/Schedule.kt @@ -64,4 +64,8 @@ class Schedule( return startDateTime.hour != 0 || startDateTime.minute != 0 } + fun content(): String { + return contentWithoutTime.ifBlank { content } + } + } diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/enums/ParsingTimeStatus.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/enums/ParsingTimeStatus.kt index 34e17f31..9d07c5cd 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/enums/ParsingTimeStatus.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/domain/enums/ParsingTimeStatus.kt @@ -2,6 +2,7 @@ package com.tistory.shanepark.dutypark.schedule.domain.enums enum class ParsingTimeStatus { WAIT, + SKIP, PARSED, NO_TIME_INFO, ALREADY_HAVE_TIME_INFO, diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingResponse.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingResponse.kt index 1ba07f15..0a7f7569 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingResponse.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingResponse.kt @@ -3,6 +3,7 @@ package com.tistory.shanepark.dutypark.schedule.timeparsing.domain data class ScheduleTimeParsingResponse( val result: Boolean = false, val hasTime: Boolean = false, - val dateTime: String? = null, + val startDateTime: String? = null, + val endDateTime: String? = null, val content: String? = null, ) diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingTask.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingTask.kt index b534d40f..b6f08f8c 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingTask.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/domain/ScheduleTimeParsingTask.kt @@ -13,7 +13,10 @@ data class ScheduleTimeParsingTask( if (schedule.id != scheduleId) { throw IllegalArgumentException("Schedule ID does not match: ${schedule.id} != $scheduleId") } - return schedule.lastModifiedDate.isAfter(requestDateTime) + return schedule.lastModifiedDate + .minusSeconds(1) + .isAfter(requestDateTime) } + } diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingQueueManager.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingQueueManager.kt index 152a8d71..0d9b97c8 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingQueueManager.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingQueueManager.kt @@ -1,7 +1,7 @@ package com.tistory.shanepark.dutypark.schedule.timeparsing.service import com.tistory.shanepark.dutypark.schedule.domain.entity.Schedule -import com.tistory.shanepark.dutypark.schedule.domain.enums.ParsingTimeStatus +import com.tistory.shanepark.dutypark.schedule.domain.enums.ParsingTimeStatus.WAIT import com.tistory.shanepark.dutypark.schedule.repository.ScheduleRepository import com.tistory.shanepark.dutypark.schedule.timeparsing.domain.ScheduleTimeParsingTask import jakarta.annotation.PostConstruct @@ -32,12 +32,14 @@ class ScheduleTimeParsingQueueManager( @PostConstruct fun init() { - val allWaitJobs = scheduleRepository.findAllByParsingTimeStatus(ParsingTimeStatus.WAIT) + val allWaitJobs = scheduleRepository.findAllByParsingTimeStatus(WAIT) allWaitJobs.forEach { schedule -> addTask(schedule) } log.info("${allWaitJobs.size} schedules are added to the queue.") } fun addTask(schedule: Schedule) { + if (schedule.parsingTimeStatus != WAIT) + return queue.add(ScheduleTimeParsingTask(schedule.id)) startWorkIfNeeded() } diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt index e7c542c5..a8df39c4 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingService.kt @@ -48,18 +48,22 @@ class ScheduleTimeParsingService( Task: Extract time from the text and return a JSON response. - Identify time and convert it to ISO 8601 (YYYY-MM-DDTHH:MM:SS). + - If a time range is mentioned (e.g., "2시~4시"), assign the first time to `startDateTime` and the second time to `endDateTime`. + - If only one time is mentioned, set both `startDateTime` and `endDateTime` to the same value. - Remove the identified time from the text. The remaining text becomes `content`. + - if AM/PM is not specified, assume based on the content. - If no time is found, return: - { "result": true, "hasTime": false} - - If multiple time exists, return: + { "result": true, "hasTime": false } + - If more than two time exist, return: { "result": false } - + Respond in JSON format only, with the following fields: - result - hasTime - - dateTime + - startDateTime + - endDateTime - content - + No explanations. === diff --git a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingWorker.kt b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingWorker.kt index 2b4d43f4..9b8ee6d6 100644 --- a/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingWorker.kt +++ b/src/main/kotlin/com/tistory/shanepark/dutypark/schedule/timeparsing/service/ScheduleTimeParsingWorker.kt @@ -14,7 +14,7 @@ import java.time.LocalDateTime import java.time.format.DateTimeParseException /** - * Do not use @Transaction dirty checking since it will be on another thread. + * Do not use @Transaction dirty checking since it will be working on another thread. */ @Service class ScheduleTimeParsingWorker( @@ -51,9 +51,11 @@ class ScheduleTimeParsingWorker( if (haveNoTimeInfo(response, schedule)) return try { - val parsed: LocalDateTime = LocalDateTime.parse(response.dateTime.toString()) + val parsedStart = LocalDateTime.parse(response.startDateTime.toString()) + val parsedEnd = LocalDateTime.parse(response.endDateTime.toString()) schedule.parsingTimeStatus = PARSED - schedule.startDateTime = parsed + schedule.startDateTime = parsedStart + schedule.endDateTime = parsedEnd schedule.contentWithoutTime = response.content ?: "" scheduleRepository.save(schedule) } catch (e: DateTimeParseException) { @@ -88,7 +90,7 @@ class ScheduleTimeParsingWorker( } private fun alreadyHaveTimeInfo(schedule: Schedule): Boolean { - if (schedule.hasTimeInfo()) { + if (schedule.hasTimeInfo() || schedule.startDateTime != schedule.endDateTime) { schedule.parsingTimeStatus = ALREADY_HAVE_TIME_INFO scheduleRepository.save(schedule) return true diff --git a/src/main/resources/db/migration/v2/V2.0.25__skip_parse_old_schedules.sql b/src/main/resources/db/migration/v2/V2.0.25__skip_parse_old_schedules.sql new file mode 100644 index 00000000..9575d2a8 --- /dev/null +++ b/src/main/resources/db/migration/v2/V2.0.25__skip_parse_old_schedules.sql @@ -0,0 +1,3 @@ +update schedule +set parsing_time_status = 'SKIP' +where start_date_time < '2025-01-01 00:00:00'; diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css index de5ccd28..86886619 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -413,18 +413,22 @@ a.homeButton:hover { margin: 10px; } -#detail-view-modal .schedules .schedule.visibility-private { - background-color: mistyrose; -} - -#detail-view-modal .schedules .schedule.visibility-public { +#detail-view-modal .schedules .schedule.visibility-PUBLIC { background-color: honeydew; } -#detail-view-modal .schedules .schedule.visibility-friends { +#detail-view-modal .schedules .schedule.visibility-FRIENDS { background-color: aliceblue; } +#detail-view-modal .schedules .schedule.visibility-FAMILY { + background-color: moccasin; +} + +#detail-view-modal .schedules .schedule.visibility-PRIVATE { + background-color: mistyrose; +} + .schedules .schedule .schedule-content { margin: 0; padding: 0; @@ -438,13 +442,15 @@ a.homeButton:hover { display: none; } -.schedule-add .content-length, +.schedule-edit { + display: grid; +} + .schedule-edit .content-length { font-size: 0.6em; color: #333; } -.schedule-add textarea, .schedule-edit textarea { font-size: 1em; } @@ -540,10 +546,6 @@ a.homeButton:hover { text-align: right; } -.shadow { - box-shadow: 2px 4px 7px 3px #eaeaea; -} - .navbar { font-size: 1.5em; color: rgba(255, 255, 255, 0.55); diff --git a/src/main/resources/templates/duty/duty.html b/src/main/resources/templates/duty/duty.html index f90a636c..4521d838 100644 --- a/src/main/resources/templates/duty/duty.html +++ b/src/main/resources/templates/duty/duty.html @@ -394,14 +394,14 @@
-
- @@ -412,25 +412,36 @@ - - - - + + +
- +
- + +
+
+ +
+ +
+
@@ -471,57 +482,63 @@
-
- - - - - - - -
-
- -
- +
+
+ + + + + + +
+
+ +
+ +
-
-
- -
- +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
-
- - +
+ + +
@@ -563,7 +580,14 @@ department: {}, dDays: [], selectedDday: null, - detailView: null, + detailView: { + 'dutyColor': '', + 'dutyType': '', + 'id': '', + 'month': '', + 'year': '', + 'day': '', + }, batchEditMode: false, isCreateScheduleMode: false, calendarVisibility: "[[${member.calendarVisibility}]]", @@ -575,6 +599,8 @@ content: '', description: '', startDateTime: '', + startDate: '', + startTime: '', startDateTimeOld: '', endDateTime: '', visibility: 'FAMILY', @@ -584,6 +610,8 @@ content: '', description: '', startDateTime: '', + startDate: '', + startTime: '', startDateTimeOld: '', endDateTime: '', visibility: '', @@ -802,16 +830,18 @@ app.monthSelector.year = app.year; } , - 'createSchedule.startDateTime': + 'createSchedule.startTime': function () { + app.createSchedule.startDateTime = app.createSchedule.startDate + 'T' + app.createSchedule.startTime; if (app.createSchedule.startDateTimeOld === app.createSchedule.endDateTime || app.createSchedule.startDateTime > app.createSchedule.endDateTime) { app.createSchedule.endDateTime = app.createSchedule.startDateTime; } app.createSchedule.startDateTimeOld = app.createSchedule.startDateTime; } , - 'editingSchedule.startDateTime': + 'editingSchedule.startTime': function () { + app.editingSchedule.startDateTime = app.editingSchedule.startDate + 'T' + app.editingSchedule.startTime; if (app.editingSchedule.startDateTimeOld === app.editingSchedule.endDateTime || app.editingSchedule.startDateTime > app.editingSchedule.endDateTime) { app.editingSchedule.endDateTime = app.editingSchedule.startDateTime; } @@ -1025,6 +1055,8 @@ content: '', description: '', startDateTime: app.formattedDateTime(app.detailView), + startDate: app.formattedDate(app.detailView.year, app.detailView.month, app.detailView.day), + startTime: '00:00', endDateTime: app.formattedDateTime(app.detailView), visibility: 'FAMILY', } @@ -1032,7 +1064,7 @@ , isDutyType(duty, dutyType) { if (duty?.dutyType) { - return dutyType.name == duty.dutyType + return dutyType.name === duty.dutyType } return !dutyType.id } @@ -1063,14 +1095,12 @@ }); } }) - } - , + }, formattedDateTime(duty) { const month = duty.month.toString().padStart(2, '0'); const day = duty.day.toString().padStart(2, '0'); return `${duty.year}-${month}-${day}T00:00`; - } - , + }, scheduleCreateMode() { app.isCreateScheduleMode = true; const schedules = document.querySelector('#detail-view-modal .schedules'); @@ -1126,6 +1156,8 @@ app.editingSchedule.content = schedule.content; app.editingSchedule.description = schedule.description; app.editingSchedule.startDateTime = schedule.startDateTime; + app.editingSchedule.startDate = schedule.startDateTime.split('T')[0]; + app.editingSchedule.startTime = schedule.startDateTime.split('T')[1]; app.editingSchedule.endDateTime = schedule.endDateTime; app.editingSchedule.visibility = schedule.visibility; } diff --git a/src/main/resources/templates/layout/layout.html b/src/main/resources/templates/layout/layout.html index 8111bdc0..455119dd 100644 --- a/src/main/resources/templates/layout/layout.html +++ b/src/main/resources/templates/layout/layout.html @@ -19,13 +19,22 @@