diff --git a/application/src/main/kotlin/com/dobby/service/ExperimentPostService.kt b/application/src/main/kotlin/com/dobby/service/ExperimentPostService.kt index 584b7c60..6be99688 100644 --- a/application/src/main/kotlin/com/dobby/service/ExperimentPostService.kt +++ b/application/src/main/kotlin/com/dobby/service/ExperimentPostService.kt @@ -8,6 +8,7 @@ import com.dobby.exception.InvalidRequestValueException import com.dobby.gateway.CacheGateway import com.dobby.usecase.experiment.CreateExperimentPostUseCase import com.dobby.usecase.experiment.DeleteExperimentPostUseCase +import com.dobby.usecase.experiment.ExtractExperimentPostKeywordsUseCase import com.dobby.usecase.experiment.GenerateExperimentPostPreSignedUrlUseCase import com.dobby.usecase.experiment.GetExperimentPostApplyMethodUseCase import com.dobby.usecase.experiment.GetExperimentPostCountsByAreaUseCase @@ -41,6 +42,7 @@ class ExperimentPostService( private val getExperimentPostTotalCountByCustomFilterUseCase: GetExperimentPostTotalCountByCustomFilterUseCase, private val getMyExperimentPostsUseCase: GetMyExperimentPostsUseCase, private val getMyExperimentPostTotalCountUseCase: GetMyExperimentPostTotalCountUseCase, + private val extractExperimentPostKeywordsUseCase: ExtractExperimentPostKeywordsUseCase, private val cacheGateway: CacheGateway ) { @Transactional @@ -165,6 +167,10 @@ class ExperimentPostService( evictExperimentPostCountsCaches() } + fun extractExperimentPostKeywords(input: ExtractExperimentPostKeywordsUseCase.Input): ExtractExperimentPostKeywordsUseCase.Output { + return extractExperimentPostKeywordsUseCase.execute(input) + } + private fun evictExperimentPostCountsCaches() { listOf("ALL", "OPEN").forEach { cacheGateway.evict("experimentPostCounts:$it") } } diff --git a/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt b/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt new file mode 100644 index 00000000..55ecd569 --- /dev/null +++ b/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt @@ -0,0 +1,17 @@ +package com.dobby.usecase.experiment + +import com.dobby.gateway.experiment.ExperimentKeywordExtractionGateway +import com.dobby.model.experiment.keyword.ExperimentPostKeyword +import com.dobby.usecase.UseCase + +class ExtractExperimentPostKeywordsUseCase( + private val experimentKeywordExtractionGateway: ExperimentKeywordExtractionGateway +) : UseCase { + data class Input(val text: String) + data class Output(val experimentPostKeyword: ExperimentPostKeyword) + + override fun execute(input: Input): Output { + val experimentPostKeyword = experimentKeywordExtractionGateway.extractKeywords(input.text) + return Output(experimentPostKeyword) + } +} diff --git a/application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt b/application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt new file mode 100644 index 00000000..007573ee --- /dev/null +++ b/application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt @@ -0,0 +1,34 @@ +package com.dobby.usecase.experiment + +import com.dobby.gateway.experiment.ExperimentKeywordExtractionGateway +import com.dobby.model.experiment.keyword.ExperimentPostKeyword +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class ExtractExperimentPostKeywordsUseCaseTest : BehaviorSpec({ + val experimentKeywordExtractionGateway = mockk() + val extractExperimentPostKeywordsUseCase = ExtractExperimentPostKeywordsUseCase(experimentKeywordExtractionGateway) + + given("실험 게시글 텍스트에서 키워드를 추출할 때") { + val inputText = "남성 20-30대 대상 설문조사 참여자 모집합니다. 시간은 1시간 소요되며 참가비 10,000원 지급합니다." + val input = ExtractExperimentPostKeywordsUseCase.Input(inputText) + val mockExperimentPostKeyword = mockk() + + every { experimentKeywordExtractionGateway.extractKeywords(inputText) } returns mockExperimentPostKeyword + + `when`("키워드 추출을 요청하면") { + val result = extractExperimentPostKeywordsUseCase.execute(input) + + then("추출된 키워드 정보를 반환해야 한다") { + result.experimentPostKeyword shouldBe mockExperimentPostKeyword + + verify(exactly = 1) { + experimentKeywordExtractionGateway.extractKeywords(inputText) + } + } + } + } +}) diff --git a/domain/src/main/kotlin/com/dobby/exception/DobbyException.kt b/domain/src/main/kotlin/com/dobby/exception/DobbyException.kt index e83f0e68..b9d6edb3 100644 --- a/domain/src/main/kotlin/com/dobby/exception/DobbyException.kt +++ b/domain/src/main/kotlin/com/dobby/exception/DobbyException.kt @@ -77,3 +77,8 @@ data object ExperimentPostLeadResearcherException : ClientException("EP0013", "L sealed class ServerException(code: String, message: String, cause: Throwable? = null) : DobbyException(code, message, cause) data object UnknownServerErrorException : ServerException("DB0001", "An unknown error has occurred") + +/** + * OpenAI API call specific exceptions + */ +data class CustomOpenAiCallException(override val message: String, override val cause: Throwable? = null) : ServerException("AI0001", message, cause) diff --git a/domain/src/main/kotlin/com/dobby/gateway/experiment/ExperimentKeywordExtractionGateway.kt b/domain/src/main/kotlin/com/dobby/gateway/experiment/ExperimentKeywordExtractionGateway.kt new file mode 100644 index 00000000..a189843d --- /dev/null +++ b/domain/src/main/kotlin/com/dobby/gateway/experiment/ExperimentKeywordExtractionGateway.kt @@ -0,0 +1,7 @@ +package com.dobby.gateway.experiment + +import com.dobby.model.experiment.keyword.ExperimentPostKeyword + +interface ExperimentKeywordExtractionGateway { + fun extractKeywords(text: String): ExperimentPostKeyword +} diff --git a/domain/src/main/kotlin/com/dobby/model/experiment/keyword/ApplyMethodKeyword.kt b/domain/src/main/kotlin/com/dobby/model/experiment/keyword/ApplyMethodKeyword.kt new file mode 100644 index 00000000..83e11696 --- /dev/null +++ b/domain/src/main/kotlin/com/dobby/model/experiment/keyword/ApplyMethodKeyword.kt @@ -0,0 +1,9 @@ +package com.dobby.model.experiment.keyword + +data class ApplyMethodKeyword( + val content: String?, + val isFormUrl: Boolean, + val formUrl: String?, + val isPhoneNum: Boolean, + val phoneNum: String? +) diff --git a/domain/src/main/kotlin/com/dobby/model/experiment/keyword/ExperimentPostKeyword.kt b/domain/src/main/kotlin/com/dobby/model/experiment/keyword/ExperimentPostKeyword.kt new file mode 100644 index 00000000..cea8f404 --- /dev/null +++ b/domain/src/main/kotlin/com/dobby/model/experiment/keyword/ExperimentPostKeyword.kt @@ -0,0 +1,13 @@ +package com.dobby.model.experiment.keyword + +import com.dobby.enums.MatchType +import com.dobby.enums.experiment.TimeSlot + +data class ExperimentPostKeyword( + val targetGroup: TargetGroupKeyword?, + val applyMethod: ApplyMethodKeyword?, + val matchType: MatchType?, + val reward: String?, + val count: Int?, + val timeRequired: TimeSlot? +) diff --git a/domain/src/main/kotlin/com/dobby/model/experiment/keyword/TargetGroupKeyword.kt b/domain/src/main/kotlin/com/dobby/model/experiment/keyword/TargetGroupKeyword.kt new file mode 100644 index 00000000..04b3ce6c --- /dev/null +++ b/domain/src/main/kotlin/com/dobby/model/experiment/keyword/TargetGroupKeyword.kt @@ -0,0 +1,10 @@ +package com.dobby.model.experiment.keyword + +import com.dobby.enums.member.GenderType + +data class TargetGroupKeyword( + var startAge: Int?, + var endAge: Int?, + var genderType: GenderType?, + var otherCondition: String? +) diff --git a/infrastructure/src/main/kotlin/com/dobby/config/OpenAiFeignConfig.kt b/infrastructure/src/main/kotlin/com/dobby/config/OpenAiFeignConfig.kt new file mode 100644 index 00000000..7e5008c8 --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/config/OpenAiFeignConfig.kt @@ -0,0 +1,17 @@ +package com.dobby.config + +import com.dobby.config.properties.OpenAiProperties +import feign.RequestInterceptor +import org.springframework.context.annotation.Bean + +class OpenAiFeignConfig( + private val openAiProperties: OpenAiProperties +) { + @Bean + fun openAiRequestInterceptor(): RequestInterceptor { + return RequestInterceptor { template -> + template.header("Authorization", "Bearer ${openAiProperties.api.key}") + template.header("Content-Type", "application/json") + } + } +} diff --git a/infrastructure/src/main/kotlin/com/dobby/config/properties/OpenAiProperties.kt b/infrastructure/src/main/kotlin/com/dobby/config/properties/OpenAiProperties.kt new file mode 100644 index 00000000..b6ae0b5f --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/config/properties/OpenAiProperties.kt @@ -0,0 +1,14 @@ +package com.dobby.config.properties + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties(prefix = "openai") +data class OpenAiProperties( + var api: Api = Api() +) { + data class Api( + var key: String = "" + ) +} diff --git a/infrastructure/src/main/kotlin/com/dobby/external/feign/openAi/OpenAiFeignClient.kt b/infrastructure/src/main/kotlin/com/dobby/external/feign/openAi/OpenAiFeignClient.kt new file mode 100644 index 00000000..514c412a --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/external/feign/openAi/OpenAiFeignClient.kt @@ -0,0 +1,18 @@ +package com.dobby.external.feign.openAi + +import com.dobby.api.dto.request.OpenAiRequest +import com.dobby.api.dto.response.OpenAiResponse +import com.dobby.config.OpenAiFeignConfig +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody + +@FeignClient( + name = "openAiClient", + url = "https://api.openai.com/v1", + configuration = [OpenAiFeignConfig::class] +) +interface OpenAiFeignClient { + @PostMapping("/chat/completions") + fun chatCompletion(@RequestBody request: OpenAiRequest): OpenAiResponse +} diff --git a/infrastructure/src/main/kotlin/com/dobby/external/gateway/experiment/ExperimentKeywordExtractionGatewayImpl.kt b/infrastructure/src/main/kotlin/com/dobby/external/gateway/experiment/ExperimentKeywordExtractionGatewayImpl.kt new file mode 100644 index 00000000..9ee5c29c --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/external/gateway/experiment/ExperimentKeywordExtractionGatewayImpl.kt @@ -0,0 +1,78 @@ +package com.dobby.external.gateway.experiment + +import com.dobby.api.dto.request.OpenAiRequest +import com.dobby.exception.CustomOpenAiCallException +import com.dobby.external.feign.openAi.OpenAiFeignClient +import com.dobby.external.prompt.ExperimentPostKeywordMapper +import com.dobby.external.prompt.PromptTemplate +import com.dobby.external.prompt.PromptTemplateLoader +import com.dobby.external.prompt.dto.ExperimentPostKeywordDto +import com.dobby.gateway.experiment.ExperimentKeywordExtractionGateway +import com.dobby.model.experiment.keyword.ExperimentPostKeyword +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import feign.FeignException +import org.springframework.stereotype.Component + +@Component +class ExperimentKeywordExtractionGatewayImpl( + private val openAiFeignClient: OpenAiFeignClient, + private val promptTemplateLoader: PromptTemplateLoader, + private val mapper: ExperimentPostKeywordMapper +) : ExperimentKeywordExtractionGateway { + + private val objectMapper = jacksonObjectMapper() + + private val promptTemplate: PromptTemplate by lazy { + promptTemplateLoader.loadPrompt("prompts/keyword_extraction_prompt.json") + } + + override fun extractKeywords(text: String): ExperimentPostKeyword { + val promptJson = objectMapper.writeValueAsString(promptTemplate) + val prompt = promptJson.replace("{{text}}", escapeJsonString(text)) + val messages = listOf( + OpenAiRequest.Message(role = "user", content = prompt) + ) + + val request = OpenAiRequest( + model = "gpt-4o", + temperature = 0.2, + messages = messages + ) + + val content = try { + val response = openAiFeignClient.chatCompletion(request) + response.choices.firstOrNull()?.message?.content + ?: throw IllegalStateException("No response received from OpenAI") + } catch (e: FeignException) { + throw CustomOpenAiCallException("OpenAI API call failed (status=${e.status()})", e) + } catch (e: Exception) { + throw IllegalStateException("Unexpected error occurred during OpenAI API call", e) + } + + return try { + val cleanedContent = cleanJsonResponse(content) + val dto = objectMapper.readValue(cleanedContent) + mapper.toDomain(dto) + } catch (e: Exception) { + throw IllegalStateException("Failed to parse response: $content", e) + } + } + + private fun escapeJsonString(text: String): String { + return text.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + + private fun cleanJsonResponse(content: String): String { + return content.trim() + .replace(Regex("^```json\\s*"), "") + .replace(Regex("```\\s*$"), "") + .replace(Regex("^`+"), "") + .replace(Regex("`+$"), "") + .trim() + } +} diff --git a/infrastructure/src/main/kotlin/com/dobby/external/prompt/ExperimentPostKeywordMapper.kt b/infrastructure/src/main/kotlin/com/dobby/external/prompt/ExperimentPostKeywordMapper.kt new file mode 100644 index 00000000..b1986c76 --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/external/prompt/ExperimentPostKeywordMapper.kt @@ -0,0 +1,87 @@ +package com.dobby.external.prompt + +import com.dobby.enums.MatchType +import com.dobby.enums.experiment.TimeSlot +import com.dobby.enums.member.GenderType +import com.dobby.external.prompt.dto.ApplyMethodDto +import com.dobby.external.prompt.dto.ExperimentPostKeywordDto +import com.dobby.external.prompt.dto.TargetGroupDto +import com.dobby.model.experiment.keyword.ApplyMethodKeyword +import com.dobby.model.experiment.keyword.ExperimentPostKeyword +import com.dobby.model.experiment.keyword.TargetGroupKeyword +import org.springframework.stereotype.Component + +@Component +class ExperimentPostKeywordMapper { + + fun toDomain(dto: ExperimentPostKeywordDto): ExperimentPostKeyword { + return ExperimentPostKeyword( + targetGroup = dto.targetGroup?.let { targetGroupDto -> + TargetGroupKeyword( + startAge = targetGroupDto.startAge ?: 0, + endAge = targetGroupDto.endAge ?: 0, + genderType = targetGroupDto.genderType?.let { genderStr -> + when (genderStr) { + "MALE" -> GenderType.MALE + "FEMALE" -> GenderType.FEMALE + "ALL" -> GenderType.ALL + else -> GenderType.ALL + } + } ?: GenderType.ALL, + otherCondition = targetGroupDto.otherCondition + ) + }, + applyMethod = dto.applyMethod?.let { applyMethodDto -> + ApplyMethodKeyword( + content = applyMethodDto.content ?: "", + isFormUrl = applyMethodDto.isFormUrl, + formUrl = applyMethodDto.formUrl ?: "", + isPhoneNum = applyMethodDto.isPhoneNum, + phoneNum = applyMethodDto.phoneNum ?: "" + ) + }, + matchType = dto.matchType?.let { matchTypeStr -> + try { + MatchType.valueOf(matchTypeStr) + } catch (e: IllegalArgumentException) { + MatchType.ALL + } + } ?: MatchType.ALL, + reward = dto.reward ?: "", + count = dto.count ?: 0, + timeRequired = dto.timeRequired?.takeIf { it.isNotBlank() }?.let { timeSlotStr -> + try { + TimeSlot.valueOf(timeSlotStr) + } catch (e: IllegalArgumentException) { + null + } + } + ) + } + + fun toDto(domain: ExperimentPostKeyword): ExperimentPostKeywordDto { + return ExperimentPostKeywordDto( + targetGroup = domain.targetGroup?.let { targetGroupDomain -> + TargetGroupDto( + startAge = targetGroupDomain.startAge, + endAge = targetGroupDomain.endAge, + genderType = targetGroupDomain.genderType?.name, + otherCondition = targetGroupDomain.otherCondition + ) + }, + applyMethod = domain.applyMethod?.let { applyMethodDomain -> + ApplyMethodDto( + content = applyMethodDomain.content, + isFormUrl = applyMethodDomain.isFormUrl, + formUrl = applyMethodDomain.formUrl, + isPhoneNum = applyMethodDomain.isPhoneNum, + phoneNum = applyMethodDomain.phoneNum + ) + }, + matchType = domain.matchType?.name, + reward = domain.reward, + count = domain.count, + timeRequired = domain.timeRequired?.name + ) + } +} diff --git a/infrastructure/src/main/kotlin/com/dobby/external/prompt/PromptTemplate.kt b/infrastructure/src/main/kotlin/com/dobby/external/prompt/PromptTemplate.kt new file mode 100644 index 00000000..47db413e --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/external/prompt/PromptTemplate.kt @@ -0,0 +1,9 @@ +package com.dobby.external.prompt + +data class PromptTemplate( + val description: String, + val input: Map, + val extract_items: List, + val output_format: Map, + val conditions: List +) diff --git a/infrastructure/src/main/kotlin/com/dobby/external/prompt/PromptTemplateLoader.kt b/infrastructure/src/main/kotlin/com/dobby/external/prompt/PromptTemplateLoader.kt new file mode 100644 index 00000000..9d92c24a --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/external/prompt/PromptTemplateLoader.kt @@ -0,0 +1,19 @@ +package com.dobby.external.prompt + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.springframework.stereotype.Component + +@Component +class PromptTemplateLoader { + + private val mapper = jacksonObjectMapper() + + fun loadPrompt(resourcePath: String): PromptTemplate { + val resource = this::class.java.classLoader.getResource(resourcePath) + ?: throw IllegalArgumentException("프롬프트 파일을 찾을 수 없습니다: $resourcePath") + + return resource.openStream().use { inputStream -> + mapper.readValue(inputStream, PromptTemplate::class.java) + } + } +} diff --git a/infrastructure/src/main/kotlin/com/dobby/external/prompt/dto/ApplyMethodDto.kt b/infrastructure/src/main/kotlin/com/dobby/external/prompt/dto/ApplyMethodDto.kt new file mode 100644 index 00000000..ba63abb9 --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/external/prompt/dto/ApplyMethodDto.kt @@ -0,0 +1,9 @@ +package com.dobby.external.prompt.dto + +data class ApplyMethodDto( + val content: String? = null, + val isFormUrl: Boolean = false, + val formUrl: String? = null, + val isPhoneNum: Boolean = false, + val phoneNum: String? = null +) diff --git a/infrastructure/src/main/kotlin/com/dobby/external/prompt/dto/ExperimentPostKeywordDto.kt b/infrastructure/src/main/kotlin/com/dobby/external/prompt/dto/ExperimentPostKeywordDto.kt new file mode 100644 index 00000000..5857c34b --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/external/prompt/dto/ExperimentPostKeywordDto.kt @@ -0,0 +1,14 @@ +package com.dobby.external.prompt.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class ExperimentPostKeywordDto( + @JsonProperty("targetGroupInfo") + val targetGroup: TargetGroupDto? = null, + @JsonProperty("applyMethodInfo") + val applyMethod: ApplyMethodDto? = null, + val matchType: String? = null, + val reward: String? = null, + val count: Int? = null, + val timeRequired: String? = null +) diff --git a/infrastructure/src/main/kotlin/com/dobby/external/prompt/dto/TargetGroupDto.kt b/infrastructure/src/main/kotlin/com/dobby/external/prompt/dto/TargetGroupDto.kt new file mode 100644 index 00000000..a289cd94 --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/external/prompt/dto/TargetGroupDto.kt @@ -0,0 +1,8 @@ +package com.dobby.external.prompt.dto + +data class TargetGroupDto( + val startAge: Int? = null, + val endAge: Int? = null, + val genderType: String? = null, + val otherCondition: String? = null +) diff --git a/infrastructure/src/main/resources/prompts/keyword_extraction_prompt.json b/infrastructure/src/main/resources/prompts/keyword_extraction_prompt.json new file mode 100644 index 00000000..5313e256 --- /dev/null +++ b/infrastructure/src/main/resources/prompts/keyword_extraction_prompt.json @@ -0,0 +1,42 @@ +{ + "description": "공고 게시글에서 실험 참여 관련 정보를 추출하여 JSON 형태로 반환하세요.", + "input": { + "text": "{{text}}" + }, + "extract_items": [ + "진행 방식 (대면 / 비대면 / 대면+비대면 중 선택)", + "참여 보상 (주관식)", + "실험에 참여하려면 어떻게 하나요? (주관식, URL 및 전화번호 포함 가능)", + "참여 대상 나이 (시작 나이 - 끝 나이)", + "참여 대상 성별 (남성 / 여성 / 무관)", + "참여 대상 기타 조건 (주관식)", + "참여 횟수 (숫자)", + "소요 시간 (예: 30분, 1시간, 1시간 30분, 등)" + ], + "output_format": { + "targetGroupInfo": { + "startAge": "시작 나이 (숫자)", + "endAge": "끝 나이 (숫자)", + "genderType": "MALE/FEMALE/ALL", + "otherCondition": "기타 조건 (문자열, 최대 300자)" + }, + "applyMethodInfo": { + "content": "참여 방법 설명 (문자열, 최대 200자)", + "isFormUrl": "URL 포함 여부 (boolean)", + "formUrl": "신청 URL (문자열, 최대 100자)", + "isPhoneNum": "전화번호 포함 여부 (boolean)", + "phoneNum": "전화번호 (문자열, 최대 50자)" + }, + "matchType": "OFFLINE/ONLINE/ALL", + "reward": "참여 보상 (문자열, 최대 170자)", + "count": "참여 횟수 (숫자)", + "timeRequired": "LESS_30M/ABOUT_30M/ABOUT_1H/ABOUT_1H30M/ABOUT_2H/ABOUT_2H30M/ABOUT_3H/ABOUT_3H30M/ABOUT_4H" + }, + "conditions": [ + "공고 본문을 정확히 분석하여 각 항목에 맞는 정보를 추출한다.", + "추출된 정보는 반드시 output_format에 따라 JSON 형태로 출력한다.", + "정보가 명시되지 않은 경우 해당 필드는 빈 문자열(\"\") 또는 0으로 설정한다.", + "URL이나 전화번호가 포함된 경우 해당 boolean 값을 true로 설정한다.", + "각 항목은 지정된 최대 글자 수를 초과하지 않도록 한다." + ] +} diff --git a/presentation/src/main/kotlin/com/dobby/api/controller/ExperimentPostController.kt b/presentation/src/main/kotlin/com/dobby/api/controller/ExperimentPostController.kt index b52da6d5..b3cc573f 100644 --- a/presentation/src/main/kotlin/com/dobby/api/controller/ExperimentPostController.kt +++ b/presentation/src/main/kotlin/com/dobby/api/controller/ExperimentPostController.kt @@ -2,6 +2,7 @@ package com.dobby.api.controller import com.dobby.api.dto.request.PreSignedUrlRequest import com.dobby.api.dto.request.experiment.CreateExperimentPostRequest +import com.dobby.api.dto.request.experiment.ExtractKeywordRequest import com.dobby.api.dto.request.experiment.UpdateExperimentPostRequest import com.dobby.api.dto.response.PaginatedResponse import com.dobby.api.dto.response.PreSignedUrlResponse @@ -10,6 +11,7 @@ import com.dobby.api.dto.response.experiment.ExperimentPostApplyMethodResponse import com.dobby.api.dto.response.experiment.ExperimentPostCountsResponse import com.dobby.api.dto.response.experiment.ExperimentPostDetailResponse import com.dobby.api.dto.response.experiment.ExperimentPostResponse +import com.dobby.api.dto.response.experiment.ExtractKeywordResponse import com.dobby.api.dto.response.experiment.UpdateExperimentPostResponse import com.dobby.api.dto.response.member.DefaultResponse import com.dobby.api.dto.response.member.MyExperimentPostResponse @@ -216,4 +218,19 @@ class ExperimentPostController( val isLast = totalCount <= count * page return ExperimentPostMapper.toGetMyExperimentPostsResponse(posts, page, totalCount, isLast) } + + @PreAuthorize("hasRole('RESEARCHER')") + @PostMapping("/extract-keywords") + @Operation( + summary = "실험 공고 키워드 추출 API", + description = "실험 공고 텍스트에서 키워드를 추출합니다." + ) + fun extractExperimentPostKeywords( + @RequestBody @Valid + request: ExtractKeywordRequest + ): ExtractKeywordResponse { + val input = ExperimentPostMapper.toExtractKeywordUseCaseInput(request) + val output = experimentPostService.extractExperimentPostKeywords(input) + return ExperimentPostMapper.toExtractKeywordResponse(output) + } } diff --git a/presentation/src/main/kotlin/com/dobby/api/dto/request/OpenAiRequest.kt b/presentation/src/main/kotlin/com/dobby/api/dto/request/OpenAiRequest.kt new file mode 100644 index 00000000..058c4b04 --- /dev/null +++ b/presentation/src/main/kotlin/com/dobby/api/dto/request/OpenAiRequest.kt @@ -0,0 +1,12 @@ +package com.dobby.api.dto.request + +data class OpenAiRequest( + val model: String, + val temperature: Double, + val messages: List +) { + data class Message( + val role: String, + val content: String + ) +} diff --git a/presentation/src/main/kotlin/com/dobby/api/dto/request/experiment/ExtractKeywordRequest.kt b/presentation/src/main/kotlin/com/dobby/api/dto/request/experiment/ExtractKeywordRequest.kt new file mode 100644 index 00000000..043f3bde --- /dev/null +++ b/presentation/src/main/kotlin/com/dobby/api/dto/request/experiment/ExtractKeywordRequest.kt @@ -0,0 +1,10 @@ +package com.dobby.api.dto.request.experiment + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +data class ExtractKeywordRequest( + @NotBlank + @Schema(description = "키워드를 추출할 텍스트", example = "심리학 실험 참여자를 모집합니다. 인지 심리학 관련 실험으로 약 30분 소요됩니다.") + val text: String +) diff --git a/presentation/src/main/kotlin/com/dobby/api/dto/response/OpenAiResponse.kt b/presentation/src/main/kotlin/com/dobby/api/dto/response/OpenAiResponse.kt new file mode 100644 index 00000000..510ed079 --- /dev/null +++ b/presentation/src/main/kotlin/com/dobby/api/dto/response/OpenAiResponse.kt @@ -0,0 +1,14 @@ +package com.dobby.api.dto.response + +data class OpenAiResponse( + val choices: List +) { + data class Choice( + val message: Message + ) + + data class Message( + val role: String, + val content: String + ) +} diff --git a/presentation/src/main/kotlin/com/dobby/api/dto/response/experiment/ExtractKeywordResponse.kt b/presentation/src/main/kotlin/com/dobby/api/dto/response/experiment/ExtractKeywordResponse.kt new file mode 100644 index 00000000..978b49a1 --- /dev/null +++ b/presentation/src/main/kotlin/com/dobby/api/dto/response/experiment/ExtractKeywordResponse.kt @@ -0,0 +1,9 @@ +package com.dobby.api.dto.response.experiment + +import com.dobby.model.experiment.keyword.ExperimentPostKeyword +import io.swagger.v3.oas.annotations.media.Schema + +data class ExtractKeywordResponse( + @Schema(description = "추출된 키워드 정보") + val experimentPostKeyword: ExperimentPostKeyword +) diff --git a/presentation/src/main/kotlin/com/dobby/api/mapper/ExperimentPostMapper.kt b/presentation/src/main/kotlin/com/dobby/api/mapper/ExperimentPostMapper.kt index 366035d0..e07ceaf1 100644 --- a/presentation/src/main/kotlin/com/dobby/api/mapper/ExperimentPostMapper.kt +++ b/presentation/src/main/kotlin/com/dobby/api/mapper/ExperimentPostMapper.kt @@ -3,6 +3,7 @@ package com.dobby.api.mapper import com.dobby.api.dto.request.PreSignedUrlRequest import com.dobby.api.dto.request.experiment.ApplyMethodInfo import com.dobby.api.dto.request.experiment.CreateExperimentPostRequest +import com.dobby.api.dto.request.experiment.ExtractKeywordRequest import com.dobby.api.dto.request.experiment.ImageListInfo import com.dobby.api.dto.request.experiment.TargetGroupInfo import com.dobby.api.dto.request.experiment.UpdateExperimentPostRequest @@ -15,6 +16,7 @@ import com.dobby.api.dto.response.experiment.ExperimentPostApplyMethodResponse import com.dobby.api.dto.response.experiment.ExperimentPostCountsResponse import com.dobby.api.dto.response.experiment.ExperimentPostDetailResponse import com.dobby.api.dto.response.experiment.ExperimentPostResponse +import com.dobby.api.dto.response.experiment.ExtractKeywordResponse import com.dobby.api.dto.response.experiment.PostInfo import com.dobby.api.dto.response.experiment.UpdateExperimentPostResponse import com.dobby.api.dto.response.member.MyExperimentPostResponse @@ -25,6 +27,7 @@ import com.dobby.enums.experiment.RecruitStatus import com.dobby.enums.member.GenderType import com.dobby.usecase.experiment.CreateExperimentPostUseCase import com.dobby.usecase.experiment.DeleteExperimentPostUseCase +import com.dobby.usecase.experiment.ExtractExperimentPostKeywordsUseCase import com.dobby.usecase.experiment.GenerateExperimentPostPreSignedUrlUseCase import com.dobby.usecase.experiment.GetExperimentPostApplyMethodUseCase import com.dobby.usecase.experiment.GetExperimentPostCountsByAreaUseCase @@ -517,4 +520,16 @@ object ExperimentPostMapper { order = order ) } + + fun toExtractKeywordUseCaseInput(request: ExtractKeywordRequest): ExtractExperimentPostKeywordsUseCase.Input { + return ExtractExperimentPostKeywordsUseCase.Input( + text = request.text + ) + } + + fun toExtractKeywordResponse(output: ExtractExperimentPostKeywordsUseCase.Output): ExtractKeywordResponse { + return ExtractKeywordResponse( + experimentPostKeyword = output.experimentPostKeyword + ) + } }