Files
sb-backend/src/main/java/com/sb/web/account/service/AiEntryParser.java
T
sbandClaude Opus 4.8 2802ec9d5a feat(ai): 월말 예상 지출을 AI 추정으로 — 일회성 큰 지출 왜곡 제거
선형 run-rate는 노트북 등 일회성 지출을 남은 날에 곱해 비현실적 값(2천만) 생성.
AI가 분류를 보고 일회성을 일평균에서 제외해 추정 + 근거 문장 반환(경과일/총일수 전달).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:28:53 +09:00

268 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 자연어 한 줄 입력("어제 스타벅스 5천원")을 Claude(Haiku)로 파싱해 폼 자동채움 필드로 변환한다.
* 키(app.anthropic-api-key) 미설정이거나 호출 실패 시 Optional.empty() → 호출부가 규칙기반 파서로 폴백.
* AI는 폼 prefill 용도이며 저장은 사용자 확인 후.
*/
@Slf4j
@Component
public class AiEntryParser {
/** Anthropic API 키. 미설정 시 AI 파싱 비활성(규칙기반으로 폴백). */
@Value("${app.anthropic-api-key:}")
private String apiKey;
/** 사용 모델. 저렴·빠른 Haiku 권장(학습 미사용). */
@Value("${app.anthropic-model:claude-haiku-4-5}")
private String model;
private final ObjectMapper om = new ObjectMapper();
private final RestClient rest = RestClient.builder().baseUrl("https://api.anthropic.com").build();
public boolean enabled() {
return apiKey != null && !apiKey.isBlank();
}
/** 시작 시 AI 파서 활성 여부·키 로딩 상태를 로그로 남겨 진단을 쉽게 한다(키는 앞부분만 마스킹). */
@jakarta.annotation.PostConstruct
void logConfig() {
if (enabled()) {
String masked = apiKey.length() > 14 ? apiKey.substring(0, 14) + "…(len=" + apiKey.length() + ")" : "(len=" + apiKey.length() + ")";
log.info("[ai-parse] 활성화됨 — model={}, key={}", model, masked);
} else {
log.warn("[ai-parse] 비활성(ANTHROPIC_API_KEY 미설정) — 규칙기반 파서만 사용");
}
}
/** 자유 텍스트 → 파싱 결과. 실패/미설정 시 empty. 분류·계좌 후보를 주면 AI가 그 중에서 골라 채운다. */
public Optional<ParsedEntryResponse> parse(String text, LocalDate today,
List<String> categoryHints, List<String> walletHints) {
if (!enabled() || text == null || text.isBlank()) {
log.info("[ai-parse] 건너뜀 — enabled={}, textBlank={}", enabled(), (text == null || text.isBlank()));
return Optional.empty();
}
log.info("[ai-parse] 요청 수신 — text='{}'", text);
String system = withHints("""
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입 메모를 구조화한다.
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"}
규칙:
- amount: 원 단위 정수. "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000.
- type: 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME, 그 외 소비는 EXPENSE(기본).
- merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 없으면 빈 문자열.
- date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘.
- category: 거래 내용에 가장 알맞은 분류를 아래 '분류 후보'에서 정확히 하나 고른다(예: 라면·점심→식비, 택시→교통, 커피→카페). 후보에 마땅한 게 없으면 빈 문자열. 후보에 없는 값을 지어내지 말 것.
- wallet: 텍스트에 결제수단/계좌가 명시(예: 삼성카드, 현금, 신한, 체크카드)됐고 아래 '결제수단 후보'에 있으면 그 이름을 정확히 넣는다. 언급 없거나 후보에 없으면 빈 문자열.
- 금액을 못 찾거나 거래가 아니면 recognized=false.
분류 후보: __CATS__
결제수단 후보: __WALLETS__
""", today, categoryHints, walletHints);
return callClaude(system, text, 300, today, categoryHints, walletHints);
}
/** 영수증 이미지 → 구조화(Claude Vision). 실패/미설정 시 empty → 호출부가 기존 OCR 로 폴백. */
public Optional<ParsedEntryResponse> parseReceipt(byte[] image, String mediaType, LocalDate today,
List<String> categoryHints, List<String> walletHints) {
if (!enabled() || image == null || image.length == 0) {
log.info("[ai-parse] 영수증 건너뜀 — enabled={}, empty={}", enabled(), (image == null || image.length == 0));
return Optional.empty();
}
log.info("[ai-parse] 영수증 수신 — {} bytes, media={}", image.length, mediaType);
String system = withHints("""
너는 한국어 영수증 판독기다. 첨부된 영수증 이미지를 보고 거래 하나로 구조화한다.
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"}
규칙:
- amount: 최종 결제/합계 금액(원 단위 정수, 부가세 포함 총액).
- type: 영수증은 대부분 EXPENSE. 환불 영수증이면 INCOME.
- merchant: 상호명(가맹점).
- date: 영수증의 거래일(YYYY-MM-DD). 안 보이면 오늘=__TODAY__.
- category: 품목/업종에 가장 알맞은 분류를 아래 '분류 후보'에서 정확히 하나 고른다. 없으면 빈 문자열. 후보에 없는 값 지어내지 말 것.
- wallet: 영수증에 카드사/결제수단이 보이고 아래 '결제수단 후보'에 있으면 그 이름. 아니면 빈 문자열.
- 영수증이 아니거나 금액을 못 읽으면 recognized=false.
분류 후보: __CATS__
결제수단 후보: __WALLETS__
""", today, categoryHints, walletHints);
String base64 = Base64.getEncoder().encodeToString(image);
String mt = (mediaType == null || mediaType.isBlank()) ? "image/jpeg" : mediaType;
List<Object> content = List.of(
Map.of("type", "image", "source", Map.of("type", "base64", "media_type", mt, "data", base64)),
Map.of("type", "text", "text", "이 영수증을 위 스키마로 구조화해줘."));
return callClaude(system, content, 400, today, categoryHints, walletHints);
}
/** 이번 달 집계(JSON) → 현황 코멘트 + 절약 가이드. 미설정/실패 시 빈 응답. */
public AiCommentResponse financeComment(String summaryJson) {
if (!enabled() || summaryJson == null || summaryJson.isBlank()) return emptyComment();
try {
String system = """
너는 한국어 개인 가계부 코치다. 아래 사용자의 이번 달 집계(JSON, 금액 단위 원)를 보고 세 가지를 만든다.
1) bullets: 현황 관찰 2~3개. 각 한 문장, 구체적 숫자 인용, 잔소리·과장 없이 담백하게.
(예: 전월 대비 지출 증감, 예산 대비 소비 속도, 특정 분류 과다, 수입 대비 지출 비율)
2) tips: 이 사용자의 지출 패턴에 딱 맞춘 '실행 가능한' 절약 제안 2~3개. 두루뭉술한 원칙 금지,
구체적 분류·습관을 지목(예: "카페 지출이 8만원인데 주 2회로 줄이면 약 3만원 절약"). 각 한 문장, 제안형.
3) forecast: 월말 예상 지출(원, 정수). 계산법 = '지금까지 실제 지출(expense)' + '남은 날 × 평소 하루 지출'.
단, 노트북·가전·여행 등 '일회성 큰 지출'로 보이는 분류는 남은 날에 반복되지 않으므로 하루 지출 산정에서 제외한다.
(평소 하루 지출 ≈ (expense − 일회성 큰 지출) ÷ elapsedDays). forecastNote 에 한 문장 근거(무엇을 일회성으로 봤는지).
elapsedDays 가 0 이면(과거/미래 달) forecast=0, forecastNote="".
반드시 아래 JSON '하나만' 출력한다(마크다운/설명 금지):
{"bullets":["관찰1","관찰2"],"tips":["절약제안1","절약제안2"],"forecast":정수,"forecastNote":"근거 한 문장"}
데이터가 빈약하면 bullets/tips 는 빈 배열, forecast=0.
""";
Map<String, Object> reqBody = Map.of(
"model", model,
"max_tokens", 600,
"system", system,
"messages", List.of(Map.of("role", "user", "content", summaryJson)));
JsonNode resp = rest.post()
.uri("/v1/messages")
.header("x-api-key", apiKey)
.header("anthropic-version", "2023-06-01")
.contentType(MediaType.APPLICATION_JSON)
.body(reqBody)
.retrieve()
.body(JsonNode.class);
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 emptyComment();
JsonNode root = om.readTree(json);
List<String> bullets = strArray(root.path("bullets"));
List<String> tips = strArray(root.path("tips"));
long forecast = Math.max(0, root.path("forecast").asLong(0));
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) {
log.warn("[ai-comment] 실패: {}", e.toString());
return emptyComment();
}
}
private AiCommentResponse emptyComment() {
return AiCommentResponse.builder().bullets(List.of()).tips(List.of()).build();
}
/** JsonNode 배열 → 공백 제거한 문자열 리스트. */
private List<String> strArray(JsonNode arr) {
List<String> 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<String> categoryHints, List<String> walletHints) {
String catList = (categoryHints == null || categoryHints.isEmpty()) ? "(없음)" : String.join(", ", categoryHints);
String walletList = (walletHints == null || walletHints.isEmpty()) ? "(없음)" : String.join(", ", walletHints);
return body.replace("__TODAY__", today.toString()).replace("__CATS__", catList).replace("__WALLETS__", walletList);
}
/** Claude Messages 호출 → 응답 JSON 파싱 → 결과. 후보에 없는 분류·계좌는 버림. 실패/미인식 시 empty. */
private Optional<ParsedEntryResponse> callClaude(String system, Object userContent, int maxTokens,
LocalDate today, List<String> categoryHints, List<String> walletHints) {
try {
Map<String, Object> reqBody = Map.of(
"model", model,
"max_tokens", maxTokens,
"system", system,
"messages", List.of(Map.of("role", "user", "content", userContent)));
JsonNode resp = rest.post()
.uri("/v1/messages")
.header("x-api-key", apiKey)
.header("anthropic-version", "2023-06-01")
.contentType(MediaType.APPLICATION_JSON)
.body(reqBody)
.retrieve()
.body(JsonNode.class);
String out = stripFences(resp.path("content").path(0).path("text").asText("")).trim();
log.info("[ai-parse] 모델 원문='{}'", out);
// 모델이 설명문을 섞어 보내도 첫 '{'~마지막 '}' 만 추출해 JSON 파싱(견고성).
String json = extractJson(out);
if (json == null) {
log.info("[ai-parse] JSON 없음 — 미인식으로 간주");
return Optional.empty();
}
JsonNode j = om.readTree(json);
String type = "INCOME".equalsIgnoreCase(j.path("type").asText("EXPENSE")) ? "INCOME" : "EXPENSE";
LocalDate date;
try {
date = LocalDate.parse(j.path("date").asText());
} catch (Exception e) {
date = today;
}
ParsedEntryResponse result = ParsedEntryResponse.builder()
.recognized(j.path("recognized").asBoolean(false))
.type(type)
.amount(Math.max(0, j.path("amount").asLong(0)))
.merchant(j.path("merchant").asText(""))
.date(date)
.category(pickFromHints(j.path("category").asText(""), categoryHints))
.wallet(pickFromHints(j.path("wallet").asText(""), walletHints))
.build();
log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}, category={}, wallet={}",
result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate(),
result.getCategory(), result.getWallet());
return Optional.of(result);
} catch (Exception e) {
log.warn("[ai-parse] 실패 → 폴백: {}", e.toString());
return Optional.empty();
}
}
/** 모델이 반환한 값이 후보 목록에 실제 존재할 때만 채택(대소문자·공백 무시). 없으면 빈 문자열. */
private String pickFromHints(String value, List<String> hints) {
if (value == null || value.isBlank() || hints == null) return "";
String v = value.trim();
for (String h : hints) {
if (h != null && h.trim().equalsIgnoreCase(v)) return h;
}
return "";
}
/** 설명문이 앞뒤로 붙어도 첫 '{'~마지막 '}' 구간만 잘라 JSON 만 추출. 중괄호 없으면 null. */
private String extractJson(String s) {
int a = s.indexOf('{');
int b = s.lastIndexOf('}');
if (a >= 0 && b > a) return s.substring(a, b + 1);
return null;
}
/** 모델이 ```json ... ``` 로 감쌌을 때 코드펜스 제거. */
private String stripFences(String s) {
String t = s.trim();
if (t.startsWith("```")) {
int nl = t.indexOf('\n');
if (nl >= 0) t = t.substring(nl + 1);
if (t.endsWith("```")) t = t.substring(0, t.length() - 3);
}
return t;
}
}