feat : 서비스 분석 및 실행결과 저장기능 개발.

This commit is contained in:
lsh9672 2025-06-16 14:13:58 +09:00
parent 2373a07772
commit 84ef442775
4 changed files with 437 additions and 94 deletions

View File

@ -475,6 +475,8 @@ public class AnalyticsService implements AnalyticsUseCase {
// 1. 리뷰 데이터 수집 // 1. 리뷰 데이터 수집
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, days); List<String> reviewData = externalReviewPort.getRecentReviews(storeId, days);
log.info("review Data check ===> {}", reviewData);
if (reviewData.isEmpty()) { if (reviewData.isEmpty()) {
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId); log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
return createDefaultAIFeedback(storeId); return createDefaultAIFeedback(storeId);

View File

@ -8,19 +8,30 @@ import com.azure.ai.textanalytics.models.AnalyzeSentimentResult;
import com.azure.ai.textanalytics.models.DocumentSentiment; import com.azure.ai.textanalytics.models.DocumentSentiment;
import com.azure.ai.textanalytics.models.TextSentiment; import com.azure.ai.textanalytics.models.TextSentiment;
import com.azure.core.credential.AzureKeyCredential; import com.azure.core.credential.AzureKeyCredential;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ktds.hi.analytics.biz.domain.AiFeedback; import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.domain.SentimentType; import com.ktds.hi.analytics.biz.domain.SentimentType;
import com.ktds.hi.analytics.biz.usecase.out.AIServicePort; import com.ktds.hi.analytics.biz.usecase.out.AIServicePort;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* AI 서비스 어댑터 클래스 * AI 서비스 어댑터 클래스
@ -29,135 +40,356 @@ import java.util.List;
@Slf4j @Slf4j
@Component @Component
public class AIServiceAdapter implements AIServicePort { public class AIServiceAdapter implements AIServicePort {
@Value("${ai.azure.cognitive.endpoint}")
private String cognitiveEndpoint; @Value("${ai-api.openai.base-url:https://api.openai.com/v1}")
private String openaiBaseUrl;
@Value("${ai.azure.cognitive.key}")
private String cognitiveKey; @Value("${ai-api.openai.api-key}")
@Value("${ai.openai.api-key}")
private String openaiApiKey; private String openaiApiKey;
@Value("${ai-api.openai.model:gpt-4o-mini}")
private String openaiModel;
private TextAnalyticsClient textAnalyticsClient; private TextAnalyticsClient textAnalyticsClient;
private RestTemplate restTemplate;
private ObjectMapper objectMapper;
@PostConstruct @PostConstruct
public void initializeClients() { public void initializeClients() {
// Azure Cognitive Services 클라이언트 초기화 // Azure Cognitive Services 클라이언트 초기화
textAnalyticsClient = new TextAnalyticsClientBuilder() // textAnalyticsClient = new TextAnalyticsClientBuilder()
.credential(new AzureKeyCredential(cognitiveKey)) // .credential(new AzureKeyCredential(cognitiveKey))
.endpoint(cognitiveEndpoint) // .endpoint(cognitiveEndpoint)
.buildClient(); // .buildClient();
//
log.info("AI 서비스 클라이언트 초기화 완료"); // log.info("AI 서비스 클라이언트 초기화 완료");
// OpenAI API 클라이언트 초기화
restTemplate = new RestTemplate();
objectMapper = new ObjectMapper();
if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) {
log.warn("OpenAI API 키가 설정되지 않았습니다. AI 기능이 제한될 수 있습니다.");
} else {
log.info("OpenAI API 클라이언트 초기화 완료");
}
} }
@Override @Override
public AiFeedback generateFeedback(List<String> reviewData) { public AiFeedback generateFeedback(List<String> reviewData) {
log.info("AI 피드백 생성 시작: 리뷰 수={}", reviewData.size());
log.info("OpenAI 피드백 생성 시작: 리뷰 수={}", reviewData.size());
try { try {
if (reviewData.isEmpty()) { if (reviewData.isEmpty()) {
return createEmptyFeedback(); return createEmptyFeedback();
} }
// 1. 감정 분석 수행 // OpenAI API 호출하여 전체 리뷰 분석
List<SentimentType> sentiments = reviewData.stream() String analysisResult = callOpenAIForAnalysis(reviewData);
.map(this::analyzeSentiment)
.toList(); // 결과 파싱 AiFeedback 객체 생성
return parseAnalysisResult(analysisResult, reviewData.size());
// 2. 긍정/부정 비율 계산
long positiveCount = sentiments.stream()
.mapToLong(s -> s == SentimentType.POSITIVE ? 1 : 0)
.sum();
long negativeCount = sentiments.stream()
.mapToLong(s -> s == SentimentType.NEGATIVE ? 1 : 0)
.sum();
double positiveRate = (double) positiveCount / reviewData.size() * 100;
double negativeRate = (double) negativeCount / reviewData.size() * 100;
// 3. 피드백 생성
AiFeedback feedback = AiFeedback.builder()
.summary(generateSummary(positiveRate, negativeRate, reviewData.size()))
.positivePoints(generatePositivePoints(reviewData, sentiments))
.improvementPoints(generateImprovementPoints(reviewData, sentiments))
.recommendations(generateRecommendations(positiveRate, negativeRate))
.sentimentAnalysis(String.format("긍정: %.1f%%, 부정: %.1f%%", positiveRate, negativeRate))
.confidenceScore(calculateConfidenceScore(reviewData.size()))
.generatedAt(LocalDateTime.now())
.build();
log.info("AI 피드백 생성 완료: 긍정률={}%, 부정률={}%", positiveRate, negativeRate);
return feedback;
} catch (Exception e) { } catch (Exception e) {
log.error("AI 피드백 생성 중 오류 발생", e); log.error("OpenAI 피드백 생성 중 오류 발생", e);
throw new RuntimeException("AI 피드백 생성에 실패했습니다.", e); return createFallbackFeedback(reviewData);
} }
} }
@Override @Override
public SentimentType analyzeSentiment(String content) { public SentimentType analyzeSentiment(String content) {
try { try {
DocumentSentiment documentSentiment = textAnalyticsClient.analyzeSentiment(content); String prompt = String.format(
TextSentiment sentiment = documentSentiment.getSentiment(); "다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로만 답변해주세요.\n\n리뷰: %s",
content
);
if (sentiment == TextSentiment.POSITIVE) { String result = callOpenAI(prompt);
if (result.toUpperCase().contains("POSITIVE")) {
return SentimentType.POSITIVE; return SentimentType.POSITIVE;
} else if (sentiment == TextSentiment.NEGATIVE) { } else if (result.toUpperCase().contains("NEGATIVE")) {
return SentimentType.NEGATIVE; return SentimentType.NEGATIVE;
} else if (sentiment == TextSentiment.NEUTRAL) {
return SentimentType.NEUTRAL;
} else if (sentiment == TextSentiment.MIXED) {
return SentimentType.NEUTRAL; // MIXED는 NEUTRAL로 처리
} else { } else {
return SentimentType.NEUTRAL; return SentimentType.NEUTRAL;
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length()))); log.warn("OpenAI 감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length())));
return SentimentType.NEUTRAL; return SentimentType.NEUTRAL;
} }
} }
@Override @Override
public List<String> generateActionPlan(AiFeedback feedback) { public List<String> generateActionPlan(AiFeedback feedback) {
log.info("실행 계획 생성 시작"); log.info("OpenAI 실행 계획 생성 시작");
try { try {
// 개선점을 기반으로 실행 계획 생성 String prompt = String.format(
List<String> actionPlans = feedback.getImprovementPoints().stream() """
.map(this::convertToActionPlan) 다음 AI 피드백을 바탕으로 구체적인 실행 계획 3개를 생성해주세요.
.toList(); 계획은 실행 가능하고 구체적이어야 합니다.
log.info("실행 계획 생성 완료: 계획 수={}", actionPlans.size()); 요약: %s
return actionPlans; 개선점: %s
실행 계획을 다음 형식으로 작성해주세요:
1. [구체적인 실행 계획 1]
2. [구체적인 실행 계획 2]
3. [구체적인 실행 계획 3]
""",
feedback.getSummary(),
String.join(", ", feedback.getImprovementPoints())
);
String result = callOpenAI(prompt);
return parseActionPlans(result);
} catch (Exception e) { } catch (Exception e) {
log.error("실행 계획 생성 중 오류 발생", e); log.error("OpenAI 실행 계획 생성 중 오류 발생", e);
return Arrays.asList("서비스 품질 개선을 위한 직원 교육 실시", "고객 피드백 수집 체계 구축"); return Arrays.asList(
"서비스 품질 개선을 위한 직원 교육 실시",
"고객 피드백 수집 체계 구축",
"매장 운영 프로세스 개선"
);
} }
} }
/**
* OpenAI API를 호출하여 전체 리뷰 분석 수행
*/
private String callOpenAIForAnalysis(List<String> reviewData) {
String reviewsText = String.join("\n- ", reviewData);
String prompt = String.format(
"""
다음은 매장의 고객 리뷰들입니다. 이를 분석하여 다음 JSON 형식으로 답변해주세요:
{
"summary": "전체적인 분석 요약 (2-3문장)",
"positivePoints": ["긍정적 요소1", "긍정적 요소2", "긍정적 요소3"],
"improvementPoints": ["개선점1", "개선점2", "개선점3"],
"recommendations": ["추천사항1", "추천사항2", "추천사항3"],
"sentimentAnalysis": "전체적인 감정 분석 결과",
"confidenceScore": 0.85
}
리뷰 목록:
- %s
분석 다음 사항을 고려해주세요:
1. 긍정적 요소는 고객들이 자주 언급하는 좋은 점들
2. 개선점은 부정적 피드백이나 불만사항
3. 추천사항은 매장 운영에 도움이 구체적인 제안
4. 신뢰도 점수는 0.0-1.0 사이의
""",
reviewsText
);
return callOpenAI(prompt);
}
/**
* OpenAI API 호출
*/
private String callOpenAI(String prompt) {
if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) {
throw new RuntimeException("OpenAI API 키가 설정되지 않았습니다.");
}
try {
// 요청 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(openaiApiKey);
// 요청 바디 생성
OpenAIRequest request = OpenAIRequest.builder()
.model(openaiModel)
.messages(List.of(
OpenAIMessage.builder()
.role("user")
.content(prompt)
.build()
))
.maxTokens(1500)
.temperature(0.7)
.build();
String requestBody = objectMapper.writeValueAsString(request);
HttpEntity<String> entity = new HttpEntity<>(requestBody, headers);
// API 호출
String url = openaiBaseUrl + "/chat/completions";
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class
);
// 응답 파싱
return parseOpenAIResponse(response.getBody());
} catch (Exception e) {
log.error("OpenAI API 호출 실패", e);
throw new RuntimeException("OpenAI API 호출에 실패했습니다.", e);
}
}
/**
* OpenAI 응답 파싱
*/
private String parseOpenAIResponse(String responseBody) {
try {
Map<String, Object> response = objectMapper.readValue(responseBody, Map.class);
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
if (choices != null && !choices.isEmpty()) {
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
return (String) message.get("content");
}
throw new RuntimeException("OpenAI 응답에서 내용을 찾을 수 없습니다.");
} catch (JsonProcessingException e) {
log.error("OpenAI 응답 파싱 실패", e);
throw new RuntimeException("OpenAI 응답 파싱에 실패했습니다.", e);
}
}
/**
* 분석 결과를 AiFeedback 객체로 파싱
*/
private AiFeedback parseAnalysisResult(String analysisResult, int totalReviews) {
try {
// JSON 형태로 응답이 왔다고 가정하고 파싱
Map<String, Object> result = objectMapper.readValue(analysisResult, Map.class);
return AiFeedback.builder()
.summary((String) result.get("summary"))
.positivePoints((List<String>) result.get("positivePoints"))
.improvementPoints((List<String>) result.get("improvementPoints"))
.recommendations((List<String>) result.get("recommendations"))
.sentimentAnalysis((String) result.get("sentimentAnalysis"))
.confidenceScore(((Number) result.get("confidenceScore")).doubleValue())
.generatedAt(LocalDateTime.now())
.build();
} catch (Exception e) {
log.warn("OpenAI 분석 결과 파싱 실패, 기본 분석 수행", e);
return performBasicAnalysis(analysisResult, totalReviews);
}
}
/**
* 기본 분석 수행 (파싱 실패 fallback)
*/
private AiFeedback performBasicAnalysis(String analysisResult, int totalReviews) {
return AiFeedback.builder()
.summary(String.format("총 %d개의 리뷰를 AI로 분석했습니다.", totalReviews))
.positivePoints(Arrays.asList("고객 서비스", "음식 품질", "매장 분위기"))
.improvementPoints(Arrays.asList("대기시간 단축", "메뉴 다양성", "가격 경쟁력"))
.recommendations(Arrays.asList("고객 피드백 적극 반영", "서비스 교육 강화", "매장 환경 개선"))
.sentimentAnalysis("전반적으로 긍정적인 평가")
.confidenceScore(0.75)
.generatedAt(LocalDateTime.now())
.build();
}
/**
* 실행 계획 파싱
*/
private List<String> parseActionPlans(String result) {
// 숫자로 시작하는 라인들을 찾아서 실행 계획으로 추출
String[] lines = result.split("\n");
return Arrays.stream(lines)
.filter(line -> line.matches("^\\d+\\..*"))
.map(line -> line.replaceFirst("^\\d+\\.\\s*", "").trim())
.filter(line -> !line.isEmpty())
.limit(5) // 최대 5개까지
.toList();
}
/** /**
* 피드백 생성 * 피드백 생성
*/ */
private AiFeedback createEmptyFeedback() { private AiFeedback createEmptyFeedback() {
return AiFeedback.builder() return AiFeedback.builder()
.summary("분석할 리뷰 데이터가 없습니다.") .summary("분석할 리뷰 데이터가 없습니다.")
.positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가")) .positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가"))
.improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요")) .improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요"))
.recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행")) .recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행"))
.sentimentAnalysis("데이터 부족") .sentimentAnalysis("데이터 부족")
.confidenceScore(0.0) .confidenceScore(0.0)
.generatedAt(LocalDateTime.now()) .generatedAt(LocalDateTime.now())
.build(); .build();
} }
/**
* Fallback 피드백 생성 (OpenAI 호출 실패 )
*/
private AiFeedback createFallbackFeedback(List<String> reviewData) {
log.warn("OpenAI 호출 실패로 fallback 분석 수행");
// 간단한 키워드 기반 분석
long positiveCount = reviewData.stream()
.mapToLong(review -> countPositiveKeywords(review))
.sum();
long negativeCount = reviewData.stream()
.mapToLong(review -> countNegativeKeywords(review))
.sum();
double positiveRate = positiveCount > 0 ? (double) positiveCount / (positiveCount + negativeCount) * 100 : 50.0;
return AiFeedback.builder()
.summary(String.format("총 %d개의 리뷰를 분석했습니다. (간편 분석)", reviewData.size()))
.positivePoints(Arrays.asList("서비스", "", "분위기"))
.improvementPoints(Arrays.asList("대기시간", "가격", "청결도"))
.recommendations(Arrays.asList("고객 의견 수렴", "서비스 개선", "품질 향상"))
.sentimentAnalysis(String.format("긍정 비율: %.1f%%", positiveRate))
.confidenceScore(0.6)
.generatedAt(LocalDateTime.now())
.build();
}
private long countPositiveKeywords(String review) {
String[] positiveWords = {"", "맛있", "친절", "깨끗", "만족", "추천", "최고"};
return Arrays.stream(positiveWords)
.mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0)
.sum();
}
private long countNegativeKeywords(String review) {
String[] negativeWords = {"나쁘", "맛없", "불친절", "더럽", "실망", "최악", "별로"};
return Arrays.stream(negativeWords)
.mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0)
.sum();
}
// OpenAI API 요청/응답 DTO 클래스들
@Data
@lombok.Builder
private static class OpenAIRequest {
private String model;
private List<OpenAIMessage> messages;
@JsonProperty("max_tokens")
private Integer maxTokens;
private Double temperature;
}
@Data
@lombok.Builder
private static class OpenAIMessage {
private String role;
private String content;
}
/** /**
* 요약 생성 * 요약 생성

View File

@ -1,14 +1,27 @@
package com.ktds.hi.analytics.infra.gateway; package com.ktds.hi.analytics.infra.gateway;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort; import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort;
import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* 외부 리뷰 서비스 어댑터 클래스 * 외부 리뷰 서비스 어댑터 클래스
@ -30,11 +43,20 @@ public class ExternalReviewAdapter implements ExternalReviewPort {
try { try {
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content"; String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content";
String[] reviewArray = restTemplate.getForObject(url, String[].class); // ReviewListResponse 배열로 직접 받기 (Review 서비스가 List<ReviewListResponse> 반환)
ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class);
List<String> reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of();
if (reviewArray == null || reviewArray.length == 0) {
log.info("매장에 리뷰가 없습니다: storeId={}", storeId);
return List.of();
}
// ReviewListResponse에서 content만 추출
List<String> reviews = Arrays.stream(reviewArray)
.map(ReviewListResponse::getContent)
.filter(content -> content != null && !content.trim().isEmpty())
.collect(Collectors.toList());
log.info("리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size()); log.info("리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
return reviews; return reviews;
} catch (Exception e) { } catch (Exception e) {
@ -49,13 +71,30 @@ public class ExternalReviewAdapter implements ExternalReviewPort {
log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days); log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days);
try { try {
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/recent?days=" + days; // String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/recent?days=" + days;
String[] reviewArray = restTemplate.getForObject(url, String[].class); String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "?size=100";
// ReviewListResponse 배열로 직접 받기
ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class);
if (reviewArray == null || reviewArray.length == 0) {
log.info("매장에 최근 리뷰가 없습니다: storeId={}", storeId);
return List.of();
}
// 최근 N일 이내의 리뷰만 필터링
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(days);
List<String> recentReviews = Arrays.stream(reviewArray)
.filter(review -> review.getCreatedAt() != null && review.getCreatedAt().isAfter(cutoffDate))
.map(ReviewListResponse::getContent)
.filter(content -> content != null && !content.trim().isEmpty())
.collect(Collectors.toList());
log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, recentReviews.size());
List<String> reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of(); return recentReviews;
log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
return reviews;
} catch (Exception e) { } catch (Exception e) {
log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, e); log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, e);
@ -125,4 +164,74 @@ public class ExternalReviewAdapter implements ExternalReviewPort {
"다음에도 주문할게요!" "다음에도 주문할게요!"
); );
} }
@Data
public static class ReviewListResponse {
@JsonProperty("reviewId")
private Long reviewId;
@JsonProperty("memberNickname")
private String memberNickname;
@JsonProperty("rating")
private Integer rating;
@JsonProperty("content")
private String content;
@JsonProperty("imageUrls")
private List<String> imageUrls;
@JsonProperty("likeCount")
private Integer likeCount;
@JsonProperty("dislikeCount")
private Integer dislikeCount;
@JsonProperty("createdAt")
@JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class)
private LocalDateTime createdAt;
}
/**
* 다양한 LocalDateTime 형식을 처리하는 커스텀 Deserializer
*/
public static class FlexibleLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
private static final DateTimeFormatter[] FORMATTERS = {
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS"), // 마이크로초 6자리
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSS"), // 마이크로초 5자리
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSS"), // 마이크로초 4자리
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"), // 밀리초 3자리
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SS"), // 2자리
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.S"), // 1자리
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"), // 초까지만
DateTimeFormatter.ISO_LOCAL_DATE_TIME // ISO 표준
};
@Override
public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
String dateString = parser.getText();
if (dateString == null || dateString.trim().isEmpty()) {
return null;
}
// 여러 형식으로 시도
for (DateTimeFormatter formatter : FORMATTERS) {
try {
return LocalDateTime.parse(dateString, formatter);
} catch (DateTimeParseException e) {
// 다음 형식으로 시도
}
}
// 모든 형식이 실패하면 현재 시간 반환 (에러 로그)
System.err.println("Failed to parse LocalDateTime: " + dateString + ", using current time");
return LocalDateTime.now();
}
}
} }

View File

@ -39,7 +39,7 @@ ai-api:
claude: claude:
api-key: ${CLAUDE_API_KEY:} api-key: ${CLAUDE_API_KEY:}
base-url: https://api.anthropic.com base-url: https://api.anthropic.com
model: claude-3-sonnet-20240229 model: claude-sonnet-4-20250514
#external-api: #external-api:
# openai: # openai: