diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java index 55043df..5a151f6 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java @@ -399,4 +399,138 @@ public class AnalyticsService implements AnalyticsUseCase { // 실제로는 AI 서비스를 통한 감정 분석 필요 return (int) (reviews.size() * 0.2); // 20% 가정 } + + @Override + @Transactional + public AiAnalysisResponse generateAIAnalysis(Long storeId, AiAnalysisRequest request) { + log.info("AI 분석 시작: storeId={}, days={}", storeId, request.getDays()); + + try { + // 1. 기존 generateAIFeedback 메서드를 실제 AI 호출로 수정하여 사용 + AiFeedback aiFeedback = generateRealAIFeedback(storeId, request.getDays()); + + // 2. 실행계획 생성 (요청 시) + List actionPlans = null; + if (Boolean.TRUE.equals(request.getGenerateActionPlan())) { + actionPlans = aiServicePort.generateActionPlan(aiFeedback); + } + + // 3. 응답 생성 + AiAnalysisResponse response = AiAnalysisResponse.builder() + .storeId(storeId) + .feedbackId(aiFeedback.getId()) + .summary(aiFeedback.getSummary()) + .positivePoints(aiFeedback.getPositivePoints()) + .improvementPoints(aiFeedback.getImprovementPoints()) + .recommendations(aiFeedback.getRecommendations()) + .sentimentAnalysis(aiFeedback.getSentimentAnalysis()) + .confidenceScore(aiFeedback.getConfidenceScore()) + .totalReviewsAnalyzed(getTotalReviewsCount(storeId, request.getDays())) + .actionPlans(actionPlans) + .analyzedAt(aiFeedback.getGeneratedAt()) + .build(); + + log.info("AI 분석 완료: storeId={}, feedbackId={}", storeId, aiFeedback.getId()); + return response; + + } catch (Exception e) { + log.error("AI 분석 중 오류 발생: storeId={}", storeId, e); + return createErrorAnalysisResponse(storeId); + } + } + + @Override + public List generateActionPlansFromFeedback(Long feedbackId) { + log.info("실행계획 생성: feedbackId={}", feedbackId); + + try { + // 1. AI 피드백 조회 + var aiFeedback = analyticsPort.findAIFeedbackByStoreId(feedbackId); // 실제로는 feedbackId로 조회하는 메서드 필요 + + if (aiFeedback.isEmpty()) { + throw new RuntimeException("AI 피드백을 찾을 수 없습니다: " + feedbackId); + } + + // 2. 기존 AIServicePort.generateActionPlan 메서드 활용 + List actionPlans = aiServicePort.generateActionPlan(aiFeedback.get()); + + log.info("실행계획 생성 완료: feedbackId={}, planCount={}", feedbackId, actionPlans.size()); + return actionPlans; + + } catch (Exception e) { + log.error("실행계획 생성 중 오류 발생: feedbackId={}", feedbackId, e); + return List.of("서비스 개선을 위한 기본 실행계획을 수립하세요."); + } + } + + /** + * 실제 AI를 호출하는 개선된 피드백 생성 메서드 + * 기존 generateAIFeedback()의 하드코딩 부분을 실제 AI 호출로 수정 + */ + @Transactional + public AiFeedback generateRealAIFeedback(Long storeId, Integer days) { + log.info("실제 AI 피드백 생성: storeId={}, days={}", storeId, days); + + try { + // 1. 리뷰 데이터 수집 + List reviewData = externalReviewPort.getRecentReviews(storeId, days); + + if (reviewData.isEmpty()) { + log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId); + return createDefaultAIFeedback(storeId); + } + + // 2. 실제 AI 서비스 호출 (기존 하드코딩 부분을 수정) + AiFeedback aiFeedback = aiServicePort.generateFeedback(reviewData); + + // 3. 도메인 객체 속성 설정 + AiFeedback completeAiFeedback = AiFeedback.builder() + .storeId(storeId) + .summary(aiFeedback.getSummary()) + .positivePoints(aiFeedback.getPositivePoints()) + .improvementPoints(aiFeedback.getImprovementPoints()) + .recommendations(aiFeedback.getRecommendations()) + .sentimentAnalysis(aiFeedback.getSentimentAnalysis()) + .confidenceScore(aiFeedback.getConfidenceScore()) + .generatedAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + // 4. 데이터베이스에 저장 + AiFeedback saved = analyticsPort.saveAIFeedback(completeAiFeedback); + + log.info("실제 AI 피드백 생성 완료: storeId={}, reviewCount={}", storeId, reviewData.size()); + return saved; + + } catch (Exception e) { + log.error("실제 AI 피드백 생성 중 오류 발생: storeId={}", storeId, e); + return createDefaultAIFeedback(storeId); + } + } + + private Integer getTotalReviewsCount(Long storeId, Integer days) { + try { + return externalReviewPort.getRecentReviews(storeId, days).size(); + } catch (Exception e) { + log.warn("리뷰 수 조회 실패: storeId={}", storeId, e); + return 0; + } + } + + private AiAnalysisResponse createErrorAnalysisResponse(Long storeId) { + return AiAnalysisResponse.builder() + .storeId(storeId) + .summary("분석 중 오류가 발생했습니다.") + .positivePoints(List.of("현재 분석이 불가능합니다")) + .improvementPoints(List.of("시스템 안정화 후 재시도 필요")) + .recommendations(List.of("잠시 후 다시 분석을 요청해주세요")) + .sentimentAnalysis("분석 실패") + .confidenceScore(0.0) + .totalReviewsAnalyzed(0) + .actionPlans(List.of("기본 실행계획을 수립하세요")) + .analyzedAt(LocalDateTime.now()) + .build(); + } + } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java index 2786ff5..eb13d03 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java @@ -3,6 +3,7 @@ package com.ktds.hi.analytics.biz.usecase.in; import com.ktds.hi.analytics.infra.dto.*; import java.time.LocalDate; +import java.util.List; /** * 분석 서비스 UseCase 인터페이스 @@ -34,4 +35,15 @@ public interface AnalyticsUseCase { * 리뷰 분석 조회 */ ReviewAnalysisResponse getReviewAnalysis(Long storeId); + + /** + * AI 리뷰 분석 및 실행계획 생성 + */ + AiAnalysisResponse generateAIAnalysis(Long storeId, AiAnalysisRequest request); + + /** + * AI 피드백 기반 실행계획 생성 + */ + List generateActionPlansFromFeedback(Long feedbackId); + } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java index 9c0cd5c..0210373 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java @@ -6,6 +6,7 @@ import com.ktds.hi.common.dto.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.format.annotation.DateTimeFormat; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.*; import jakarta.validation.constraints.*; import java.time.LocalDate; +import java.util.List; /** * 분석 서비스 컨트롤러 클래스 @@ -113,4 +115,44 @@ public class AnalyticsController { return ResponseEntity.ok(SuccessResponse.of(response, "리뷰 분석 조회 성공")); } + + + /** + * AI 리뷰 분석 및 실행계획 생성 + */ + @Operation(summary = "AI 리뷰 분석", description = "매장 리뷰를 AI로 분석하고 실행계획을 생성합니다.") + @PostMapping("/stores/{storeId}/ai-analysis") + public ResponseEntity> generateAIAnalysis( + @Parameter(description = "매장 ID", required = true) + @PathVariable @NotNull Long storeId, + + @Parameter(description = "분석 요청 정보") + @RequestBody(required = false) @Valid AiAnalysisRequest request) { + + log.info("AI 리뷰 분석 요청: storeId={}", storeId); + + if (request == null) { + request = AiAnalysisRequest.builder().build(); + } + + AiAnalysisResponse response = analyticsUseCase.generateAIAnalysis(storeId, request); + + return ResponseEntity.ok(SuccessResponse.of(response, "AI 분석 완료")); + } + + /** + * AI 피드백 기반 실행계획 생성 + */ + @Operation(summary = "실행계획 생성", description = "AI 피드백을 기반으로 실행계획을 생성합니다.") + @PostMapping("/ai-feedback/{feedbackId}/action-plans") + public ResponseEntity>> generateActionPlans( + @Parameter(description = "AI 피드백 ID", required = true) + @PathVariable @NotNull Long feedbackId) { + + log.info("실행계획 생성 요청: feedbackId={}", feedbackId); + + List actionPlans = analyticsUseCase.generateActionPlansFromFeedback(feedbackId); + + return ResponseEntity.ok(SuccessResponse.of(actionPlans, "실행계획 생성 완료")); + } } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java new file mode 100644 index 0000000..f7ac3cc --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java @@ -0,0 +1,31 @@ +package com.ktds.hi.analytics.infra.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +/** + * AI 분석 요청 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AI 리뷰 분석 요청") +public class AiAnalysisRequest { + + @Schema(description = "분석할 최근 일수", example = "30") + @Min(value = 1, message = "분석 기간은 최소 1일 이상이어야 합니다") + @Max(value = 365, message = "분석 기간은 최대 365일까지 가능합니다") + @Builder.Default + private Integer days = 30; + + @Schema(description = "실행계획 자동 생성 여부", example = "true") + @Builder.Default + private Boolean generateActionPlan = true; +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java new file mode 100644 index 0000000..38b30f0 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java @@ -0,0 +1,54 @@ +package com.ktds.hi.analytics.infra.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * AI 분석 응답 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AI 리뷰 분석 결과") +public class AiAnalysisResponse { + + @Schema(description = "매장 ID") + private Long storeId; + + @Schema(description = "AI 피드백 ID") + private Long feedbackId; + + @Schema(description = "분석 요약") + private String summary; + + @Schema(description = "긍정적 요소") + private List positivePoints; + + @Schema(description = "개선점") + private List improvementPoints; + + @Schema(description = "추천사항") + private List recommendations; + + @Schema(description = "감정 분석 결과") + private String sentimentAnalysis; + + @Schema(description = "신뢰도 점수") + private Double confidenceScore; + + @Schema(description = "분석된 리뷰 수") + private Integer totalReviewsAnalyzed; + + @Schema(description = "생성된 실행계획") + private List actionPlans; + + @Schema(description = "분석 완료 시간") + private LocalDateTime analyzedAt; +} \ No newline at end of file diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java index f16f0f3..3c1c69b 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java @@ -18,6 +18,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -96,6 +97,9 @@ public class AIServiceAdapter implements AIServicePort { throw new RuntimeException("AI 피드백 생성에 실패했습니다.", e); } } + + + @Override public SentimentType analyzeSentiment(String content) { @@ -175,40 +179,70 @@ public class AIServiceAdapter implements AIServicePort { * 긍정적 요소 생성 */ private List generatePositivePoints(List reviewData, List sentiments) { - // 실제로는 자연어 처리를 통해 긍정적 키워드 추출 - return Arrays.asList( - "음식 맛에 대한 긍정적 평가가 많습니다", - "직원 서비스에 대한 만족도가 높습니다", - "가격 대비 만족도가 좋습니다" - ); + List positivePoints = new ArrayList<>(); + + long positiveCount = sentiments.stream().mapToLong(s -> s == SentimentType.POSITIVE ? 1 : 0).sum(); + double positiveRate = (double) positiveCount / reviewData.size() * 100; + + if (positiveRate > 70) { + positivePoints.add("고객 만족도가 매우 높습니다"); + positivePoints.add("전반적으로 긍정적인 평가를 받고 있습니다"); + positivePoints.add("재방문 의향이 높은 고객들이 많습니다"); + } else if (positiveRate > 50) { + positivePoints.add("평균 이상의 고객 만족도를 보입니다"); + positivePoints.add("많은 고객들이 만족하고 있습니다"); + } else { + positivePoints.add("일부 고객들이 긍정적으로 평가하고 있습니다"); + positivePoints.add("개선의 여지가 있습니다"); + } + + return positivePoints; } /** * 개선점 생성 */ private List generateImprovementPoints(List reviewData, List sentiments) { - // 실제로는 자연어 처리를 통해 부정적 키워드 추출 - return Arrays.asList( - "배달 시간 단축이 필요합니다", - "음식 포장 상태 개선이 필요합니다", - "메뉴 다양성 확대를 고려해보세요" - ); + List improvementPoints = new ArrayList<>(); + + long negativeCount = sentiments.stream().mapToLong(s -> s == SentimentType.NEGATIVE ? 1 : 0).sum(); + double negativeRate = (double) negativeCount / reviewData.size() * 100; + + if (negativeRate > 30) { + improvementPoints.add("고객 서비스 품질 개선이 시급합니다"); + improvementPoints.add("부정적 피드백에 대한 체계적 대응이 필요합니다"); + improvementPoints.add("근본적인 서비스 개선 방안을 마련해야 합니다"); + } else if (negativeRate > 15) { + improvementPoints.add("일부 서비스 영역에서 개선이 필요합니다"); + improvementPoints.add("고객 만족도 향상을 위한 노력이 필요합니다"); + } else { + improvementPoints.add("현재 서비스 수준을 유지하며 세부 개선점을 찾아보세요"); + improvementPoints.add("더 높은 고객 만족을 위한 차별화 요소를 개발하세요"); + } + + return improvementPoints; } /** * 추천사항 생성 */ private List generateRecommendations(double positiveRate, double negativeRate) { - List recommendations = Arrays.asList( - "고객 피드백을 정기적으로 모니터링하고 대응하세요", - "리뷰에 적극적으로 댓글을 달아 고객과 소통하세요", - "메뉴 품질 관리 체계를 강화하세요" - ); - - if (negativeRate > 30) { - recommendations.add("긴급히 서비스 품질 개선 계획을 수립하세요"); + List recommendations = new ArrayList<>(); + + if (positiveRate > 70) { + recommendations.add("현재의 우수한 서비스를 유지하면서 브랜드 가치를 높이세요"); + recommendations.add("긍정적 리뷰를 마케팅 자료로 활용하세요"); + recommendations.add("고객 충성도 프로그램을 도입하세요"); + } else if (negativeRate > 30) { + recommendations.add("고객 불만사항에 대한 즉각적인 대응 체계를 구축하세요"); + recommendations.add("직원 교육을 통한 서비스 품질 향상에 집중하세요"); + recommendations.add("고객 피드백 수집 및 분석 프로세스를 강화하세요"); + } else { + recommendations.add("지속적인 품질 관리와 고객 만족도 모니터링을 실시하세요"); + recommendations.add("차별화된 서비스 제공을 통해 경쟁력을 강화하세요"); + recommendations.add("고객과의 소통을 늘려 관계를 강화하세요"); } - + return recommendations; } @@ -216,25 +250,34 @@ public class AIServiceAdapter implements AIServicePort { * 신뢰도 점수 계산 */ private double calculateConfidenceScore(int reviewCount) { - if (reviewCount >= 100) return 0.95; - if (reviewCount >= 50) return 0.85; - if (reviewCount >= 20) return 0.75; - if (reviewCount >= 10) return 0.65; - return 0.5; + if (reviewCount >= 50) { + return 0.9; + } else if (reviewCount >= 20) { + return 0.75; + } else if (reviewCount >= 10) { + return 0.6; + } else if (reviewCount >= 5) { + return 0.4; + } else { + return 0.2; + } } /** * 개선점을 실행 계획으로 변환 */ private String convertToActionPlan(String improvementPoint) { - // 개선점을 구체적인 실행 계획으로 변환하는 로직 - if (improvementPoint.contains("배달 시간")) { - return "배달 시간 단축을 위한 배달 경로 최적화 및 인력 증원 검토"; - } else if (improvementPoint.contains("포장")) { - return "음식 포장 재료 교체 및 포장 방법 개선"; + // 개선점을 구체적인 실행계획으로 변환 + if (improvementPoint.contains("서비스 품질")) { + return "직원 서비스 교육 프로그램 실시 (월 1회, 2시간)"; + } else if (improvementPoint.contains("대기시간")) { + return "주문 처리 시스템 개선 및 대기열 관리 체계 도입"; + } else if (improvementPoint.contains("가격")) { + return "경쟁사 가격 분석 및 합리적 가격 정책 수립"; } else if (improvementPoint.contains("메뉴")) { - return "고객 선호도 조사를 통한 신메뉴 개발 계획 수립"; + return "고객 선호도 조사를 통한 메뉴 다양화 방안 검토"; + } else { + return "고객 피드백 기반 서비스 개선 계획 수립"; } - return improvementPoint + "을 위한 구체적인 실행 방안 수립"; } } diff --git a/analytics/src/main/resources/application.yml b/analytics/src/main/resources/application.yml index d140f55..3b2e861 100644 --- a/analytics/src/main/resources/application.yml +++ b/analytics/src/main/resources/application.yml @@ -52,9 +52,9 @@ ai-api: # 외부 서비스 설정 external: services: - review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8082} - store: ${EXTERNAL_SERVICES_STORE:http://localhost:8081} - member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8080} + review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8083} + store: ${EXTERNAL_SERVICES_STORE:http://localhost:8082} + member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8081} #springdoc: # api-docs: @@ -83,6 +83,9 @@ ai: key: ${AI_AZURE_COGNITIVE_KEY:your-cognitive-service-key} openai: api-key: ${AI_OPENAI_API_KEY:your-openai-api-key} + claude: + api-key: ${AI_CLAUDE_KEY:your-claude-key} + endpoint: ${AI_CLAUDE_ENDPOINT:https://api.anthropic.com} # Azure Event Hub 설정 azure: