스키마에 TRANSFER 타입 + toWallet(받는 계좌) 추가. 이체 시 wallet=출금·toWallet=입금 계좌를 후보 중에서 매칭, 분류는 비움. 기존엔 이체가 EXPENSE로 뭉개지던 문제 해결. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,10 +13,11 @@ import java.time.LocalDate;
|
|||||||
public class ParsedEntryResponse {
|
public class ParsedEntryResponse {
|
||||||
|
|
||||||
private boolean recognized; // 거래로 인식되었는가
|
private boolean recognized; // 거래로 인식되었는가
|
||||||
private String type; // INCOME / EXPENSE
|
private String type; // INCOME / EXPENSE / TRANSFER
|
||||||
private long amount;
|
private long amount;
|
||||||
private String merchant; // 가맹점/적요(메모 후보)
|
private String merchant; // 가맹점/적요(메모 후보)
|
||||||
private LocalDate date;
|
private LocalDate date;
|
||||||
private String category; // AI 추천 분류(후보 목록 중). 없으면 빈 문자열
|
private String category; // AI 추천 분류(후보 목록 중). 없으면 빈 문자열
|
||||||
private String wallet; // AI 추천 결제수단(계좌) 이름(후보 목록 중). 없으면 빈 문자열
|
private String wallet; // AI 추천 계좌(후보 중). 지출/수입=결제수단, 이체=보내는(출금) 계좌
|
||||||
|
private String toWallet; // 이체 시 받는(입금) 계좌(후보 중). 아니면 빈 문자열
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,19 +61,21 @@ public class AiEntryParser {
|
|||||||
}
|
}
|
||||||
log.info("[ai-parse] 요청 수신 — text='{}'", text);
|
log.info("[ai-parse] 요청 수신 — text='{}'", text);
|
||||||
String system = withHints("""
|
String system = withHints("""
|
||||||
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입 메모를 구조화한다.
|
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입/이체 메모를 구조화한다.
|
||||||
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
|
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
|
||||||
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"}
|
{"recognized":boolean,"type":"INCOME"|"EXPENSE"|"TRANSFER","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열","toWallet":"문자열"}
|
||||||
규칙:
|
규칙:
|
||||||
- amount: 원 단위 정수. "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000.
|
- amount: 원 단위 정수. "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000.
|
||||||
- type: 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME, 그 외 소비는 EXPENSE(기본).
|
- type: 이체/송금/옮김/입출금 간 이동은 TRANSFER. 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME. 그 외 소비는 EXPENSE(기본).
|
||||||
- merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 없으면 빈 문자열.
|
- TRANSFER 판단: "A에서 B로 5만원", "A→B 이체", "카드값 냈어(계좌→카드)" 처럼 내 계좌 사이 이동. 이때 category는 빈 문자열.
|
||||||
|
- merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 이체면 메모(예: 카드대금)나 빈 문자열.
|
||||||
- date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘.
|
- date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘.
|
||||||
- category: 거래 내용에 가장 알맞은 분류를 아래 '분류 후보'에서 정확히 하나 고른다(예: 라면·점심→식비, 택시→교통, 커피→카페). 후보에 마땅한 게 없으면 빈 문자열. 후보에 없는 값을 지어내지 말 것.
|
- category: 지출/수입일 때만. 거래 내용에 알맞은 분류를 아래 '분류 후보'에서 정확히 하나(예: 라면→식비, 택시→교통). 없거나 이체면 빈 문자열. 후보에 없는 값 지어내지 말 것.
|
||||||
- wallet: 텍스트에 결제수단/계좌가 명시(예: 삼성카드, 현금, 신한, 체크카드)됐고 아래 '결제수단 후보'에 있으면 그 이름을 정확히 넣는다. 언급 없거나 후보에 없으면 빈 문자열.
|
- wallet: 지출/수입이면 결제수단(출금 계좌). 이체면 '보내는(출금)' 계좌. 아래 '계좌 후보'에 있는 이름만, 언급 없으면 빈 문자열.
|
||||||
|
- toWallet: 이체일 때 '받는(입금)' 계좌. '계좌 후보'에 있는 이름만. 이체가 아니거나 언급 없으면 빈 문자열.
|
||||||
- 금액을 못 찾거나 거래가 아니면 recognized=false.
|
- 금액을 못 찾거나 거래가 아니면 recognized=false.
|
||||||
분류 후보: __CATS__
|
분류 후보: __CATS__
|
||||||
결제수단 후보: __WALLETS__
|
계좌 후보: __WALLETS__
|
||||||
""", today, categoryHints, walletHints);
|
""", today, categoryHints, walletHints);
|
||||||
return callClaude(system, text, 300, today, categoryHints, walletHints);
|
return callClaude(system, text, 300, today, categoryHints, walletHints);
|
||||||
}
|
}
|
||||||
@@ -204,25 +206,29 @@ public class AiEntryParser {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
JsonNode j = om.readTree(json);
|
JsonNode j = om.readTree(json);
|
||||||
String type = "INCOME".equalsIgnoreCase(j.path("type").asText("EXPENSE")) ? "INCOME" : "EXPENSE";
|
String rawType = j.path("type").asText("EXPENSE").toUpperCase();
|
||||||
|
String type = ("INCOME".equals(rawType) || "TRANSFER".equals(rawType)) ? rawType : "EXPENSE";
|
||||||
LocalDate date;
|
LocalDate date;
|
||||||
try {
|
try {
|
||||||
date = LocalDate.parse(j.path("date").asText());
|
date = LocalDate.parse(j.path("date").asText());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
date = today;
|
date = today;
|
||||||
}
|
}
|
||||||
|
boolean transfer = "TRANSFER".equals(type);
|
||||||
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(pickFromHints(j.path("category").asText(""), categoryHints))
|
// 이체는 분류 없음
|
||||||
|
.category(transfer ? "" : pickFromHints(j.path("category").asText(""), categoryHints))
|
||||||
.wallet(pickFromHints(j.path("wallet").asText(""), walletHints))
|
.wallet(pickFromHints(j.path("wallet").asText(""), walletHints))
|
||||||
|
.toWallet(transfer ? pickFromHints(j.path("toWallet").asText(""), walletHints) : "")
|
||||||
.build();
|
.build();
|
||||||
log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}, category={}, wallet={}",
|
log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}, category={}, wallet={}, toWallet={}",
|
||||||
result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate(),
|
result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate(),
|
||||||
result.getCategory(), result.getWallet());
|
result.getCategory(), result.getWallet(), result.getToWallet());
|
||||||
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());
|
||||||
|
|||||||
Reference in New Issue
Block a user