From a34a8250a4863f123216cfffb06686d1f492c81c Mon Sep 17 00:00:00 2001 From: sb Date: Sun, 5 Jul 2026 15:13:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=EC=9E=AC=EB=AC=B4=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=EC=97=90=20=EC=A0=88=EC=95=BD=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C(tips)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 집계 기반으로 현황 코멘트(bullets)에 더해 지출 패턴 맞춤 절약 제안(tips) 2~3개 생성. Co-Authored-By: Claude Opus 4.8 --- .../sb/web/account/dto/AiCommentResponse.java | 3 +- .../web/account/service/AccountService.java | 7 +-- .../sb/web/account/service/AiEntryParser.java | 58 ++++++++++++------- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/sb/web/account/dto/AiCommentResponse.java b/src/main/java/com/sb/web/account/dto/AiCommentResponse.java index d2b54fd..c23720b 100644 --- a/src/main/java/com/sb/web/account/dto/AiCommentResponse.java +++ b/src/main/java/com/sb/web/account/dto/AiCommentResponse.java @@ -12,5 +12,6 @@ import java.util.List; @Builder public class AiCommentResponse { - private List bullets; + private List bullets; // 현황 관찰/코멘트 + private List tips; // 절약 가이드(실행 가능한 제안) } diff --git a/src/main/java/com/sb/web/account/service/AccountService.java b/src/main/java/com/sb/web/account/service/AccountService.java index 7e78068..7e36bc3 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -326,11 +326,10 @@ public class AccountService { public com.sb.web.account.dto.AiCommentResponse aiComment(com.sb.web.account.dto.AiCommentRequest req) { try { String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(req); - return com.sb.web.account.dto.AiCommentResponse.builder() - .bullets(aiEntryParser.financeComment(json)) - .build(); + return aiEntryParser.financeComment(json); } catch (Exception e) { - return com.sb.web.account.dto.AiCommentResponse.builder().bullets(java.util.List.of()).build(); + return com.sb.web.account.dto.AiCommentResponse.builder() + .bullets(java.util.List.of()).tips(java.util.List.of()).build(); } } diff --git a/src/main/java/com/sb/web/account/service/AiEntryParser.java b/src/main/java/com/sb/web/account/service/AiEntryParser.java index d531769..a08dd08 100644 --- a/src/main/java/com/sb/web/account/service/AiEntryParser.java +++ b/src/main/java/com/sb/web/account/service/AiEntryParser.java @@ -2,6 +2,7 @@ package com.sb.web.account.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.sb.web.account.dto.AiCommentResponse; import com.sb.web.account.dto.ParsedEntryResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -108,21 +109,23 @@ public class AiEntryParser { return callClaude(system, content, 400, today, categoryHints, walletHints); } - /** 이번 달 집계(JSON) → 2~3개의 한국어 재무 코멘트. 미설정/실패 시 빈 리스트. */ - public List financeComment(String summaryJson) { - if (!enabled() || summaryJson == null || summaryJson.isBlank()) return List.of(); + /** 이번 달 집계(JSON) → 현황 코멘트 + 절약 가이드. 미설정/실패 시 빈 응답. */ + public AiCommentResponse financeComment(String summaryJson) { + if (!enabled() || summaryJson == null || summaryJson.isBlank()) return emptyComment(); try { String system = """ - 너는 한국어 개인 가계부 코치다. 아래 사용자의 이번 달 집계(JSON, 금액 단위 원)를 보고 - 도움이 되는 코멘트를 2~3개 만든다. 각 코멘트는 한 문장, 가능하면 구체적 숫자를 인용하고, - 잔소리·과장 없이 담백하게. 반드시 아래 JSON '하나만' 출력한다(마크다운/설명 금지): - {"bullets":["문장1","문장2"]} - 관점 예: 전월 대비 지출 증감, 예산 대비 소비 속도, 특정 분류 과다, 수입 대비 지출 비율. - 데이터가 빈약하거나 코멘트할 게 없으면 bullets 를 빈 배열로. + 너는 한국어 개인 가계부 코치다. 아래 사용자의 이번 달 집계(JSON, 금액 단위 원)를 보고 두 가지를 만든다. + 1) bullets: 현황 관찰 2~3개. 각 한 문장, 구체적 숫자 인용, 잔소리·과장 없이 담백하게. + (예: 전월 대비 지출 증감, 예산 대비 소비 속도, 특정 분류 과다, 수입 대비 지출 비율) + 2) tips: 이 사용자의 지출 패턴에 딱 맞춘 '실행 가능한' 절약 제안 2~3개. 두루뭉술한 원칙 금지, + 구체적 분류·습관을 지목(예: "카페 지출이 8만원인데 주 2회로 줄이면 약 3만원 절약"). 각 한 문장, 제안형. + 반드시 아래 JSON '하나만' 출력한다(마크다운/설명 금지): + {"bullets":["관찰1","관찰2"],"tips":["절약제안1","절약제안2"]} + 데이터가 빈약하면 해당 배열을 빈 배열로. """; Map reqBody = Map.of( "model", model, - "max_tokens", 400, + "max_tokens", 600, "system", system, "messages", List.of(Map.of("role", "user", "content", summaryJson))); JsonNode resp = rest.post() @@ -136,23 +139,34 @@ public class AiEntryParser { String out = stripFences(resp.path("content").path(0).path("text").asText("")).trim(); log.info("[ai-comment] 원문='{}'", out); String json = extractJson(out); - if (json == null) return List.of(); - JsonNode arr = om.readTree(json).path("bullets"); - List bullets = new ArrayList<>(); - if (arr.isArray()) { - for (JsonNode n : arr) { - String s = n.asText("").trim(); - if (!s.isEmpty()) bullets.add(s); - } - } - log.info("[ai-comment] 결과 — {}개", bullets.size()); - return bullets; + if (json == null) return emptyComment(); + JsonNode root = om.readTree(json); + List bullets = strArray(root.path("bullets")); + List tips = strArray(root.path("tips")); + log.info("[ai-comment] 결과 — 코멘트 {}개, 절약팁 {}개", bullets.size(), tips.size()); + return AiCommentResponse.builder().bullets(bullets).tips(tips).build(); } catch (Exception e) { log.warn("[ai-comment] 실패: {}", e.toString()); - return List.of(); + return emptyComment(); } } + private AiCommentResponse emptyComment() { + return AiCommentResponse.builder().bullets(List.of()).tips(List.of()).build(); + } + + /** JsonNode 배열 → 공백 제거한 문자열 리스트. */ + private List strArray(JsonNode arr) { + List out = new ArrayList<>(); + if (arr != null && arr.isArray()) { + for (JsonNode n : arr) { + String s = n.asText("").trim(); + if (!s.isEmpty()) out.add(s); + } + } + return out; + } + /** 분류·계좌 후보와 오늘 날짜를 시스템 프롬프트 템플릿에 주입. */ private String withHints(String body, LocalDate today, List categoryHints, List walletHints) { String catList = (categoryHints == null || categoryHints.isEmpty()) ? "(없음)" : String.join(", ", categoryHints);