From 811cc1b391e959f9f9f2242f6b9ae60f033d9dd8 Mon Sep 17 00:00:00 2001 From: sb Date: Sun, 5 Jul 2026 11:24:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=EC=9E=90=EC=97=B0=EC=96=B4=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=20=EC=9E=85=EB=A0=A5=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=20=E2=80=94=20Claude=20Haiku=20=ED=94=84=EB=A1=9D=EC=8B=9C(?= =?UTF-8?q?=ED=82=A4=20=EC=84=9C=EB=B2=84=EB=B3=B4=EA=B4=80=C2=B7=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EA=B7=9C=EC=B9=99=20=ED=8F=B4=EB=B0=B1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../web/account/service/AccountService.java | 15 ++- .../sb/web/account/service/AiEntryParser.java | 105 ++++++++++++++++++ src/main/resources/application.yml | 3 + 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/sb/web/account/service/AiEntryParser.java 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 f76b5d9..37423a6 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -49,6 +49,7 @@ public class AccountService { private final WalletMapper walletMapper; private final InvestService investService; private final CardNotificationParser notificationParser; + private final AiEntryParser aiEntryParser; private final PointService pointService; /* ===================== 항목 ===================== */ @@ -299,10 +300,18 @@ public class AccountService { * 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성. * 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1). */ - /** 문자/푸시 텍스트만 파싱해 폼 자동채움용 결과 반환 (저장 안 함). iPhone 등 알림 자동수집 불가 대비. */ + /** + * 문자/푸시·자연어 한 줄 텍스트를 파싱해 폼 자동채움용 결과 반환 (저장 안 함). + * AI 파서(Claude Haiku)가 설정돼 있으면 우선 시도("어제 스타벅스 5천원" 같은 자유 입력 처리), + * 미설정/실패/미인식이면 기존 규칙기반 카드알림 파서로 폴백. + */ public ParsedEntryResponse parseText(NotificationRequest req) { - CardNotificationParser.Parsed p = - notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now()); + java.time.LocalDate today = java.time.LocalDate.now(); + java.util.Optional 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() .recognized(p.isRecognized()) .type(p.isIncome() ? "INCOME" : "EXPENSE") diff --git a/src/main/java/com/sb/web/account/service/AiEntryParser.java b/src/main/java/com/sb/web/account/service/AiEntryParser.java new file mode 100644 index 0000000..f9f4d68 --- /dev/null +++ b/src/main/java/com/sb/web/account/service/AiEntryParser.java @@ -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 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 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; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index acf6282..8b80859 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -81,6 +81,9 @@ app: google-client-id: ${GOOGLE_CLIENT_ID:} # 애플 로그인 aud(=iOS 앱 번들 ID). 네이티브 Sign in with Apple 의 identity token 대상값. 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: