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
43 changes: 20 additions & 23 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ plugins {
id 'io.spring.dependency-management' version '1.1.7'
}

ext {
springAiVersion = "1.1.2"
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
description = 'team4-backend'
Expand All @@ -18,47 +14,48 @@ java {
}
}

configurations {
compileOnly {
extendsFrom annotationProcessor
}
}

repositories {
mavenCentral()
}

dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:1.1.2"
}
}

dependencies {
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

// Spring Boot
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-web-services'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-webflux'

// OpenAPI
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'

// DB
runtimeOnly 'com.mysql:mysql-connector-j'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.ws:spring-ws-test'

testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'org.springframework.boot:spring-boot-starter-webflux'

// models
implementation 'org.springframework.ai:spring-ai-starter-model-google-genai'
// Spring AI
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
}
implementation 'org.springframework.ai:spring-ai-starter-model-google-genai'

dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion"
}
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.ws:spring-ws-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@
import com.example.team4backend.common.response.ApiResult;
import com.example.team4backend.dto.PromptRequest;
import com.example.team4backend.service.AiService;
import com.example.team4backend.service.ImageInput;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
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;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.List;

@Tag(name = "Ai", description = "LLM 호출 API")
@RestController
Expand All @@ -19,10 +26,36 @@ public class AiController {

@PostMapping("/prompts")
public ResponseEntity<ApiResult<Void>> chat(@RequestBody PromptRequest promptRequest) {
System.out.println("=== AiController.chat() 호출됨 === userInput: " + promptRequest.getUserInput());
String aiResponse = aiService.generateContent(promptRequest.getUserInput());
System.out.println("=== AiController.chat() 응답 완료 ===");

return ResponseEntity.ok(ApiResult.ok(aiResponse));
}

@PostMapping(value = "/prompts/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResult<Void>> chatWithImages(
@RequestPart("images") List<MultipartFile> images) {

if (images == null || images.isEmpty()) {
throw new IllegalArgumentException("최소 1개 이상의 이미지가 필요합니다.");
}
if (images.size() > 3) {
throw new IllegalArgumentException("이미지는 최대 3개까지 업로드할 수 있습니다.");
}

List<ImageInput> imageInputs = images.stream()
.map(file -> {
try {
String mimeType = file.getContentType() != null
? file.getContentType()
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
return new ImageInput(file.getBytes(), mimeType);
} catch (IOException e) {
throw new RuntimeException("이미지 파일을 읽는 중 오류가 발생했습니다.", e);
}
})
.toList();

String aiResponse = aiService.generateContentFromImages(imageInputs);
return ResponseEntity.ok(ApiResult.ok(aiResponse));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,14 @@ public class TargetPerson extends BaseTimeEntity {
@Column(columnDefinition = "TEXT")
private String recommendedOpening;

@Column(columnDefinition = "LONGTEXT")
private String chatContent;

private Instant deletedAt;

@Builder
public TargetPerson(User user, String name, Relationship relationship, ChatStyle chatStyle, Integer age, String phoneNumber, LocalDate birthday, String interests) {
public TargetPerson(User user, String name, Relationship relationship, ChatStyle chatStyle, String chatContent,
Integer age, String phoneNumber, LocalDate birthday, String interests) {
this.user = user;
this.name = name;
this.age = age;
Expand All @@ -64,6 +68,7 @@ public TargetPerson(User user, String name, Relationship relationship, ChatStyle
this.interests = interests;
this.relationship = relationship;
this.chatStyle = chatStyle;
this.chatContent = chatContent;
this.deletedAt = null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public record MessageListResponse(
@Schema(description = "대상자 이름", example = "홍길동")
String name,

@Schema(description = "전화번호", example = "01029050166")
String phoneNumber,

@Schema(description = "추천 서두", example = "안녕하세요! 오랜만이에요")
String recommendedOpening,

Expand All @@ -25,6 +28,7 @@ public static MessageListResponse of(TargetPerson target, String recommendedOpen
return new MessageListResponse(
target.getId(),
target.getName(),
target.getPhoneNumber(),
recommendedOpening,
target.getLastMessageDate()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@


public record TargetListResponse(
@Schema(description = "대상자 ID", example = "1")
Long targetId,

@Schema(description = "이름", example = "홍길동")
String name,

Expand All @@ -13,6 +16,7 @@ public record TargetListResponse(
){
public static TargetListResponse from(TargetPerson target) {
return new TargetListResponse(
target.getId(),
target.getName(),
target.getRelationship().getDescription()
);
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/example/team4backend/dto/TargetRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public record TargetRequest(
@NotNull(message = "채팅 스타일은 필수입니다.")
Long chatStyleId,

String chatContent,

@Schema(description = "나이", example = "25")
Integer age,

Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/example/team4backend/service/AiService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.example.team4backend.service;

import java.util.List;

public interface AiService {
String generateContent(String prompt);
Object getOptions();
String generateContentFromImages(List<ImageInput> images);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.List;

@Qualifier
@Service
public class GeminiService implements AiService {
Expand Down Expand Up @@ -51,4 +53,9 @@ public String generateContent(String prompt) {
public Object getOptions() {
return null;
}

@Override
public String generateContentFromImages(List<ImageInput> images) {
return "";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.example.team4backend.service;

public record ImageInput(byte[] data, String mimeType) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ public class KakaoOAuthService {
@Value("${oauth2.kakao.client-id}")
private String clientId;

@Value("${oauth2.kakao.client-secret:}")
private String clientSecret;

@Value("${oauth2.kakao.redirect-uri}")
private String redirectUri;

Expand All @@ -43,9 +40,6 @@ public KakaoTokenResponse getToken(String code) {
body.add("client_id", clientId);
body.add("redirect_uri", redirectUri);
body.add("code", code);
if (clientSecret != null && !clientSecret.isBlank()) {
body.add("client_secret", clientSecret);
}

try {
return webClient.post()
Expand Down
98 changes: 92 additions & 6 deletions src/main/java/com/example/team4backend/service/OpenAiService.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
package com.example.team4backend.service;

import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.content.Media;

import org.springframework.core.io.ByteArrayResource;
import org.springframework.util.MimeType;

import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.List;


import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;

import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import javax.imageio.ImageIO;

@Primary
@Service
Expand All @@ -23,16 +36,89 @@ public OpenAiService(OpenAiChatModel chatModel) {

@Override
public String generateContent(String prompt) {
var options = (OpenAiChatOptions) getOptions();
return chatModel.call(new Prompt(prompt, options))
.getResult().getOutput().getText();
}


@Override
public String generateContentFromImages(List<ImageInput> images) {

String fixedPrompt = """
You are an assistant that extracts text from images.

If the image is a chat or messaging conversation:
- Extract ONLY the messages sent by the current user.
- The current user's messages are on the RIGHT side.
- NEVER extract any LEFT-side messages.
- Remove timestamps, names, profile info, system notices, and reactions.
- Output ONLY the extracted message text in Korean.
""";

var options = (OpenAiChatOptions) getOptions();

return chatModel.call(new Prompt(prompt, options)).getResult()
.getOutput().getText();
List<Media> mediaList = images.stream()
.map(image -> {
CroppedImage cropped = cropRightOnly(image.data(), image.mimeType(), 0.70);

return new Media(
MimeType.valueOf(cropped.mimeType()),
new ByteArrayResource(cropped.bytes())
);
})
.toList();

UserMessage userMessage = UserMessage.builder()
.text(fixedPrompt)
.media(mediaList)
.build();

return chatModel
.call(new Prompt(List.of(userMessage), options))
.getResult()
.getOutput()
.getText();
}

public Object getOptions() {
// TODO: relationship table에서 weight 출력
private CroppedImage cropRightOnly(byte[] originalBytes, String originalMimeType, double keepRatio) {
try {
BufferedImage img = ImageIO.read(new ByteArrayInputStream(originalBytes));
if (img == null) return new CroppedImage(originalBytes, originalMimeType);

int w = img.getWidth();
int h = img.getHeight();

int keepW = Math.max(1, (int) Math.round(w * keepRatio));
int x = Math.max(0, w - keepW);

BufferedImage rightOnly = img.getSubimage(x, 0, keepW, h);

String format = mimeToFormat(originalMimeType);
String outMime = format.equals("png") ? "image/png" : "image/jpeg";

ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(rightOnly, format, baos);

return new CroppedImage(baos.toByteArray(), outMime);
} catch (Exception e) {
return new CroppedImage(originalBytes, originalMimeType);
}
}

private String mimeToFormat(String mime) {
if (mime == null) return "png";
String m = mime.toLowerCase();
if (m.contains("png")) return "png";
if (m.contains("jpeg") || m.contains("jpg")) return "jpg";
return "png";
}

private record CroppedImage(byte[] bytes, String mimeType) {}



public Object getOptions() {
return OpenAiChatOptions.builder()
.model(modelName)
.temperature(0.5)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public Long addTarget(Long userId, TargetRequest dto) {
.name(dto.name())
.relationship(relationship)
.chatStyle(chatStyle)
.chatContent(dto.chatContent())
.age(dto.age())
.phoneNumber(dto.phoneNumber())
.birthday(dto.birthday())
Expand Down