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
34 changes: 34 additions & 0 deletions src/main/java/org/umc/valuedi/domain/mbti/enums/MbtiTraits.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.umc.valuedi.domain.mbti.enums;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.List;

@Getter
@RequiredArgsConstructor
public enum MbtiTraits {

APGV(List.of("기본계획", "감정개입", "즉흥허용", "도전성", "규칙완화", "후회가능", "허용범위설정", "숙려권장")),
APGR(List.of("리스크인지", "계산형도전", "구조중시", "장단기분리", "균형추구", "현실판단", "결정피로주의", "기준기반선호")),
APCV(List.of("안정선호", "예측가능선호", "보수적", "단순구조선호", "변화회피", "결정미룸", "소액확장권장", "실행체크리스트")),
APCR(List.of("목표지향", "원칙준수", "절제소비", "장기지속", "계획으로불안관리", "성과시각화선호", "규칙강함", "유연성확보")),
AIGV(List.of("감정우선", "충동성", "즉시행동", "손실회피", "확인회피", "고위험주의", "한도설정필수", "대기규칙권장")),
AIGR(List.of("불안존재", "준비후도전", "기준설정", "정보비교", "변동감수", "분산선호", "자동화선호", "자금분리")),
AICV(List.of("타인영향", "불안높음", "결정부담", "유행민감", "확신짧음", "기준필요", "자동저축선호", "패턴기록권장")),
AICR(List.of("목표분명", "불안동반", "조심스러움", "실행지연", "점검빈번", "목표형선호", "단계저축선호", "시각화선호")),
SPGV(List.of("중립감정", "계획존재", "깊은고민회피", "충분하면실행", "사후관리약함", "방치위험", "중간점검필요", "알림장치유리")),
SPGR(List.of("전략중심", "장기목표", "계산형", "기준명확", "구조화강점", "공격가능", "결정피로주의", "자동화로부담감소")),
SPCV(List.of("안정최우선", "보수적", "확신없으면미룸", "현상유지", "단계적확장필요", "자극회피", "정기점검필요", "단순상품선호")),
SPCR(List.of("계획중심", "기준중요", "즉흥적음", "장기안정", "지속력", "여유부족주의", "도전부족가능", "상향목표제안유리")),
SIGV(List.of("기회민감", "빠른결단", "속도강점", "불안동반", "손실시자책", "브레이크필요", "한도설정", "리스크기준정리")),
SIGR(List.of("도전성", "구조분석", "흐름중시", "정보비교", "복잡감당", "판단지연피로", "전략정리필요", "단순화선호")),
SICV(List.of("관심낮음", "관리귀찮음", "안전지향", "결정미룸", "기회놓침가능", "자동이체필수", "기본값선호", "단순상품선호")),
SICR(List.of("안정지향", "목표지향", "합리적", "장기계획", "침착함", "예측가능선호", "정체주의", "정기점검필요"));

private final List<String> traits;

public static List<String> of(MbtiType type) {
return MbtiTraits.valueOf(type.name()).getTraits();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ public ApiResponse<SavingsResponseDTO.SavingsListResponse> recommend(
return ApiResponse.onSuccess(GeneralSuccessCode.OK, result);
}

// 최신 추천 15개 조회
// 최신 추천 10개 조회
@GetMapping
public ApiResponse<SavingsResponseDTO.SavingsListResponse> latest15(
public ApiResponse<SavingsResponseDTO.SavingsListResponse> latest10(
@RequestParam(required = false) String rsrvType,
@CurrentMember Long memberId
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public interface RecommendationControllerDocs {
로그인 사용자(JWT)의 현재 MBTI를 바탕으로 Gemini 추천을 생성하고 DB를 갱신합니다.
MBTI 검사 완료 후 이 API를 호출하여 맞춤 추천을 받을 수 있습니다.

- 응답 속도는 Gemini API 호출을 포함하므로 약 3~7초 정도 소요될 수 있습니다
- 응답 속도는 Gemini API 호출을 포함하므로 약 10초 정도 소요될 수 있습니다
""",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
Expand Down Expand Up @@ -84,10 +84,10 @@ ApiResponse<SavingsResponseDTO.SavingsListResponse> recommend(
);

@Operation(
summary = "최신 추천 15개 조회 API",
summary = "최신 추천 10개 조회 API",
description = """
로그인 사용자(JWT)의 '현재 활성 MBTI 테스트' 기준으로
DB에 저장된 최신 추천 15개를 조회 (Gemini 호출 X)
DB에 저장된 최신 추천 10개를 조회 (Gemini 호출 X)
""",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
Expand All @@ -99,7 +99,7 @@ ApiResponse<SavingsResponseDTO.SavingsListResponse> recommend(
examples = {
@ExampleObject(
name = "success",
summary = "추천 15개 조회 성공",
summary = "추천 10개 조회 성공",
value = """
{
"isSuccess": true,
Expand Down Expand Up @@ -139,7 +139,7 @@ ApiResponse<SavingsResponseDTO.SavingsListResponse> recommend(
)
}
)
ApiResponse<SavingsResponseDTO.SavingsListResponse> latest15(
ApiResponse<SavingsResponseDTO.SavingsListResponse> latest10(
@Parameter(
description = "적립유형 필터 (S=정기적금, F=자유적금). 미입력 시 전체",
schema = @Schema(allowableValues = {"S", "F"}, example = "S")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,15 @@ boolean existsByMemberIdAndMemberMbtiTestId(
@Param("memberId") Long memberId,
@Param("memberMbtiTestId") Long memberMbtiTestId
);

@Query("""
select r
from Recommendation r
where r.member.id = :memberId
and r.memberMbtiTestId = :memberMbtiTestId
""")
List<Recommendation> findAllByMemberIdAndMemberMbtiTestId(
@Param("memberId") Long memberId,
@Param("memberMbtiTestId") Long memberMbtiTestId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.umc.valuedi.domain.mbti.dto.FinanceMbtiTypeInfoDto;
import org.umc.valuedi.domain.mbti.entity.MemberMbtiTest;
import org.umc.valuedi.domain.mbti.enums.MbtiTraits;
import org.umc.valuedi.domain.mbti.enums.MbtiType;
import org.umc.valuedi.domain.mbti.exception.MbtiException;
import org.umc.valuedi.domain.mbti.exception.code.MbtiErrorCode;
Expand All @@ -18,7 +18,6 @@
import org.umc.valuedi.domain.savings.entity.Recommendation;
import org.umc.valuedi.domain.savings.entity.Savings;
import org.umc.valuedi.domain.savings.entity.SavingsOption;
import org.umc.valuedi.domain.savings.enums.RecommendationStatus;
import org.umc.valuedi.domain.savings.exception.SavingsException;
import org.umc.valuedi.domain.savings.exception.code.SavingsErrorCode;
import org.umc.valuedi.domain.savings.repository.RecommendationRepository;
Expand All @@ -36,10 +35,10 @@
@RequiredArgsConstructor
public class RecommendationService {

private static final int RECOMMEND_COUNT = 15;
private static final int RECOMMEND_COUNT = 10;
private static final int TOP3_COUNT = 3;

private static final int CANDIDATE_LIMIT = 40;
private static final int CANDIDATE_LIMIT = 25;

private final MemberMbtiTestRepository memberMbtiTestRepository;
private final FinanceMbtiProvider financeMbtiProvider;
Expand All @@ -65,8 +64,7 @@ public SavingsResponseDTO.SavingsListResponse generateAndSaveRecommendations(Lon

// 제미나이 프롬프트 생성
MbtiType mbtiType = memberMbtiTest.getResultType();
FinanceMbtiTypeInfoDto financeMbtiTypeInfo = financeMbtiProvider.get(mbtiType);
String prompt = buildPrompt(mbtiType, financeMbtiTypeInfo, candidates, RECOMMEND_COUNT);
String prompt = buildPrompt(mbtiType, candidates, RECOMMEND_COUNT);

// 제미나이 호출
log.info("[Recommend] Gemini request. memberId={}, promptChars={}", memberId, prompt.length());
Expand All @@ -91,12 +89,13 @@ public SavingsResponseDTO.SavingsListResponse generateAndSaveRecommendations(Lon
List<Savings> savingsList = items.stream()
.map(i -> savingsRepository.findByFinPrdtCd(i.finPrdtCd()).orElse(null))
.filter(Objects::nonNull)
.filter(new LinkedHashSet<>()::add)
.toList();

return SavingsConverter.toSavingsListResponseDTO(savingsList, savingsList.size(), 1, 1);
}

// 추천 상품 15개 조회
// 추천 상품 10개 조회
@Transactional(readOnly = true)
public SavingsResponseDTO.SavingsListResponse getRecommendation(Long memberId, String rsrvType) {
Long mbtiTestId = memberMbtiTestRepository.findCurrentActiveTest(memberId)
Expand Down Expand Up @@ -154,7 +153,6 @@ private SavingsResponseDTO.SavingsListResponse emptyResponse() {

private String buildPrompt(
MbtiType mbtiType,
FinanceMbtiTypeInfoDto typeInfo,
List<SavingsOption> candidates,
int recommendCount
) {
Expand Down Expand Up @@ -183,18 +181,22 @@ private String buildPrompt(
candidateText = "[]";
}

// JSON만 출력 강제 + 키 이름 DTO와 일치(GeminiSavingsResponseDTO.Result가 recommendations를 가지도록)
List<String> traits = MbtiTraits.of(mbtiType); // enum에서 가져오기
String traitsText;
try {
traitsText = objectMapper.writeValueAsString(traits); // ["...", "..."]
} catch (Exception e) {
traitsText = "[]";
}

// JSON만 출력 강제
return """
당신은 금융 추천 엔진입니다.
아래 "사용자 금융 MBTI"와 "후보 적금 옵션 목록"을 기반으로, 사용자에게 가장 적합한 적금 옵션 %d개를 추천하세요.
아래 "사용자 성향(traits)"과 "후보 적금 옵션 목록"을 기반으로, 가장 적합한 적금 옵션 %d개를 추천하세요.

[사용자 금융 MBTI]
- type: %s
- title: %s
- tagline: %s
- detail: %s
- warning: %s
- recommend: %s
[사용자]
- mbtiType: %s
- traits: %s

[후보 적금 옵션 목록]
%s
Expand All @@ -203,12 +205,13 @@ private String buildPrompt(
- 반드시 JSON만 출력하세요. (설명 문장, 마크다운 금지)
- 반드시 아래 스키마를 정확히 지키세요.
- optionId는 후보 목록에 있는 값만 사용하세요.
- 동일한 finPrdtCd를 가진 옵션은 하나만 선택하세요. (같은 상품의 중복 추천 금지)
- score는 0~1 사이 숫자(소수)로, 높을수록 추천 우선순위입니다.
- reasons는 1~3개. reasonCode는 대문자 스네이크로 작성하세요(예: HIGH_RATE, MATCH_TERM, MBTI_FIT).
- reasons는 1~3개. reasonCode는 대문자 스네이크로 작성하세요(예: HIGH_RATE, MATCH_TERM).

[JSON 스키마]
{
"rationale": "전체 추천 요약(한 문단)",
"rationale": "전체 추천 요약(한 문장)",
"recommendations": [
{
"finPrdtCd": "string",
Expand All @@ -223,11 +226,7 @@ private String buildPrompt(
""".formatted(
recommendCount,
mbtiType.name(),
safeStr(typeInfo.title()),
safeStr(typeInfo.tagline()),
safeStr(typeInfo.detail()),
safeStr(String.join(" / ", safeList(typeInfo.cautions()))),
safeStr(String.join(" / ", safeList(typeInfo.recommendedActions()))),
traitsText,
candidateText
);
}
Expand Down Expand Up @@ -263,10 +262,6 @@ private static <T> List<T> safeList(List<T> list) {
return list == null ? List.of() : list;
}

private static String safeStr(String s) {
return s == null ? "" : s;
}

private static BigDecimal nullSafe(BigDecimal bd) {
return bd == null ? BigDecimal.ZERO : bd;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ public SavingsResponseDTO.RecommendResponse saveRecommendations(
Map<Long, SavingsOption> optionById = pickedOptions.stream()
.collect(Collectors.toMap(SavingsOption::getId, Function.identity()));

// 기존 추천 삭제 (중복 방지)
List<Recommendation> existing = recommendationRepository.findAllByMemberIdAndMemberMbtiTestId(memberId, memberMbtiTest.getId());
if (!existing.isEmpty()) {
recommendationRepository.deleteAll(existing);
}

// 추천 상품 저장
LocalDateTime now = LocalDateTime.now();
List<Recommendation> toSave = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package org.umc.valuedi.global.external.genai.client;

import com.google.genai.Client;
import com.google.genai.types.GenerateContentConfig;
import com.google.genai.types.GenerateContentResponse;
import com.google.genai.types.ThinkingConfig;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -21,13 +23,13 @@ public class GeminiClient {
private final Client genaiClient;
private final GeminiProperties geminiProperties;

private static final int MAX_ATTEMPTS = 4;
private static final int MAX_ATTEMPTS = 2;

private static final Duration PER_ATTEMPT_TIMEOUT = Duration.ofSeconds(30); // 시도별 제한
private static final Duration OVERALL_DEADLINE = Duration.ofSeconds(240); // 전체 제한
private static final Duration PER_ATTEMPT_TIMEOUT = Duration.ofSeconds(90); // 시도별 제한
private static final Duration OVERALL_DEADLINE = Duration.ofSeconds(190); // 전체 제한

private static final long BASE_BACKOFF_MILLIS = 2_000;
private static final long MAX_BACKOFF_MILLIS = 90_000;
private static final long MAX_BACKOFF_MILLIS = 8_000;

private static final ExecutorService executor =
Executors.newFixedThreadPool(4, r -> {
Expand Down Expand Up @@ -109,9 +111,13 @@ public String generateText(String prompt) {
throw new GeminiException(GeminiErrorCode.GEMINI_CALL_FAILED, lastCause);
}

private static final GenerateContentConfig NO_THINKING_CONFIG = GenerateContentConfig.builder()
.thinkingConfig(ThinkingConfig.builder().thinkingBudget(0).build())
.build();

private String callWithTimeout(String prompt, Duration timeout) throws TimeoutException, ExecutionException, InterruptedException {
Future<String> future = executor.submit(() -> {
GenerateContentResponse response = genaiClient.models.generateContent(geminiProperties.getModel(), prompt, null);
GenerateContentResponse response = genaiClient.models.generateContent(geminiProperties.getModel(), prompt, NO_THINKING_CONFIG);
return response.text();
});

Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ fss:
genai:
gemini:
api-key: ${GEMINI_API_KEY}
model: gemini-3-flash-preview
model: gemini-2.5-flash

springdoc:
swagger-ui:
Expand Down