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