Skip to content
Open
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,4 @@ jobs:
${{ runner.os }}-gradle-

- name: Run platform tests
run: ./gradlew :platform-api:test :auth-domain:test :auth-application:test :auth-adapter:test :member-domain:test :member-application:test :member-adapter:test
run: ./gradlew :platform-api:test :auth-domain:test :auth-application:test :auth-adapter:test :member-domain:test :member-application:test :member-adapter:test :template-adapter:test :template-application:test :template-domain:test :ai-adapter:test :ai-application:test :ai-domain:test
Copy link
Copy Markdown
Member

@inpink inpink Nov 16, 2025

Choose a reason for hiding this comment

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

이거 패키지 추가할 때마다 여기에 다 적어줘야하면 불편함이 클 거 같아요 백로그 담아두고 추후 개선해보겠습니다 🫡

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

물론 인혁님이 해주셔도 좋아용!! ㅋㅋㅋㅋ

Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class SecurityConfig(
private val WHITE_LIST = arrayOf(
"/actuator/**",
"/api/v1/dev/auth/**",
"/api/v1/auth/**"
"/api/v1/auth/**",
)
}
@Bean
Expand Down
27 changes: 27 additions & 0 deletions platform/ai/adapter/build.gradle.kts
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")
}

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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

요기도 클래스명에 모델명을 명시하면 좋을 거 같아요! 추후 모델 변경에 대비해 ImageModelPort { 로 추상화된 점은 좋다고 생각합니당

) : ImageModelPort {

override fun generate(prompt: String, model: String): ByteArray {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 {


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

여기 공백은 0~1줄이 좋아 보입니다!

val request = GptCompletionRequest(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
*/
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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()
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
){
}
Loading