-
Notifications
You must be signed in to change notification settings - Fork 0
Feat: 카드뉴스 템플릿 자동 생성 기능 구현 #42
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
base: develop
Are you sure you want to change the base?
Changes from all commits
168e685
71fb24e
bd39e8d
03ea921
1e19837
b5faaee
cc3bd65
4ffdfc8
0bb2ed2
128b931
a10167b
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,27 @@ | ||
| plugins { | ||
| kotlin("jvm") | ||
| kotlin("plugin.spring") | ||
| kotlin("plugin.jpa") | ||
| id("io.spring.dependency-management") | ||
| } | ||
InHyeok-J marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| dependencies { | ||
|
|
||
| implementation(project(":ai-domain")) | ||
| implementation(project(":ai-application")) | ||
| implementation(project(":security")) | ||
|
|
||
| implementation("org.springframework.boot:spring-boot-starter-web") | ||
| implementation("org.springframework.boot:spring-boot-starter-webflux") | ||
| implementation("org.springframework.boot:spring-boot-starter-data-jpa") | ||
| implementation("io.awspring.cloud:spring-cloud-aws-s3:3.4.0") | ||
| implementation("com.fasterxml.jackson.module:jackson-module-kotlin") | ||
| implementation("org.jetbrains.kotlin:kotlin-reflect") | ||
|
|
||
| // --- test --- | ||
| testImplementation("org.springframework.boot:spring-boot-starter-test") { | ||
| exclude(group = "org.mockito") | ||
| } | ||
| testImplementation("com.ninja-squad:springmockk:latest.release") | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package app.cardcapture.ai.outbound.imageai.google | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value | ||
| import org.springframework.context.annotation.Bean | ||
| import org.springframework.context.annotation.Configuration | ||
| import org.springframework.web.reactive.function.client.ExchangeStrategies | ||
| import org.springframework.web.reactive.function.client.WebClient | ||
|
|
||
| @Configuration | ||
| class GoogleAiConfig ( | ||
| @Value("\${google.key}") private val apiKey: String | ||
| ){ | ||
|
|
||
| private val baseUrl = "https://generativelanguage.googleapis.com" | ||
| private val keyHeader = "x-goog-api-key" | ||
|
|
||
| @Bean | ||
| fun googleImageWebClient(): WebClient{ | ||
| val strategies = ExchangeStrategies.builder() | ||
| .codecs { config -> | ||
| config.defaultCodecs().maxInMemorySize(10 * 1024 * 1024) | ||
| } | ||
| .build() | ||
|
|
||
| return WebClient.builder() | ||
| .defaultHeader(keyHeader, apiKey) | ||
| .baseUrl(baseUrl) | ||
| .exchangeStrategies(strategies) | ||
| .build() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package app.cardcapture.ai.outbound.imageai.google | ||
|
|
||
| import app.cardcapture.ai.outbound.imageai.google.dto.GeminiPreviewImageRequest | ||
| import app.cardcapture.ai.outbound.imageai.google.dto.GeminiPreviewImageResponse | ||
| import app.cardcapture.ai.port.outbound.ImageModelPort | ||
| import org.springframework.http.HttpStatusCode | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.web.reactive.function.client.WebClient | ||
| import reactor.core.publisher.Mono | ||
| import java.util.* | ||
|
|
||
| @Component | ||
| class ImageModelAdapter( | ||
| private val googleImageWebClient: WebClient, | ||
|
Member
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. 요기도 클래스명에 모델명을 명시하면 좋을 거 같아요! 추후 모델 변경에 대비해 ImageModelPort { 로 추상화된 점은 좋다고 생각합니당 |
||
| ) : ImageModelPort { | ||
|
|
||
| override fun generate(prompt: String, model: String): ByteArray { | ||
|
Member
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. 이 메서드에서 model이라는 파라미터는 어디에 사용되나용?? 사용되지 않는다면 삭제하거나, 사용된다면 Enum으로 관리해보면 어떨까용? |
||
|
|
||
| val request = GeminiPreviewImageRequest( | ||
| contents = listOf( | ||
| GeminiPreviewImageRequest.ImagerPartsBlock( | ||
| parts = listOf(GeminiPreviewImageRequest.TextBlock(prompt)), | ||
| ), | ||
| ), | ||
| ) | ||
|
|
||
| val response = googleImageWebClient.post() | ||
| .uri("v1beta/models/gemini-2.5-flash-image-preview:generateContent") | ||
| .bodyValue(request) | ||
| .retrieve() | ||
| .onStatus(HttpStatusCode::isError) { res -> | ||
| res.bodyToMono(String::class.java).flatMap { body -> | ||
| val msg = "Gemini image API error: ${res.statusCode()} body=$body" | ||
| Mono.error(IllegalStateException(msg)) | ||
| } | ||
| } | ||
| .bodyToMono(GeminiPreviewImageResponse::class.java) | ||
| .block() ?: error("Empty response from Gemini image API") | ||
|
|
||
|
|
||
| val base64Data = response.candidates | ||
| .firstOrNull() | ||
| ?.content | ||
| ?.parts | ||
| ?.firstOrNull { it.inlineData != null } | ||
| ?.inlineData | ||
| ?.data | ||
| ?: error("Image data missing") | ||
| return Base64.getDecoder().decode(base64Data) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package app.cardcapture.ai.outbound.imageai.google.dto | ||
|
|
||
| data class GeminiPreviewImageRequest( | ||
| val contents: List<ImagerPartsBlock> | ||
| ) { | ||
|
|
||
| data class ImagerPartsBlock( | ||
| val parts: List<TextBlock> | ||
| ) | ||
|
|
||
| data class TextBlock( | ||
| val text: String | ||
| ) | ||
|
Member
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. 가독성과 재사용성을 위해 GeminiPreviewImageRequest, ImagerPartsBlock, TextBlock를 각각 별도 파일로 분리하면 어떨까용? |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package app.cardcapture.ai.outbound.imageai.google.dto | ||
|
|
||
|
|
||
| data class GeminiPreviewImageResponse( | ||
| val candidates: List<Candidate>, | ||
| val modelVersion: String? = null | ||
| ) { | ||
| data class Candidate( | ||
| val content: Content? | ||
| ) | ||
|
|
||
| data class Content( | ||
| val parts: List<Part>? | ||
| ) | ||
|
|
||
| data class Part( | ||
| val text: String? = null, | ||
| val inlineData: InlineData? = null | ||
| ) | ||
|
|
||
| data class InlineData( | ||
| val mimeType: String? = null, | ||
| val data: String? = null | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package app.cardcapture.ai.outbound.llm.openai | ||
|
|
||
| import app.cardcapture.ai.domain.LlmRequest | ||
| import app.cardcapture.ai.domain.LlmResponse | ||
| import app.cardcapture.ai.outbound.llm.openai.dto.GptCompletionMessageRequest | ||
| import app.cardcapture.ai.outbound.llm.openai.dto.GptCompletionRequest | ||
| import app.cardcapture.ai.outbound.llm.openai.dto.GptMessageRole | ||
| import app.cardcapture.ai.outbound.llm.openai.dto.GptResponseFormat | ||
| import app.cardcapture.ai.port.outbound.LlmSenderPort | ||
| import com.fasterxml.jackson.databind.ObjectMapper | ||
| import org.springframework.http.HttpStatusCode | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.web.reactive.function.client.WebClient | ||
| import reactor.core.publisher.Mono | ||
|
|
||
|
|
||
| @Component | ||
| class LlmSenderAdapter( | ||
|
Member
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. 위에서 GeminiPreviewImageRequest를 사용했는데, 해당 클래스도 GPT 전용으로 사용한다면 네이밍에 OpenAI나 GPT가 들어가면 어떨까요? |
||
| private val openAiWebClient: WebClient, | ||
| private val objectMapper: ObjectMapper | ||
| ) : LlmSenderPort { | ||
|
|
||
| override fun send(request: LlmRequest): LlmResponse { | ||
|
|
||
|
|
||
|
Member
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. 여기 공백은 0~1줄이 좋아 보입니다! |
||
| val request = GptCompletionRequest( | ||
|
Member
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. send 메서드에 파라미터도 request고, 메서드 내부에도 request가 있어서 헷갈릴 수 있어 보여용 |
||
| model = request.model.model, | ||
| stream = false, | ||
| messages = listOf( | ||
| GptCompletionMessageRequest( | ||
| role = GptMessageRole.SYSTEM, | ||
| content = request.system, | ||
| ), | ||
| GptCompletionMessageRequest( | ||
| role = GptMessageRole.USER, | ||
| content = request.user, | ||
| ), | ||
| ), | ||
| response_format = GptResponseFormat( | ||
| type = "json_object", | ||
| ), | ||
| ) | ||
|
|
||
| val result = openAiWebClient.post() | ||
| .uri("/chat/completions") | ||
| .bodyValue(request) | ||
| .retrieve() | ||
| .onStatus(HttpStatusCode::isError) { res -> | ||
| res.bodyToMono(String::class.java).flatMap { body -> | ||
| val msg = "Open AI error : ${res.statusCode()} body = $body" | ||
| Mono.error(IllegalStateException(msg)) | ||
| } | ||
| } | ||
| .bodyToMono(String::class.java) | ||
| .block() | ||
| val content = objectMapper.readTree(result)["choices"][0]["message"]["content"].asText() | ||
|
|
||
| /* | ||
| * TODO: content error handling ( empty value ) | ||
| */ | ||
|
|
||
| return LlmResponse( | ||
| jsonValueRaw = content, | ||
| resourceUsage = null, | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package app.cardcapture.ai.outbound.llm.openai | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value | ||
| import org.springframework.context.annotation.Bean | ||
| import org.springframework.context.annotation.Configuration | ||
| import org.springframework.http.HttpHeaders | ||
| import org.springframework.web.reactive.function.client.WebClient | ||
|
|
||
| @Configuration | ||
| class OpenAiConfig ( | ||
| @Value("\${openai.key}") private val apiKey: String | ||
| ){ | ||
|
|
||
| private val baseUrl = "https://api.openai.com/v1" | ||
|
|
||
| @Bean | ||
| fun openAiWebClient(): WebClient { | ||
| /* | ||
| * @TODO: exception Handling | ||
| */ | ||
|
Member
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. 요기 TODO내용들은 다음 PR에서 다룰 예정인지 궁금합니당! |
||
| return WebClient.builder() | ||
| .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer $apiKey") | ||
| .baseUrl(baseUrl) | ||
| .build() | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package app.cardcapture.ai.outbound.llm.openai.dto | ||
|
|
||
| data class GptCompletionMessageRequest( | ||
| val role : GptMessageRole, | ||
| val content: String, | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package app.cardcapture.ai.outbound.llm.openai.dto | ||
|
|
||
| data class GptCompletionRequest( | ||
| val messages: List<GptCompletionMessageRequest>, | ||
| val model: String, | ||
| val stream: Boolean, | ||
| val response_format: GptResponseFormat | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package app.cardcapture.ai.outbound.llm.openai.dto | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonValue | ||
|
|
||
| enum class GptMessageRole { | ||
| SYSTEM, | ||
| USER, | ||
| ASSISTANT; | ||
|
|
||
| @JsonValue | ||
| override fun toString(): String { | ||
| return name.lowercase() | ||
| } | ||
|
Member
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. toString 구현 후 소문자로 바꿔주시는 이유 궁금해요! |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package app.cardcapture.ai.outbound.llm.openai.dto | ||
|
|
||
| data class GptResponseFormat ( | ||
| val type: String | ||
| ){ | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
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.
이거 패키지 추가할 때마다 여기에 다 적어줘야하면 불편함이 클 거 같아요 백로그 담아두고 추후 개선해보겠습니다 🫡
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.
물론 인혁님이 해주셔도 좋아용!! ㅋㅋㅋㅋ