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 폴백. * *

보안 불변: 외부 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 recommend(String occasion, String keyword, int limit) { List 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 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 summarizeReviews(Long productId) { List contents = reviewMapper.recentContents(productId, 20); Map stats = reviewMapper.stats(productId); Map 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 nlSearch(String query) { BigDecimal maxPrice = parsePrice(query); String occasion = parseOccasion(query); String flowerType = parseFlower(query); List items = productMapper.search(null, null, "ON_SALE", occasion, flowerType, maxPrice, "sales", 20, 0); Map 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 dailyClearanceBouquet(Long storeId) { List inv = storeInventoryMapper.findByStore(storeId); inv.sort((a, b) -> Integer.compare(b.getOnHand() == null ? 0 : b.getOnHand(), a.getOnHand() == null ? 0 : a.getOnHand())); List 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 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 demandForecast(String season, List> storeSales) { double multiplier = "valentine".equalsIgnoreCase(season) ? 3.5 : "mother".equalsIgnoreCase(season) ? 3.0 : 1.5; List> forecast = new ArrayList<>(); for (Map s : storeSales) { Object oc = s.get("orderCount"); int recent = oc == null ? 0 : ((Number) oc).intValue(); int predicted = (int) Math.round(recent * multiplier); Map 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 out = new LinkedHashMap<>(); out.put("season", season); out.put("forecast", forecast); out.put("source", "fallback-heuristic"); return out; } /** 7. AI 카드 메시지 작성 도우미. 폴백: 행사별 템플릿. */ public List 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 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> transferRecommendations(List storeIds) { // 상품별 매장 재고 집계 Map> 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> recs = new ArrayList<>(); for (Map.Entry> e : byProduct.entrySet()) { List 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 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 ps) { StringBuilder sb = new StringBuilder(); for (MallProduct p : ps) { if (sb.length() > 0) sb.append(", "); sb.append(p.getName()); } return sb.toString(); } private List reorderByAi(List pool, String ai) { String lower = ai.toLowerCase(); List 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 contents) { Map 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 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."); }; } }