feat : 서비스 분석 및 실행결과 저장기능 개발.
This commit is contained in:
parent
2373a07772
commit
84ef442775
@ -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);
|
||||||
|
|||||||
@ -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 서비스 어댑터 클래스
|
||||||
@ -30,120 +41,280 @@ import java.util.List;
|
|||||||
@Component
|
@Component
|
||||||
public class AIServiceAdapter implements AIServicePort {
|
public class AIServiceAdapter implements AIServicePort {
|
||||||
|
|
||||||
@Value("${ai.azure.cognitive.endpoint}")
|
|
||||||
private String cognitiveEndpoint;
|
|
||||||
|
|
||||||
@Value("${ai.azure.cognitive.key}")
|
@Value("${ai-api.openai.base-url:https://api.openai.com/v1}")
|
||||||
private String cognitiveKey;
|
private String openaiBaseUrl;
|
||||||
|
|
||||||
@Value("${ai.openai.api-key}")
|
@Value("${ai-api.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();
|
|
||||||
|
|
||||||
// 2. 긍정/부정 비율 계산
|
// 결과 파싱 및 AiFeedback 객체 생성
|
||||||
long positiveCount = sentiments.stream()
|
return parseAnalysisResult(analysisResult, reviewData.size());
|
||||||
.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();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 빈 피드백 생성
|
* 빈 피드백 생성
|
||||||
*/
|
*/
|
||||||
@ -159,6 +330,67 @@ public class AIServiceAdapter implements AIServicePort {
|
|||||||
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 요약 생성
|
* 요약 생성
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
List<String> reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of();
|
if (reviewArray == null || reviewArray.length == 0) {
|
||||||
log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
|
log.info("매장에 최근 리뷰가 없습니다: storeId={}", storeId);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
return reviews;
|
// 최근 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());
|
||||||
|
|
||||||
|
return recentReviews;
|
||||||
|
|
||||||
} 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user