feat(ai): 월말 예상 지출을 AI 추정으로 — 일회성 큰 지출 왜곡 제거
Deploy / deploy (push) Failing after 12m37s

선형 run-rate는 노트북 등 일회성 지출을 남은 날에 곱해 비현실적 값(2천만) 생성.
AI가 분류를 보고 일회성을 일평균에서 제외해 추정 + 근거 문장 반환(경과일/총일수 전달).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-05 15:28:53 +09:00
parent a34a8250a4
commit 2802ec9d5a
3 changed files with 21 additions and 7 deletions
@@ -17,6 +17,8 @@ public class AiCommentRequest {
private long prevExpense; // 직전 달 지출 합계(대비용) private long prevExpense; // 직전 달 지출 합계(대비용)
private long budget; // 이번 달 예산 합계 private long budget; // 이번 달 예산 합계
private long spent; // 이번 달 예산 대비 소비액 private long spent; // 이번 달 예산 대비 소비액
private int elapsedDays; // 이번 달 경과일(현재 달일 때만 >0, 아니면 0)
private int totalDays; // 이번 달 총일수
private List<CategoryAmount> categories; // 분류별 지출 상위 private List<CategoryAmount> categories; // 분류별 지출 상위
@Data @Data
@@ -14,4 +14,6 @@ public class AiCommentResponse {
private List<String> bullets; // 현황 관찰/코멘트 private List<String> bullets; // 현황 관찰/코멘트
private List<String> tips; // 절약 가이드(실행 가능한 제안) private List<String> tips; // 절약 가이드(실행 가능한 제안)
private Long forecast; // 월말 예상 지출(원) — 일회성 큰 지출 제외 추정. 없으면 null
private String forecastNote; // 예상 지출 근거 한 문장
} }
@@ -114,14 +114,18 @@ public class AiEntryParser {
if (!enabled() || summaryJson == null || summaryJson.isBlank()) return emptyComment(); if (!enabled() || summaryJson == null || summaryJson.isBlank()) return emptyComment();
try { try {
String system = """ String system = """
너는 한국어 개인 가계부 코치다. 아래 사용자의 이번 달 집계(JSON, 금액 단위 원)를 보고 가지를 만든다. 너는 한국어 개인 가계부 코치다. 아래 사용자의 이번 달 집계(JSON, 금액 단위 원)를 보고 가지를 만든다.
1) bullets: 현황 관찰 2~3개. 각 한 문장, 구체적 숫자 인용, 잔소리·과장 없이 담백하게. 1) bullets: 현황 관찰 2~3개. 각 한 문장, 구체적 숫자 인용, 잔소리·과장 없이 담백하게.
(예: 전월 대비 지출 증감, 예산 대비 소비 속도, 특정 분류 과다, 수입 대비 지출 비율) (예: 전월 대비 지출 증감, 예산 대비 소비 속도, 특정 분류 과다, 수입 대비 지출 비율)
2) tips: 이 사용자의 지출 패턴에 딱 맞춘 '실행 가능한' 절약 제안 2~3개. 두루뭉술한 원칙 금지, 2) tips: 이 사용자의 지출 패턴에 딱 맞춘 '실행 가능한' 절약 제안 2~3개. 두루뭉술한 원칙 금지,
구체적 분류·습관을 지목(예: "카페 지출이 8만원인데 주 2회로 줄이면 약 3만원 절약"). 각 한 문장, 제안형. 구체적 분류·습관을 지목(예: "카페 지출이 8만원인데 주 2회로 줄이면 약 3만원 절약"). 각 한 문장, 제안형.
3) forecast: 월말 예상 지출(원, 정수). 계산법 = '지금까지 실제 지출(expense)' + '남은 날 × 평소 하루 지출'.
단, 노트북·가전·여행 등 '일회성 큰 지출'로 보이는 분류는 남은 날에 반복되지 않으므로 하루 지출 산정에서 제외한다.
(평소 하루 지출 ≈ (expense − 일회성 큰 지출) ÷ elapsedDays). forecastNote 에 한 문장 근거(무엇을 일회성으로 봤는지).
elapsedDays 가 0 이면(과거/미래 달) forecast=0, forecastNote="".
반드시 아래 JSON '하나만' 출력한다(마크다운/설명 금지): 반드시 아래 JSON '하나만' 출력한다(마크다운/설명 금지):
{"bullets":["관찰1","관찰2"],"tips":["절약제안1","절약제안2"]} {"bullets":["관찰1","관찰2"],"tips":["절약제안1","절약제안2"],"forecast":정수,"forecastNote":"근거 한 문장"}
데이터가 빈약하면 해당 배열을 빈 배열로. 데이터가 빈약하면 bullets/tips 는 빈 배열, forecast=0.
"""; """;
Map<String, Object> reqBody = Map.of( Map<String, Object> reqBody = Map.of(
"model", model, "model", model,
@@ -143,8 +147,14 @@ public class AiEntryParser {
JsonNode root = om.readTree(json); JsonNode root = om.readTree(json);
List<String> bullets = strArray(root.path("bullets")); List<String> bullets = strArray(root.path("bullets"));
List<String> tips = strArray(root.path("tips")); List<String> tips = strArray(root.path("tips"));
log.info("[ai-comment] 결과 — 코멘트 {}개, 절약팁 {}개", bullets.size(), tips.size()); long forecast = Math.max(0, root.path("forecast").asLong(0));
return AiCommentResponse.builder().bullets(bullets).tips(tips).build(); String forecastNote = root.path("forecastNote").asText("").trim();
log.info("[ai-comment] 결과 — 코멘트 {}개, 절약팁 {}개, 예상지출 {}", bullets.size(), tips.size(), forecast);
return AiCommentResponse.builder()
.bullets(bullets).tips(tips)
.forecast(forecast > 0 ? forecast : null)
.forecastNote(forecast > 0 ? forecastNote : null)
.build();
} catch (Exception e) { } catch (Exception e) {
log.warn("[ai-comment] 실패: {}", e.toString()); log.warn("[ai-comment] 실패: {}", e.toString());
return emptyComment(); return emptyComment();