303 lines
15 KiB
Java
303 lines
15 KiB
Java
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.");
|
||
};
|
||
}
|
||
}
|