Skip to content

Commit a97ba3b

Browse files
authored
Merge pull request #152 from prgrms-aibe-devcourse/refactor/expense-LLM
refactor : Prompt DB에서 관리
2 parents 5ece97d + cf6a158 commit a97ba3b

6 files changed

Lines changed: 127 additions & 46 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package store.lastdance.domain.prompt;
2+
3+
import jakarta.persistence.*;
4+
import lombok.AccessLevel;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
import store.lastdance.domain.common.BaseTimeEntity;
9+
10+
@Entity
11+
@Table(name = "prompts")
12+
@Getter
13+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
14+
public class Prompt extends BaseTimeEntity {
15+
@Id
16+
@GeneratedValue(strategy = GenerationType.IDENTITY)
17+
private Long id;
18+
19+
@Column(name = "prompt_type", unique = true, nullable = false) // Renamed to prompt_type
20+
private String promptType;
21+
22+
@Column(name = "prompt_content", columnDefinition = "TEXT", nullable = false)
23+
private String promptContent;
24+
25+
@Builder
26+
public Prompt(String promptType, String promptContent) { // Renamed parameter
27+
this.promptType = promptType;
28+
this.promptContent = promptContent;
29+
}
30+
31+
public void updateContent(String promptContent) {
32+
this.promptContent = promptContent;
33+
}
34+
}

src/main/java/store/lastdance/exception/ErrorCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ public enum ErrorCode {
8888
TOO_MANY_REQUESTS("요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.", HttpStatus.TOO_MANY_REQUESTS),
8989
HISTORY_NOT_FOUND("LLM 분석 기록을 찾을 수 없습니다.", HttpStatus.NOT_FOUND )
9090
,
91-
INVALID_HISTORY_REQUEST("LLM 분석 기록 요청이 유효하지 않습니다.", HttpStatus.BAD_REQUEST);
91+
INVALID_HISTORY_REQUEST("LLM 분석 기록 요청이 유효하지 않습니다.", HttpStatus.BAD_REQUEST), PROMPT_NOT_FOUND("프롬프트를 찾을 수 없습니다", HttpStatus.NOT_FOUND )
92+
,;
9293

9394
private final String message;
9495
private final HttpStatus httpStatus;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package store.lastdance.repository.prompt;
2+
3+
import org.springframework.data.jpa.repository.JpaRepository;
4+
import store.lastdance.domain.prompt.Prompt;
5+
6+
import java.util.Optional;
7+
8+
public interface PromptRepository extends JpaRepository<Prompt, Long> {
9+
Optional<Prompt> findByPromptType(String promptType);
10+
}

src/main/java/store/lastdance/service/admin/AdminServiceImpl.java

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import store.lastdance.exception.CustomException;
3737
import store.lastdance.exception.ErrorCode;
3838

39+
import java.time.LocalDate;
3940
import java.time.LocalDateTime;
4041
import java.time.format.DateTimeFormatter;
4142
import java.util.*;
@@ -960,8 +961,53 @@ public ExpenseAnalyzerFeedbackStatsDTO getExpenseAnalyzerFeedbackStats(UUID user
960961
LocalDateTime startDate = parsePeriod(period, endDate);
961962
statsSummary = expenseAnalysisHistoryRepository.getFeedbackStatsSummaryBetween(startDate, endDate);
962963
trends = expenseAnalysisHistoryRepository.findFeedbackTrendsBetween(startDate, endDate);
963-
}
964964

965+
// 날짜별 데이터 보간 로직 추가
966+
Map<LocalDate, FeedbackTrendDTO> trendMap = trends.stream()
967+
.collect(Collectors.toMap(
968+
dto -> LocalDate.of(dto.year(), dto.month(), dto.day()),
969+
dto -> dto
970+
));
971+
972+
List<FeedbackTrendDTO> interpolatedTrends = new ArrayList<>();
973+
LocalDate current = startDate.toLocalDate();
974+
long lastUpCount = 0L;
975+
long lastDownCount = 0L;
976+
977+
while (!current.isAfter(endDate.toLocalDate())) {
978+
FeedbackTrendDTO dailyTrend = trendMap.get(current);
979+
long currentUpCountForInterpolation = lastUpCount;
980+
long currentDownCountForInterpolation = lastDownCount;
981+
long currentTotalCount = 0L;
982+
983+
if (dailyTrend != null) {
984+
currentTotalCount = dailyTrend.totalCount();
985+
// 해당 날짜에 실제 up/down 피드백이 있다면 그 값을 사용
986+
if (dailyTrend.upCount() > 0 || dailyTrend.downCount() > 0) {
987+
currentUpCountForInterpolation = dailyTrend.upCount();
988+
currentDownCountForInterpolation = dailyTrend.downCount();
989+
}
990+
// else, dailyTrend가 존재하지만 up/down이 0이면 이전 값을 유지 (currentUpCountForInterpolation, currentDownCountForInterpolation은 lastUpCount, lastDownCount 그대로)
991+
}
992+
993+
// 현재 날짜의 DTO를 추가 (필요시 보간된 값 사용)
994+
interpolatedTrends.add(new FeedbackTrendDTO(
995+
current.getYear(),
996+
current.getMonthValue(),
997+
current.getDayOfMonth(),
998+
currentTotalCount, // 실제 totalCount 사용, 없으면 0
999+
currentUpCountForInterpolation,
1000+
currentDownCountForInterpolation
1001+
));
1002+
1003+
// 다음 반복을 위해 lastUpCount와 lastDownCount 업데이트
1004+
lastUpCount = currentUpCountForInterpolation;
1005+
lastDownCount = currentDownCountForInterpolation;
1006+
1007+
current = current.plusDays(1);
1008+
}
1009+
trends = interpolatedTrends;
1010+
}
9651011
long upCount = statsSummary.upCount() != null ? statsSummary.upCount() : 0L;
9661012
long downCount = statsSummary.downCount() != null ? statsSummary.downCount() : 0L;
9671013
long totalFeedbacks = upCount + downCount;

src/main/java/store/lastdance/service/expense/ExpenseAnalyzerImpl.java

Lines changed: 10 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import store.lastdance.dto.gemini.GeminiResponseDTO;
1212
import store.lastdance.exception.CustomException;
1313
import store.lastdance.exception.ErrorCode;
14+
import store.lastdance.service.prompt.PromptService; // Import PromptService
1415

1516
import java.util.List;
1617
import java.util.regex.Matcher;
@@ -29,16 +30,21 @@
2930
@Slf4j
3031
public class ExpenseAnalyzerImpl implements ExpenseAnalyzer {
3132

33+
// Regex Pattern for JSON block parsing (kept as static final for now)
34+
private static final Pattern JSON_BLOCK_PATTERN = Pattern.compile("```json\\s*([\\s\\S]+?)\\s*```|```\\s*([\\s\\S]+?)\\s*```", Pattern.CASE_INSENSITIVE);
35+
3236
private final ObjectMapper objectMapper;
3337
private final WebClient webClient;
38+
private final PromptService promptService; // Inject PromptService
3439

35-
public ExpenseAnalyzerImpl(ObjectMapper objectMapper, @Value("${GOOGLE_GEMINI_KEY}") String apiKey
40+
public ExpenseAnalyzerImpl(ObjectMapper objectMapper, @Value("${GOOGLE_GEMINI_KEY}") String apiKey, PromptService promptService
3641
) {
3742
this.objectMapper = objectMapper;
3843
this.webClient = WebClient.builder()
3944
.baseUrl("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" + apiKey)
4045
.defaultHeader("Content-Type", "application/json")
4146
.build();
47+
this.promptService = promptService; // Assign injected service
4248
}
4349

4450
@Override
@@ -56,47 +62,8 @@ public AnalyzeExpenseResponseDTO.Suggestion analyzerExpenseData(String expenseJs
5662
}
5763

5864
private String createPrompt(String expenseJson) {
59-
String systemInstruction = """
60-
당신은 재정관리 전문가입니다.
61-
사용자의 지출 데이터를 기반으로, 불필요한 지출을 줄일 수 있는 가장 효과적인 절약 팁 하나를 제안해주세요.
62-
답변은 오직 *JSON 형식*에 맞춰 개선 제안 하나만 포함해야 합니다.
63-
""";
64-
String userPrompt = "나의 지출 내역은 다음과 같다: ```json\n" + expenseJson + "\n```\n\n" +
65-
"이 데이터를 바탕으로 가장 효과적인 개선 제안 하나를 다음 형식에 맞춰 제공해주세요.";
66-
String formatInstruction = """
67-
***Format***
68-
반드시 첫 줄부터 아래 포맷만 출력하고, 안내 문구나 예시 등은 출력하지 마세요.
69-
- "title" : [개선 제안의 제목]
70-
- "description" : [구체적인 설명, Markdown 형식으로 작성]
71-
- "effect" : [예상되는 효과]
72-
- "difficulty" : [쉬움/보통/어려움 중 하나]
73-
74-
***Format Example***
75-
{
76-
"title" : "자동 저축 설정"
77-
"description" : **Markdown 형식으로 작성**
78-
"
79-
지출 내역을 분석한 결과, '그룹' 유형의 지출이 상당히 많습니다. 특히 '식비', '유흥', '쇼핑' 카테고리
80-
에서 그룹 지출이 빈번하게 발생하고 있습니다.
81-
### 제안:
82-
83-
- 지출 항목 분석: 그룹 내에서 어떤 항목에 가장 많은 지출이 발생하는지 파악합니다. (예: 식비, 엔터테인먼트, 쇼핑 등)
84-
- 예산 설정: 각 항목별로 합리적인 월별 예산을 설정합니다. 예산을 초과하지 않도록 그룹 구성원들과 함께 노력합니다.
85-
- 대안 모색: 더 저렴한 대안을 찾아봅니다. (예: 외식 대신 직접 요리, 저렴한 엔터테인먼트 활동 찾기)
86-
- 정기적인 검토: 매달 예산 대비 실제 지출을 검토하고, 필요에 따라 예산을 조정합니다.
87-
"
88-
"effect" : "연간 목표 달성률 40% 향상"
89-
"difficulty" : "쉬움"
90-
}
91-
************
92-
위 형식을 반드시 준수하여 JSON 객체만 응답하세요.
93-
""";
94-
95-
String finalPrompt = systemInstruction + "\n\n" +
96-
userPrompt + "\n\n" +
97-
formatInstruction;
98-
99-
return finalPrompt;
65+
String combinedPromptTemplate = promptService.getPromptContent("LLM_EXPENSE_ANALYSIS_PROMPT");
66+
return String.format(combinedPromptTemplate, expenseJson);
10067
}
10168
private GeminiRequestDTO createRequestJson(String prompt) {
10269
GeminiRequestDTO dto = new GeminiRequestDTO(List.of(new GeminiRequestDTO.Content(List.of(new GeminiRequestDTO.Part(prompt)))));
@@ -114,8 +81,7 @@ private AnalyzeExpenseResponseDTO.Suggestion parseSuggestionResponse(GeminiRespo
11481
log.info("LLM이 생성한 텍스트 : {}", rawText);
11582

11683
String jsonText = null;
117-
Pattern p = Pattern.compile("```json\\s*([\\s\\S]+?)\\s*```|```\\s*([\\s\\S]+?)\\s*```", Pattern.CASE_INSENSITIVE);
118-
Matcher m = p.matcher(rawText);
84+
Matcher m = JSON_BLOCK_PATTERN.matcher(rawText); // Use the static final Pattern
11985
if(m.find()) {
12086
jsonText = m.group(1) != null ? m.group(1) : m.group(2);
12187
} else if (rawText.startsWith("{") && rawText.endsWith("}")) {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package store.lastdance.service.prompt;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.cache.annotation.Cacheable;
6+
import org.springframework.stereotype.Service;
7+
import store.lastdance.domain.prompt.Prompt;
8+
import store.lastdance.exception.CustomException;
9+
import store.lastdance.exception.ErrorCode;
10+
import store.lastdance.repository.prompt.PromptRepository;
11+
12+
@Service
13+
@RequiredArgsConstructor
14+
@Slf4j
15+
public class PromptService {
16+
private final PromptRepository promptRepository;
17+
18+
@Cacheable(value = "prompts", key = "#promptType")
19+
public String getPromptContent(String promptType) {
20+
return promptRepository.findByPromptType(promptType)
21+
.map(Prompt::getPromptContent)
22+
.orElseThrow(() -> new CustomException(ErrorCode.PROMPT_NOT_FOUND));
23+
}
24+
}

0 commit comments

Comments
 (0)