사용자의 실제 카테고리·계좌 후보를 AI에 전달해 그 중에서 선택(할루시네이션 방지 검증). 라면→식비, 삼성카드→해당 계좌 등 폼 자동 채움 확장. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,11 @@ package com.sb.web.account.dto;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카드 결제 푸시 알림 원문 (네이티브 알림 리스너 → 백엔드).
|
* 카드 결제 푸시 알림 원문 (네이티브 알림 리스너 → 백엔드).
|
||||||
|
* 빠른입력(자연어) 파싱 시에는 사용자의 분류·계좌 후보 목록을 함께 실어 AI가 그 중에서 고르게 한다.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class NotificationRequest {
|
public class NotificationRequest {
|
||||||
@@ -16,4 +19,10 @@ public class NotificationRequest {
|
|||||||
|
|
||||||
/** 알림 본문 */
|
/** 알림 본문 */
|
||||||
private String text;
|
private String text;
|
||||||
|
|
||||||
|
/** (선택) AI 파싱용 분류 후보 이름 목록 — 이 중에서 카테고리를 고르게 함(없으면 미추천) */
|
||||||
|
private List<String> categoryHints;
|
||||||
|
|
||||||
|
/** (선택) AI 파싱용 결제수단(계좌) 후보 이름 목록 — 텍스트에 결제수단 언급 시 이 중에서 고름 */
|
||||||
|
private List<String> walletHints;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ public class ParsedEntryResponse {
|
|||||||
private long amount;
|
private long amount;
|
||||||
private String merchant; // 가맹점/적요(메모 후보)
|
private String merchant; // 가맹점/적요(메모 후보)
|
||||||
private LocalDate date;
|
private LocalDate date;
|
||||||
|
private String category; // AI 추천 분류(후보 목록 중). 없으면 빈 문자열
|
||||||
|
private String wallet; // AI 추천 결제수단(계좌) 이름(후보 목록 중). 없으면 빈 문자열
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,7 +307,8 @@ public class AccountService {
|
|||||||
*/
|
*/
|
||||||
public ParsedEntryResponse parseText(NotificationRequest req) {
|
public ParsedEntryResponse parseText(NotificationRequest req) {
|
||||||
java.time.LocalDate today = java.time.LocalDate.now();
|
java.time.LocalDate today = java.time.LocalDate.now();
|
||||||
java.util.Optional<ParsedEntryResponse> ai = aiEntryParser.parse(req.getText(), today);
|
java.util.Optional<ParsedEntryResponse> ai =
|
||||||
|
aiEntryParser.parse(req.getText(), today, req.getCategoryHints(), req.getWalletHints());
|
||||||
if (ai.isPresent() && ai.get().isRecognized()) {
|
if (ai.isPresent() && ai.get().isRecognized()) {
|
||||||
return ai.get();
|
return ai.get();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,25 +49,35 @@ public class AiEntryParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 자유 텍스트 → 파싱 결과. 실패/미설정 시 empty. */
|
/** 자유 텍스트 → 파싱 결과. 실패/미설정 시 empty. 분류·계좌 후보를 주면 AI가 그 중에서 골라 채운다. */
|
||||||
public Optional<ParsedEntryResponse> parse(String text, LocalDate today) {
|
public Optional<ParsedEntryResponse> parse(String text, LocalDate today,
|
||||||
|
List<String> categoryHints, List<String> walletHints) {
|
||||||
if (!enabled() || text == null || text.isBlank()) {
|
if (!enabled() || text == null || text.isBlank()) {
|
||||||
log.info("[ai-parse] 건너뜀 — enabled={}, textBlank={}", enabled(), (text == null || text.isBlank()));
|
log.info("[ai-parse] 건너뜀 — enabled={}, textBlank={}", enabled(), (text == null || text.isBlank()));
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
log.info("[ai-parse] 요청 수신 — text='{}'", text);
|
log.info("[ai-parse] 요청 수신 — text='{}'", text);
|
||||||
try {
|
try {
|
||||||
|
String catList = (categoryHints == null || categoryHints.isEmpty()) ? "(없음)" : String.join(", ", categoryHints);
|
||||||
|
String walletList = (walletHints == null || walletHints.isEmpty()) ? "(없음)" : String.join(", ", walletHints);
|
||||||
String system = ("""
|
String system = ("""
|
||||||
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입 메모를 구조화한다.
|
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입 메모를 구조화한다.
|
||||||
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
|
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
|
||||||
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD"}
|
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"}
|
||||||
규칙:
|
규칙:
|
||||||
- amount: 원 단위 정수. "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000.
|
- amount: 원 단위 정수. "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000.
|
||||||
- type: 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME, 그 외 소비는 EXPENSE(기본).
|
- type: 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME, 그 외 소비는 EXPENSE(기본).
|
||||||
- merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 없으면 빈 문자열.
|
- merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 없으면 빈 문자열.
|
||||||
- date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘.
|
- date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘.
|
||||||
|
- category: 거래 내용에 가장 알맞은 분류를 아래 '분류 후보'에서 정확히 하나 고른다(예: 라면·점심→식비, 택시→교통, 커피→카페). 후보에 마땅한 게 없으면 빈 문자열. 후보에 없는 값을 지어내지 말 것.
|
||||||
|
- wallet: 텍스트에 결제수단/계좌가 명시(예: 삼성카드, 현금, 신한, 체크카드)됐고 아래 '결제수단 후보'에 있으면 그 이름을 정확히 넣는다. 언급 없거나 후보에 없으면 빈 문자열.
|
||||||
- 금액을 못 찾거나 거래가 아니면 recognized=false.
|
- 금액을 못 찾거나 거래가 아니면 recognized=false.
|
||||||
""").replace("__TODAY__", today.toString());
|
분류 후보: __CATS__
|
||||||
|
결제수단 후보: __WALLETS__
|
||||||
|
""")
|
||||||
|
.replace("__TODAY__", today.toString())
|
||||||
|
.replace("__CATS__", catList)
|
||||||
|
.replace("__WALLETS__", walletList);
|
||||||
|
|
||||||
Map<String, Object> reqBody = Map.of(
|
Map<String, Object> reqBody = Map.of(
|
||||||
"model", model,
|
"model", model,
|
||||||
@@ -101,15 +111,21 @@ public class AiEntryParser {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
date = today;
|
date = today;
|
||||||
}
|
}
|
||||||
|
// 후보 목록에 실제 있는 값만 채택(모델이 없는 값을 지어내는 것 방지)
|
||||||
|
String category = pickFromHints(j.path("category").asText(""), categoryHints);
|
||||||
|
String wallet = pickFromHints(j.path("wallet").asText(""), walletHints);
|
||||||
ParsedEntryResponse result = ParsedEntryResponse.builder()
|
ParsedEntryResponse result = ParsedEntryResponse.builder()
|
||||||
.recognized(j.path("recognized").asBoolean(false))
|
.recognized(j.path("recognized").asBoolean(false))
|
||||||
.type(type)
|
.type(type)
|
||||||
.amount(Math.max(0, j.path("amount").asLong(0)))
|
.amount(Math.max(0, j.path("amount").asLong(0)))
|
||||||
.merchant(j.path("merchant").asText(""))
|
.merchant(j.path("merchant").asText(""))
|
||||||
.date(date)
|
.date(date)
|
||||||
|
.category(category)
|
||||||
|
.wallet(wallet)
|
||||||
.build();
|
.build();
|
||||||
log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}",
|
log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}, category={}, wallet={}",
|
||||||
result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate());
|
result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate(),
|
||||||
|
result.getCategory(), result.getWallet());
|
||||||
return Optional.of(result);
|
return Optional.of(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[ai-parse] 실패 → 규칙기반 폴백: {}", e.toString());
|
log.warn("[ai-parse] 실패 → 규칙기반 폴백: {}", e.toString());
|
||||||
@@ -117,6 +133,16 @@ public class AiEntryParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 모델이 반환한 값이 후보 목록에 실제 존재할 때만 채택(대소문자·공백 무시). 없으면 빈 문자열. */
|
||||||
|
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. */
|
/** 설명문이 앞뒤로 붙어도 첫 '{'~마지막 '}' 구간만 잘라 JSON 만 추출. 중괄호 없으면 null. */
|
||||||
private String extractJson(String s) {
|
private String extractJson(String s) {
|
||||||
int a = s.indexOf('{');
|
int a = s.indexOf('{');
|
||||||
|
|||||||
Reference in New Issue
Block a user