-
Notifications
You must be signed in to change notification settings - Fork 0
[YS-509] feat: 공고 키워드 자동완성 기능 구현 (재배포) #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1a620cd
06cefe1
d0846a3
2894018
e810c00
d3c9469
124279a
2ed756d
b79fecf
e375f91
020f6e0
9e21503
045bced
1d5b35e
a1ac050
7ccfe47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }) |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion
-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 |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @configuration 어노테이션이 누락되었습니다. Spring 설정 클래스로 인식되도록 @configuration 어노테이션을 추가해야 합니다. 다음과 같이 수정하세요: +@Configuration
class OpenAiFeignConfig(
private val openAiProperties: OpenAiProperties
) {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| @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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| 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 | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CustomOpenAiCallException에서 컴파일 오류 발생 가능성Throwable.message는String?타입이므로, 이를override val message: String(nonnull)로 재정의하면 null-ability가 좁아져 컴파일이 실패합니다. 또한 예외를data class로 선언할 필요성이 낮아 equals/hashCode 생성 등 부가 오버헤드만 증가합니다.🤖 Prompt for AI Agents