guardia-mall/backend/src/main/java/com/zioinfo/mall/ai/MallAiService.java

303 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.zioinfo.mall.ai;
import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper;
import com.zioinfo.mall.product.MallProduct;
import com.zioinfo.mall.product.mapper.ProductMapper;
import com.zioinfo.mall.review.mapper.ReviewMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
/**
* GUARDiA Mall AI 서비스 — Ollama(localhost) 적극 활용 + 전부 Java 폴백.
*
* <p>보안 불변: 외부 AI API 금지. OllamaClient는 localhost:11434만 호출하고
* 빈 응답 시 결정론적 Java 폴백으로 동작한다(서비스 중단 없음).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MallAiService {
private final OllamaClient ollama;
private final ProductMapper productMapper;
private final ReviewMapper reviewMapper;
private final StoreInventoryMapper storeInventoryMapper;
/** 1. 상품 추천 — 행사/키워드 기반. AI 실패 시 인기/평점 폴백. */
public List<MallProduct> recommend(String occasion, String keyword, int limit) {
List<MallProduct> pool = productMapper.search(null, keyword, "ON_SALE", occasion, null, null, "sales", 30, 0);
if (pool.isEmpty()) {
pool = productMapper.search(null, null, "ON_SALE", null, null, null, "rating", 30, 0);
}
String names = joinNames(pool);
String prompt = "You are a florist recommender. From this catalog: [" + names + "]. "
+ "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion)
+ "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated.";
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) {
List<MallProduct> ordered = reorderByAi(pool, ai);
if (!ordered.isEmpty()) return ordered.subList(0, Math.min(limit, ordered.size()));
}
return pool.subList(0, Math.min(limit, pool.size())); // Java 폴백: 판매순
}
/** 2. 리뷰 AI 요약. 폴백: 평균 평점 + 최근 키워드 요약. */
public Map<String, Object> summarizeReviews(Long productId) {
List<String> contents = reviewMapper.recentContents(productId, 20);
Map<String, Object> stats = reviewMapper.stats(productId);
Map<String, Object> out = new LinkedHashMap<>();
out.put("stats", stats);
if (contents.isEmpty()) {
out.put("summary", "No reviews yet.");
out.put("source", "fallback");
return out;
}
String prompt = "Summarize these flower bouquet reviews in 2 concise sentences (pros/cons):\n"
+ String.join("\n", contents);
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) {
out.put("summary", ai);
out.put("source", "ollama");
} else {
out.put("summary", "Customers mention: " + topKeywords(contents) + ". Avg rating "
+ stats.getOrDefault("avg", "N/A") + "/5.");
out.put("source", "fallback");
}
return out;
}
/** 3. 자연어 상품 검색 — "$50 이하 기념일 꽃" → 필터 추출. 폴백: 정규식 파싱. */
public Map<String, Object> nlSearch(String query) {
BigDecimal maxPrice = parsePrice(query);
String occasion = parseOccasion(query);
String flowerType = parseFlower(query);
List<MallProduct> items = productMapper.search(null, null, "ON_SALE", occasion, flowerType, maxPrice, "sales", 20, 0);
Map<String, Object> out = new LinkedHashMap<>();
out.put("query", query);
out.put("parsed", Map.of("maxPrice", maxPrice == null ? "" : maxPrice,
"occasion", occasion == null ? "" : occasion, "flowerType", flowerType == null ? "" : flowerType));
out.put("items", items);
out.put("source", "fallback-nlp");
return out;
}
/** 4. CS 자동응답 초안. 폴백: 카테고리 템플릿. */
public String csAutoReply(String subject, String content) {
String prompt = "You are a polite flower-shop customer support agent. Write a short helpful reply (<=4 sentences) to:\n"
+ "Subject: " + subject + "\nMessage: " + content;
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) return ai;
return "Thank you for reaching out about \"" + subject + "\". We're sorry for any inconvenience. "
+ "Our team is reviewing your request and will follow up shortly. "
+ "For urgent delivery issues, please reply with your order number.";
}
/**
* 5. 당일 재고 소진 추천 — 매장 잔여 재고로 AI 부케 구성(Farmgirl 'Daily Standard').
* 폴백: 잔여 수량 많은 상위 상품을 묶음 제안.
*/
public Map<String, Object> dailyClearanceBouquet(Long storeId) {
List<com.zioinfo.mall.inventory.MallStoreInventory> inv = storeInventoryMapper.findByStore(storeId);
inv.sort((a, b) -> Integer.compare(b.getOnHand() == null ? 0 : b.getOnHand(),
a.getOnHand() == null ? 0 : a.getOnHand()));
List<String> surplus = new ArrayList<>();
for (com.zioinfo.mall.inventory.MallStoreInventory si : inv) {
if (si.getOnHand() != null && si.getOnHand() > 0 && Boolean.TRUE.equals(si.getAvailable())) {
surplus.add(si.getProductName() + " (x" + si.getOnHand() + ")");
}
if (surplus.size() >= 6) break;
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("storeId", storeId);
out.put("surplus", surplus);
if (surplus.isEmpty()) {
out.put("bouquet", "No surplus stock to compose today.");
out.put("source", "fallback");
return out;
}
String prompt = "Compose a creative 'Daily Standard' bouquet name and 1-line description using surplus flowers: ["
+ String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION.";
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) {
out.put("bouquet", ai);
out.put("source", "ollama");
} else {
out.put("bouquet", "Today's Designer's Choice | A fresh seasonal mix featuring "
+ surplus.get(0).replaceAll("\\s*\\(.*\\)", "") + " and more.");
out.put("source", "fallback");
}
return out;
}
/**
* 6. 피크시즌 수요 예측 — 매장별 용량 계획(밸런타인/어머니날).
* 폴백: 최근 일평균 주문 × 시즌 배수 vs 용량 비교.
*/
public Map<String, Object> demandForecast(String season, List<Map<String, Object>> storeSales) {
double multiplier = "valentine".equalsIgnoreCase(season) ? 3.5
: "mother".equalsIgnoreCase(season) ? 3.0 : 1.5;
List<Map<String, Object>> forecast = new ArrayList<>();
for (Map<String, Object> s : storeSales) {
Object oc = s.get("orderCount");
int recent = oc == null ? 0 : ((Number) oc).intValue();
int predicted = (int) Math.round(recent * multiplier);
Map<String, Object> row = new LinkedHashMap<>();
row.put("storeId", s.get("storeId"));
row.put("storeName", s.get("storeName"));
row.put("recentOrders", recent);
row.put("predictedOrders", predicted);
row.put("multiplier", multiplier);
forecast.add(row);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("season", season);
out.put("forecast", forecast);
out.put("source", "fallback-heuristic");
return out;
}
/** 7. AI 카드 메시지 작성 도우미. 폴백: 행사별 템플릿. */
public List<String> cardMessages(String occasion, String tone, String recipient) {
String prompt = "Write 3 short flower-card messages for occasion='" + occasion
+ "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient)
+ "'. One per line, no numbering.";
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) {
List<String> lines = new ArrayList<>();
for (String l : ai.split("\n")) {
String t = l.replaceAll("^[0-9.\\-)\\s]+", "").trim();
if (!t.isEmpty()) lines.add(t);
}
if (!lines.isEmpty()) return lines;
}
return fallbackCards(occasion);
}
/**
* 8. 매장간 재고 이양 추천 — 잉여/부족 매칭.
* 폴백: 상품별로 재고 많은 매장(공급) → 적은/품절 매장(수요) 매칭.
*/
public List<Map<String, Object>> transferRecommendations(List<Long> storeIds) {
// 상품별 매장 재고 집계
Map<Long, List<com.zioinfo.mall.inventory.MallStoreInventory>> byProduct = new HashMap<>();
for (Long sid : storeIds) {
for (com.zioinfo.mall.inventory.MallStoreInventory si : storeInventoryMapper.findByStore(sid)) {
byProduct.computeIfAbsent(si.getProductId(), k -> new ArrayList<>()).add(si);
}
}
List<Map<String, Object>> recs = new ArrayList<>();
for (Map.Entry<Long, List<com.zioinfo.mall.inventory.MallStoreInventory>> e : byProduct.entrySet()) {
List<com.zioinfo.mall.inventory.MallStoreInventory> list = e.getValue();
com.zioinfo.mall.inventory.MallStoreInventory max = null, min = null;
for (com.zioinfo.mall.inventory.MallStoreInventory si : list) {
int oh = si.getOnHand() == null ? 0 : si.getOnHand();
if (max == null || oh > (max.getOnHand() == null ? 0 : max.getOnHand())) max = si;
if (min == null || oh < (min.getOnHand() == null ? 0 : min.getOnHand())) min = si;
}
if (max != null && min != null && !Objects.equals(max.getStoreId(), min.getStoreId())) {
int surplus = (max.getOnHand() == null ? 0 : max.getOnHand());
int shortage = (min.getOnHand() == null ? 0 : min.getOnHand());
if (surplus - shortage >= 10) {
int qty = (surplus - shortage) / 2;
Map<String, Object> r = new LinkedHashMap<>();
r.put("productId", e.getKey());
r.put("productName", max.getProductName());
r.put("fromStoreId", max.getStoreId());
r.put("toStoreId", min.getStoreId());
r.put("suggestedQty", qty);
r.put("reason", "Surplus " + surplus + " vs shortage " + shortage);
recs.add(r);
}
}
}
return recs;
}
// ---------------------------------------------------------------- helpers
private String joinNames(List<MallProduct> ps) {
StringBuilder sb = new StringBuilder();
for (MallProduct p : ps) {
if (sb.length() > 0) sb.append(", ");
sb.append(p.getName());
}
return sb.toString();
}
private List<MallProduct> reorderByAi(List<MallProduct> pool, String ai) {
String lower = ai.toLowerCase();
List<MallProduct> ordered = new ArrayList<>();
for (MallProduct p : pool) {
if (p.getName() != null && lower.contains(p.getName().toLowerCase())) ordered.add(p);
}
return ordered;
}
private BigDecimal parsePrice(String q) {
if (q == null) return null;
java.util.regex.Matcher m = java.util.regex.Pattern.compile("\\$?\\s*(\\d+(?:\\.\\d+)?)").matcher(q);
BigDecimal found = null;
if ((q.contains("under") || q.contains("이하") || q.contains("below") || q.contains("$")) && m.find()) {
found = new BigDecimal(m.group(1));
}
return found;
}
private String parseOccasion(String q) {
if (q == null) return null;
String l = q.toLowerCase();
if (l.contains("anniversary") || l.contains("기념일")) return "ANNIVERSARY";
if (l.contains("birthday") || l.contains("생일")) return "BIRTHDAY";
if (l.contains("sympathy") || l.contains("조의")) return "SYMPATHY";
if (l.contains("romance") || l.contains("love") || l.contains("사랑")) return "ROMANCE";
if (l.contains("get well") || l.contains("쾌유")) return "GETWELL";
if (l.contains("congrat") || l.contains("축하")) return "CONGRATS";
return null;
}
private String parseFlower(String q) {
if (q == null) return null;
String l = q.toLowerCase();
if (l.contains("rose") || l.contains("장미")) return "ROSES";
if (l.contains("tulip") || l.contains("튤립")) return "TULIPS";
if (l.contains("lily") || l.contains("백합")) return "LILIES";
if (l.contains("sunflower") || l.contains("해바라기")) return "SUNFLOWERS";
if (l.contains("hydrangea") || l.contains("수국")) return "HYDRANGEAS";
return null;
}
private String topKeywords(List<String> contents) {
Map<String, Integer> freq = new HashMap<>();
for (String c : contents) {
for (String w : c.toLowerCase().split("\\W+")) {
if (w.length() >= 5) freq.merge(w, 1, Integer::sum);
}
}
return freq.entrySet().stream()
.sorted((a, b) -> b.getValue() - a.getValue())
.limit(3).map(Map.Entry::getKey)
.reduce((a, b) -> a + ", " + b).orElse("quality, freshness");
}
private List<String> fallbackCards(String occasion) {
String o = occasion == null ? "" : occasion.toUpperCase();
return switch (o) {
case "BIRTHDAY" -> List.of("Happy Birthday! Wishing you a day as bright as these blooms.",
"Another year more wonderful — enjoy every petal!",
"Hope your special day blossoms with joy.");
case "ANNIVERSARY" -> List.of("Happy Anniversary — here's to many more years in bloom.",
"Celebrating your love today and always.",
"Forever and always, with these flowers.");
case "SYMPATHY" -> List.of("With heartfelt sympathy in your time of loss.",
"Thinking of you with deepest condolences.",
"May these flowers bring a moment of peace.");
default -> List.of("Just because you deserve something beautiful.",
"Sending smiles your way with these blooms.",
"A little flower to brighten your day.");
};
}
}