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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

import jakarta.validation.constraints.NotBlank;

/** 저장 전 폼 내용으로도 후보를 만들 수 있게 battleId 대신 배틀 내용을 받는다. */
/**
* 저장 전 폼 내용으로도 후보를 만들 수 있게 battleId 대신 배틀 내용을 받는다.
* 썸네일은 제목(주제)을 그리므로 A/B 입장은 받지 않는다. 요약/설명은 제목의 맥락으로만 쓴다.
*/
public record AdminBattleThumbnailCandidateRequest(
@NotBlank String title,
String summary,
String description,
Side optionA,
Side optionB
) {
public record Side(String title, String stance) {}
}
String description
) {}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.swyp.picke.domain.battle.thumbnail;

import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest;
import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest.Side;
import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse;
import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse.Candidate;
import com.swyp.picke.global.common.exception.CustomException;
Expand All @@ -18,14 +17,16 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
* 배틀 내용 + 공통 스타일로 썸네일 후보를 여러 장 만들어 S3 에 올린다.
* 후보는 동시에 생성하고, 일부가 실패해도 성공한 것만 돌려준다.
* 배틀 원문은 먼저 장면 묘사로 바꿔 후보마다 다른 장면을 쓰고, 후보는 동시에 생성한다.
* 일부가 실패해도 성공한 것만 돌려준다.
*/
@Slf4j
@Service
Expand All @@ -35,18 +36,24 @@ public class BattleThumbnailCandidateService {
static final int CANDIDATE_COUNT = 3;

private final BattleThumbnailStyleSource styleSource;
private final ThumbnailSceneWriter sceneWriter;
private final OpenAiImageClient imageClient;
private final S3UploadService s3UploadService;
private final S3PresignedUrlService s3PresignedUrlService;

public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandidateRequest request) {
String prompt = buildPrompt(styleSource.promptTemplate(), request);
String template = styleSource.promptTemplate();
List<ReferenceImage> references = styleSource.referenceImages();
List<String> prompts = scenesFor(request).stream()
.map(scene -> buildPrompt(template, scene))
.toList();

AtomicInteger blockedCount = new AtomicInteger();
List<Candidate> candidates;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<Candidate>> futures = IntStream.range(0, CANDIDATE_COUNT)
.mapToObj(i -> CompletableFuture.supplyAsync(() -> generateOne(prompt, references), executor))
List<CompletableFuture<Candidate>> futures = prompts.stream()
.map(prompt -> CompletableFuture.supplyAsync(
() -> generateOne(prompt, references, blockedCount), executor))
.toList();
candidates = futures.stream()
.map(CompletableFuture::join)
Expand All @@ -55,16 +62,34 @@ public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandi
}

if (candidates.isEmpty()) {
throw new CustomException(ErrorCode.BATTLE_THUMBNAIL_GENERATION_FAILED);
throw new CustomException(blockedCount.get() > 0
? ErrorCode.BATTLE_THUMBNAIL_MODERATION_BLOCKED
: ErrorCode.BATTLE_THUMBNAIL_GENERATION_FAILED);
}
return new AdminBattleThumbnailCandidatesResponse(candidates);
}

private Candidate generateOne(String prompt, List<ReferenceImage> references) {
/** 후보 수만큼 장면을 만든다. 장면 묘사에 실패하면 배틀 원문을 그대로 쓴다. */
private List<String> scenesFor(AdminBattleThumbnailCandidateRequest request) {
List<String> scenes = sceneWriter.write(request, CANDIDATE_COUNT);
if (scenes.isEmpty()) {
scenes = List.of(ThumbnailSceneWriter.describeBattle(request));
}
List<String> source = scenes;
return IntStream.range(0, CANDIDATE_COUNT)
.mapToObj(i -> source.get(i % source.size()))
.toList();
}

private Candidate generateOne(String prompt, List<ReferenceImage> references, AtomicInteger blockedCount) {
try {
byte[] image = imageClient.generate(prompt, references);
String key = upload(image);
return new Candidate(key, s3PresignedUrlService.generatePresignedUrl(key));
} catch (ImageModerationBlockedException e) {
blockedCount.incrementAndGet();
log.warn("[Thumbnail] 썸네일 후보 1장이 안전 필터에 막힘. prompt={}", prompt, e);
return null;
} catch (Exception e) {
log.warn("[Thumbnail] 썸네일 후보 1장 생성 실패", e);
return null;
Expand All @@ -82,28 +107,7 @@ private String upload(byte[] image) throws IOException {
}
}

static String buildPrompt(String template, AdminBattleThumbnailCandidateRequest request) {
return template
.replace("{title}", orEmpty(request.title()))
.replace("{summary}", orEmpty(request.summary()))
.replace("{description}", orEmpty(request.description()))
.replace("{optionA}", describe(request.optionA()))
.replace("{optionB}", describe(request.optionB()));
}

private static String describe(Side side) {
if (side == null) {
return "";
}
String title = orEmpty(side.title());
String stance = orEmpty(side.stance());
if (stance.isEmpty()) {
return title;
}
return title.isEmpty() ? stance : title + " - " + stance;
}

private static String orEmpty(String value) {
return value == null ? "" : value.trim();
static String buildPrompt(String template, String scene) {
return template.replace("{scene}", scene);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
public interface BattleThumbnailStyleSource {

/**
* 프롬프트 템플릿. 다음 자리표시자를 배틀 내용으로 치환한다:
* {title} {summary} {description} {optionA} {optionB}
* 프롬프트 템플릿. {scene} 자리에 배틀 내용으로 만든 장면 묘사가 들어간다.
*/
String promptTemplate();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.swyp.picke.domain.battle.thumbnail;

/** OpenAI 안전 필터(moderation_blocked)가 이미지 생성 요청을 거절했다. */
public class ImageModerationBlockedException extends RuntimeException {

public ImageModerationBlockedException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;

/**
Expand Down Expand Up @@ -43,10 +44,17 @@ public class OpenAiImageClient {
private String size;

public byte[] generate(String prompt, List<ReferenceImage> references) {
Map<?, ?> response = references.isEmpty()
? callGenerations(prompt)
: callEdits(prompt, references);
return decodeFirstImage(response);
try {
Map<?, ?> response = references.isEmpty()
? callGenerations(prompt)
: callEdits(prompt, references);
return decodeFirstImage(response);
} catch (HttpClientErrorException e) {
if (e.getResponseBodyAsString().contains("moderation_blocked")) {
throw new ImageModerationBlockedException("OpenAI 안전 필터가 이미지 생성을 거절했습니다.", e);
}
throw e;
}
}

private Map<?, ?> callGenerations(String prompt) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.swyp.picke.domain.battle.thumbnail;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

/**
* 배틀 제목(주제)을 썸네일로 그릴 상징적인 장면 묘사로 바꾼다. A/B 입장이 아니라 제목 자체를 그린다.
* 토론 원문(안락사, 전쟁 등)을 이미지 프롬프트에 그대로 넣지 않고, 위해를 직접 그리지 않는 은유적 장면으로 옮겨
* 후보마다 서로 다른 장면을 쓰게 한다. {@code OpenAiEmotionClassifier} 와 같은 방식(RestClient + openai.* 설정)을 쓴다.
*/
@Slf4j
@Component
public class ThumbnailSceneWriter {

private static final int CONNECT_TIMEOUT_MS = 5000;
private static final int READ_TIMEOUT_MS = 30000;

private static final String SYSTEM = """
너는 철학 토론 배틀의 썸네일 일러스트 장면을 기획하는 아트 디렉터다.
배틀 제목이 다루는 주제(대상, 상황, 질문) 자체를 상징적으로 보여주는 장면을 서로 다르게 %d개 묘사해라.
한 줄 요약과 설명은 제목의 의미를 파악하는 맥락으로만 쓴다.
규칙:
- 찬반 두 입장을 나눠 그리거나, 화면을 좌우로 분할하거나, 두 대상이 맞서는 대립 구도로 만들지 않는다.
- 각 장면은 2~3문장의 한국어로, 화면에 무엇이 어디에 어떻게 보이는지 구체적으로 쓴다.
- 실루엣, 사물, 빛과 그림자, 공간, 자연물 같은 은유로 표현한다.
- 부상, 피, 시신, 무기 사용, 자해, 죽어가는 사람 등 위해를 직접 묘사하지 않는다.
- 텍스트, 글자, 숫자, 로고는 장면에 넣지 않는다.
- 화풍이나 색상은 쓰지 않는다(별도로 지정된다).
반드시 JSON 만 출력: {"scenes":["...","..."]}
""";

private final ObjectMapper objectMapper = new ObjectMapper();

@Value("${openai.api-key}")
private String apiKey;

@Value("${openai.url}")
private String openaiUrl;

@Value("${openai.model}")
private String model;

/** 실패하면 빈 목록을 돌려준다. 호출부는 배틀 원문으로 대체한다. */
public List<String> write(AdminBattleThumbnailCandidateRequest request, int count) {
try {
JsonNode root = call(SYSTEM.formatted(count), describeBattle(request));
List<String> scenes = new ArrayList<>();
for (JsonNode scene : root.path("scenes")) {
String text = scene.asText("").trim();
if (!text.isEmpty()) {
scenes.add(text);
}
}
return scenes;
} catch (Exception e) {
log.warn("[Thumbnail] 장면 묘사 생성 실패 - 배틀 원문으로 생성", e);
return List.of();
}
}

static String describeBattle(AdminBattleThumbnailCandidateRequest request) {
return "제목: " + orEmpty(request.title())
+ "\n한 줄 요약: " + orEmpty(request.summary())
+ "\n설명: " + orEmpty(request.description());
}

private static String orEmpty(String value) {
return value == null ? "" : value.trim();
}

private JsonNode call(String systemPrompt, String userPrompt) throws Exception {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);
factory.setReadTimeout(READ_TIMEOUT_MS);
RestClient restClient = RestClient.builder().requestFactory(factory).build();

Map<String, Object> requestBody = Map.of(
"model", model,
"response_format", Map.of("type", "json_object"),
"messages", List.of(
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", userPrompt)));

Map<?, ?> response = restClient.post()
.uri(openaiUrl)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(requestBody)
.retrieve()
.body(Map.class);

List<?> choices = (List<?>) response.get("choices");
Map<?, ?> choice = (Map<?, ?>) choices.get(0);
Map<?, ?> message = (Map<?, ?>) choice.get("message");
return objectMapper.readTree((String) message.get("content"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public enum ErrorCode {
BATTLE_CANNOT_REVERT_TO_PENDING(HttpStatus.BAD_REQUEST, "BATTLE_400_REVERT", "발행된 시나리오는 다시 대기(PENDING) 상태로 되돌릴 수 없습니다."),
BATTLE_NOT_QUIZ(HttpStatus.BAD_REQUEST, "BATTLE_400_QUIZ", "해당 배틀은 퀴즈 타입이 아닙니다."),
BATTLE_NOT_POLL(HttpStatus.BAD_REQUEST, "BATTLE_400_POLL", "해당 배틀은 투표 타입이 아닙니다."),
BATTLE_THUMBNAIL_MODERATION_BLOCKED(HttpStatus.UNPROCESSABLE_ENTITY, "BATTLE_422_THUMB", "배틀 주제가 이미지 생성 안전 정책에 걸려 썸네일 후보를 만들지 못했습니다. 다시 생성하거나 직접 업로드해 주세요."),
BATTLE_THUMBNAIL_GENERATION_FAILED(HttpStatus.BAD_GATEWAY, "BATTLE_502_THUMB", "썸네일 후보 생성에 실패했습니다. 잠시 후 다시 시도해 주세요."),

// Scenario
Expand Down
8 changes: 2 additions & 6 deletions src/main/resources/battle-thumbnail/prompt.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,5 @@
마지막(4번째) 첨부 이미지는 색상 팔레트입니다. 색상과 전체 색감은 이 팔레트의 색만 사용해 맞춰주세요. 팔레트의 색상 칸 모양이나 숫자, 글자는 그리지 마세요.
텍스트, 글자, 로고, 워터마크는 포함하지 마세요.

아래 철학 토론 배틀의 핵심 갈등을 하나의 상징적인 장면으로 표현해주세요.
- 주제: {title}
- 한 줄 요약: {summary}
- 설명: {description}
- A 입장: {optionA}
- B 입장: {optionB}
아래 장면을 하나의 상징적인 일러스트로 그려주세요.
{scene}
Loading
Loading