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(); } /** 시작 시 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. */ public Optional parse(String text, LocalDate today) { 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); 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(); 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) .build(); log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}", result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate()); return Optional.of(result); } catch (Exception e) { log.warn("[ai-parse] 실패 → 규칙기반 폴백: {}", e.toString()); return Optional.empty(); } } /** 설명문이 앞뒤로 붙어도 첫 '{'~마지막 '}' 구간만 잘라 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; } }