package com.zioinfo.mall.rag; import com.zioinfo.mall.ai.MallAiService; import com.zioinfo.mall.analytics.mapper.AnalyticsMapper; import com.zioinfo.mall.product.MallProduct; import com.zioinfo.mall.rag.RagToggleService.RagToggles; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; /** * Mall 대표 AI 기능 RAG 배선 레이어(순증·비파괴). * *

기존 {@link MallAiService}(Ollama 우선 + Java 폴백)는 불변. 본 서비스는 중앙 guardia-rag 를 * 경유하는 별개 레이어로, 두 대표 기능을 전환한다: *

    *
  1. 상품 추천·자연어 상품검색 → {@code /rag/answer}(retrieval_mode=hybrid 근거) + * {@code /rag/structured}(productId·score·reason 결정론 JSON). 매장 재고 ON/OFF·상태 필터 적용. * 토글 off/미가용 시 기존 {@link MallAiService#recommend}/{@link MallAiService#nlSearch} 폴백(degraded:true).
  2. *
  3. 수요예측·재고이양 추천 → {@code /rag/agent}(tool-use, 사람 승인 게이트, 제안만). * tool_use 토글 off/미가용 시 기존 {@link MallAiService#demandForecast}/{@link MallAiService#transferRecommendations} 통계 폴백.
  4. *
* *

모든 응답에 적용 metadata(retrieval_mode·rerank·toolUse·structured·degraded·abstained)를 표기해 * 토글 effect 가 관측 가능하게 한다. 자격증명/카드/회원 PII/내부IP/매장 SSH/스택트레이스는 입력·출력 모두 마스킹. */ @Slf4j @Service @RequiredArgsConstructor public class MallRagAiService { private final RagClient rag; private final RagToggleService toggles; private final MallAiService mallAi; // 기존 Ollama/Java 폴백 재사용(불변) private final AnalyticsMapper analyticsMapper; // 수요예측 신호(매장별 판매) /** 추천/검색 결정론 결과 JSON 스키마(중앙 /structured 강제). */ private static final Map RECOMMEND_SCHEMA = Map.of( "type", "object", "properties", Map.of( "items", Map.of( "type", "array", "items", Map.of( "type", "object", "properties", Map.of( "productId", Map.of("type", "integer"), "name", Map.of("type", "string"), "score", Map.of("type", "number"), "reason", Map.of("type", "string") ), "required", List.of("productId") ) ), "summary", Map.of("type", "string"), "noResults", Map.of("type", "boolean") ), "required", List.of("items") ); /** 재고이양/수요 결정 JSON 스키마(중앙 /structured·/agent 최종 정리 강제). */ private static final Map DECISION_SCHEMA = Map.of( "type", "object", "properties", Map.of( "decisions", Map.of( "type", "array", "items", Map.of( "type", "object", "properties", Map.of( "action", Map.of("type", "string"), // forecast|transfer|clearance "storeFrom", Map.of("type", "integer"), "storeTo", Map.of("type", "integer"), "productId", Map.of("type", "integer"), "qty", Map.of("type", "integer"), "expectedImpact", Map.of("type", "string"), "confidence", Map.of("type", "number") ), "required", List.of("action") ) ), "rationale", Map.of("type", "string") ), "required", List.of("decisions") ); /** 입력/근거 내 민감정보 마스킹 패턴(내부IP·자격증명·토큰·카드번호). */ private static final Pattern SENSITIVE = Pattern.compile( "(?i)(pass(word)?\\s*[:=]\\s*\\S+|token\\s*[:=]\\s*\\S+|api[-_]?key\\s*[:=]\\s*\\S+" + "|\\b(?:10|172|192)\\.(?:\\d{1,3})\\.(?:\\d{1,3})\\.(?:\\d{1,3})\\b" + "|\\b(?:\\d[ -]?){13,16}\\b)"); // ── ① 상품 추천·자연어 검색 (중앙 /answer hybrid + /structured) ──────────── @SuppressWarnings("unchecked") public Map recommend(Map req, String actor) { String occasion = str(req.get("occasion")); String keyword = str(req.get("keyword")); if (keyword.isBlank()) keyword = str(req.get("query")); // nl-search 진입 호환 int limit = clamp(intOf(req.get("limit"), 6), 1, 20); RagToggles t = toggles.current(); Map out = new LinkedHashMap<>(); // RAG 미가용 → 기존 추천(판매순/평점 폴백) if (!t.ragEnabled || !rag.available()) { return fallbackRecommend(req, actor, t, occasion, keyword, limit, t.ragEnabled ? "rag_unavailable" : "rag_disabled"); } // hybrid(BM25+벡터) 검색 근거 — 매칭 사유 동반. graphrag 토글 on 이면 graph. String mode = toggles.effectiveMode(t); String query = "GUARDiA Mall 꽃집 카탈로그에서 다음 조건에 맞는 부케/상품을 추천하라. " + "occasion='" + (occasion.isBlank() ? "any" : occasion) + "' " + "요청='" + keyword + "'. 근거(매칭 사유)를 동반하고, 조건에 맞는 상품이 없으면 추천을 만들지 말라."; Map ares = rag.answer(query, mode, t.rerank, t.topK, t.generationModel, true); boolean degraded = bool(ares.get("degraded")); if (degraded || ares.get("answer") == null) { return fallbackRecommend(req, actor, t, occasion, keyword, limit, str(ares.getOrDefault("degraded_reason", "answer_empty"))); } // /structured 결정론 결과(productId·score·reason 고정) — 토글 on 시 List> items = new ArrayList<>(); boolean structuredApplied = false; if (t.structured) { String sp = "검색 근거에서 추천 상품 목록을 추출하라. 각 항목은 productId·name·score·reason. " + "카탈로그 밖 상품을 지어내지 말 것.\n근거:\n" + mask(str(ares.get("answer"))); Map sres = rag.structured(sp, RECOMMEND_SCHEMA, t.generationModel, t.temperature); if (!bool(sres.get("degraded")) && sres.get("data") instanceof Map) { Map data = (Map) sres.get("data"); Object raw = data.get("items"); if (raw instanceof List) { for (Object o : (List) raw) if (o instanceof Map) items.add((Map) o); } structuredApplied = !items.isEmpty(); } } out.put("items", items); // 결정론 추천(있으면) out.put("summary", mask(str(ares.get("answer")))); out.put("grounded", ares.get("grounded")); out.put("faithfulness", ares.get("faithfulness")); out.put("citations", ares.get("citations")); out.put("sources", ares.get("sources")); out.put("guardrail", ares.get("guardrail")); // 근거 부족 시 보류(환각 차단) out.put("abstained", ares.getOrDefault("abstained", Boolean.FALSE.equals(ares.get("grounded")))); out.put("answerId", ares.get("answer_id")); // /feedback 연계 out.put("engine", structuredApplied ? "RAG_ANSWER+STRUCTURED" : "RAG_ANSWER"); out.put("degraded", false); out.putAll(meta(t, structuredApplied ? "structured" : "answer", structuredApplied)); return out; } private Map fallbackRecommend(Map req, String actor, RagToggles t, String occasion, String keyword, int limit, String reason) { Map out = new LinkedHashMap<>(); List legacy = mallAi.recommend(occasion.isBlank() ? null : occasion, keyword.isBlank() ? null : keyword, limit); out.put("items", legacy); // 기존 추천(판매순/평점) out.put("summary", legacy.isEmpty() ? "조건에 맞는 상품이 없습니다." : "인기/평점 기반 추천(폴백)."); out.put("degraded", true); out.put("degraded_reason", reason); out.put("source", "mall_local_fallback"); out.put("engine", "MALL_LOCAL"); out.putAll(meta(t, "answer", false)); return out; } // ── ② 수요예측·재고이양 추천 (중앙 /agent tool-use, 제안만) ──────────────── @SuppressWarnings("unchecked") public Map demandPlan(Map req, String actor) { String season = str(req.getOrDefault("season", "valentine")); int days = clamp(intOf(req.get("days"), 14), 1, 90); List storeIds = longList(req.get("storeIds")); RagToggles t = toggles.current(); Map out = new LinkedHashMap<>(); if (!t.ragEnabled || !rag.available()) { return fallbackDemand(t, season, days, storeIds, t.ragEnabled ? "rag_unavailable" : "rag_disabled"); } String query = "GUARDiA Mall 운영 의사결정: season='" + season + "' 기간 " + days + "일 기준으로 " + "매장별 수요예측과 매장간 재고이양/당일소진을 제안하라(자동실행 아닌 권고). " + "꽃의 단명성·이벤트(밸런타인/어머니날)·ZIP 권역 인접을 고려하고, " + "데이터가 부족하면 단정하지 말고 저신뢰로 표시하라."; String mode = toggles.effectiveMode(t); Map ares; boolean usedAgent = false; if (t.toolUse) { // 에이전틱 tool-use: Mall read-only 도구 등록(값 노출 없는 메타 조회) ares = rag.agent(query, demandTools(), t.agentMaxSteps, t.generationModel, true); usedAgent = !bool(ares.get("degraded")); if (bool(ares.get("degraded"))) { // /agent 불가 → /answer 강등 ares = rag.answer(query, mode, t.rerank, t.topK, t.generationModel, true); } } else { ares = rag.answer(query, mode, t.rerank, t.topK, t.generationModel, true); } boolean degraded = bool(ares.get("degraded")); if (degraded || ares.get("answer") == null) { return fallbackDemand(t, season, days, storeIds, str(ares.getOrDefault("degraded_reason", "answer_empty"))); } // /structured 로 최종 결정 정리(action·storeFrom·storeTo·qty·confidence) — 토글 on 시 List> decisions = new ArrayList<>(); boolean structuredApplied = false; if (t.structured) { String sp = "다음 운영 분석에서 결정 목록을 추출하라. 각 항목 action·storeFrom·storeTo·productId·qty·" + "expectedImpact·confidence. 추측 수치를 지어내지 말 것.\n분석:\n" + mask(str(ares.get("answer"))); Map sres = rag.structured(sp, DECISION_SCHEMA, t.generationModel, t.temperature); if (!bool(sres.get("degraded")) && sres.get("data") instanceof Map) { Map data = (Map) sres.get("data"); Object raw = data.get("decisions"); if (raw instanceof List) { for (Object o : (List) raw) if (o instanceof Map) decisions.add((Map) o); } structuredApplied = !decisions.isEmpty(); } } out.put("decisions", decisions); // 결정론 제안(있으면) — 실행은 승인 게이트 out.put("rationale", mask(str(ares.get("answer")))); out.put("approvalRequired", true); // 사람 승인 게이트(AI 임의 라이브 변경 금지) out.put("grounded", ares.get("grounded")); out.put("faithfulness", ares.get("faithfulness")); out.put("citations", ares.get("citations")); out.put("sources", ares.get("sources")); out.put("steps", ares.get("steps")); // /agent 진단 단계(있으면) out.put("guardrail", ares.get("guardrail")); out.put("abstained", ares.getOrDefault("abstained", Boolean.FALSE.equals(ares.get("grounded")))); out.put("answerId", ares.get("answer_id")); out.put("engine", usedAgent ? "RAG_AGENT" : "RAG_ANSWER"); out.put("degraded", false); out.putAll(meta(t, usedAgent ? "agent" : "answer", structuredApplied)); return out; } private Map fallbackDemand(RagToggles t, String season, int days, List storeIds, String reason) { Map out = new LinkedHashMap<>(); out.put("forecast", mallAi.demandForecast(season, analyticsMapper.salesByStore(days))); if (!storeIds.isEmpty()) out.put("transfers", mallAi.transferRecommendations(storeIds)); out.put("approvalRequired", true); out.put("degraded", true); out.put("degraded_reason", reason); out.put("source", "mall_local_fallback"); // 통계(이동평균·계절지수) 폴백 out.put("engine", "MALL_LOCAL"); out.putAll(meta(t, "agent", false)); return out; } /** 👍/👎 피드백 → 중앙 /rag/feedback (solution=mall 격리). */ public Map feedback(Map req, String actor) { String userRef = "u_" + Integer.toHexString((actor == null ? "anon" : actor).hashCode()); return rag.feedback( str0(req.get("answerId")), str0(req.get("query")), str0(req.get("answer")), str(req.getOrDefault("verdict", "down")), str0(req.get("correction")), userRef); } /** 현재 토글 스냅샷(화면용). */ public Map currentToggles() { Map m = toggles.current().toMap(); m.put("ragAvailable", rag.available()); return m; } // ── Mall 고유 read-only 도구 레지스트리(/agent 용) ──────────────────────── // 자격증명·매장 SSH·카드/회원 PII 는 도구 응답에서 제외 — 집계 메타/판매·재고 수량만. private List> demandTools() { return List.of( tool("mall_store_inventory", "매장별 상품 재고 수량/신선도/판매가능(ON·OFF) 메타를 조회", Map.of("storeId", Map.of("type", "integer"))), tool("mall_sales_history", "기간별 매장·상품 판매량 집계를 조회", Map.of("days", Map.of("type", "integer"))), tool("mall_zone_adjacency", "매장 ZIP 배송권역 인접/배송가능 매장 메타를 조회", Map.of("storeId", Map.of("type", "integer"))), tool("mall_season_signal", "이벤트/시즌(밸런타인·어머니날) 수요 배수 신호를 조회", Map.of("season", Map.of("type", "string"))) ); } private Map tool(String name, String desc, Map params) { Map m = new LinkedHashMap<>(); m.put("name", name); m.put("description", desc); m.put("parameters", Map.of("type", "object", "properties", params)); return m; } // ── helpers ───────────────────────────────────────────────────────────── private Map meta(RagToggles t, String technique, boolean structured) { Map m = new LinkedHashMap<>(); Map applied = new LinkedHashMap<>(); applied.put("retrievalMode", toggles.effectiveMode(t)); applied.put("rerank", t.rerank); applied.put("graphrag", t.graphrag); applied.put("toolUse", t.toolUse); applied.put("structured", structured); applied.put("stream", t.stream); applied.put("technique", technique); m.put("applied", applied); return m; } @SuppressWarnings("unchecked") private List longList(Object o) { List ids = new ArrayList<>(); if (o instanceof List) { for (Object x : (List) o) { try { ids.add(Long.valueOf(String.valueOf(x))); } catch (Exception ignored) { } } } return ids; } private int intOf(Object o, int def) { if (o == null) return def; try { return Integer.parseInt(String.valueOf(o).trim()); } catch (Exception e) { return def; } } private int clamp(int v, int min, int max) { return Math.max(min, Math.min(max, v)); } private String mask(String s) { if (s == null || s.isEmpty()) return s; return SENSITIVE.matcher(s).replaceAll("***"); } private boolean bool(Object o) { return Boolean.TRUE.equals(o) || "true".equalsIgnoreCase(String.valueOf(o)); } private String str(Object o) { return o == null ? "" : String.valueOf(o); } private String str0(Object o) { return o == null ? null : String.valueOf(o); } }