선형 run-rate는 노트북 등 일회성 지출을 남은 날에 곱해 비현실적 값(2천만) 생성. AI가 분류를 보고 일회성을 일평균에서 제외해 추정 + 근거 문장 반환(경과일/총일수 전달). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
268 lines
16 KiB
Java
268 lines
16 KiB
Java
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;
|
||
}
|
||
}
|