diff --git a/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java index 4fff1e2..dd2aacb 100644 --- a/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java +++ b/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java @@ -81,7 +81,10 @@ public class SecurityConfig { "/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER") .requestMatchers(HttpMethod.DELETE, "/api/mall/product/**", "/api/mall/category/**", "/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER") - // 그 외 mall API (장바구니·주문·결제·배송·찜·CS·회원·AI) — 인증 사용자 + // 최신 AI 기법(중앙 guardia-rag) — 운영 의사결정·토글 변경은 MANAGER+ (추천/피드백/토글조회는 인증 사용자) + .requestMatchers(HttpMethod.POST, "/api/mall/rag/demand-plan").hasAnyRole("ADMIN", "MANAGER") + .requestMatchers(HttpMethod.PUT, "/api/mall/rag/toggles/**").hasAnyRole("ADMIN", "MANAGER") + // 그 외 mall API (장바구니·주문·결제·배송·찜·CS·회원·AI·RAG 추천/피드백/토글조회) — 인증 사용자 .requestMatchers("/api/mall/**").authenticated() // 나머지 모든 API/WS/Actuator는 인증 (아래 SPA permit 보다 먼저 — API 노출 방지) .requestMatchers("/api/**", "/ws/**", "/actuator/**").authenticated() diff --git a/backend/src/main/java/com/zioinfo/mall/rag/MallRagAiService.java b/backend/src/main/java/com/zioinfo/mall/rag/MallRagAiService.java new file mode 100644 index 0000000..a4709f8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/rag/MallRagAiService.java @@ -0,0 +1,344 @@ +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); } +} diff --git a/backend/src/main/java/com/zioinfo/mall/rag/RagClient.java b/backend/src/main/java/com/zioinfo/mall/rag/RagClient.java new file mode 100644 index 0000000..1cee640 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/rag/RagClient.java @@ -0,0 +1,193 @@ +package com.zioinfo.mall.rag; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 얇은 중앙 guardia-rag REST 클라이언트 (Mall 전용). + * + *

핵심 설계: Mall 은 LangChain·벡터·에이전트 로직을 Java 에 재구현하지 않는다. 중앙 Python + * guardia-rag 의 고급검색·구조화·에이전틱·검증을 REST 로 호출하기만 한다. 미가용/타임아웃/RAM 부족 시 + * 절대 예외를 전파하지 않고 {@code degraded:true} 가 표시된 빈/폴백 결과를 돌려준다(서비스 계층이 + * Mall 로컬 폴백 수행 — 기존 {@code MallAiService} 불변). + * + *

보안 불변: 온프레미스 전용. 응답에서 자격증명/카드/회원 PII/스택트레이스 미노출. 오류는 요약만 로깅. + * + *

호출 계약(중앙, http://127.0.0.1:8020): + *

    + *
  • POST /rag/answer — 검색(retrieval_mode hybrid|graph|vector)+근거생성+인용+guardrail (추천/검색 용)
  • + *
  • POST /rag/structured — format:json 결정론 JSON (추천/검색 결과 스키마 강제 용)
  • + *
  • POST /rag/agent — tool-use 에이전틱 (수요예측·재고이양 결정, 무거움→옵트인)
  • + *
  • POST /rag/feedback — 👍/👎 피드백 (solution=mall 격리)
  • + *
+ */ +@Slf4j +@Component +public class RagClient { + + private final WebClient.Builder builder; + private final RagProperties props; + + public RagClient(WebClient.Builder builder, RagProperties props) { + this.builder = builder; + this.props = props; + } + + /** 중앙 RAG 사용 가능 여부(설정 enabled + 설정 엔드포인트 도달). */ + public boolean available() { + if (!props.isEnabled()) return false; + try { + builder.baseUrl(props.getBaseUrl()).build() + .get().uri("/rag/trust/settings?solution=" + props.getSolution()) + .retrieve().bodyToMono(String.class) + .timeout(Duration.ofSeconds(3)).block(); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * POST /rag/structured — 스키마 강제 결정론 JSON (추천/검색 결과 등). + * + * @return {@code {data:{...}, valid, fallback_used, degraded}} 형태. 미가용 시 {@code degraded:true}. + */ + public Map structured(String prompt, Map schema, + String model, double temperature) { + if (!props.isEnabled()) return degraded("rag_disabled"); + try { + Map body = new LinkedHashMap<>(); + body.put("solution", props.getSolution()); + body.put("prompt", prompt); + if (schema != null) body.put("schema", schema); + if (model != null) body.put("model", model); + body.put("temperature", temperature); + body.put("max_retries", 1); + Map res = post("/rag/structured", body); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("RAG /structured 일시 불가 — Mall 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + /** + * POST /rag/answer — 검색+생성+검증+guardrail. + * + * @return {@code {answer, grounded, faithfulness, citations[], guardrail{...}, sources[], answer_id, degraded}}. + * 미가용 시 {@code degraded:true}. + */ + public Map answer(String query, String retrievalMode, boolean rerank, + int topK, String model, boolean verify) { + if (!props.isEnabled()) return degraded("rag_disabled"); + try { + Map options = new LinkedHashMap<>(); + options.put("rerank", rerank); + options.put("stream", false); + options.put("verify", verify); + if (model != null) options.put("model", model); + + Map body = new LinkedHashMap<>(); + body.put("solution", props.getSolution()); + body.put("query", query); + body.put("retrieval_mode", retrievalMode == null ? "vector" : retrievalMode); + body.put("top_k", topK <= 0 ? 5 : topK); + body.put("options", options); + Map res = post("/rag/answer", body); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("RAG /answer 일시 불가 — Mall 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + /** + * POST /rag/agent — 에이전틱 tool-use (수요예측·재고이양 결정). + * + *

등록 도구(tools)는 Mall 고유 read-only 도구 메타(매장 재고/판매이력/시즌/ZIP 권역). + * 무거운 기법이므로 토글 opt-in 시에만 호출하고, 미가용 시 {@code /answer} 또는 통계 폴백으로 강등한다. + * + * @return {@code {answer, steps[], citations[], sources[], grounded, faithfulness, answer_id, degraded}}. + */ + public Map agent(String query, List> tools, + int maxSteps, String model, boolean verify) { + if (!props.isEnabled()) return degraded("rag_disabled"); + try { + Map options = new LinkedHashMap<>(); + options.put("max_steps", maxSteps <= 0 ? 4 : maxSteps); + options.put("verify", verify); + options.put("stream", false); + if (model != null) options.put("model", model); + + Map body = new LinkedHashMap<>(); + body.put("solution", props.getSolution()); + body.put("query", query); + if (tools != null) body.put("tools", tools); + body.put("options", options); + Map res = post("/rag/agent", body); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("RAG /agent 일시 불가 — Mall 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + /** + * POST /rag/feedback — 👍/👎 피드백(solution=mall 격리). + * + * @return {@code {feedback_id, stored}} 또는 미가용 시 {@code {stored:false, degraded:true}}. + */ + public Map feedback(String answerId, String query, String answer, + String verdict, String correction, String userRef) { + if (!props.isEnabled()) return Map.of("stored", false, "degraded", true); + try { + Map body = new LinkedHashMap<>(); + body.put("solution", props.getSolution()); + if (answerId != null) body.put("answer_id", answerId); + if (query != null) body.put("query", query); + if (answer != null) body.put("answer", answer); + body.put("verdict", verdict); + if (correction != null) body.put("correction", correction); + if (userRef != null) body.put("user_ref", userRef); + Map res = post("/rag/feedback", body); + return res != null ? res : Map.of("stored", false, "degraded", true); + } catch (Exception e) { + log.warn("RAG /feedback 일시 불가: {}", summarize(e)); + return Map.of("stored", false, "degraded", true); + } + } + + // ── helpers ─────────────────────────────────────────────────────────── + @SuppressWarnings("unchecked") + private Map post(String path, Map body) { + return builder.baseUrl(props.getBaseUrl()).build() + .post().uri(path) + .header("X-Solution-Key", props.getSolution()) + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofMillis(props.getTimeoutMs())) + .map(m -> (Map) m) + .block(); + } + + private Map degraded(String reason) { + Map out = new LinkedHashMap<>(); + out.put("degraded", true); + out.put("degraded_reason", reason); + return out; + } + + /** 스택트레이스 미노출: 메시지 1줄만 요약. */ + private String summarize(Exception e) { + String m = e.getMessage(); + if (m == null) return e.getClass().getSimpleName(); + return m.length() > 160 ? m.substring(0, 160) : m; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/rag/RagController.java b/backend/src/main/java/com/zioinfo/mall/rag/RagController.java new file mode 100644 index 0000000..ebb30d6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/rag/RagController.java @@ -0,0 +1,74 @@ +package com.zioinfo.mall.rag; + +import com.zioinfo.mall.admin.SettingService; +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * Mall 최신 AI 기법(중앙 guardia-rag) 배선 API — 별개 레이어. + * + *

    + *
  • POST /api/mall/rag/recommend — 상품 추천·자연어 검색(/answer hybrid + /structured, 근거+보류)
  • + *
  • POST /api/mall/rag/demand-plan — 수요예측·재고이양 추천(/agent tool-use, 승인 게이트)
  • + *
  • POST /api/mall/rag/feedback — 👍/👎 피드백(/rag/feedback, solution=mall 격리)
  • + *
  • GET /api/mall/rag/toggles — 현재 기법/신뢰도 토글 스냅샷
  • + *
  • PUT /api/mall/rag/toggles/{key} — 토글 변경(MANAGER+, mall_setting 격리 저장)
  • + *
+ * + *

기존 {@code /api/mall/ai/*} 는 불변. 본 컨트롤러는 중앙 경유 신규 경로만 추가한다. + * RBAC: recommend/feedback 은 인증 사용자, demand-plan·토글 변경은 MANAGER+. + */ +@RestController +@RequestMapping("/api/mall/rag") +@RequiredArgsConstructor +public class RagController { + + private final MallRagAiService service; + private final SettingService settingService; + + /** 상품 추천·자연어 검색 — 인증 고객(USER+). */ + @PostMapping("/recommend") + public ApiResponse> recommend(@RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.recommend(req, auth.getName())); + } + + /** 수요예측·재고이양 추천 — MANAGER+ (운영 의사결정). */ + @PostMapping("/demand-plan") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> demandPlan(@RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.demandPlan(req, auth.getName())); + } + + /** 피드백 — 인증 사용자. */ + @PostMapping("/feedback") + public ApiResponse> feedback(@RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.feedback(req, auth.getName())); + } + + /** 토글 스냅샷 — 인증 사용자(읽기). */ + @GetMapping("/toggles") + public ApiResponse> toggles() { + return ApiResponse.ok(service.currentToggles()); + } + + /** + * 토글 변경 — 기존 mall_setting upsert(감사로그 자동 기록) 재사용. MANAGER+. + * 허용 키는 {@code rag.*} 네임스페이스로 한정. + */ + @PutMapping("/toggles/{key}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> updateToggle(@PathVariable String key, + @RequestBody Map req) { + if (key == null || !key.startsWith("rag.")) { + return ApiResponse.fail("ERR-RAG-400: rag.* 키만 변경 가능"); + } + Object v = req.get("value"); + settingService.update(key, v == null ? "" : String.valueOf(v)); + return ApiResponse.ok(service.currentToggles()); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/rag/RagProperties.java b/backend/src/main/java/com/zioinfo/mall/rag/RagProperties.java new file mode 100644 index 0000000..3158c73 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/rag/RagProperties.java @@ -0,0 +1,41 @@ +package com.zioinfo.mall.rag; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 중앙 guardia-rag 연동 설정 (Mall 전용). + * + *

보안 불변 규칙: base-url 은 온프레미스(중앙 guardia-rag) 전용이다. + * 외부 엔드포인트를 가리키도록 설정해서는 안 된다. 기본값은 서버 내부 루프백이다. + * + *

application.yml: + *

+ * guardia:
+ *   rag:
+ *     base-url: http://127.0.0.1:8020
+ *     timeout-ms: 120000
+ *     enabled: true
+ *     solution: mall
+ * 
+ */ +@Component +@Getter +@Setter +@ConfigurationProperties(prefix = "guardia.rag") +public class RagProperties { + + /** 중앙 guardia-rag 베이스 URL (서버 내부 루프백 기본). */ + private String baseUrl = "http://127.0.0.1:8020"; + + /** 호출 타임아웃(ms). 소형모델 생성 고려 120s 기본. */ + private long timeoutMs = 120_000L; + + /** RAG 경유 마스터 스위치. false면 RagClient 가 항상 미가용으로 동작(Mall 로컬 폴백). */ + private boolean enabled = true; + + /** 솔루션 식별자(컬렉션·토글·피드백 격리 키). */ + private String solution = "mall"; +} diff --git a/backend/src/main/java/com/zioinfo/mall/rag/RagToggleService.java b/backend/src/main/java/com/zioinfo/mall/rag/RagToggleService.java new file mode 100644 index 0000000..586da10 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/rag/RagToggleService.java @@ -0,0 +1,134 @@ +package com.zioinfo.mall.rag; + +import com.zioinfo.mall.admin.dto.MallSetting; +import com.zioinfo.mall.admin.mapper.SettingMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Mall RAG 기법/신뢰도 토글 서비스. + * + *

기존 {@code mall_setting}(key-value) 테이블을 재사용해 RAG 토글을 격리 저장한다(신규 테이블 + * 신설 없음, admin 설정 화면과 자연 통합). 키 네임스페이스 {@code rag.*} 로 다른 설정과 충돌 회피. + * + *

무거운 기법(graphrag·rerank·tool_use/agent·stream)은 서버 RAM 제약상 기본 off. + * retrieval_mode 기본 vector, agent step 상한 강제. + */ +@Service +@RequiredArgsConstructor +public class RagToggleService { + + private final SettingMapper settingMapper; + + public static final String K_ENABLED = "rag.enabled"; + public static final String K_MODE = "rag.retrieval_mode"; // vector|hybrid|graph + public static final String K_RERANK = "rag.rerank"; // true|false (무거움→기본 off) + public static final String K_GRAPHRAG = "rag.graphrag"; // true|false (무거움→기본 off) + public static final String K_TOOL_USE = "rag.tool_use"; // true|false (에이전틱 /agent, 무거움→off) + public static final String K_STRUCTURED = "rag.structured"; // true|false (추천/검색 결정론 JSON, 기본 on) + public static final String K_STREAM = "rag.stream"; // true|false (SSE, 기본 off) + public static final String K_TOP_K = "rag.top_k"; + public static final String K_AGENT_STEPS = "rag.agent_max_steps"; + public static final String K_FAITHFULNESS = "rag.faithfulness_threshold"; + public static final String K_TEMPERATURE = "rag.temperature"; + public static final String K_GEN_MODEL = "rag.generation_model"; + + /** 현재 토글 전부 조회(기본값 머지). 화면/배선 공용. */ + public RagToggles current() { + Map m = new LinkedHashMap<>(); + List all = settingMapper.findAll(); + if (all != null) for (MallSetting s : all) m.put(s.getKey(), s.getValue()); + + RagToggles t = new RagToggles(); + t.ragEnabled = bool(m.get(K_ENABLED), true); + t.retrievalMode = mode(m.get(K_MODE)); + t.rerank = bool(m.get(K_RERANK), false); // 무거움→off 기본 + t.graphrag = bool(m.get(K_GRAPHRAG), false); // 무거움→off 기본 + t.toolUse = bool(m.get(K_TOOL_USE), false); // 에이전틱→off 기본(옵트인) + t.structured = bool(m.get(K_STRUCTURED), true); // 결정론 출력→on 기본 + t.stream = bool(m.get(K_STREAM), false); // SSE→off 기본 + t.topK = clampInt(m.get(K_TOP_K), 6, 1, 20); + t.agentMaxSteps = clampInt(m.get(K_AGENT_STEPS), 4, 1, 8); + t.faithfulnessThreshold = clampDouble(m.get(K_FAITHFULNESS), 0.5, 0.0, 1.0); + t.temperature = clampDouble(m.get(K_TEMPERATURE), 0.2, 0.0, 1.0); + t.generationModel = blankTo(m.get(K_GEN_MODEL), "llama3.2:1b"); + return t; + } + + /** retrieval_mode 결정: graphrag 토글 on 이면 graph 우선, 아니면 설정 mode. */ + public String effectiveMode(RagToggles t) { + if (t.graphrag) return "graph"; + return t.retrievalMode; + } + + // ── 정규화/클램프 (상한 강제 — 폭주 차단) ─────────────────────────────── + private boolean bool(String v, boolean def) { + if (v == null) return def; + return "true".equalsIgnoreCase(v.trim()) || "1".equals(v.trim()) || "on".equalsIgnoreCase(v.trim()); + } + + private String mode(String v) { + if (v == null) return "vector"; + String s = v.trim().toLowerCase(); + return (s.equals("hybrid") || s.equals("graph") || s.equals("vector")) ? s : "vector"; + } + + private int clampInt(String v, int def, int min, int max) { + try { + int n = Integer.parseInt(v.trim()); + return Math.max(min, Math.min(max, n)); + } catch (Exception e) { + return def; + } + } + + private double clampDouble(String v, double def, double min, double max) { + try { + double n = Double.parseDouble(v.trim()); + return Math.max(min, Math.min(max, n)); + } catch (Exception e) { + return def; + } + } + + private String blankTo(String v, String def) { + return (v == null || v.isBlank()) ? def : v.trim(); + } + + /** 토글 스냅샷 DTO(응답 metadata·화면 공용). */ + public static class RagToggles { + public boolean ragEnabled = true; + public String retrievalMode = "vector"; + public boolean rerank = false; + public boolean graphrag = false; + public boolean toolUse = false; + public boolean structured = true; + public boolean stream = false; + public int topK = 6; + public int agentMaxSteps = 4; + public double faithfulnessThreshold = 0.5; + public double temperature = 0.2; + public String generationModel = "llama3.2:1b"; + + public Map toMap() { + Map m = new LinkedHashMap<>(); + m.put("ragEnabled", ragEnabled); + m.put("retrievalMode", retrievalMode); + m.put("rerank", rerank); + m.put("graphrag", graphrag); + m.put("toolUse", toolUse); + m.put("structured", structured); + m.put("stream", stream); + m.put("topK", topK); + m.put("agentMaxSteps", agentMaxSteps); + m.put("faithfulnessThreshold", faithfulnessThreshold); + m.put("temperature", temperature); + m.put("generationModel", generationModel); + return m; + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 124d3f0..3e66b7d 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -60,6 +60,12 @@ guardia: ocr-url: ${OCR_URL:http://localhost:8005} ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b} + # 중앙 guardia-rag(온프레미스 전용) — 최신 AI 기법 경유. 미가용 시 Mall 로컬 폴백(degraded) + rag: + base-url: ${RAG_URL:http://127.0.0.1:8020} + timeout-ms: ${RAG_TIMEOUT_MS:120000} + enabled: ${RAG_ENABLED:true} + solution: mall crypto: secret: ${CRYPTO_SECRET:guardia-mall-aes-256-gcm-master-key-2026-zioinfo} jwt: diff --git a/backend/src/main/resources/db/schema.sql b/backend/src/main/resources/db/schema.sql index e8783cf..e73723d 100644 --- a/backend/src/main/resources/db/schema.sql +++ b/backend/src/main/resources/db/schema.sql @@ -47,7 +47,20 @@ INSERT INTO mall_setting (key, value) VALUES ('hours_saturday','Sat 9:00 AM - 4:00 PM'), ('hours_sunday','Sun 9:00 AM - 12:00 PM'), ('payment_provider','mock'),('tax_provider','mock'),('address_provider','mock'), -('sms_provider','mock'),('email_provider','mock') +('sms_provider','mock'),('email_provider','mock'), +-- 최신 AI 기법(중앙 guardia-rag) 토글 — 무거운 기법(graphrag·rerank·tool_use·stream)은 서버 RAM 제약상 기본 off +('rag.enabled','true'), +('rag.retrieval_mode','vector'), +('rag.rerank','false'), +('rag.graphrag','false'), +('rag.tool_use','false'), +('rag.structured','true'), +('rag.stream','false'), +('rag.top_k','6'), +('rag.agent_max_steps','4'), +('rag.faithfulness_threshold','0.5'), +('rag.temperature','0.2'), +('rag.generation_model','llama3.2:1b') ON CONFLICT (key) DO NOTHING; CREATE TABLE IF NOT EXISTS mall_ai_result ( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0bea76b..3a74e0d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,7 @@ import UserManagement from './admin/UserManagement' import AuditLog from './admin/AuditLog' import Settings from './admin/Settings' import AdminApp from './admin/AdminApp' +import AiTechniques from './admin/AiTechniques' // UIWS 이식 — 업무 모듈(관리자 영역 병합, 고객 쇼핑 화면 무영향) import UiwsLayout from './pages/uiws/UiwsLayout' @@ -101,6 +102,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* UIWS 이식 업무 모듈 (관리자 영역 병합) */} } /> diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index ffd125c..6d40d97 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -3,7 +3,7 @@ import { Outlet, Navigate, NavLink, useNavigate } from 'react-router-dom' import { LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone, Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone, - ClipboardList, CalendarDays, Mail, PieChart, + ClipboardList, CalendarDays, Mail, PieChart, Sparkles, } from 'lucide-react' import { useTranslation } from 'react-i18next' import { getMe } from '../api/client' @@ -29,6 +29,10 @@ const adminLinks = [ { to: '/admin/settings', key: 'settings', icon: Settings, roles: ['ADMIN'] }, { to: '/admin/app', key: 'appInstall', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] }, ] +// 최신 AI 기법(중앙 guardia-rag) 토글 — 라벨 i18n 미의존(고정 표기), 변경은 MANAGER+(USER 차단) +const aiLinks = [ + { to: '/admin/ai-techniques', label: 'AI Techniques', icon: Sparkles, roles: ['ADMIN', 'MANAGER'] }, +] // UIWS 이식 — 업무 모듈(관리자 영역 병합). 라벨은 i18n 미의존(고정 한/영 안전 표기). const uiwsLinks = [ { to: '/admin/uiws/worklog', label: '업무일지 Worklog', icon: ClipboardList, roles: ['ADMIN', 'MANAGER'] }, @@ -65,6 +69,7 @@ export default function AdminLayout() { const visible = links.filter(l => !role || l.roles.includes(role)) const visibleAdmin = adminLinks.filter(l => l.roles.includes(role)) + const visibleAi = aiLinks.filter(l => !role || l.roles.includes(role)) const visibleUiws = uiwsLinks.filter(l => !role || l.roles.includes(role)) const logout = () => { @@ -85,6 +90,10 @@ export default function AdminLayout() {

{t('admin.system')}
{visibleAdmin.map(({ to, key, icon: Icon }) => {t(`admin.nav.${key}`)})} )} + {visibleAi.length > 0 && (<> +
AI
+ {visibleAi.map(({ to, label, icon: Icon }) => {label})} + )} {visibleUiws.length > 0 && (<>
업무 (UIWS)
{visibleUiws.map(({ to, label, icon: Icon }) => {label})} diff --git a/frontend/src/admin/AiTechniques.tsx b/frontend/src/admin/AiTechniques.tsx new file mode 100644 index 0000000..a79cbe2 --- /dev/null +++ b/frontend/src/admin/AiTechniques.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react' +import { Sparkles, Cpu, Search, Network, ListTree, Wrench, Braces, Radio, RefreshCw, ShieldCheck } from 'lucide-react' +import { getRagToggles, updateRagToggle, type RagToggles } from '../api/client' + +/** + * Mall AI 기법 토글 설정 화면 — 중앙 guardia-rag 기법을 운영자가 켜고 끈다. + * + * rerank·graphrag·hybrid(retrieval_mode)·tool_use·structured·stream on/off + 파라미터(top_k·agent step·temperature). + * mall_setting(rag.* 네임스페이스) 격리 저장. 무거운 기법(graphrag·rerank·tool_use·stream)은 기본 off·옵트인. + * 변경은 MANAGER+ (백엔드 SecurityConfig + @PreAuthorize 통제 — USER 가 PUT 시 403). + */ +export default function AiTechniques() { + const [t, setT] = useState(null) + const [busy, setBusy] = useState('') + const [err, setErr] = useState('') + const role = localStorage.getItem('mall_role') || '' + const canEdit = role === 'ADMIN' || role === 'MANAGER' + + const load = () => getRagToggles().then(setT).catch(() => setErr('Failed to load toggles')) + useEffect(() => { load() }, []) + + const set = async (key: string, value: string | number | boolean) => { + if (!canEdit) { setErr('No permission to change (MANAGER or above)'); return } + setBusy(key); setErr('') + try { const next = await updateRagToggle(key, value); setT(next) } + catch { setErr('Change failed — check permission or server') } + finally { setBusy('') } + } + + if (!t) return
Loading…
+ + const Switch = ({ on, k, disabled }: { on: boolean; k: string; disabled?: boolean }) => ( + + ) + + const Row = ({ icon: Icon, title, desc, children }: any) => ( +
+
+ +
+
{title}
+
{desc}
+
+
+
{children}
+
+ ) + + return ( +
+
+

AI Techniques

+
+ + central guardia-rag {t.ragAvailable ? 'available' : 'unavailable (Mall fallback)'} + + +
+
+ + {err &&
{err}
} + {!canEdit &&
Read-only — changes require MANAGER role or above.
} + +
+ {/* 마스터 + 검색 기법 — 상품 추천·자연어 검색 경로에 적용 */} +
+

Retrieval & Generation

+ + + + + + + + + + + + +
+ + {/* 에이전틱·구조화·스트리밍 — 수요예측/재고이양·결정론 결과·응답 스트리밍 */} +
+

Agentic · Structured · Streaming

+ + + + + + + + + +
+ + {/* 파라미터 */} +
+

Parameters

+
+ set('rag.top_k', v)} canEdit={canEdit} /> + set('rag.agent_max_steps', v)} canEdit={canEdit} /> + set('rag.faithfulness_threshold', v)} canEdit={canEdit} /> + set('rag.temperature', v)} canEdit={canEdit} /> +
+
+ Generation model: {t.generationModel} · small model by default (server RAM). Heavy techniques are guarded by concurrency limits and fallback. +
+
+
+
+ ) +} + +function Num({ label, value, min, max, step, onSave, canEdit }: + { label: string; value: number; min: number; max: number; step: number; onSave: (v: number) => void; canEdit: boolean }) { + const [v, setV] = useState(String(value)) + useEffect(() => { setV(String(value)) }, [value]) + return ( +
+ + setV(e.target.value)} + onBlur={() => { const n = Number(v); if (!Number.isNaN(n)) onSave(n) }} + className="w-full bg-ink border border-edge rounded-lg text-sm px-3 py-2 outline-none focus:border-brand disabled:opacity-50" /> +
+ ) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d7be5ee..b5d75f4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -241,3 +241,18 @@ export const deleteEvent = (id: number) => api.delete(`/api/mall/event/${id}`) export const getEventPerformance = (id: number) => u(api.get(`/api/mall/event/${id}/performance`)) export const aiEventCopy = (eventType: string, theme: string, tone: string) => u(api.post('/api/mall/event/ai/copy', { eventType, theme, tone })) + +/* ───────────── 23. 최신 AI 기법(중앙 guardia-rag) ───────────── */ +export interface RagToggles { + ragEnabled: boolean; ragAvailable: boolean; retrievalMode: string + rerank: boolean; graphrag: boolean; toolUse: boolean; structured: boolean; stream: boolean + topK: number; agentMaxSteps: number; faithfulnessThreshold: number; temperature: number; generationModel: string +} +export const getRagToggles = () => u(api.get('/api/mall/rag/toggles')) +export const updateRagToggle = (key: string, value: string | number | boolean) => + u(api.put(`/api/mall/rag/toggles/${key}`, { value })) +// 추천·자연어 검색(/answer hybrid + /structured, 근거+보류) — 인증 사용자 +export const ragRecommend = (req: object) => u(api.post('/api/mall/rag/recommend', req)) +// 수요예측·재고이양 추천(/agent tool-use, 승인 게이트) — MANAGER+ +export const ragDemandPlan = (req: object) => u(api.post('/api/mall/rag/demand-plan', req)) +export const ragFeedback = (req: object) => u(api.post('/api/mall/rag/feedback', req))