Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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") }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ExtractExperimentPostKeywordsUseCase.Input, ExtractExperimentPostKeywordsUseCase.Output> {
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)
}
}
Original file line number Diff line number Diff line change
@@ -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<ExperimentKeywordExtractionGateway>()
val extractExperimentPostKeywordsUseCase = ExtractExperimentPostKeywordsUseCase(experimentKeywordExtractionGateway)

given("실험 게시글 텍스트에서 키워드를 추출할 때") {
val inputText = "남성 20-30대 대상 설문조사 참여자 모집합니다. 시간은 1시간 소요되며 참가비 10,000원 지급합니다."
val input = ExtractExperimentPostKeywordsUseCase.Input(inputText)
val mockExperimentPostKeyword = mockk<ExperimentPostKeyword>()

every { experimentKeywordExtractionGateway.extractKeywords(inputText) } returns mockExperimentPostKeyword

`when`("키워드 추출을 요청하면") {
val result = extractExperimentPostKeywordsUseCase.execute(input)

then("추출된 키워드 정보를 반환해야 한다") {
result.experimentPostKeyword shouldBe mockExperimentPostKeyword

verify(exactly = 1) {
experimentKeywordExtractionGateway.extractKeywords(inputText)
}
}
}
}
})
5 changes: 5 additions & 0 deletions domain/src/main/kotlin/com/dobby/exception/DobbyException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +81 to +84
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

CustomOpenAiCallException에서 컴파일 오류 발생 가능성

Throwable.messageString? 타입이므로, 이를 override val message: String(nonnull)로 재정의하면 null-ability가 좁아져 컴파일이 실패합니다. 또한 예외를 data class로 선언할 필요성이 낮아 equals/hashCode 생성 등 부가 오버헤드만 증가합니다.

-data class CustomOpenAiCallException(override val message: String, override val cause: Throwable? = null) : ServerException("AI0001", message, cause)
+class CustomOpenAiCallException(
+    message: String,
+    cause: Throwable? = null
+) : ServerException("AI0001", message, cause)
🤖 Prompt for AI Agents
In domain/src/main/kotlin/com/dobby/exception/DobbyException.kt around lines 81
to 84, the CustomOpenAiCallException class overrides the nullable
Throwable.message property with a non-null String, causing a compile error due
to nullability mismatch. Also, declaring it as a data class is unnecessary and
adds overhead. To fix this, change CustomOpenAiCallException to a regular class
(not data class) and override message with a nullable String type matching
Throwable.message.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.dobby.gateway.experiment

import com.dobby.model.experiment.keyword.ExperimentPostKeyword

interface ExperimentKeywordExtractionGateway {
fun extractKeywords(text: String): ExperimentPostKeyword
}
Original file line number Diff line number Diff line change
@@ -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?
)
Original file line number Diff line number Diff line change
@@ -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?
)
Original file line number Diff line number Diff line change
@@ -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?
)
Comment on lines +5 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ 도메인 객체의 불변성 확보를 위해 var 대신 val 사용 고려
도메인 모델은 생성 이후 변하지 않는 값이 대부분이므로 가변 프로퍼티(var)를 사용하면 예기치 않은 상태 변경이 가능해집니다. 불변 객체로 유지해도 기능 요구사항에는 영향이 없으니 다음과 같이 수정하는 것을 권장합니다.

-data class TargetGroupKeyword(
-    var startAge: Int?,
-    var endAge: Int?,
-    var genderType: GenderType?,
-    var otherCondition: String?
+data class TargetGroupKeyword(
+    val startAge: Int? = null,
+    val endAge: Int? = null,
+    val genderType: GenderType? = null,
+    val otherCondition: String? = null
 )
🤖 Prompt for AI Agents
In
domain/src/main/kotlin/com/dobby/model/experiment/keyword/TargetGroupKeyword.kt
around lines 5 to 10, the properties are declared with 'var', making them
mutable. To ensure domain object immutability and prevent unintended state
changes, change all 'var' declarations to 'val' since the values do not need to
change after creation.

Original file line number Diff line number Diff line change
@@ -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
) {
Comment on lines +7 to +9
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

@configuration 어노테이션이 누락되었습니다.

Spring 설정 클래스로 인식되도록 @configuration 어노테이션을 추가해야 합니다.

다음과 같이 수정하세요:

+@Configuration
 class OpenAiFeignConfig(
     private val openAiProperties: OpenAiProperties
 ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class OpenAiFeignConfig(
private val openAiProperties: OpenAiProperties
) {
@Configuration
class OpenAiFeignConfig(
private val openAiProperties: OpenAiProperties
) {
// existing bean definitions, e.g. RequestInterceptor bean
}
🤖 Prompt for AI Agents
In infrastructure/src/main/kotlin/com/dobby/config/OpenAiFeignConfig.kt around
lines 7 to 9, the class OpenAiFeignConfig is missing the @Configuration
annotation. Add the @Configuration annotation above the class declaration to
ensure Spring recognizes it as a configuration class.

@Bean
fun openAiRequestInterceptor(): RequestInterceptor {
return RequestInterceptor { template ->
template.header("Authorization", "Bearer ${openAiProperties.api.key}")
template.header("Content-Type", "application/json")
}
Comment on lines +12 to +15
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

API 키에 대한 null 안전성 검증을 추가하세요.

API 키가 null이거나 비어있을 경우 잘못된 Authorization 헤더가 생성될 수 있습니다.

다음과 같이 개선하는 것을 권장합니다:

 return RequestInterceptor { template ->
-    template.header("Authorization", "Bearer ${openAiProperties.api.key}")
+    val apiKey = openAiProperties.api.key
+    require(!apiKey.isNullOrBlank()) { "OpenAI API key must not be null or blank" }
+    template.header("Authorization", "Bearer $apiKey")
     template.header("Content-Type", "application/json")
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return RequestInterceptor { template ->
template.header("Authorization", "Bearer ${openAiProperties.api.key}")
template.header("Content-Type", "application/json")
}
return RequestInterceptor { template ->
val apiKey = openAiProperties.api.key
require(!apiKey.isNullOrBlank()) { "OpenAI API key must not be null or blank" }
template.header("Authorization", "Bearer $apiKey")
template.header("Content-Type", "application/json")
}
🤖 Prompt for AI Agents
In infrastructure/src/main/kotlin/com/dobby/config/OpenAiFeignConfig.kt around
lines 12 to 15, the code sets the Authorization header using the API key without
checking if the key is null or empty, which can lead to invalid headers. Add a
null and empty check for openAiProperties.api.key before setting the
Authorization header, and only set the header if the key is valid to ensure null
safety and prevent malformed requests.

}
}
Original file line number Diff line number Diff line change
@@ -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 = ""
)
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<ExperimentPostKeywordDto>(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()
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.dobby.external.prompt

data class PromptTemplate(
val description: String,
val input: Map<String, String>,
val extract_items: List<String>,
val output_format: Map<String, Any>,
val conditions: List<String>
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
)
Loading
Loading