From 168e685475a99bbdc1fd2ede81a0a4d7c2479249 Mon Sep 17 00:00:00 2001 From: inhyeok Date: Fri, 17 Oct 2025 14:27:11 +0900 Subject: [PATCH 01/11] =?UTF-8?q?Feat:=20Ai,=20template=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- platform/ai/adapter/build.gradle.kts | 26 +++++++++++++++ .../outbound/llm/openai/LlmSenderAdapter.kt | 10 ++++++ .../ai/outbound/llm/openai/OpenAiConfig.kt | 8 +++++ platform/ai/application/build.gradle.kts | 20 +++++++++++ .../port/inbound/AiTemplateDesignUseCase.kt | 8 +++++ .../inbound/dto/AiTemplateDesignCommand.kt | 33 +++++++++++++++++++ .../ai/port/outbound/LlmSenderPort.kt | 6 ++++ .../ai/service/AiTemplateDesignService.kt | 13 ++++++++ platform/ai/domain/build.gradle.kts | 11 +++++++ .../app/cardcapture/ai/domain/ImageModel.kt | 11 +++++++ .../app/cardcapture/ai/domain/LlmModel.kt | 11 +++++++ platform/template/adapter/build.gradle.kts | 27 +++++++++++++++ .../adapter/inbound/web/TemplateController.kt | 7 ++++ .../web/dto/GenerateCardTemplateRequest.kt | 10 ++++++ .../outbound/AiTemplateDesignAdapter.kt | 24 ++++++++++++++ .../template/application/build.gradle.kts | 19 +++++++++++ .../inbound/GenerateCardTemplateUseCase.kt | 9 +++++ .../dto/GenerateCardTemplateCommand.kt | 11 +++++++ .../port/outbound/TemplateDesignPort.kt | 8 +++++ .../service/GenerateCardTemplateService.kt | 27 +++++++++++++++ platform/template/domain/build.gradle.kts | 11 +++++++ .../domain/src/main/kotlin/domain/Card.kt | 4 +++ .../src/main/kotlin/domain/CardBackground.kt | 8 +++++ .../src/main/kotlin/domain/CardLayer.kt | 9 +++++ .../domain/src/main/kotlin/domain/Position.kt | 12 +++++++ .../domain/src/main/kotlin/domain/Template.kt | 10 ++++++ .../kotlin/domain/TemplateDesignPrompt.kt | 10 ++++++ settings.gradle.kts | 12 +++++++ 28 files changed, 375 insertions(+) create mode 100644 platform/ai/adapter/build.gradle.kts create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt create mode 100644 platform/ai/application/build.gradle.kts create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt create mode 100644 platform/ai/domain/build.gradle.kts create mode 100644 platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt create mode 100644 platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt create mode 100644 platform/template/adapter/build.gradle.kts create mode 100644 platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt create mode 100644 platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt create mode 100644 platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt create mode 100644 platform/template/application/build.gradle.kts create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/TemplateDesignPort.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt create mode 100644 platform/template/domain/build.gradle.kts create mode 100644 platform/template/domain/src/main/kotlin/domain/Card.kt create mode 100644 platform/template/domain/src/main/kotlin/domain/CardBackground.kt create mode 100644 platform/template/domain/src/main/kotlin/domain/CardLayer.kt create mode 100644 platform/template/domain/src/main/kotlin/domain/Position.kt create mode 100644 platform/template/domain/src/main/kotlin/domain/Template.kt create mode 100644 platform/template/domain/src/main/kotlin/domain/TemplateDesignPrompt.kt diff --git a/platform/ai/adapter/build.gradle.kts b/platform/ai/adapter/build.gradle.kts new file mode 100644 index 0000000..f573bad --- /dev/null +++ b/platform/ai/adapter/build.gradle.kts @@ -0,0 +1,26 @@ +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("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") + +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt new file mode 100644 index 0000000..dae9ead --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt @@ -0,0 +1,10 @@ +package app.cardcapture.ai.outbound.llm.openai + +import app.cardcapture.ai.port.outbound.LlmSenderPort + +class LlmSenderAdapter : LlmSenderPort { + + override fun send() { + TODO("Not yet implemented") + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt new file mode 100644 index 0000000..1eb2167 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt @@ -0,0 +1,8 @@ +package app.cardcapture.ai.outbound.llm.openai + +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenAiConfig { + +} diff --git a/platform/ai/application/build.gradle.kts b/platform/ai/application/build.gradle.kts new file mode 100644 index 0000000..c778068 --- /dev/null +++ b/platform/ai/application/build.gradle.kts @@ -0,0 +1,20 @@ + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("io.spring.dependency-management") +} + + +dependencies { + implementation(project(":ai-domain")) + implementation("org.springframework:spring-context") + implementation("org.springframework:spring-tx") + implementation("org.springframework:spring-aop") + + + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(group = "org.mockito") + } + +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt new file mode 100644 index 0000000..990cdf5 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt @@ -0,0 +1,8 @@ +package app.cardcapture.ai.port.inbound + +import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand + +interface AiTemplateDesignUseCase { + + fun design(command: AiTemplateDesignCommand): String +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt new file mode 100644 index 0000000..7e51223 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt @@ -0,0 +1,33 @@ +package app.cardcapture.ai.port.inbound.dto + +import app.cardcapture.ai.domain.ImageModel +import app.cardcapture.ai.domain.LlmModel + +data class AiTemplateDesignCommand private constructor( + val llmModel: LlmModel, + val imageModel: ImageModel, + val purpose: String, + val texts: List, + val prompt: String, +) { + + companion object { + + fun ofOtThrow( + llmModel: String, + imageModel: String, + purpose: String, + texts: List, + prompt: String + ): AiTemplateDesignCommand { + + return AiTemplateDesignCommand( + llmModel = LlmModel.from(llmModel), + imageModel = ImageModel.from(imageModel), + purpose = purpose, + texts = texts, + prompt = prompt, + ) + } + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt new file mode 100644 index 0000000..dcbde3e --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt @@ -0,0 +1,6 @@ +package app.cardcapture.ai.port.outbound + +interface LlmSenderPort { + + fun send() +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt new file mode 100644 index 0000000..6ba10e9 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt @@ -0,0 +1,13 @@ +package app.cardcapture.ai.service + +import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase +import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand +import org.springframework.stereotype.Service + +@Service +class AiTemplateDesignService : AiTemplateDesignUseCase { + + override fun design(command: AiTemplateDesignCommand): String { + TODO("Not yet implemented") + } +} diff --git a/platform/ai/domain/build.gradle.kts b/platform/ai/domain/build.gradle.kts new file mode 100644 index 0000000..9f57929 --- /dev/null +++ b/platform/ai/domain/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + kotlin("jvm") +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + // 순수 도메인 +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt new file mode 100644 index 0000000..e6f66af --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt @@ -0,0 +1,11 @@ +package app.cardcapture.ai.domain + +enum class ImageModel { + NANO_BANANA; + + companion object { + fun from(name: String): ImageModel = + ImageModel.entries.find { it.name.equals(name, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported imageModel: $name") + } +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt new file mode 100644 index 0000000..3fba096 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt @@ -0,0 +1,11 @@ +package app.cardcapture.ai.domain + +enum class LlmModel { + GPT, GEMINI; + + companion object { + fun from(name: String): LlmModel = + entries.find { it.name.equals(name, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported LlmModel: $name") + } +} diff --git a/platform/template/adapter/build.gradle.kts b/platform/template/adapter/build.gradle.kts new file mode 100644 index 0000000..f149f90 --- /dev/null +++ b/platform/template/adapter/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") +} + +dependencies { + + implementation(project(":template-domain")) + implementation(project(":template-application")) + implementation(project(":ai-application")) + + implementation(project(":security")) + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + 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") + +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt new file mode 100644 index 0000000..a23c00b --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt @@ -0,0 +1,7 @@ +package app.cardcapture.template.adapter.inbound.web + +import org.springframework.web.bind.annotation.RestController + +@RestController +class TemplateController { +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt new file mode 100644 index 0000000..6e0a68e --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt @@ -0,0 +1,10 @@ +package app.cardcapture.template.adapter.inbound.web.dto + +// TODO: validation +data class GenerateCardTemplateRequest( + val model: String, + val color: String, + val texts: List, + val purpose: String +) { +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt new file mode 100644 index 0000000..628a535 --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt @@ -0,0 +1,24 @@ +package app.cardcapture.template.adapter.outbound + +import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase +import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand +import app.cardcapture.template.application.port.outbound.TemplateDesignPort +import domain.TemplateDesignPrompt +import org.springframework.stereotype.Component + + +@Component +class AiTemplateDesignAdapter( + private val aiTemplateDesignUseCase: AiTemplateDesignUseCase +) : TemplateDesignPort { + + override fun design(prompt: TemplateDesignPrompt): String { + return aiTemplateDesignUseCase.design(AiTemplateDesignCommand.ofOtThrow( + llmModel = "GPT", + imageModel = prompt.model, + purpose = prompt.purpose, + texts = prompt.texts, + prompt = prompt.prompt + )) + } +} diff --git a/platform/template/application/build.gradle.kts b/platform/template/application/build.gradle.kts new file mode 100644 index 0000000..9bd9464 --- /dev/null +++ b/platform/template/application/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("io.spring.dependency-management") +} + + +dependencies { + implementation(project(":template-domain")) + implementation("org.springframework:spring-context") + implementation("org.springframework:spring-tx") + implementation("org.springframework:spring-aop") + + + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(group = "org.mockito") + } + +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt new file mode 100644 index 0000000..4f93214 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt @@ -0,0 +1,9 @@ +package app.cardcapture.template.application.port.inbound + +import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand +import domain.Template + +interface GenerateCardTemplateUseCase { + + fun generate(command : GenerateCardTemplateCommand): Template +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt new file mode 100644 index 0000000..73008d5 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt @@ -0,0 +1,11 @@ +package app.cardcapture.template.application.port.inbound.dto + +// TODO: class mapping +data class GenerateCardTemplateCommand ( + val purpose: String, + val texts: List, + val color: String, + val model: String, + val prompt: String, +){ +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/TemplateDesignPort.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/TemplateDesignPort.kt new file mode 100644 index 0000000..476da4e --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/TemplateDesignPort.kt @@ -0,0 +1,8 @@ +package app.cardcapture.template.application.port.outbound + +import domain.TemplateDesignPrompt + +interface TemplateDesignPort { + + fun design(prompt: TemplateDesignPrompt): String +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt new file mode 100644 index 0000000..99cfbdf --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt @@ -0,0 +1,27 @@ +package app.cardcapture.template.application.service + +import app.cardcapture.template.application.port.inbound.GenerateCardTemplateUseCase +import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand +import app.cardcapture.template.application.port.outbound.TemplateDesignPort +import domain.Template +import domain.TemplateDesignPrompt +import org.springframework.stereotype.Service + +@Service +class GenerateCardTemplateService( + private val templateDesignPort: TemplateDesignPort +): GenerateCardTemplateUseCase { + + override fun generate(command: GenerateCardTemplateCommand): Template { + val prompt = TemplateDesignPrompt( + purpose = command.purpose, + texts = command.texts, + color = command.color, + prompt = command.prompt, + model = command.model + ) + + val templateDesign = templateDesignPort.design(prompt) + TODO("Not yet implemented") + } +} diff --git a/platform/template/domain/build.gradle.kts b/platform/template/domain/build.gradle.kts new file mode 100644 index 0000000..9f57929 --- /dev/null +++ b/platform/template/domain/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + kotlin("jvm") +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + // 순수 도메인 +} diff --git a/platform/template/domain/src/main/kotlin/domain/Card.kt b/platform/template/domain/src/main/kotlin/domain/Card.kt new file mode 100644 index 0000000..464e846 --- /dev/null +++ b/platform/template/domain/src/main/kotlin/domain/Card.kt @@ -0,0 +1,4 @@ +package domain + +class Card { +} diff --git a/platform/template/domain/src/main/kotlin/domain/CardBackground.kt b/platform/template/domain/src/main/kotlin/domain/CardBackground.kt new file mode 100644 index 0000000..bd2effe --- /dev/null +++ b/platform/template/domain/src/main/kotlin/domain/CardBackground.kt @@ -0,0 +1,8 @@ +package domain + +class CardBackground( + val url : String, + val opacity: Int, + val color: String +) { +} diff --git a/platform/template/domain/src/main/kotlin/domain/CardLayer.kt b/platform/template/domain/src/main/kotlin/domain/CardLayer.kt new file mode 100644 index 0000000..dc4d0d3 --- /dev/null +++ b/platform/template/domain/src/main/kotlin/domain/CardLayer.kt @@ -0,0 +1,9 @@ +package domain + +class CardLayer( + val type: String, + val content: String, + val id : Int, + val position: Position +) { +} diff --git a/platform/template/domain/src/main/kotlin/domain/Position.kt b/platform/template/domain/src/main/kotlin/domain/Position.kt new file mode 100644 index 0000000..4803382 --- /dev/null +++ b/platform/template/domain/src/main/kotlin/domain/Position.kt @@ -0,0 +1,12 @@ +package domain + +class Position( + val x : Int, + val y : Int, + val width: Int, + val height: Int, + val rotate: Int, + val zIndex: Int, + val opacity: Int +) { +} diff --git a/platform/template/domain/src/main/kotlin/domain/Template.kt b/platform/template/domain/src/main/kotlin/domain/Template.kt new file mode 100644 index 0000000..b516d4d --- /dev/null +++ b/platform/template/domain/src/main/kotlin/domain/Template.kt @@ -0,0 +1,10 @@ +package domain + + +class Template( + val id : Long, + val prompt: String, + val fileUrl: String?, + val cards: MutableList +) { +} diff --git a/platform/template/domain/src/main/kotlin/domain/TemplateDesignPrompt.kt b/platform/template/domain/src/main/kotlin/domain/TemplateDesignPrompt.kt new file mode 100644 index 0000000..edbdbd7 --- /dev/null +++ b/platform/template/domain/src/main/kotlin/domain/TemplateDesignPrompt.kt @@ -0,0 +1,10 @@ +package domain + +data class TemplateDesignPrompt( + val purpose: String, + val texts: List, + val color: String, + val prompt: String, + val model : String, +) { +} diff --git a/settings.gradle.kts b/settings.gradle.kts index af22f43..7571d4a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,12 @@ include( "member-domain", "member-application", "member-adapter", + "ai-domain", + "ai-application", + "ai-adapter", + "template-domain", + "template-application", + "template-adapter", "platform-api") project(":auth-domain").projectDir = file("platform/auth/domain") @@ -18,6 +24,12 @@ project(":auth-adapter").projectDir = file("platform/auth/adapter") project(":member-domain").projectDir = file("platform/member/domain") project(":member-application").projectDir = file("platform/member/application") project(":member-adapter").projectDir = file("platform/member/adapter") +project(":ai-domain").projectDir = file("platform/ai/domain") +project(":ai-application").projectDir = file("platform/ai/application") +project(":ai-adapter").projectDir = file("platform/ai/adapter") +project(":template-domain").projectDir = file("platform/template/domain") +project(":template-application").projectDir = file("platform/template/application") +project(":template-adapter").projectDir = file("platform/template/adapter") project(":platform-api").projectDir = file("platform/services/api") From 71fb24ecaf7f779c75064500b1c14eccb2a71121 Mon Sep 17 00:00:00 2001 From: inhyeok Date: Tue, 4 Nov 2025 21:15:58 +0900 Subject: [PATCH 02/11] =?UTF-8?q?Feat:=20LLM=EC=97=B0=EB=8F=99=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=83=9D=EC=84=B1=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이미지 연동 제외 --- .../lib/security/SecurityConfig.kt | 4 +- .../outbound/llm/openai/LlmSenderAdapter.kt | 63 +++- .../ai/outbound/llm/openai/OpenAiConfig.kt | 21 +- .../ai/outbound/llm/openai/TestCon.kt | 30 ++ .../openai/dto/GptCompletionMessageRequest.kt | 7 + .../llm/openai/dto/GptCompletionRequest.kt | 9 + .../outbound/llm/openai/dto/GptMessageRole.kt | 14 + .../llm/openai/dto/GptResponseFormat.kt | 6 + .../prompt/SimplePromptLoaderAdapter.kt | 324 ++++++++++++++++++ platform/ai/application/build.gradle.kts | 1 + .../ai/model/TemplateDesignMetadata.kt | 45 +++ .../ai/model/TemplateLayoutPlan.kt | 48 +++ .../port/inbound/AiTemplateDesignUseCase.kt | 3 +- .../inbound/dto/AiTemplateDesignCommand.kt | 3 + .../ai/port/outbound/LlmSenderPort.kt | 5 +- .../ai/service/AiTemplateDesignService.kt | 13 +- .../cardcapture/ai/service/LayoutPlanner.kt | 41 +++ .../ai/service/MetadataExtractor.kt | 46 +++ .../app/cardcapture/ai/domain/ImageModel.kt | 2 +- .../app/cardcapture/ai/domain/LlmModel.kt | 4 +- .../app/cardcapture/ai/domain/LlmRequest.kt | 8 + .../app/cardcapture/ai/domain/LlmResponse.kt | 8 + .../cardcapture/ai/domain/prompt/PromptId.kt | 7 + .../ai/domain/prompt/PromptLoader.kt | 6 + .../ai/domain/prompt/PromptSpec.kt | 7 + platform/services/api/build.gradle.kts | 2 + .../api/src/main/resources/application.yml | 3 + .../adapter/inbound/web/TemplateController.kt | 26 +- .../web/dto/GenerateCardTemplateRequest.kt | 1 - .../web/dto/GeneratedCardTemplateResponse.kt | 40 +++ .../outbound/AiTemplateDesignAdapter.kt | 74 +++- .../outbound/GeneratedImagePortImpl.kt | 16 + .../model/AiTemplateDesignResult.kt | 8 + .../application/model/BackgroundModeDto.kt | 5 + .../application/model/BackgroundPlanDto.kt | 10 + .../application/model/PlannedImageLayerDto.kt | 10 + .../application/model/PlannedLayerDto.kt | 7 + .../application/model/PlannedTextLayerDto.kt | 10 + .../template/application/model/PositionDto.kt | 12 + .../port/outbound/AiTemplateDesignPort.kt | 9 + .../port/outbound/GenerateImagePort.kt | 7 + .../port/outbound/TemplateDesignPort.kt | 8 - .../dto/AiTemplateDesignPortCommand.kt} | 4 +- .../outbound/dto/GenerateImagePortCommand.kt | 8 + .../service/EditorJsonConverter.kt | 95 +++++ .../service/GenerateCardTemplateService.kt | 85 ++++- .../domain/src/main/kotlin/domain/Card.kt | 4 - .../src/main/kotlin/domain/CardBackground.kt | 8 - .../src/main/kotlin/domain/CardLayer.kt | 9 - .../src/main/kotlin/domain/EditorPayload.kt | 7 + .../domain/src/main/kotlin/domain/Position.kt | 12 - .../domain/src/main/kotlin/domain/Template.kt | 14 +- 52 files changed, 1138 insertions(+), 81 deletions(-) create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/TestCon.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionMessageRequest.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionRequest.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptMessageRole.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptResponseFormat.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadata.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlan.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt create mode 100644 platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmRequest.kt create mode 100644 platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmResponse.kt create mode 100644 platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptId.kt create mode 100644 platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptLoader.kt create mode 100644 platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptSpec.kt create mode 100644 platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GeneratedCardTemplateResponse.kt create mode 100644 platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImagePortImpl.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/AiTemplateDesignResult.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundModeDto.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedImageLayerDto.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedLayerDto.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedTextLayerDto.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PositionDto.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/AiTemplateDesignPort.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/GenerateImagePort.kt delete mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/TemplateDesignPort.kt rename platform/template/{domain/src/main/kotlin/domain/TemplateDesignPrompt.kt => application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt} (56%) create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt create mode 100644 platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt delete mode 100644 platform/template/domain/src/main/kotlin/domain/Card.kt delete mode 100644 platform/template/domain/src/main/kotlin/domain/CardBackground.kt delete mode 100644 platform/template/domain/src/main/kotlin/domain/CardLayer.kt create mode 100644 platform/template/domain/src/main/kotlin/domain/EditorPayload.kt delete mode 100644 platform/template/domain/src/main/kotlin/domain/Position.kt diff --git a/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt b/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt index 32bb2c9..dbe97d5 100644 --- a/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt +++ b/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt @@ -22,7 +22,9 @@ class SecurityConfig( private val WHITE_LIST = arrayOf( "/actuator/**", "/api/v1/dev/auth/**", - "/api/v1/auth/**" + "/api/v1/auth/**", + "/test/ai", + "/test/ai2", ) } @Bean diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt index dae9ead..8d6d492 100644 --- a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt @@ -1,10 +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 -class LlmSenderAdapter : LlmSenderPort { - override fun send() { - TODO("Not yet implemented") +@Component +class LlmSenderAdapter( + private val openAiWebClient: WebClient, + private val objectMapper: ObjectMapper +) : LlmSenderPort { + + override fun send(request: LlmRequest): LlmResponse { + + + val request = GptCompletionRequest( + 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, + ) } } diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt index 1eb2167..2eabe8e 100644 --- a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt @@ -1,8 +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 { +class OpenAiConfig ( + @Value("\${openai.key}") private val apiKey: String +){ + + private val baseUrl = "https://api.openai.com/v1" + + @Bean + fun openAiWebClient(): WebClient { + /* + * @TODO: exception Handling + */ + return WebClient.builder() + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer $apiKey") + .baseUrl(baseUrl) + .build() + } } diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/TestCon.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/TestCon.kt new file mode 100644 index 0000000..e08bb77 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/TestCon.kt @@ -0,0 +1,30 @@ +package app.cardcapture.ai.outbound.llm.openai + +import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase +import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class TestCon( + private val aiTemplateDesignUseCase: AiTemplateDesignUseCase +) { + + @GetMapping("/test/ai") + fun test(){ + val backGroundimagepropt = "오른쪽 아래에 커피 아이콘을, 왼쪽아래에는 자판기 아이콘이 있으면 좋을것 같아, 글자룰 크게 만들어서 강조해줘!, 배경으로 커피자판기를 마시는 사람들이 있는 사진이 백그라운드에 있었으면 좋겠어" + val nonbackGroundimagepropt = "오른쪽 아래에 커피 아이콘을, 왼쪽아래에는 자판기 아이콘이 있으면 좋을것 같아, 글자룰 크게 만들어서 강조해줘!," + + val req = AiTemplateDesignCommand.ofOtThrow( + llmModel = "GPT", + imageModel = "nanobanana", + purpose = "커피 자판기 홍보 목적", + prompt = backGroundimagepropt, + color = "#FFFFFF", + texts = listOf("새로운 커피 자판기를 만나보세요!", "많은 커피를 저렴한 가격에!") + ) + + aiTemplateDesignUseCase.design(req) + } + +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionMessageRequest.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionMessageRequest.kt new file mode 100644 index 0000000..a18b426 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionMessageRequest.kt @@ -0,0 +1,7 @@ +package app.cardcapture.ai.outbound.llm.openai.dto + +data class GptCompletionMessageRequest( + val role : GptMessageRole, + val content: String, +) { +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionRequest.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionRequest.kt new file mode 100644 index 0000000..f2ad636 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionRequest.kt @@ -0,0 +1,9 @@ +package app.cardcapture.ai.outbound.llm.openai.dto + +data class GptCompletionRequest( + val messages: List, + val model: String, + val stream: Boolean, + val response_format: GptResponseFormat +) { +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptMessageRole.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptMessageRole.kt new file mode 100644 index 0000000..b2dd474 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptMessageRole.kt @@ -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() + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptResponseFormat.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptResponseFormat.kt new file mode 100644 index 0000000..ee7cc52 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptResponseFormat.kt @@ -0,0 +1,6 @@ +package app.cardcapture.ai.outbound.llm.openai.dto + +data class GptResponseFormat ( + val type: String +){ +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt new file mode 100644 index 0000000..4bd5559 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt @@ -0,0 +1,324 @@ +package app.cardcapture.ai.outbound.prompt + +import app.cardcapture.ai.domain.prompt.PromptId +import app.cardcapture.ai.domain.prompt.PromptLoader +import app.cardcapture.ai.domain.prompt.PromptSpec +import org.springframework.stereotype.Component + +/** + * 코드에 하드코딩된 문자열을 Map으로 들고 있는 가장 단순한 로더. + * 이후 file/db 로더로 교체해도 인터페이스 동일. + */ + +@Component +class SimplePromptLoaderAdapter : PromptLoader { + + + + override fun load(id: PromptId): PromptSpec { + val system = registry[id.namespace to id.name] + ?: error("Prompt not found: ${id.namespace}/${id.name}") + return PromptSpec( + system = system, + version = "v0.0", + ) + } + + + + private val metadataPrompt = """ +You are a professional card-news design analyst. +Your job is to analyze the user's input and extract **structured metadata** for use in an automated card-news layout and image generation system. + +The result must be a **strict JSON object** compatible with a Kotlin data model. +Your output will later be converted into: +- background plan (color or image) +- text layers (with font & size) +- image layers (with image generation prompts) + +--- + +## Input +- Texts: {{texts}} +- Purpose: {{purpose}} +- Color: {{color}} +- User Prompt (optional): {{userPrompt}} + +--- + +## Task + +1. Analyze the input texts and decide: + - role, emphasis, and priority of each text block + - rough placement intention (top, center, bottom, left, right) + - proper font and size based on role/emphasis/mood. + +2. Decide whether the **background** should be a solid color or an image: + - If the user explicitly asks for a background image or clearly implies a visual scene, choose `"IMAGE"`. + - Otherwise, default to `"COLOR"` and use the color/palette. + +3. Decide what **images** (except background) are helpful: + - For each image, decide concept, purpose, importance, and rough placement. + - Also generate an English `prompt` string that can be used directly by an image generation model + (e.g., “a cute illustration of a cat studying at a desk, pastel colors, flat design, 4k”). + +Return **JSON ONLY** with the schema below. + +--- + +## JSON Schema (STRICT) + +```json +{ + "purpose": "string", + "mood": "string", + "style": "string", + "background": { + "mode": "COLOR or IMAGE", + "colorHex": "string (#RRGGBB) or null", + "prompt": "string or null", + "opacity": 0 + }, + "texts": [ + { + "role": "headline | subtitle | body | caption | cta", + "emphasis": "high | medium | low", + "priority": 1, + "text": "string", + "placement": "top-left | top-center | top-right | center-left | center | center-right | bottom-left | bottom-center | bottom-right | null", + "font": "Pretendard | NanumGothic | Jua | NotoSans | NotoSerif | BlackHanSans | DoHyeon | NanumPenScript | GothicA1 | Dongle | Sunflower | Hahmlet | Gugi | Gaegu | GamjaFlower | GowunDodum | GowunBatang | SongMyung | CuteFont | EastSeaDokdo | Stylish | SingleDay | YeonSung | GasoekOne | BagelFatOne | Orbit", + "size": "string like '18px' or '32px' (range 1px–128px)" + } + ], + "images": [ + { + "concept": "short English concept of the image (e.g. 'cute cat icon')", + "prompt": "full English prompt for an image generation model", + "purpose": "main | decorative | icon | background", + "importance": "high | medium | low", + "placement": "top-left | top-right | bottom-left | bottom-right | center | null" + } + ], + "colorScheme": { + "primary": "string (#RRGGBB)", + "secondary": "string (#RRGGBB)", + "text": "string (#RRGGBB)", + "accent": "string (#RRGGBB)", + "reasoning": "string" + }, + "constraints": ["array of strings"] +} + +Background Rules + +If the user clearly mentions or implies a background image (e.g. “배경에 벚꽃 사진 넣어줘”): +background.mode = "IMAGE" +background.prompt = detailed English prompt for the background image +background.colorHex can be null or a supporting color + +Otherwise (default): + +background.mode = "COLOR" + +background.colorHex = use colorScheme.primary or a suitable base color + +background.prompt = null + +background.opacity should be between 0 and 100. +Default to 100 unless a softer/faded background is explicitly better. + +Text Rules + +Always fill font and size: + +Headline: large size (e.g. 32–48px), bold or impactful font (e.g. Gugi, DoHyeon, BlackHanSans) + +Subtitle / body: 16–22px, readable fonts (e.g. Pretendard, NanumGothic, NotoSans) + +CTA: medium size (24–30px), clear and strong (e.g. DoHyeon, GasoekOne) + +Use placement when user intent is clear (e.g. “맨 아래 작게”, “위쪽 가운데 크게”). + +Limit to max 4 text blocks. If there are many sentences, group them logically. + +Image Rules + +For each recommended image (excluding background): + +concept is a short label (e.g. "cat with book icon"). + +prompt is a full, concrete English description for a generative image model. +Include style cues when possible (flat illustration, 3D render, pastel colors, etc.). + +purpose = "main" if it's a key visual, "icon" if small symbolic, "decorative" for supporting visuals. + +If no non-background images are needed, return "images": []. + +Color Rules + +Use purpose, mood, and style to choose a color palette: + +promotional / energetic → brighter primaries and accents + +professional → calmer, more muted palette with strong text contrast + +colorScheme.reasoning should briefly explain the choice. + +Constraints + +Derive any design, tone, or messaging constraints from: + +purpose (e.g., must look trustworthy, must feel playful) + +texts + +user instructions + +If there are no special constraints, return "constraints": []. + +Output + +✅ Output must be valid JSON only (no markdown, no comments). + +✅ All keys must be lowercase and match the schema. + +✅ Use null for unknown scalars and [] + """.trimIndent() + + private val layoutPrompt = """ + +You are a professional card-news layout designer. +You will receive a JSON object describing template design metadata as the **user message**. +Your task is to generate a final layout JSON for a single card (550x550 px). + +The user message will be a JSON with this schema (simplified): + +- purpose: string +- mood: string +- style: string +- background: { mode, colorHex, prompt, opacity } +- texts: array of text blocks with role, emphasis, priority, text, placement, font, size +- images: array of image blocks with concept, purpose, importance, placement +- colorScheme: color palette suggestion +- constraints: list of design/tone constraints + +--- + +## Your Task + +Using that metadata, plan a concrete visual layout: + +1. Decide final background: + - Use `background.mode`, `colorHex`, `prompt`, and `opacity` from metadata. + - If `mode` = `"COLOR"`, the card uses a solid color background. + - If `mode` = `"IMAGE"`, the card will later generate a background image using `prompt`. + +2. For each text you actually place in the card: + - Create a **text layer** with: + - id: unique integer, starting from 0 + - type: "text" + - role: copy from metadata (headline, body, cta, etc.) + - content: the original text (do NOT rewrite) + - font: use the font from metadata (or infer from role if missing) + - size: use the size from metadata (or infer from role/emphasis) + - position: x, y, width, height, rotate, zIndex, opacity + +3. For each image you place: + - Create an **image layer** with: + - id: unique integer + - type: "image" + - concept: short English label for the image (e.g. "cat icon") + - prompt: a full English description that can be sent to an image generation model + (style, mood, composition; e.g. "a cute flat illustration of a cat studying at a desk, pastel colors") + - position: x, y, width, height, rotate, zIndex, opacity + +The image URL will be filled later by another service, so **do not include any URL field**. + +--- + +## Position Rules + +- The card size is exactly 550x550. +- For every layer, position must be: + + - 0 <= x <= 550 + - 0 <= y <= 550 + - width > 0, height > 0 + - Keep at least 24 px margin from all edges when possible. + - Avoid heavy overlaps; slight overlaps are allowed only if visually reasonable. + +- `zIndex`: + - Background is implicit, behind all layers. + - Image layers typically have lower zIndex than text. + - Text layers (headline, CTA) should have the highest zIndex. + +- `opacity`: integer 0–100. + +When metadata includes `placement` hints like "top-center", "bottom-right", etc., +bias the element toward that area but you may shift it by 24–40 px for balance and collision avoidance. + +--- +## Output Format + +Return a single JSON object: + +{ + "background": { + "mode": "COLOR or IMAGE", + "colorHex": "#RRGGBB", + "prompt": "string or null", + "opacity": 100 + }, + "layers": [ + { + "id": 0, + "type": "text", + "role": "headline | subtitle | body | caption | cta", + "content": "string", + "font": "approved font name", + "size": "string like '24px'", + "concept": null, + "prompt": null, + "position": { ... } + }, + { + "id": 1, + "type": "image", + "role": null, + "content": null, + "font": null, + "size": null, + "concept": "short English concept", + "prompt": "full English prompt for image generation", + "position": { ... } + } + ] +} +Rules +✅ Output only JSON. No markdown, no comments, no explanations. + +✅ All keys must match the schema exactly and be lowercase (except "COLOR" / "IMAGE" values). + +✅ Text content must not be rewritten; use exactly the text from metadata. + +✅ Image prompt must be in English and descriptive. + +✅ Do not include any url field. + +✅ Ensure the JSON is strictly valid and can be parsed by a JSON parser. + +- If type = "text": role/content/font/size required, concept/prompt must be null. +- If type = "image": concept/prompt required, role/content/font/size must be null. +- Do NOT include any URL or generate flags. + + """.trimIndent() + + private val registry: Map, String> = mapOf( + // 예시: 템플릿 메타데이터 + ("templateDesign" to "metadata") to metadataPrompt, + + // 예시: 템플릿 레이아웃 스펙 플래너 + ("templateDesign" to "layout") to layoutPrompt, + ) + +} diff --git a/platform/ai/application/build.gradle.kts b/platform/ai/application/build.gradle.kts index c778068..57350a2 100644 --- a/platform/ai/application/build.gradle.kts +++ b/platform/ai/application/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation("org.springframework:spring-aop") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") testImplementation("org.springframework.boot:spring-boot-starter-test") { exclude(group = "org.mockito") } diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadata.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadata.kt new file mode 100644 index 0000000..402df32 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadata.kt @@ -0,0 +1,45 @@ +package app.cardcapture.ai.model + +data class TemplateDesignMetadata( + val purpose: String, + val mood: String, + val style: String, + val background: BackgroundBlock, + val texts: List, + val images: List, + val colorScheme: ColorScheme, + val constraints: List +) { + + data class BackgroundBlock( + val mode: String, + val colorHex: String?, + val prompt: String?, + val opacity: Int + ) + + data class TextBlock( + val role: String, // "headline", "subtitle", "body", "caption", "cta" 등 + val emphasis: String, // "high", "medium", "low" + val priority: Int, + val text: String, + val placement: String, // 선택적 위치 힌트 + val font: String? = null, // Quill 화이트리스트 중 하나 + val size: String? = null // "1px" ~ "128px" + ) + + data class ImageBlock( + val concept: String, + val purpose: String, // "main", "decorative", "icon" 등 + val importance: String, // "high", "medium", "low" + val placement: String// 선택적 위치 힌트 + ) + + data class ColorScheme( + val primary: String, + val secondary: String, + val text: String, + val accent: String, + val reasoning: String + ) +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlan.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlan.kt new file mode 100644 index 0000000..6753f88 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlan.kt @@ -0,0 +1,48 @@ +package app.cardcapture.ai.model + +data class TemplateLayoutPlan( + val background: LayoutBackground, + val layers: List +) { + + data class LayoutBackground( + val mode: String, // "COLOR" or "IMAGE" + val colorHex: String?, + val prompt: String?, + val opacity: Int + ) + + data class LayoutPosition( + val x: Int, + val y: Int, + val width: Int, + val height: Int, + val rotate: Int, + val zIndex: Int, + val opacity: Int + ) + + /** + * LLM이 주는 레이어를 그대로 받는 단일 DTO + * - type = "text" or "image" + * - text인 경우: role/content/font/size 필수, concept/prompt는 null + * - image인 경우: concept/prompt 필수, role/content/font/size는 null + */ + data class LayoutLayer( + val id: Int, + val type: String, // "text" or "image" + + // text 전용 필드 + val role: String? = null, // "headline" | "body" | ... + val content: String? = null, + val font: String? = null, + val size: String? = null, + + // image 전용 필드 + val concept: String? = null, + val prompt: String? = null, // 이미지 생성용 프롬프트 + + // 공통 + val position: LayoutPosition + ) +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt index 990cdf5..aeac2cd 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt @@ -1,8 +1,9 @@ package app.cardcapture.ai.port.inbound +import app.cardcapture.ai.model.TemplateLayoutPlan import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand interface AiTemplateDesignUseCase { - fun design(command: AiTemplateDesignCommand): String + fun design(command: AiTemplateDesignCommand): TemplateLayoutPlan } diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt index 7e51223..d52b8b0 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt @@ -8,6 +8,7 @@ data class AiTemplateDesignCommand private constructor( val imageModel: ImageModel, val purpose: String, val texts: List, + val color: String, val prompt: String, ) { @@ -18,6 +19,7 @@ data class AiTemplateDesignCommand private constructor( imageModel: String, purpose: String, texts: List, + color : String, prompt: String ): AiTemplateDesignCommand { @@ -26,6 +28,7 @@ data class AiTemplateDesignCommand private constructor( imageModel = ImageModel.from(imageModel), purpose = purpose, texts = texts, + color = color, prompt = prompt, ) } diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt index dcbde3e..7698c5a 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt @@ -1,6 +1,9 @@ package app.cardcapture.ai.port.outbound +import app.cardcapture.ai.domain.LlmRequest +import app.cardcapture.ai.domain.LlmResponse + interface LlmSenderPort { - fun send() + fun send(request: LlmRequest): LlmResponse } diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt index 6ba10e9..849f13c 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt @@ -1,13 +1,20 @@ package app.cardcapture.ai.service +import app.cardcapture.ai.model.TemplateLayoutPlan import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand import org.springframework.stereotype.Service @Service -class AiTemplateDesignService : AiTemplateDesignUseCase { +class AiTemplateDesignService( + private val metadataExtractor: MetadataExtractor, + private val layoutPlanner: LayoutPlanner +) : AiTemplateDesignUseCase { - override fun design(command: AiTemplateDesignCommand): String { - TODO("Not yet implemented") + override fun design(command: AiTemplateDesignCommand): TemplateLayoutPlan { + val metadata = metadataExtractor.extract(command) + + val result = layoutPlanner.plan(metadata, command.llmModel) + return result } } diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt new file mode 100644 index 0000000..a1f72d1 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt @@ -0,0 +1,41 @@ +package app.cardcapture.ai.service + +import app.cardcapture.ai.domain.LlmModel +import app.cardcapture.ai.domain.LlmRequest +import app.cardcapture.ai.domain.prompt.PromptId +import app.cardcapture.ai.domain.prompt.PromptLoader +import app.cardcapture.ai.model.TemplateDesignMetadata +import app.cardcapture.ai.model.TemplateLayoutPlan +import app.cardcapture.ai.port.outbound.LlmSenderPort +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.springframework.stereotype.Component + +@Component +class LayoutPlanner( + private val promptLoader: PromptLoader, + private val llmSenderPort: LlmSenderPort, + private val objectMapper: ObjectMapper +) { + + private val _NAME_SPACE: String = "templateDesign" + private val _NAME: String = "layout" + + fun plan(metadata: TemplateDesignMetadata, model : LlmModel) : TemplateLayoutPlan { + val prompt = promptLoader.load( + PromptId( + _NAME_SPACE, + _NAME + ) + ) + + val request = LlmRequest( + model = model, + system = prompt.system, + user = objectMapper.writeValueAsString(metadata) + ) + + val response = llmSenderPort.send(request) + return objectMapper.readValue(response.jsonValueRaw) + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt new file mode 100644 index 0000000..331e557 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt @@ -0,0 +1,46 @@ +package app.cardcapture.ai.service + +import app.cardcapture.ai.domain.LlmRequest +import app.cardcapture.ai.domain.prompt.PromptId +import app.cardcapture.ai.domain.prompt.PromptLoader +import app.cardcapture.ai.model.TemplateDesignMetadata +import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand +import app.cardcapture.ai.port.outbound.LlmSenderPort +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.springframework.stereotype.Component + +@Component +class MetadataExtractor( + private val promptLoader: PromptLoader, + private val objectMapper: ObjectMapper, + private val llmSenderPort: LlmSenderPort +) { + + private val _NAME_SPACE: String = "templateDesign" + private val _NAME: String = "metadata" + + fun extract(aiTemplateDesignCommand: AiTemplateDesignCommand): TemplateDesignMetadata { + + val prompt = promptLoader.load( + PromptId( + _NAME_SPACE, + _NAME, + ), + ) + val userContent = buildString { + appendLine("## Input") + appendLine("Texts: ${aiTemplateDesignCommand.texts.joinToString(" | ")}") + appendLine("Purpose: ${aiTemplateDesignCommand.purpose}") + appendLine("Color: ${aiTemplateDesignCommand.color}") + appendLine("User Prompt: ${aiTemplateDesignCommand.prompt ?: "null"}") + }.trim() + val request = LlmRequest( + model = aiTemplateDesignCommand.llmModel, + system = prompt.system, + user = userContent, + ) + val response = llmSenderPort.send(request) + return objectMapper.readValue(response.jsonValueRaw) + } +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt index e6f66af..dd5a9dc 100644 --- a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt @@ -1,7 +1,7 @@ package app.cardcapture.ai.domain enum class ImageModel { - NANO_BANANA; + NANOBANANA; companion object { fun from(name: String): ImageModel = diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt index 3fba096..a9c25e2 100644 --- a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt @@ -1,7 +1,7 @@ package app.cardcapture.ai.domain -enum class LlmModel { - GPT, GEMINI; +enum class LlmModel(val model : String) { + GPT("gpt-4o"), GEMINI("gemini-2.5-preview"); companion object { fun from(name: String): LlmModel = diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmRequest.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmRequest.kt new file mode 100644 index 0000000..5f04f90 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmRequest.kt @@ -0,0 +1,8 @@ +package app.cardcapture.ai.domain + +data class LlmRequest ( + val model : LlmModel, + val system: String, + val user : String +) { +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmResponse.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmResponse.kt new file mode 100644 index 0000000..0a36439 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmResponse.kt @@ -0,0 +1,8 @@ +package app.cardcapture.ai.domain + +data class LlmResponse( + val jsonValueRaw: String, + val resourceUsage: String? +) { + +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptId.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptId.kt new file mode 100644 index 0000000..4374c02 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptId.kt @@ -0,0 +1,7 @@ +package app.cardcapture.ai.domain.prompt + +data class PromptId( + val namespace: String, + val name : String, +) { +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptLoader.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptLoader.kt new file mode 100644 index 0000000..0445a82 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptLoader.kt @@ -0,0 +1,6 @@ +package app.cardcapture.ai.domain.prompt + +interface PromptLoader { + + fun load(id: PromptId): PromptSpec +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptSpec.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptSpec.kt new file mode 100644 index 0000000..06b9314 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptSpec.kt @@ -0,0 +1,7 @@ +package app.cardcapture.ai.domain.prompt + +data class PromptSpec( + val version: String, + val system: String, +) { +} diff --git a/platform/services/api/build.gradle.kts b/platform/services/api/build.gradle.kts index 5a3e368..be7ed33 100644 --- a/platform/services/api/build.gradle.kts +++ b/platform/services/api/build.gradle.kts @@ -10,6 +10,8 @@ dependencies { implementation(project(":security")) implementation(project(":auth-adapter")) implementation(project(":member-adapter")) + implementation(project(":ai-adapter")) + implementation(project(":template-adapter")) implementation("org.springframework.boot:spring-boot-starter-web") diff --git a/platform/services/api/src/main/resources/application.yml b/platform/services/api/src/main/resources/application.yml index e7fd033..af23bda 100644 --- a/platform/services/api/src/main/resources/application.yml +++ b/platform/services/api/src/main/resources/application.yml @@ -17,6 +17,9 @@ auth: secret-key: ${JWT_SECRET} access-token-expiration: 1h +openai: + key: ${OPENAI_KEY} + --- spring: diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt index a23c00b..6d33153 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt @@ -1,7 +1,31 @@ package app.cardcapture.template.adapter.inbound.web +import app.cardcapture.template.adapter.inbound.web.dto.GeneratedCardTemplateResponse +import app.cardcapture.template.application.port.inbound.GenerateCardTemplateUseCase +import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController @RestController -class TemplateController { +class TemplateController ( + private val generateCardTemplateUseCase: GenerateCardTemplateUseCase +){ + + @GetMapping("/test/ai2") + fun generate() : GeneratedCardTemplateResponse{ + val backGroundimagepropt = "오른쪽 아래에 커피 아이콘을, 왼쪽아래에는 자판기 아이콘이 있으면 좋을것 같아, 글자룰 크게 만들어서 강조해줘!, 배경으로 커피자판기를 마시는 사람들이 있는 사진이 백그라운드에 있었으면 좋겠어" + val nonbackGroundimagepropt = "오른쪽 아래에 커피 아이콘을, 왼쪽아래에는 자판기 아이콘이 있으면 좋을것 같아, 글자룰 크게 만들어서 강조해줘!," + + val command = GenerateCardTemplateCommand( + purpose = "커피 자판기 홍보 목적", + texts = listOf("새로운 커피 자판기를 만나보세요!", "많은 커피를 저렴한 가격에!"), + color = "#FFFFFF", + model = "nanobanana", + prompt = nonbackGroundimagepropt + ) + val result = generateCardTemplateUseCase.generate(command) + println(result) + + return GeneratedCardTemplateResponse.from(result) + } } diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt index 6e0a68e..56f228e 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt @@ -1,6 +1,5 @@ package app.cardcapture.template.adapter.inbound.web.dto -// TODO: validation data class GenerateCardTemplateRequest( val model: String, val color: String, diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GeneratedCardTemplateResponse.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GeneratedCardTemplateResponse.kt new file mode 100644 index 0000000..c9c7547 --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GeneratedCardTemplateResponse.kt @@ -0,0 +1,40 @@ +package app.cardcapture.template.adapter.inbound.web.dto + +import domain.Template + + +data class GeneratedCardTemplateResponse( + val id: Long, + val userId: Long, + val title: String, + val description: String, + val likes: Int, // TODO + val purchaseCount: Int, // TODO + val editor: String, + val fileUrl: String?, + val templateTags: List, + + ) { + + data class TemplateTagsResponse( + val english: String, + val korean: String, + ) + + companion object { + + fun from(template: Template): GeneratedCardTemplateResponse { + return GeneratedCardTemplateResponse( + id = template.id, + userId = template.userId, + title = template.title, + description = template.description, + likes = 0, + purchaseCount = 0, + editor = template.editorPayload.editor, + fileUrl = template.fileUrl, + templateTags = listOf(), + ) + } + } +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt index 628a535..9ff8a50 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt @@ -1,24 +1,74 @@ package app.cardcapture.template.adapter.outbound +import app.cardcapture.ai.model.TemplateLayoutPlan import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand -import app.cardcapture.template.application.port.outbound.TemplateDesignPort -import domain.TemplateDesignPrompt +import app.cardcapture.template.application.model.* +import app.cardcapture.template.application.port.outbound.AiTemplateDesignPort +import app.cardcapture.template.application.port.outbound.dto.AiTemplateDesignPortCommand import org.springframework.stereotype.Component @Component class AiTemplateDesignAdapter( private val aiTemplateDesignUseCase: AiTemplateDesignUseCase -) : TemplateDesignPort { - - override fun design(prompt: TemplateDesignPrompt): String { - return aiTemplateDesignUseCase.design(AiTemplateDesignCommand.ofOtThrow( - llmModel = "GPT", - imageModel = prompt.model, - purpose = prompt.purpose, - texts = prompt.texts, - prompt = prompt.prompt - )) +) : AiTemplateDesignPort { + + override fun design(prompt: AiTemplateDesignPortCommand): AiTemplateDesignResult { + val design: TemplateLayoutPlan = aiTemplateDesignUseCase.design( + AiTemplateDesignCommand.ofOtThrow( + llmModel = "GPT", + imageModel = prompt.model, + purpose = prompt.purpose, + texts = prompt.texts, + prompt = prompt.prompt, + color = prompt.color + ) + ) + + return design.toTemplateResult() } + + private fun TemplateLayoutPlan.toTemplateResult(): AiTemplateDesignResult { + val bg = BackgroundPlanDto( + mode = when (background.mode.uppercase()) { + "IMAGE" -> BackgroundModeDto.IMAGE + else -> BackgroundModeDto.COLOR + }, + colorHex = background.colorHex, + prompt = background.prompt, + url = null, + opacity = background.opacity + ) + + val layers = layers.map { layer -> + when (layer.type.lowercase()) { + "text" -> PlannedTextLayerDto( + id = layer.id, + position = layer.position.toDto(), + text = requireNotNull(layer.content) { "text layer content is null" }, + font = requireNotNull(layer.font) { "text layer font is null" }, + size = requireNotNull(layer.size) { "text layer size is null" } + ) + + "image" -> PlannedImageLayerDto( + id = layer.id, + position = layer.position.toDto(), + url = null, + generate = true, + prompt = requireNotNull(layer.prompt) { "image layer prompt is null" } + ) + + else -> error("Unknown layer type: ${layer.type}") + } + } + + return AiTemplateDesignResult( + background = bg, + layers = layers + ) + } + + private fun TemplateLayoutPlan.LayoutPosition.toDto(): PositionDto = + PositionDto(x, y, width, height, rotate, zIndex, opacity) } diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImagePortImpl.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImagePortImpl.kt new file mode 100644 index 0000000..ff6fcae --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImagePortImpl.kt @@ -0,0 +1,16 @@ +package app.cardcapture.template.adapter.outbound + +import app.cardcapture.template.application.port.outbound.GenerateImagePort +import app.cardcapture.template.application.port.outbound.dto.GenerateImagePortCommand +import org.springframework.stereotype.Component + +@Component +class GeneratedImagePortImpl ( + +): GenerateImagePort{ + + override fun generate(command: GenerateImagePortCommand): String { + + return "isUrl---!" + } +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/AiTemplateDesignResult.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/AiTemplateDesignResult.kt new file mode 100644 index 0000000..ccade3f --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/AiTemplateDesignResult.kt @@ -0,0 +1,8 @@ +package app.cardcapture.template.application.model + +data class AiTemplateDesignResult( + val background : BackgroundPlanDto, + val layers: List +){ + +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundModeDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundModeDto.kt new file mode 100644 index 0000000..227e19b --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundModeDto.kt @@ -0,0 +1,5 @@ +package app.cardcapture.template.application.model + +enum class BackgroundModeDto { + COLOR, IMAGE +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt new file mode 100644 index 0000000..63808c5 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt @@ -0,0 +1,10 @@ +package app.cardcapture.template.application.model + +data class BackgroundPlanDto( + val mode: BackgroundModeDto, + val colorHex: String? = null, + val url: String? = null, + val prompt: String? = null, + val opacity: Int = 100 +) { +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedImageLayerDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedImageLayerDto.kt new file mode 100644 index 0000000..e9e6098 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedImageLayerDto.kt @@ -0,0 +1,10 @@ +package app.cardcapture.template.application.model + +data class PlannedImageLayerDto( + override val id: Int, + override val position: PositionDto, + val url: String? = null, + val generate: Boolean = false, + val prompt: String +) : PlannedLayerDto { +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedLayerDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedLayerDto.kt new file mode 100644 index 0000000..316ff81 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedLayerDto.kt @@ -0,0 +1,7 @@ +package app.cardcapture.template.application.model + + +sealed interface PlannedLayerDto { + val id : Int + val position: PositionDto +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedTextLayerDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedTextLayerDto.kt new file mode 100644 index 0000000..cce8175 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedTextLayerDto.kt @@ -0,0 +1,10 @@ +package app.cardcapture.template.application.model + +data class PlannedTextLayerDto( + override val id: Int, + override val position: PositionDto, + val text: String, + val font: String, + val size: String +) : PlannedLayerDto { +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PositionDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PositionDto.kt new file mode 100644 index 0000000..e31898c --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PositionDto.kt @@ -0,0 +1,12 @@ +package app.cardcapture.template.application.model + +data class PositionDto( + val x: Int, + val y: Int, + val width: Int, + val height: Int, + val rotate: Int = 0, + val zIndex: Int = 0, + val opacity: Int = 100 +) + diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/AiTemplateDesignPort.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/AiTemplateDesignPort.kt new file mode 100644 index 0000000..f4f6dfa --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/AiTemplateDesignPort.kt @@ -0,0 +1,9 @@ +package app.cardcapture.template.application.port.outbound + +import app.cardcapture.template.application.model.AiTemplateDesignResult +import app.cardcapture.template.application.port.outbound.dto.AiTemplateDesignPortCommand + +interface AiTemplateDesignPort { + + fun design(prompt: AiTemplateDesignPortCommand): AiTemplateDesignResult +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/GenerateImagePort.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/GenerateImagePort.kt new file mode 100644 index 0000000..e152bb5 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/GenerateImagePort.kt @@ -0,0 +1,7 @@ +package app.cardcapture.template.application.port.outbound + +import app.cardcapture.template.application.port.outbound.dto.GenerateImagePortCommand + +interface GenerateImagePort { + fun generate(command: GenerateImagePortCommand): String +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/TemplateDesignPort.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/TemplateDesignPort.kt deleted file mode 100644 index 476da4e..0000000 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/TemplateDesignPort.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.cardcapture.template.application.port.outbound - -import domain.TemplateDesignPrompt - -interface TemplateDesignPort { - - fun design(prompt: TemplateDesignPrompt): String -} diff --git a/platform/template/domain/src/main/kotlin/domain/TemplateDesignPrompt.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt similarity index 56% rename from platform/template/domain/src/main/kotlin/domain/TemplateDesignPrompt.kt rename to platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt index edbdbd7..e7f27b8 100644 --- a/platform/template/domain/src/main/kotlin/domain/TemplateDesignPrompt.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt @@ -1,6 +1,6 @@ -package domain +package app.cardcapture.template.application.port.outbound.dto -data class TemplateDesignPrompt( +data class AiTemplateDesignPortCommand( val purpose: String, val texts: List, val color: String, diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt new file mode 100644 index 0000000..ce78232 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt @@ -0,0 +1,8 @@ +package app.cardcapture.template.application.port.outbound.dto + +data class GenerateImagePortCommand( + val prompt: String, + val aspect: String = "1:1", + val model: String +) { +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt new file mode 100644 index 0000000..c713da3 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt @@ -0,0 +1,95 @@ +package app.cardcapture.template.application.service + +import app.cardcapture.template.application.model.* +import org.springframework.stereotype.Component + +@Component +class EditorJsonConverter { + + fun toJson(plan: AiTemplateDesignResult): String { + val backgroundUrl = + if (plan.background.mode == BackgroundModeDto.IMAGE) + plan.background.url.orEmpty() + else + "" + + val bgColor = + if (plan.background.mode == BackgroundModeDto.COLOR) + plan.background.colorHex ?: "#FFFFFF" + else + plan.background.colorHex ?: "#FFFFFF" + + val layersJson = plan.layers.joinToString(",") { layer -> + when (layer) { + is PlannedTextLayerDto -> toTextLayerJson(layer) + is PlannedImageLayerDto -> toImageLayerJson(layer) + } + } + + return """ + [ + { + "id": 0, + "background": { + "url": "$backgroundUrl", + "opacity": ${plan.background.opacity}, + "color": "$bgColor" + }, + "layers": [ $layersJson ] + } + ] + """.trimIndent() + } + + private fun toTextLayerJson(layer: PlannedTextLayerDto): String { + val text = escapeJson(layer.text) + val font = escapeJson(layer.font) // PlannedTextLayerDto에 font: String 존재한다고 가정 + val size = escapeJson(layer.size) // size: String ("32px" 등) + + return """ + { + "type": "text", + "id": ${layer.id}, + "position": ${toPositionJson(layer.position)}, + "content": { + "content": { + "ops": [ + { + "insert": "$text", + "attributes": { + "font": "$font", + "size": "$size" + } + }, + { + "insert": "\n" + } + ] + } + } + } + """.trimIndent() + } + + private fun toImageLayerJson(layer: PlannedImageLayerDto): String { + val url = layer.url.orEmpty() + + return """ + { + "type": "image", + "id": ${layer.id}, + "position": ${toPositionJson(layer.position)}, + "content": { + "url": "$url" + } + } + """.trimIndent() + } + + private fun toPositionJson(pos: PositionDto): String = + """{"x":${pos.x},"y":${pos.y},"width":${pos.width},"height":${pos.height}, + "rotate":${pos.rotate},"zIndex":${pos.zIndex},"opacity":${pos.opacity}}""" + + private fun escapeJson(text: String): String = + text.replace("\"", "\\\"") +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt index 99cfbdf..c0a2ad6 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt @@ -1,27 +1,92 @@ package app.cardcapture.template.application.service +import app.cardcapture.template.application.model.* import app.cardcapture.template.application.port.inbound.GenerateCardTemplateUseCase import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand -import app.cardcapture.template.application.port.outbound.TemplateDesignPort +import app.cardcapture.template.application.port.outbound.AiTemplateDesignPort +import app.cardcapture.template.application.port.outbound.GenerateImagePort +import app.cardcapture.template.application.port.outbound.dto.AiTemplateDesignPortCommand +import app.cardcapture.template.application.port.outbound.dto.GenerateImagePortCommand +import domain.EditorPayload import domain.Template -import domain.TemplateDesignPrompt import org.springframework.stereotype.Service @Service class GenerateCardTemplateService( - private val templateDesignPort: TemplateDesignPort -): GenerateCardTemplateUseCase { + private val aiTemplateDesignPort: AiTemplateDesignPort, + private val generateImagePort: GenerateImagePort, + private val editorJsonConverter: EditorJsonConverter +) : GenerateCardTemplateUseCase { override fun generate(command: GenerateCardTemplateCommand): Template { - val prompt = TemplateDesignPrompt( + + val planResult = aiTemplateDesignPort.design( + AiTemplateDesignPortCommand( + purpose = command.purpose, + texts = command.texts, + color = command.color, + prompt = command.prompt, + model = command.model, + ), + ) + + val checkedPlanResult = planResult.copy( + background = backgroundImageCheck(planResult.background, command.model), + layers = layerImageCheck(planResult.layers, command.model), + ) + + + return Template( + id = 1L, // TODO: auto increment, + userId = 1L, // TODO: security purpose = command.purpose, - texts = command.texts, - color = command.color, prompt = command.prompt, - model = command.model + color = command.color, + title = command.purpose, + description = command.texts.firstOrNull() ?: "" , + texts = command.texts, + fileUrl = null, + editorPayload = EditorPayload(editorJsonConverter.toJson(checkedPlanResult)) ) + } + + private fun backgroundImageCheck(backgroundPlan: BackgroundPlanDto, model: String): BackgroundPlanDto { + if (backgroundPlan.mode == BackgroundModeDto.COLOR) { + return backgroundPlan + } + + val prompt = backgroundPlan.prompt ?: error("background image prompt가 필요합니다. ") + + val backGroundImageUrl = generateImagePort.generate( + GenerateImagePortCommand( + prompt = prompt, + model = model, + ), + ) + return backgroundPlan.copy( + url = backGroundImageUrl, + ) + } - val templateDesign = templateDesignPort.design(prompt) - TODO("Not yet implemented") + private fun layerImageCheck(layers: List, model: String): List { + return layers.map { layer -> + when (layer) { + is PlannedTextLayerDto -> layer + is PlannedImageLayerDto -> { + if (!layer.generate) return@map layer + val prompt = layer.prompt + val img = generateImagePort.generate( + GenerateImagePortCommand( + prompt = prompt, + model = model, + ), + ) + layer.copy( + url = img, + generate = false, + ) + } + } + } } } diff --git a/platform/template/domain/src/main/kotlin/domain/Card.kt b/platform/template/domain/src/main/kotlin/domain/Card.kt deleted file mode 100644 index 464e846..0000000 --- a/platform/template/domain/src/main/kotlin/domain/Card.kt +++ /dev/null @@ -1,4 +0,0 @@ -package domain - -class Card { -} diff --git a/platform/template/domain/src/main/kotlin/domain/CardBackground.kt b/platform/template/domain/src/main/kotlin/domain/CardBackground.kt deleted file mode 100644 index bd2effe..0000000 --- a/platform/template/domain/src/main/kotlin/domain/CardBackground.kt +++ /dev/null @@ -1,8 +0,0 @@ -package domain - -class CardBackground( - val url : String, - val opacity: Int, - val color: String -) { -} diff --git a/platform/template/domain/src/main/kotlin/domain/CardLayer.kt b/platform/template/domain/src/main/kotlin/domain/CardLayer.kt deleted file mode 100644 index dc4d0d3..0000000 --- a/platform/template/domain/src/main/kotlin/domain/CardLayer.kt +++ /dev/null @@ -1,9 +0,0 @@ -package domain - -class CardLayer( - val type: String, - val content: String, - val id : Int, - val position: Position -) { -} diff --git a/platform/template/domain/src/main/kotlin/domain/EditorPayload.kt b/platform/template/domain/src/main/kotlin/domain/EditorPayload.kt new file mode 100644 index 0000000..7ad2919 --- /dev/null +++ b/platform/template/domain/src/main/kotlin/domain/EditorPayload.kt @@ -0,0 +1,7 @@ +package domain + +data class EditorPayload( + val editor: String +){ + +} diff --git a/platform/template/domain/src/main/kotlin/domain/Position.kt b/platform/template/domain/src/main/kotlin/domain/Position.kt deleted file mode 100644 index 4803382..0000000 --- a/platform/template/domain/src/main/kotlin/domain/Position.kt +++ /dev/null @@ -1,12 +0,0 @@ -package domain - -class Position( - val x : Int, - val y : Int, - val width: Int, - val height: Int, - val rotate: Int, - val zIndex: Int, - val opacity: Int -) { -} diff --git a/platform/template/domain/src/main/kotlin/domain/Template.kt b/platform/template/domain/src/main/kotlin/domain/Template.kt index b516d4d..be93943 100644 --- a/platform/template/domain/src/main/kotlin/domain/Template.kt +++ b/platform/template/domain/src/main/kotlin/domain/Template.kt @@ -1,10 +1,22 @@ package domain +/* +* title, description 값을 따로 FE로 받지 않는다. +* title -> purpose, +* description -> texts +* purpose, texts, prompt, color 는 추후 table 분리 가능성. +*/ class Template( val id : Long, + val userId: Long, + val title: String, + val description: String, val prompt: String, val fileUrl: String?, - val cards: MutableList + val purpose: String, + val texts: List, + val color: String, + val editorPayload: EditorPayload ) { } From bd39e8da6d695c74e41ecb171e8f89d2975c4a24 Mon Sep 17 00:00:00 2001 From: inhyeok Date: Wed, 5 Nov 2025 22:58:51 +0900 Subject: [PATCH 03/11] =?UTF-8?q?Feat:=20Image=20=EC=83=9D=EC=84=B1/=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/security/SecurityConfig.kt | 2 - platform/ai/adapter/build.gradle.kts | 1 + .../outbound/imageai/google/GoogleAiConfig.kt | 31 +++++++++++ .../imageai/google/ImageModelAdapter.kt | 51 +++++++++++++++++++ .../google/dto/GeminiPreviewImageRequest.kt | 14 +++++ .../google/dto/GeminiPreviewImageResponse.kt | 25 +++++++++ .../ai/outbound/llm/openai/TestCon.kt | 30 ----------- .../ai/outbound/s3/ImageStorageAdapter.kt | 46 +++++++++++++++++ .../cardcapture/ai/outbound/s3/S3Config.kt | 33 ++++++++++++ .../ai/port/inbound/AiImageGenerateUseCase.kt | 8 +++ .../inbound/dto/AiImageGenerateCommand.kt | 21 ++++++++ .../inbound/dto/AiTemplateDesignCommand.kt | 2 +- .../ai/port/outbound/ImageModelPort.kt | 7 +++ .../ai/port/outbound/ImageStoragePort.kt | 6 +++ .../ai/service/AiImageGenerateService.kt | 19 +++++++ .../cardcapture/ai/service/LayoutPlanner.kt | 8 +-- .../ai/service/MetadataExtractor.kt | 8 +-- .../api/src/main/resources/application.yml | 13 +++++ .../adapter/inbound/web/TemplateController.kt | 30 ++++------- .../web/dto/GenerateCardTemplateRequest.kt | 19 +++++-- ...nse.kt => GenerateCardTemplateResponse.kt} | 6 +-- .../outbound/AiTemplateDesignAdapter.kt | 2 +- .../adapter/outbound/GeneratedImageAdapter.kt | 23 +++++++++ .../outbound/GeneratedImagePortImpl.kt | 16 ------ .../dto/GenerateCardTemplateCommand.kt | 1 - .../outbound/dto/GenerateImagePortCommand.kt | 1 - 26 files changed, 338 insertions(+), 85 deletions(-) create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/GoogleAiConfig.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/ImageModelAdapter.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageRequest.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageResponse.kt delete mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/TestCon.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt create mode 100644 platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiImageGenerateUseCase.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiImageGenerateCommand.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageModelPort.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt rename platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/{GeneratedCardTemplateResponse.kt => GenerateCardTemplateResponse.kt} (84%) create mode 100644 platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImageAdapter.kt delete mode 100644 platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImagePortImpl.kt diff --git a/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt b/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt index dbe97d5..8aa8d10 100644 --- a/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt +++ b/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt @@ -23,8 +23,6 @@ class SecurityConfig( "/actuator/**", "/api/v1/dev/auth/**", "/api/v1/auth/**", - "/test/ai", - "/test/ai2", ) } @Bean diff --git a/platform/ai/adapter/build.gradle.kts b/platform/ai/adapter/build.gradle.kts index f573bad..8ca760e 100644 --- a/platform/ai/adapter/build.gradle.kts +++ b/platform/ai/adapter/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { 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") diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/GoogleAiConfig.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/GoogleAiConfig.kt new file mode 100644 index 0000000..e2ab914 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/GoogleAiConfig.kt @@ -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() + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/ImageModelAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/ImageModelAdapter.kt new file mode 100644 index 0000000..f0af4d8 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/ImageModelAdapter.kt @@ -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, +) : ImageModelPort { + + override fun generate(prompt: String, model: String): ByteArray { + + 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) + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageRequest.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageRequest.kt new file mode 100644 index 0000000..b8b557b --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageRequest.kt @@ -0,0 +1,14 @@ +package app.cardcapture.ai.outbound.imageai.google.dto + +data class GeminiPreviewImageRequest( + val contents: List +) { + + data class ImagerPartsBlock( + val parts: List + ) + + data class TextBlock( + val text: String + ) +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageResponse.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageResponse.kt new file mode 100644 index 0000000..71cdbb6 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageResponse.kt @@ -0,0 +1,25 @@ +package app.cardcapture.ai.outbound.imageai.google.dto + + +data class GeminiPreviewImageResponse( + val candidates: List, + val modelVersion: String? = null +) { + data class Candidate( + val content: Content? + ) + + data class Content( + val parts: List? + ) + + data class Part( + val text: String? = null, + val inlineData: InlineData? = null + ) + + data class InlineData( + val mimeType: String? = null, + val data: String? = null + ) +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/TestCon.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/TestCon.kt deleted file mode 100644 index e08bb77..0000000 --- a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/TestCon.kt +++ /dev/null @@ -1,30 +0,0 @@ -package app.cardcapture.ai.outbound.llm.openai - -import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase -import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -class TestCon( - private val aiTemplateDesignUseCase: AiTemplateDesignUseCase -) { - - @GetMapping("/test/ai") - fun test(){ - val backGroundimagepropt = "오른쪽 아래에 커피 아이콘을, 왼쪽아래에는 자판기 아이콘이 있으면 좋을것 같아, 글자룰 크게 만들어서 강조해줘!, 배경으로 커피자판기를 마시는 사람들이 있는 사진이 백그라운드에 있었으면 좋겠어" - val nonbackGroundimagepropt = "오른쪽 아래에 커피 아이콘을, 왼쪽아래에는 자판기 아이콘이 있으면 좋을것 같아, 글자룰 크게 만들어서 강조해줘!," - - val req = AiTemplateDesignCommand.ofOtThrow( - llmModel = "GPT", - imageModel = "nanobanana", - purpose = "커피 자판기 홍보 목적", - prompt = backGroundimagepropt, - color = "#FFFFFF", - texts = listOf("새로운 커피 자판기를 만나보세요!", "많은 커피를 저렴한 가격에!") - ) - - aiTemplateDesignUseCase.design(req) - } - -} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt new file mode 100644 index 0000000..711c727 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt @@ -0,0 +1,46 @@ +package app.cardcapture.ai.outbound.s3 + +import app.cardcapture.ai.port.outbound.ImageStoragePort +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.util.* + + +@Component +class ImageStorageAdapter( + private val s3Client: S3Client, + @Value("\${cloud.aws.s3.bucket}") private val bucket: String, + @Value("\${cloud.aws.region.static}") private val region: String, +) : ImageStoragePort { + + + override fun upload(bytes: ByteArray, key: String, fileName: String): String { + val safePrefix = key.trimEnd('/') + val safeFileName = makeFileName(fileName) + val objectKey = "$safePrefix/${UUID.randomUUID()}_$safeFileName" + + + val request = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType("image/png") + .build() + + s3Client.putObject(request, RequestBody.fromBytes(bytes)) + + + return "https://$bucket.s3.$region.amazonaws.com/$objectKey" + } + + private fun makeFileName(name: String): String { + val safeName = name + .take(30) + .replace(Regex("[^a-zA-Z0-9가-힣]"), "_") + .trim('_') + + return "${safeName}.png" + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt new file mode 100644 index 0000000..e61c8c8 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt @@ -0,0 +1,33 @@ +package app.cardcapture.ai.outbound.s3 + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client + + +@Configuration +class S3Config { + + + @Value("\${cloud.aws.credentials.access-key}") + private val accessKey: String? = null + + @Value("\${cloud.aws.credentials.secret-key}") + private val secretKey: String? = null + + @Value("\${cloud.aws.region.static}") + private val region: String? = null + + @Bean + fun amazonS3(): S3Client? { + val credentials = AwsBasicCredentials.create(accessKey, secretKey) + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build() + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiImageGenerateUseCase.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiImageGenerateUseCase.kt new file mode 100644 index 0000000..566ce2d --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiImageGenerateUseCase.kt @@ -0,0 +1,8 @@ +package app.cardcapture.ai.port.inbound + +import app.cardcapture.ai.port.inbound.dto.AiImageGenerateCommand + +interface AiImageGenerateUseCase { + + fun generate(command: AiImageGenerateCommand): String +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiImageGenerateCommand.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiImageGenerateCommand.kt new file mode 100644 index 0000000..d6452ad --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiImageGenerateCommand.kt @@ -0,0 +1,21 @@ +package app.cardcapture.ai.port.inbound.dto + +import app.cardcapture.ai.domain.ImageModel + +data class AiImageGenerateCommand( + val prompt: String, + val model: ImageModel +) { + + companion object { + fun ofOrThrow( + prompt: String, + model: String + ): AiImageGenerateCommand { + return AiImageGenerateCommand( + prompt = prompt, + model = ImageModel.from(model), + ) + } + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt index d52b8b0..38fae63 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt @@ -14,7 +14,7 @@ data class AiTemplateDesignCommand private constructor( companion object { - fun ofOtThrow( + fun ofOrThrow( llmModel: String, imageModel: String, purpose: String, diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageModelPort.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageModelPort.kt new file mode 100644 index 0000000..1af32ee --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageModelPort.kt @@ -0,0 +1,7 @@ +package app.cardcapture.ai.port.outbound + +interface ImageModelPort { + + fun generate(prompt: String, model: String) : ByteArray + +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt new file mode 100644 index 0000000..ab764ca --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt @@ -0,0 +1,6 @@ +package app.cardcapture.ai.port.outbound + +interface ImageStoragePort { + + fun upload(bytes: ByteArray, key: String, fileName: String): String +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt new file mode 100644 index 0000000..5e2a5c2 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt @@ -0,0 +1,19 @@ +package app.cardcapture.ai.service + +import app.cardcapture.ai.port.inbound.AiImageGenerateUseCase +import app.cardcapture.ai.port.inbound.dto.AiImageGenerateCommand +import app.cardcapture.ai.port.outbound.ImageModelPort +import app.cardcapture.ai.port.outbound.ImageStoragePort +import org.springframework.stereotype.Service + +@Service +class AiImageGenerateService( + private val imageModelPort: ImageModelPort, + private val imageStoragePort: ImageStoragePort +): AiImageGenerateUseCase { + + override fun generate(command: AiImageGenerateCommand): String { + val byteArrays = imageModelPort.generate(command.prompt, command.model.name) + return imageStoragePort.upload(byteArrays, "generate", command.prompt) + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt index a1f72d1..92f5ec8 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt @@ -18,14 +18,14 @@ class LayoutPlanner( private val objectMapper: ObjectMapper ) { - private val _NAME_SPACE: String = "templateDesign" - private val _NAME: String = "layout" + private val namespace: String = "templateDesign" + private val name: String = "layout" fun plan(metadata: TemplateDesignMetadata, model : LlmModel) : TemplateLayoutPlan { val prompt = promptLoader.load( PromptId( - _NAME_SPACE, - _NAME + namespace, + name ) ) diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt index 331e557..890e4c5 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt @@ -17,15 +17,15 @@ class MetadataExtractor( private val llmSenderPort: LlmSenderPort ) { - private val _NAME_SPACE: String = "templateDesign" - private val _NAME: String = "metadata" + private val namespace: String = "templateDesign" + private val name: String = "metadata" fun extract(aiTemplateDesignCommand: AiTemplateDesignCommand): TemplateDesignMetadata { val prompt = promptLoader.load( PromptId( - _NAME_SPACE, - _NAME, + namespace, + name, ), ) val userContent = buildString { diff --git a/platform/services/api/src/main/resources/application.yml b/platform/services/api/src/main/resources/application.yml index af23bda..baff0fd 100644 --- a/platform/services/api/src/main/resources/application.yml +++ b/platform/services/api/src/main/resources/application.yml @@ -20,6 +20,19 @@ auth: openai: key: ${OPENAI_KEY} +google: + key: ${GOOGLE_KEY} + +cloud: + aws: + s3: + bucket: ${S3_BUCKET} + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: + static: ${S3_REGION} + --- spring: diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt index 6d33153..836a6de 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt @@ -1,31 +1,23 @@ package app.cardcapture.template.adapter.inbound.web -import app.cardcapture.template.adapter.inbound.web.dto.GeneratedCardTemplateResponse +import app.cardcapture.template.adapter.inbound.web.dto.GenerateCardTemplateRequest +import app.cardcapture.template.adapter.inbound.web.dto.GenerateCardTemplateResponse import app.cardcapture.template.application.port.inbound.GenerateCardTemplateUseCase -import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand -import org.springframework.web.bind.annotation.GetMapping +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController @RestController -class TemplateController ( +class TemplateController( private val generateCardTemplateUseCase: GenerateCardTemplateUseCase -){ +) { - @GetMapping("/test/ai2") - fun generate() : GeneratedCardTemplateResponse{ - val backGroundimagepropt = "오른쪽 아래에 커피 아이콘을, 왼쪽아래에는 자판기 아이콘이 있으면 좋을것 같아, 글자룰 크게 만들어서 강조해줘!, 배경으로 커피자판기를 마시는 사람들이 있는 사진이 백그라운드에 있었으면 좋겠어" - val nonbackGroundimagepropt = "오른쪽 아래에 커피 아이콘을, 왼쪽아래에는 자판기 아이콘이 있으면 좋을것 같아, 글자룰 크게 만들어서 강조해줘!," + @PostMapping("/api/template") + fun generate(@RequestBody request: GenerateCardTemplateRequest): ResponseEntity { - val command = GenerateCardTemplateCommand( - purpose = "커피 자판기 홍보 목적", - texts = listOf("새로운 커피 자판기를 만나보세요!", "많은 커피를 저렴한 가격에!"), - color = "#FFFFFF", - model = "nanobanana", - prompt = nonbackGroundimagepropt - ) - val result = generateCardTemplateUseCase.generate(command) - println(result) + val result = generateCardTemplateUseCase.generate(request.toCommand()) - return GeneratedCardTemplateResponse.from(result) + return ResponseEntity.ok(GenerateCardTemplateResponse.from(result)) } } diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt index 56f228e..915a196 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt @@ -1,9 +1,22 @@ package app.cardcapture.template.adapter.inbound.web.dto +import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand + data class GenerateCardTemplateRequest( - val model: String, - val color: String, + val purpose: String, val texts: List, - val purpose: String + val color: String, + val model: String, + val prompt: String ) { + + fun toCommand() : GenerateCardTemplateCommand{ + return GenerateCardTemplateCommand( + purpose = purpose, + texts = texts, + color = color, + model = model, + prompt = prompt + ) + } } diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GeneratedCardTemplateResponse.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt similarity index 84% rename from platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GeneratedCardTemplateResponse.kt rename to platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt index c9c7547..24d6eb3 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GeneratedCardTemplateResponse.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt @@ -3,7 +3,7 @@ package app.cardcapture.template.adapter.inbound.web.dto import domain.Template -data class GeneratedCardTemplateResponse( +data class GenerateCardTemplateResponse( val id: Long, val userId: Long, val title: String, @@ -23,8 +23,8 @@ data class GeneratedCardTemplateResponse( companion object { - fun from(template: Template): GeneratedCardTemplateResponse { - return GeneratedCardTemplateResponse( + fun from(template: Template): GenerateCardTemplateResponse { + return GenerateCardTemplateResponse( id = template.id, userId = template.userId, title = template.title, diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt index 9ff8a50..4d140cb 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt @@ -16,7 +16,7 @@ class AiTemplateDesignAdapter( override fun design(prompt: AiTemplateDesignPortCommand): AiTemplateDesignResult { val design: TemplateLayoutPlan = aiTemplateDesignUseCase.design( - AiTemplateDesignCommand.ofOtThrow( + AiTemplateDesignCommand.ofOrThrow( llmModel = "GPT", imageModel = prompt.model, purpose = prompt.purpose, diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImageAdapter.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImageAdapter.kt new file mode 100644 index 0000000..8bbed7b --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImageAdapter.kt @@ -0,0 +1,23 @@ +package app.cardcapture.template.adapter.outbound + +import app.cardcapture.ai.port.inbound.AiImageGenerateUseCase +import app.cardcapture.ai.port.inbound.dto.AiImageGenerateCommand +import app.cardcapture.template.application.port.outbound.GenerateImagePort +import app.cardcapture.template.application.port.outbound.dto.GenerateImagePortCommand +import org.springframework.stereotype.Component + +@Component +class GeneratedImageAdapter( + private val aiImageGenerateUseCase: AiImageGenerateUseCase +) : GenerateImagePort { + + override fun generate(command: GenerateImagePortCommand): String { + + return aiImageGenerateUseCase.generate( + AiImageGenerateCommand.ofOrThrow( + prompt = command.prompt, + model = command.model, + ), + ) + } +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImagePortImpl.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImagePortImpl.kt deleted file mode 100644 index ff6fcae..0000000 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImagePortImpl.kt +++ /dev/null @@ -1,16 +0,0 @@ -package app.cardcapture.template.adapter.outbound - -import app.cardcapture.template.application.port.outbound.GenerateImagePort -import app.cardcapture.template.application.port.outbound.dto.GenerateImagePortCommand -import org.springframework.stereotype.Component - -@Component -class GeneratedImagePortImpl ( - -): GenerateImagePort{ - - override fun generate(command: GenerateImagePortCommand): String { - - return "isUrl---!" - } -} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt index 73008d5..1ef5985 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt @@ -1,6 +1,5 @@ package app.cardcapture.template.application.port.inbound.dto -// TODO: class mapping data class GenerateCardTemplateCommand ( val purpose: String, val texts: List, diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt index ce78232..54ca53a 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt @@ -2,7 +2,6 @@ package app.cardcapture.template.application.port.outbound.dto data class GenerateImagePortCommand( val prompt: String, - val aspect: String = "1:1", val model: String ) { } From 03ea9215073652b726a9ee76cd8953554f007514 Mon Sep 17 00:00:00 2001 From: inhyeok Date: Wed, 5 Nov 2025 23:15:08 +0900 Subject: [PATCH 04/11] =?UTF-8?q?Fix:=20ci=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20bug=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- .../main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt | 2 +- .../auth/application/service/DevelopmentLoginServiceTest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab08419..f9fe2f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt index 890e4c5..7f3501f 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt @@ -33,7 +33,7 @@ class MetadataExtractor( appendLine("Texts: ${aiTemplateDesignCommand.texts.joinToString(" | ")}") appendLine("Purpose: ${aiTemplateDesignCommand.purpose}") appendLine("Color: ${aiTemplateDesignCommand.color}") - appendLine("User Prompt: ${aiTemplateDesignCommand.prompt ?: "null"}") + appendLine("User Prompt: ${aiTemplateDesignCommand.prompt}") }.trim() val request = LlmRequest( model = aiTemplateDesignCommand.llmModel, diff --git a/platform/auth/application/src/test/kotlin/app/cardcapture/auth/application/service/DevelopmentLoginServiceTest.kt b/platform/auth/application/src/test/kotlin/app/cardcapture/auth/application/service/DevelopmentLoginServiceTest.kt index 6838058..cf07387 100644 --- a/platform/auth/application/src/test/kotlin/app/cardcapture/auth/application/service/DevelopmentLoginServiceTest.kt +++ b/platform/auth/application/src/test/kotlin/app/cardcapture/auth/application/service/DevelopmentLoginServiceTest.kt @@ -33,7 +33,7 @@ class DevelopmentLoginServiceTest( assertThat(token).isEqualTo("token") verify { - loadMemberPort.loadMemberByOAuth(oauthId, OAuthProvider.GOOGLE.name) + loadMemberPort.loadMemberByOAuth(oauthId, OAuthProvider.GOOGLE) issueTokenPort.issue(member) } } From 1e19837b5c5fc61a91224291d619437876e6d644 Mon Sep 17 00:00:00 2001 From: inhyeok Date: Wed, 5 Nov 2025 23:16:26 +0900 Subject: [PATCH 05/11] =?UTF-8?q?Refactor:=20template=20domain=20package?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/inbound/web/dto/GenerateCardTemplateResponse.kt | 2 +- .../application/port/inbound/GenerateCardTemplateUseCase.kt | 2 +- .../application/service/GenerateCardTemplateService.kt | 4 ++-- .../{ => app/cardcapture/template}/domain/EditorPayload.kt | 2 +- .../kotlin/{ => app/cardcapture/template}/domain/Template.kt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename platform/template/domain/src/main/kotlin/{ => app/cardcapture/template}/domain/EditorPayload.kt (58%) rename platform/template/domain/src/main/kotlin/{ => app/cardcapture/template}/domain/Template.kt (92%) diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt index 24d6eb3..f6b379c 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt @@ -1,6 +1,6 @@ package app.cardcapture.template.adapter.inbound.web.dto -import domain.Template +import app.cardcapture.template.domain.Template data class GenerateCardTemplateResponse( diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt index 4f93214..448a292 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt @@ -1,7 +1,7 @@ package app.cardcapture.template.application.port.inbound import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand -import domain.Template +import app.cardcapture.template.domain.Template interface GenerateCardTemplateUseCase { diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt index c0a2ad6..2bc3701 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt @@ -7,8 +7,8 @@ import app.cardcapture.template.application.port.outbound.AiTemplateDesignPort import app.cardcapture.template.application.port.outbound.GenerateImagePort import app.cardcapture.template.application.port.outbound.dto.AiTemplateDesignPortCommand import app.cardcapture.template.application.port.outbound.dto.GenerateImagePortCommand -import domain.EditorPayload -import domain.Template +import app.cardcapture.template.domain.EditorPayload +import app.cardcapture.template.domain.Template import org.springframework.stereotype.Service @Service diff --git a/platform/template/domain/src/main/kotlin/domain/EditorPayload.kt b/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/EditorPayload.kt similarity index 58% rename from platform/template/domain/src/main/kotlin/domain/EditorPayload.kt rename to platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/EditorPayload.kt index 7ad2919..c220383 100644 --- a/platform/template/domain/src/main/kotlin/domain/EditorPayload.kt +++ b/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/EditorPayload.kt @@ -1,4 +1,4 @@ -package domain +package app.cardcapture.template.domain data class EditorPayload( val editor: String diff --git a/platform/template/domain/src/main/kotlin/domain/Template.kt b/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/Template.kt similarity index 92% rename from platform/template/domain/src/main/kotlin/domain/Template.kt rename to platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/Template.kt index be93943..3d3fb00 100644 --- a/platform/template/domain/src/main/kotlin/domain/Template.kt +++ b/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/Template.kt @@ -1,4 +1,4 @@ -package domain +package app.cardcapture.template.domain /* * title, description 값을 따로 FE로 받지 않는다. From b5faaee617eb1e38c28564e03c4a873b2e57a883 Mon Sep 17 00:00:00 2001 From: inhyeok Date: Sat, 8 Nov 2025 21:09:49 +0900 Subject: [PATCH 06/11] =?UTF-8?q?Refactor:=20S3Config=20nullable=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/cardcapture/ai/outbound/s3/S3Config.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt index e61c8c8..6ac3fa2 100644 --- a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt @@ -10,20 +10,20 @@ import software.amazon.awssdk.services.s3.S3Client @Configuration -class S3Config { - - +class S3Config( @Value("\${cloud.aws.credentials.access-key}") - private val accessKey: String? = null + private val accessKey: String, @Value("\${cloud.aws.credentials.secret-key}") - private val secretKey: String? = null + private val secretKey: String, @Value("\${cloud.aws.region.static}") - private val region: String? = null + private val region: String, +) { + @Bean - fun amazonS3(): S3Client? { + fun amazonS3(): S3Client { val credentials = AwsBasicCredentials.create(accessKey, secretKey) return S3Client.builder() .region(Region.of(region)) From cc3bd6592569a4d53642742392807033587bc850 Mon Sep 17 00:00:00 2001 From: inhyeok Date: Sat, 8 Nov 2025 21:19:47 +0900 Subject: [PATCH 07/11] =?UTF-8?q?Refactor:=20imageStoragePort=20filename?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/outbound/s3/ImageStorageAdapter.kt | 14 ++------------ .../ai/port/outbound/ImageStoragePort.kt | 2 +- .../ai/service/AiImageGenerateService.kt | 2 +- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt index 711c727..7520080 100644 --- a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt @@ -17,10 +17,9 @@ class ImageStorageAdapter( ) : ImageStoragePort { - override fun upload(bytes: ByteArray, key: String, fileName: String): String { + override fun upload(bytes: ByteArray, key: String): String { val safePrefix = key.trimEnd('/') - val safeFileName = makeFileName(fileName) - val objectKey = "$safePrefix/${UUID.randomUUID()}_$safeFileName" + val objectKey = "$safePrefix/${UUID.randomUUID()}.png" val request = PutObjectRequest.builder() @@ -34,13 +33,4 @@ class ImageStorageAdapter( return "https://$bucket.s3.$region.amazonaws.com/$objectKey" } - - private fun makeFileName(name: String): String { - val safeName = name - .take(30) - .replace(Regex("[^a-zA-Z0-9가-힣]"), "_") - .trim('_') - - return "${safeName}.png" - } } diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt index ab764ca..db694a7 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt @@ -2,5 +2,5 @@ package app.cardcapture.ai.port.outbound interface ImageStoragePort { - fun upload(bytes: ByteArray, key: String, fileName: String): String + fun upload(bytes: ByteArray, key: String): String } diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt index 5e2a5c2..c7cf1e1 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt @@ -14,6 +14,6 @@ class AiImageGenerateService( override fun generate(command: AiImageGenerateCommand): String { val byteArrays = imageModelPort.generate(command.prompt, command.model.name) - return imageStoragePort.upload(byteArrays, "generate", command.prompt) + return imageStoragePort.upload(byteArrays, "generate") } } From 4ffdfc8efa6b08f310a9d2961c29e011f566f2fa Mon Sep 17 00:00:00 2001 From: inhyeok Date: Sat, 8 Nov 2025 21:31:05 +0900 Subject: [PATCH 08/11] =?UTF-8?q?Refactor:=20ai=20=EC=9E=85=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20json=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prompt/SimplePromptLoaderAdapter.kt | 13 ++++++--- .../ai/model/TemplateDesignMetadataCommand.kt | 9 ++++++ ...ata.kt => TemplateDesignMetadataResult.kt} | 2 +- ...outPlan.kt => TemplateLayoutPlanResult.kt} | 2 +- .../port/inbound/AiTemplateDesignUseCase.kt | 4 +-- .../ai/service/AiTemplateDesignService.kt | 4 +-- .../cardcapture/ai/service/LayoutPlanner.kt | 21 +++++++++----- .../ai/service/MetadataExtractor.kt | 29 ++++++++++++------- .../outbound/AiTemplateDesignAdapter.kt | 8 ++--- 9 files changed, 60 insertions(+), 32 deletions(-) create mode 100644 platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataCommand.kt rename platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/{TemplateDesignMetadata.kt => TemplateDesignMetadataResult.kt} (96%) rename platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/{TemplateLayoutPlan.kt => TemplateLayoutPlanResult.kt} (97%) diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt index 4bd5559..ef9603e 100644 --- a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt @@ -39,10 +39,15 @@ Your output will later be converted into: --- ## Input -- Texts: {{texts}} -- Purpose: {{purpose}} -- Color: {{color}} -- User Prompt (optional): {{userPrompt}} +The user message will be provided **as a JSON object** with the following fields: + +```json +{ + "texts": ["string"], + "purpose": "string", + "color": "string", + "userPrompt": "string (optional)" +} --- diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataCommand.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataCommand.kt new file mode 100644 index 0000000..15c3c0e --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataCommand.kt @@ -0,0 +1,9 @@ +package app.cardcapture.ai.model + +data class TemplateDesignMetadataCommand( + val texts: List, + val purpose: String, + val color: String, + val userPrompt: String +) { +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadata.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataResult.kt similarity index 96% rename from platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadata.kt rename to platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataResult.kt index 402df32..7c781bc 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadata.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataResult.kt @@ -1,6 +1,6 @@ package app.cardcapture.ai.model -data class TemplateDesignMetadata( +data class TemplateDesignMetadataResult( val purpose: String, val mood: String, val style: String, diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlan.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlanResult.kt similarity index 97% rename from platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlan.kt rename to platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlanResult.kt index 6753f88..47cea35 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlan.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlanResult.kt @@ -1,6 +1,6 @@ package app.cardcapture.ai.model -data class TemplateLayoutPlan( +data class TemplateLayoutPlanResult( val background: LayoutBackground, val layers: List ) { diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt index aeac2cd..abaa78b 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt @@ -1,9 +1,9 @@ package app.cardcapture.ai.port.inbound -import app.cardcapture.ai.model.TemplateLayoutPlan +import app.cardcapture.ai.model.TemplateLayoutPlanResult import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand interface AiTemplateDesignUseCase { - fun design(command: AiTemplateDesignCommand): TemplateLayoutPlan + fun design(command: AiTemplateDesignCommand): TemplateLayoutPlanResult } diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt index 849f13c..23ccb97 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt @@ -1,6 +1,6 @@ package app.cardcapture.ai.service -import app.cardcapture.ai.model.TemplateLayoutPlan +import app.cardcapture.ai.model.TemplateLayoutPlanResult import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand import org.springframework.stereotype.Service @@ -11,7 +11,7 @@ class AiTemplateDesignService( private val layoutPlanner: LayoutPlanner ) : AiTemplateDesignUseCase { - override fun design(command: AiTemplateDesignCommand): TemplateLayoutPlan { + override fun design(command: AiTemplateDesignCommand): TemplateLayoutPlanResult { val metadata = metadataExtractor.extract(command) val result = layoutPlanner.plan(metadata, command.llmModel) diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt index 92f5ec8..15c9127 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt @@ -4,9 +4,10 @@ import app.cardcapture.ai.domain.LlmModel import app.cardcapture.ai.domain.LlmRequest import app.cardcapture.ai.domain.prompt.PromptId import app.cardcapture.ai.domain.prompt.PromptLoader -import app.cardcapture.ai.model.TemplateDesignMetadata -import app.cardcapture.ai.model.TemplateLayoutPlan +import app.cardcapture.ai.model.TemplateDesignMetadataResult +import app.cardcapture.ai.model.TemplateLayoutPlanResult import app.cardcapture.ai.port.outbound.LlmSenderPort +import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import org.springframework.stereotype.Component @@ -21,21 +22,27 @@ class LayoutPlanner( private val namespace: String = "templateDesign" private val name: String = "layout" - fun plan(metadata: TemplateDesignMetadata, model : LlmModel) : TemplateLayoutPlan { + fun plan(metadata: TemplateDesignMetadataResult, model: LlmModel): TemplateLayoutPlanResult { val prompt = promptLoader.load( PromptId( namespace, - name - ) + name, + ), ) val request = LlmRequest( model = model, system = prompt.system, - user = objectMapper.writeValueAsString(metadata) + user = objectMapper.writeValueAsString(metadata), ) + val response = llmSenderPort.send(request) - return objectMapper.readValue(response.jsonValueRaw) + + try { + return objectMapper.readValue(response.jsonValueRaw) + } catch (e: JsonProcessingException) { + throw IllegalStateException("fail to llm send result") + } } } diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt index 7f3501f..0b3aa83 100644 --- a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt @@ -3,9 +3,11 @@ package app.cardcapture.ai.service import app.cardcapture.ai.domain.LlmRequest import app.cardcapture.ai.domain.prompt.PromptId import app.cardcapture.ai.domain.prompt.PromptLoader -import app.cardcapture.ai.model.TemplateDesignMetadata +import app.cardcapture.ai.model.TemplateDesignMetadataCommand +import app.cardcapture.ai.model.TemplateDesignMetadataResult import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand import app.cardcapture.ai.port.outbound.LlmSenderPort +import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import org.springframework.stereotype.Component @@ -20,7 +22,7 @@ class MetadataExtractor( private val namespace: String = "templateDesign" private val name: String = "metadata" - fun extract(aiTemplateDesignCommand: AiTemplateDesignCommand): TemplateDesignMetadata { + fun extract(aiTemplateDesignCommand: AiTemplateDesignCommand): TemplateDesignMetadataResult { val prompt = promptLoader.load( PromptId( @@ -28,19 +30,24 @@ class MetadataExtractor( name, ), ) - val userContent = buildString { - appendLine("## Input") - appendLine("Texts: ${aiTemplateDesignCommand.texts.joinToString(" | ")}") - appendLine("Purpose: ${aiTemplateDesignCommand.purpose}") - appendLine("Color: ${aiTemplateDesignCommand.color}") - appendLine("User Prompt: ${aiTemplateDesignCommand.prompt}") - }.trim() + + val content = TemplateDesignMetadataCommand( + texts = aiTemplateDesignCommand.texts, + purpose = aiTemplateDesignCommand.purpose, + color = aiTemplateDesignCommand.color, + userPrompt = aiTemplateDesignCommand.prompt + ) + val request = LlmRequest( model = aiTemplateDesignCommand.llmModel, system = prompt.system, - user = userContent, + user = objectMapper.writeValueAsString(content), ) val response = llmSenderPort.send(request) - return objectMapper.readValue(response.jsonValueRaw) + try { + return objectMapper.readValue(response.jsonValueRaw) + } catch (e: JsonProcessingException) { + throw IllegalStateException("fail to llm send result") + } } } diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt index 4d140cb..62b934e 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt @@ -1,6 +1,6 @@ package app.cardcapture.template.adapter.outbound -import app.cardcapture.ai.model.TemplateLayoutPlan +import app.cardcapture.ai.model.TemplateLayoutPlanResult import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand import app.cardcapture.template.application.model.* @@ -15,7 +15,7 @@ class AiTemplateDesignAdapter( ) : AiTemplateDesignPort { override fun design(prompt: AiTemplateDesignPortCommand): AiTemplateDesignResult { - val design: TemplateLayoutPlan = aiTemplateDesignUseCase.design( + val design: TemplateLayoutPlanResult = aiTemplateDesignUseCase.design( AiTemplateDesignCommand.ofOrThrow( llmModel = "GPT", imageModel = prompt.model, @@ -29,7 +29,7 @@ class AiTemplateDesignAdapter( return design.toTemplateResult() } - private fun TemplateLayoutPlan.toTemplateResult(): AiTemplateDesignResult { + private fun TemplateLayoutPlanResult.toTemplateResult(): AiTemplateDesignResult { val bg = BackgroundPlanDto( mode = when (background.mode.uppercase()) { "IMAGE" -> BackgroundModeDto.IMAGE @@ -69,6 +69,6 @@ class AiTemplateDesignAdapter( ) } - private fun TemplateLayoutPlan.LayoutPosition.toDto(): PositionDto = + private fun TemplateLayoutPlanResult.LayoutPosition.toDto(): PositionDto = PositionDto(x, y, width, height, rotate, zIndex, opacity) } From 0bb2ed2e6834bf1986bb1248f237225b35f4070c Mon Sep 17 00:00:00 2001 From: inhyeok Date: Sat, 8 Nov 2025 21:35:41 +0900 Subject: [PATCH 09/11] =?UTF-8?q?Refactor:=20LLM=20Model=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../template/adapter/outbound/AiTemplateDesignAdapter.kt | 2 +- .../port/outbound/dto/AiTemplateDesignPortCommand.kt | 1 + .../template/application/service/GenerateCardTemplateService.kt | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt index 62b934e..971023a 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt @@ -17,7 +17,7 @@ class AiTemplateDesignAdapter( override fun design(prompt: AiTemplateDesignPortCommand): AiTemplateDesignResult { val design: TemplateLayoutPlanResult = aiTemplateDesignUseCase.design( AiTemplateDesignCommand.ofOrThrow( - llmModel = "GPT", + llmModel = prompt.llmModel, imageModel = prompt.model, purpose = prompt.purpose, texts = prompt.texts, diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt index e7f27b8..7b79df1 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt @@ -6,5 +6,6 @@ data class AiTemplateDesignPortCommand( val color: String, val prompt: String, val model : String, + val llmModel: String, ) { } diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt index 2bc3701..204f6e7 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt @@ -27,6 +27,7 @@ class GenerateCardTemplateService( color = command.color, prompt = command.prompt, model = command.model, + llmModel = "GPT" // TODO 현재 기획상 단일 LLM 사용 ), ) From 128b93161bb4f902296770a09f9fbec80065b1e3 Mon Sep 17 00:00:00 2001 From: inhyeok Date: Sat, 8 Nov 2025 21:51:31 +0900 Subject: [PATCH 10/11] =?UTF-8?q?Refactor:=20JsonConverter=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84=20=EC=B2=98=EB=A6=AC,=20nu?= =?UTF-8?q?llable=20=EC=B2=98=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbound/AiTemplateDesignAdapter.kt | 23 ++++++------ .../application/model/BackgroundPlanDto.kt | 35 ++++++++++++++++++- .../service/EditorJsonConverter.kt | 18 ++++++---- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt index 971023a..ba9720b 100644 --- a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt @@ -30,16 +30,19 @@ class AiTemplateDesignAdapter( } private fun TemplateLayoutPlanResult.toTemplateResult(): AiTemplateDesignResult { - val bg = BackgroundPlanDto( - mode = when (background.mode.uppercase()) { - "IMAGE" -> BackgroundModeDto.IMAGE - else -> BackgroundModeDto.COLOR - }, - colorHex = background.colorHex, - prompt = background.prompt, - url = null, - opacity = background.opacity - ) + val bg = when (background.mode.uppercase()) { + "IMAGE" -> BackgroundPlanDto.image( + prompt = requireNotNull(background.prompt), + opacity = background.opacity + ) + + "COLOR" -> BackgroundPlanDto.color( + colorHex = requireNotNull(background.colorHex) { "COLOR mode background colorHex is null" }, + opacity = background.opacity + ) + + else -> error("Unknown background mode: ${background.mode}") + } val layers = layers.map { layer -> when (layer.type.lowercase()) { diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt index 63808c5..c2c2fe1 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt @@ -1,10 +1,43 @@ package app.cardcapture.template.application.model -data class BackgroundPlanDto( +data class BackgroundPlanDto private constructor( val mode: BackgroundModeDto, val colorHex: String? = null, val url: String? = null, val prompt: String? = null, val opacity: Int = 100 ) { + companion object { + fun color(colorHex: String, opacity: Int = 100): BackgroundPlanDto { + require(colorHex.isNotBlank()) { "colorHex must not be blank for COLOR mode" } + return BackgroundPlanDto( + mode = BackgroundModeDto.COLOR, + colorHex = colorHex, + url = null, + prompt = null, + opacity = opacity + ) + } + + fun image( prompt: String, opacity: Int = 100): BackgroundPlanDto { + require(prompt.isNotBlank()) { "url must not be blank for IMAGE mode" } + return BackgroundPlanDto( + mode = BackgroundModeDto.IMAGE, + colorHex = null, + prompt = prompt, + opacity = opacity + ) + } + } + + init { + when (mode) { + BackgroundModeDto.COLOR -> require(url == null && prompt == null) { + "For COLOR mode, url and prompt must be null" + } + BackgroundModeDto.IMAGE -> require(colorHex == null) { + "For IMAGE mode, colorHex must be null" + } + } + } } diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt index c713da3..d9eeb0d 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt @@ -4,7 +4,8 @@ import app.cardcapture.template.application.model.* import org.springframework.stereotype.Component @Component -class EditorJsonConverter { +class EditorJsonConverter( +) { fun toJson(plan: AiTemplateDesignResult): String { val backgroundUrl = @@ -13,11 +14,9 @@ class EditorJsonConverter { else "" - val bgColor = - if (plan.background.mode == BackgroundModeDto.COLOR) - plan.background.colorHex ?: "#FFFFFF" - else - plan.background.colorHex ?: "#FFFFFF" + val bgColor = plan.background.colorHex ?: "" + + val layersJson = plan.layers.joinToString(",") { layer -> when (layer) { @@ -91,5 +90,10 @@ class EditorJsonConverter { "rotate":${pos.rotate},"zIndex":${pos.zIndex},"opacity":${pos.opacity}}""" private fun escapeJson(text: String): String = - text.replace("\"", "\\\"") + text.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } From a10167bdb872046802c0ff7ab44f55bf63de054e Mon Sep 17 00:00:00 2001 From: inhyeok Date: Sat, 8 Nov 2025 23:06:19 +0900 Subject: [PATCH 11/11] Fix: error message --- .../cardcapture/template/application/model/BackgroundPlanDto.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt index c2c2fe1..f2fd823 100644 --- a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt @@ -20,7 +20,7 @@ data class BackgroundPlanDto private constructor( } fun image( prompt: String, opacity: Int = 100): BackgroundPlanDto { - require(prompt.isNotBlank()) { "url must not be blank for IMAGE mode" } + require(prompt.isNotBlank()) { "prompt must not be blank for IMAGE mode" } return BackgroundPlanDto( mode = BackgroundModeDto.IMAGE, colorHex = null,