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: 23 additions & 20 deletions src/main/java/com/finz/controller/CoachController.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.finz.controller;

import com.finz.dto.GlobalResponseDto;
import com.finz.dto.coach.CoachMessageDto;
import com.finz.dto.coach.CoachResponseDto;
import com.finz.dto.coach.MessageRequest;
import com.finz.service.CoachService;
Expand All @@ -9,48 +11,49 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.finz.dto.coach.CoachMessageDto;

import java.util.List;

@Slf4j
@RestController
@RequestMapping("/api/coach")
@RequiredArgsConstructor
@Tag(name = "Coach", description = "AI 코치 대화 API")
@Tag(name = "Coach", description = "AI 코치 상담 API")
public class CoachController {

private final CoachService coachService;

// 1인 사용자용 고정 ID
private static final Long DEFAULT_USER_ID = 1L;

// 빠른 제안: 목표 설정 대화 시작
@PostMapping("/start-goal-setting")
@Operation(summary = "목표 설정 대화 시작", description = "AI 코치가 사용자의 정보를 바탕으로 목표 설정 대화를 시작합니다.")
public ResponseEntity<CoachResponseDto> startGoalSetting() {
log.info("목표 설정 시작 요청");
// 목표 상담 요청
@PostMapping("/goal-consult/{userId}")
@Operation(summary = "목표 상담 요청", description = "AI 코치와 목표 설정 대화를 시작합니다.")
public ResponseEntity<GlobalResponseDto<CoachResponseDto>> startGoalConsult(
@PathVariable Long userId) {
log.info("목표 상담 요청 - userId: {}", userId);

CoachResponseDto response = coachService.startGoalSettingConversation(DEFAULT_USER_ID);
return ResponseEntity.ok(response);
return ResponseEntity.ok(
coachService.requestGoalConsult(userId)
);
}

// 메시지 전송 (대화 진행)
@PostMapping("/message")
@PostMapping("/message/{userId}")
@Operation(summary = "메시지 전송", description = "사용자 메시지를 전송하고 AI 코치의 응답을 받습니다.")
public ResponseEntity<CoachResponseDto> sendMessage(@RequestBody MessageRequest request) {
log.info("메시지 전송 - type: {}", request.getMessageType());
public ResponseEntity<CoachResponseDto> sendMessage(
@PathVariable Long userId,
@RequestBody MessageRequest request) {
log.info("메시지 전송 - userId: {}, type: {}", userId, request.getMessageType());

CoachResponseDto response = coachService.generateResponse(DEFAULT_USER_ID, request);
CoachResponseDto response = coachService.generateResponse(userId, request);
return ResponseEntity.ok(response);
}

// 그간의 대화 내역 조회
@GetMapping("/history")
@GetMapping("/history/{userId}")
@Operation(summary = "대화 내역 조회", description = "AI 코치와의 전체 대화 내역을 조회합니다.")
public ResponseEntity<List<CoachMessageDto>> getChatHistory() {
log.info("대화 내역 조회 요청 - userId: {}", DEFAULT_USER_ID);
public ResponseEntity<List<CoachMessageDto>> getChatHistory(@PathVariable Long userId) {
log.info("대화 내역 조회 요청 - userId: {}", userId);

List<CoachMessageDto> history = coachService.getChatHistory(DEFAULT_USER_ID);
List<CoachMessageDto> history = coachService.getChatHistory(userId);
return ResponseEntity.ok(history);
}
}
1 change: 0 additions & 1 deletion src/main/java/com/finz/dto/coach/CoachResponseDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,4 @@
public class CoachResponseDto {
private String message;
private MessageType messageType;
private GoalSuggestion suggestedGoal; // nullable
}
16 changes: 16 additions & 0 deletions src/main/java/com/finz/dto/coach/GoalConsultRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.finz.dto.coach;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "목표 상담 요청")
public class GoalConsultRequest {

@Schema(description = "사용자 ID", example = "1")
private Long userId;
}
76 changes: 52 additions & 24 deletions src/main/java/com/finz/infrastructure/gemini/GeminiApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public String chat(String systemPrompt, List<GeminiMessage> history, String user
return callGeminiApi(contents);
}

// Gemini API 호출
// Gemini API 호출 (재시도 로직 포함)
private String callGeminiApi(List<GeminiRequest.Content> contents) {

String url = apiUrl + "?key=" + apiKey;
Expand All @@ -90,31 +90,59 @@ private String callGeminiApi(List<GeminiRequest.Content> contents) {

HttpEntity<GeminiRequest> entity = new HttpEntity<>(request, headers);

try {
log.debug("Gemini API 호출 - URL: {}", url);

GeminiResponse response = restTemplate.postForObject(
url,
entity,
GeminiResponse.class
);

if (response != null && !response.getCandidates().isEmpty()) {
String text = response.getCandidates().get(0)
.getContent()
.getParts()
.get(0)
.getText();
// 최대 3번 재시도
int maxRetries = 3;
int retryCount = 0;

while (retryCount < maxRetries) {
try {
log.debug("Gemini API 호출 시도 {}/{} - URL: {}", retryCount + 1, maxRetries, url);

GeminiResponse response = restTemplate.postForObject(
url,
entity,
GeminiResponse.class
);

if (response != null && !response.getCandidates().isEmpty()) {
String text = response.getCandidates().get(0)
.getContent()
.getParts()
.get(0)
.getText();

log.debug("Gemini API 응답 성공");
return text;
}

throw new RuntimeException("Gemini API 응답이 비어있습니다.");

log.debug("Gemini API 응답: {}", text);
return text;
} catch (Exception e) {
retryCount++;

// 503 에러인 경우
if (e.getMessage() != null && e.getMessage().contains("503")) {
if (retryCount < maxRetries) {
log.warn("Gemini API 과부하 (503) - {}초 후 재시도 {}/{}",
retryCount * 2, retryCount, maxRetries);
try {
Thread.sleep(retryCount * 2000L); // 2초, 4초, 6초
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
continue;
} else {
log.error("Gemini API 재시도 초과 - 최대 {}번 시도 완료", maxRetries);
throw new RuntimeException("AI 서비스가 현재 사용량이 많습니다. 잠시 후 다시 시도해주세요.");
}
}

// 다른 에러는 즉시 throw
log.error("Gemini API 호출 실패", e);
throw new RuntimeException("AI 응답 생성 중 오류가 발생했습니다: " + e.getMessage());
}

throw new RuntimeException("Gemini API 응답이 비어있습니다.");

} catch (Exception e) {
log.error("Gemini API 호출 실패", e);
throw new RuntimeException("AI 응답 생성 중 오류가 발생했습니다: " + e.getMessage());
}

throw new RuntimeException("AI 서비스가 현재 사용량이 많습니다. 잠시 후 다시 시도해주세요.");
}
}
67 changes: 22 additions & 45 deletions src/main/java/com/finz/service/CoachService.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.finz.domain.user.User;
import com.finz.domain.user.UserRepository;
import com.finz.dto.coach.*;
import com.finz.dto.GlobalResponseDto;
import com.finz.infrastructure.gemini.GeminiApiClient;
import com.finz.infrastructure.gemini.dto.GeminiMessage;
import lombok.RequiredArgsConstructor;
Expand All @@ -21,8 +22,6 @@
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

@Slf4j
Expand All @@ -36,6 +35,21 @@ public class CoachService {
private final ExpenseRepository expenseRepository;
private final GeminiApiClient geminiClient;

// 목표 상담 요청
@Transactional
public GlobalResponseDto<CoachResponseDto> requestGoalConsult(Long userId) {
log.info("목표 상담 요청 - userId: {}", userId);

CoachResponseDto data = startGoalSettingConversation(userId);

return GlobalResponseDto.<CoachResponseDto>builder()
.status(200)
.success(true)
.message("목표 상담이 시작되었습니다.")
.data(data)
.build();
}

@Transactional(readOnly = true) // 데이터 변경이 없는 조회 작업
public List<CoachMessageDto> getChatHistory(Long userId) {
log.info("대화 내역 조회 - userId: {}", userId);
Expand Down Expand Up @@ -144,25 +158,18 @@ public CoachResponseDto generateResponse(Long userId, MessageRequest request) {
.build();

messageRepository.save(aiMsg);

// 7. 목표 제안 추출
GoalSuggestion suggestion = null;
if (request.getMessageType() == MessageType.GOAL_SETTING) {
suggestion = extractGoalFromResponse(aiResponse);
}


return CoachResponseDto.builder()
.message(aiResponse)
.messageType(request.getMessageType())
.suggestedGoal(suggestion)
.build();
}

// 개인화된 목표 설정 시스템 프롬프트 생성
private String buildGoalSettingPrompt(User user, List<Goal> goals, List<ExpensePattern> expenses) {
StringBuilder prompt = new StringBuilder();

prompt.append("당신은 FiNZ의 친근한 AI 재무 코치입니다.\n");
prompt.append("당신은 Finz의 친근한 AI 재무 코치입니다.\n");
prompt.append("사용자가 '목표 설정' 버튼을 눌러서 대화를 시작했습니다.\n\n");

// 사용자 개인 정보 포함
Expand Down Expand Up @@ -233,9 +240,9 @@ private String buildGoalSettingPrompt(User user, List<Goal> goals, List<ExpenseP
// 일반 대화용 시스템 프롬프트
private String buildGeneralChatPrompt(User user, List<Goal> goals, List<ExpensePattern> expenses) {
StringBuilder prompt = new StringBuilder();

prompt.append("당신은 FiNZ의 친근한 AI 재무 코치입니다.\n\n");

prompt.append("당신은 Finz의 친근한 AI 재무 코치입니다.\n\n");
prompt.append("## 사용자 정보\n");
prompt.append(String.format("- 이름: %s\n", user.getNickname()));
prompt.append(String.format("- 연령대: %s\n", user.getAgeGroup().getDescription()));
Expand All @@ -258,36 +265,6 @@ private List<GeminiMessage> convertToGeminiFormat(List<CoachMessage> messages) {
.collect(Collectors.toList());
}

// AI 응답에서 목표 정보 추출
private GoalSuggestion extractGoalFromResponse(String response) {
Pattern amountPattern = Pattern.compile("(\\d{1,3}(?:,\\d{3})*|\\d+)\\s*(?:만\\s*)?원");
Matcher matcher = amountPattern.matcher(response);

if (matcher.find()) {
String amountStr = matcher.group(1).replace(",", "");
int amount = Integer.parseInt(amountStr);

if (response.contains("만원") || response.contains("만 원")) {
amount *= 10000;
}

String goalType = "저축";
if (response.contains("줄이") || response.contains("절약")) {
goalType = "지출 줄이기";
} else if (response.contains("투자")) {
goalType = "투자";
}

return GoalSuggestion.builder()
.goalType(goalType)
.targetAmount(amount)
.durationMonths(1)
.build();
}

return null;
}

@Transactional
public void processNewExpenseRecord(Long userId, Expense expense) {

Expand Down