feat(ai): 자연어 한 줄 입력 파싱 — Claude Haiku 프록시(키 서버보관·실패 시 규칙 폴백)
Deploy / deploy (push) Failing after 13m20s
Deploy / deploy (push) Failing after 13m20s
- AiEntryParser: /v1/messages(Haiku) 호출로 '어제 스타벅스 5천원'→{type,amount,merchant,date} 추출
- AccountService.parseText: AI 우선 시도, 미설정/실패/미인식 시 기존 규칙기반 파서로 폴백(무중단)
- app.anthropic-api-key/anthropic-model 설정(키 없으면 AI 비활성)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@ public class AccountService {
|
|||||||
private final WalletMapper walletMapper;
|
private final WalletMapper walletMapper;
|
||||||
private final InvestService investService;
|
private final InvestService investService;
|
||||||
private final CardNotificationParser notificationParser;
|
private final CardNotificationParser notificationParser;
|
||||||
|
private final AiEntryParser aiEntryParser;
|
||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
|
|
||||||
/* ===================== 항목 ===================== */
|
/* ===================== 항목 ===================== */
|
||||||
@@ -299,10 +300,18 @@ public class AccountService {
|
|||||||
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
|
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
|
||||||
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
|
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
|
||||||
*/
|
*/
|
||||||
/** 문자/푸시 텍스트만 파싱해 폼 자동채움용 결과 반환 (저장 안 함). iPhone 등 알림 자동수집 불가 대비. */
|
/**
|
||||||
|
* 문자/푸시·자연어 한 줄 텍스트를 파싱해 폼 자동채움용 결과 반환 (저장 안 함).
|
||||||
|
* AI 파서(Claude Haiku)가 설정돼 있으면 우선 시도("어제 스타벅스 5천원" 같은 자유 입력 처리),
|
||||||
|
* 미설정/실패/미인식이면 기존 규칙기반 카드알림 파서로 폴백.
|
||||||
|
*/
|
||||||
public ParsedEntryResponse parseText(NotificationRequest req) {
|
public ParsedEntryResponse parseText(NotificationRequest req) {
|
||||||
CardNotificationParser.Parsed p =
|
java.time.LocalDate today = java.time.LocalDate.now();
|
||||||
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
|
java.util.Optional<ParsedEntryResponse> ai = aiEntryParser.parse(req.getText(), today);
|
||||||
|
if (ai.isPresent() && ai.get().isRecognized()) {
|
||||||
|
return ai.get();
|
||||||
|
}
|
||||||
|
CardNotificationParser.Parsed p = notificationParser.parse(req.getTitle(), req.getText(), today);
|
||||||
return ParsedEntryResponse.builder()
|
return ParsedEntryResponse.builder()
|
||||||
.recognized(p.isRecognized())
|
.recognized(p.isRecognized())
|
||||||
.type(p.isIncome() ? "INCOME" : "EXPENSE")
|
.type(p.isIncome() ? "INCOME" : "EXPENSE")
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
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.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 자유 텍스트 → 파싱 결과. 실패/미설정 시 empty. */
|
||||||
|
public Optional<ParsedEntryResponse> parse(String text, LocalDate today) {
|
||||||
|
if (!enabled() || text == null || text.isBlank()) return Optional.empty();
|
||||||
|
try {
|
||||||
|
String system = ("""
|
||||||
|
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입 메모를 구조화한다.
|
||||||
|
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
|
||||||
|
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD"}
|
||||||
|
규칙:
|
||||||
|
- amount: 원 단위 정수. "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000.
|
||||||
|
- type: 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME, 그 외 소비는 EXPENSE(기본).
|
||||||
|
- merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 없으면 빈 문자열.
|
||||||
|
- date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘.
|
||||||
|
- 금액을 못 찾거나 거래가 아니면 recognized=false.
|
||||||
|
""").replace("__TODAY__", today.toString());
|
||||||
|
|
||||||
|
Map<String, Object> reqBody = Map.of(
|
||||||
|
"model", model,
|
||||||
|
"max_tokens", 300,
|
||||||
|
"system", system,
|
||||||
|
"messages", List.of(Map.of("role", "user", "content", text)));
|
||||||
|
|
||||||
|
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();
|
||||||
|
JsonNode j = om.readTree(out);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return Optional.of(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)
|
||||||
|
.build());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ai-parse] 실패 → 규칙기반 폴백: {}", e.toString());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모델이 ```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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,9 @@ app:
|
|||||||
google-client-id: ${GOOGLE_CLIENT_ID:}
|
google-client-id: ${GOOGLE_CLIENT_ID:}
|
||||||
# 애플 로그인 aud(=iOS 앱 번들 ID). 네이티브 Sign in with Apple 의 identity token 대상값.
|
# 애플 로그인 aud(=iOS 앱 번들 ID). 네이티브 Sign in with Apple 의 identity token 대상값.
|
||||||
apple-client-id: ${APPLE_CLIENT_ID:kr.sblog.slimbudget}
|
apple-client-id: ${APPLE_CLIENT_ID:kr.sblog.slimbudget}
|
||||||
|
# AI 자연어 입력 파싱(Claude). 키 미설정 시 규칙기반 파서로 폴백(무중단). Haiku 권장(학습 미사용·저렴).
|
||||||
|
anthropic-api-key: ${ANTHROPIC_API_KEY:}
|
||||||
|
anthropic-model: ${ANTHROPIC_MODEL:claude-haiku-4-5}
|
||||||
|
|
||||||
# ===== Logging =====
|
# ===== Logging =====
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
Reference in New Issue
Block a user