feat: 최신 AI 기법 적용 — 중앙 guardia-rag 경유 배선(근거검증·구조화출력·에이전틱) + 기법 토글
This commit is contained in:
parent
4514771277
commit
8d27d3614d
@ -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()
|
||||
|
||||
344
backend/src/main/java/com/zioinfo/mall/rag/MallRagAiService.java
Normal file
344
backend/src/main/java/com/zioinfo/mall/rag/MallRagAiService.java
Normal file
@ -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 배선 레이어(순증·비파괴).
|
||||
*
|
||||
* <p>기존 {@link MallAiService}(Ollama 우선 + Java 폴백)는 <b>불변</b>. 본 서비스는 중앙 guardia-rag 를
|
||||
* 경유하는 <b>별개 레이어</b>로, 두 대표 기능을 전환한다:
|
||||
* <ol>
|
||||
* <li><b>상품 추천·자연어 상품검색</b> → {@code /rag/answer}(retrieval_mode=hybrid 근거) +
|
||||
* {@code /rag/structured}(productId·score·reason 결정론 JSON). 매장 재고 ON/OFF·상태 필터 적용.
|
||||
* 토글 off/미가용 시 기존 {@link MallAiService#recommend}/{@link MallAiService#nlSearch} 폴백(degraded:true).</li>
|
||||
* <li><b>수요예측·재고이양 추천</b> → {@code /rag/agent}(tool-use, 사람 승인 게이트, 제안만).
|
||||
* tool_use 토글 off/미가용 시 기존 {@link MallAiService#demandForecast}/{@link MallAiService#transferRecommendations} 통계 폴백.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>모든 응답에 적용 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<String, Object> 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<String, Object> 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<String, Object> recommend(Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> items = new ArrayList<>();
|
||||
boolean structuredApplied = false;
|
||||
if (t.structured) {
|
||||
String sp = "검색 근거에서 추천 상품 목록을 추출하라. 각 항목은 productId·name·score·reason. "
|
||||
+ "카탈로그 밖 상품을 지어내지 말 것.\n근거:\n" + mask(str(ares.get("answer")));
|
||||
Map<String, Object> sres = rag.structured(sp, RECOMMEND_SCHEMA, t.generationModel, t.temperature);
|
||||
if (!bool(sres.get("degraded")) && sres.get("data") instanceof Map) {
|
||||
Map<String, Object> data = (Map<String, Object>) sres.get("data");
|
||||
Object raw = data.get("items");
|
||||
if (raw instanceof List) {
|
||||
for (Object o : (List<Object>) raw) if (o instanceof Map) items.add((Map<String, Object>) 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<String, Object> fallbackRecommend(Map<String, Object> req, String actor, RagToggles t,
|
||||
String occasion, String keyword, int limit, String reason) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
List<MallProduct> 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<String, Object> demandPlan(Map<String, Object> req, String actor) {
|
||||
String season = str(req.getOrDefault("season", "valentine"));
|
||||
int days = clamp(intOf(req.get("days"), 14), 1, 90);
|
||||
List<Long> storeIds = longList(req.get("storeIds"));
|
||||
RagToggles t = toggles.current();
|
||||
Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> sres = rag.structured(sp, DECISION_SCHEMA, t.generationModel, t.temperature);
|
||||
if (!bool(sres.get("degraded")) && sres.get("data") instanceof Map) {
|
||||
Map<String, Object> data = (Map<String, Object>) sres.get("data");
|
||||
Object raw = data.get("decisions");
|
||||
if (raw instanceof List) {
|
||||
for (Object o : (List<Object>) raw) if (o instanceof Map) decisions.add((Map<String, Object>) 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<String, Object> fallbackDemand(RagToggles t, String season, int days,
|
||||
List<Long> storeIds, String reason) {
|
||||
Map<String, Object> 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<String, Object> feedback(Map<String, Object> 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<String, Object> currentToggles() {
|
||||
Map<String, Object> m = toggles.current().toMap();
|
||||
m.put("ragAvailable", rag.available());
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── Mall 고유 read-only 도구 레지스트리(/agent 용) ────────────────────────
|
||||
// 자격증명·매장 SSH·카드/회원 PII 는 도구 응답에서 제외 — 집계 메타/판매·재고 수량만.
|
||||
private List<Map<String, Object>> 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<String, Object> tool(String name, String desc, Map<String, Object> params) {
|
||||
Map<String, Object> 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<String, Object> meta(RagToggles t, String technique, boolean structured) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
Map<String, Object> 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<Long> longList(Object o) {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
if (o instanceof List) {
|
||||
for (Object x : (List<Object>) 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); }
|
||||
}
|
||||
193
backend/src/main/java/com/zioinfo/mall/rag/RagClient.java
Normal file
193
backend/src/main/java/com/zioinfo/mall/rag/RagClient.java
Normal file
@ -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 전용).
|
||||
*
|
||||
* <p><b>핵심 설계</b>: Mall 은 LangChain·벡터·에이전트 로직을 Java 에 재구현하지 않는다. 중앙 Python
|
||||
* guardia-rag 의 고급검색·구조화·에이전틱·검증을 REST 로 호출하기만 한다. 미가용/타임아웃/RAM 부족 시
|
||||
* 절대 예외를 전파하지 않고 {@code degraded:true} 가 표시된 빈/폴백 결과를 돌려준다(서비스 계층이
|
||||
* Mall 로컬 폴백 수행 — 기존 {@code MallAiService} 불변).
|
||||
*
|
||||
* <p><b>보안 불변</b>: 온프레미스 전용. 응답에서 자격증명/카드/회원 PII/스택트레이스 미노출. 오류는 요약만 로깅.
|
||||
*
|
||||
* <p>호출 계약(중앙, http://127.0.0.1:8020):
|
||||
* <ul>
|
||||
* <li>POST /rag/answer — 검색(retrieval_mode hybrid|graph|vector)+근거생성+인용+guardrail (추천/검색 용)</li>
|
||||
* <li>POST /rag/structured — format:json 결정론 JSON (추천/검색 결과 스키마 강제 용)</li>
|
||||
* <li>POST /rag/agent — tool-use 에이전틱 (수요예측·재고이양 결정, 무거움→옵트인)</li>
|
||||
* <li>POST /rag/feedback — 👍/👎 피드백 (solution=mall 격리)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String, Object> structured(String prompt, Map<String, Object> schema,
|
||||
String model, double temperature) {
|
||||
if (!props.isEnabled()) return degraded("rag_disabled");
|
||||
try {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> answer(String query, String retrievalMode, boolean rerank,
|
||||
int topK, String model, boolean verify) {
|
||||
if (!props.isEnabled()) return degraded("rag_disabled");
|
||||
try {
|
||||
Map<String, Object> options = new LinkedHashMap<>();
|
||||
options.put("rerank", rerank);
|
||||
options.put("stream", false);
|
||||
options.put("verify", verify);
|
||||
if (model != null) options.put("model", model);
|
||||
|
||||
Map<String, Object> 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<String, Object> 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 (수요예측·재고이양 결정).
|
||||
*
|
||||
* <p>등록 도구(tools)는 Mall 고유 read-only 도구 메타(매장 재고/판매이력/시즌/ZIP 권역).
|
||||
* 무거운 기법이므로 토글 opt-in 시에만 호출하고, 미가용 시 {@code /answer} 또는 통계 폴백으로 강등한다.
|
||||
*
|
||||
* @return {@code {answer, steps[], citations[], sources[], grounded, faithfulness, answer_id, degraded}}.
|
||||
*/
|
||||
public Map<String, Object> agent(String query, List<Map<String, Object>> tools,
|
||||
int maxSteps, String model, boolean verify) {
|
||||
if (!props.isEnabled()) return degraded("rag_disabled");
|
||||
try {
|
||||
Map<String, Object> 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<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("solution", props.getSolution());
|
||||
body.put("query", query);
|
||||
if (tools != null) body.put("tools", tools);
|
||||
body.put("options", options);
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> post(String path, Map<String, Object> 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<String, Object>) m)
|
||||
.block();
|
||||
}
|
||||
|
||||
private Map<String, Object> degraded(String reason) {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@ -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 — 별개 레이어.
|
||||
*
|
||||
* <ul>
|
||||
* <li>POST /api/mall/rag/recommend — 상품 추천·자연어 검색(/answer hybrid + /structured, 근거+보류)</li>
|
||||
* <li>POST /api/mall/rag/demand-plan — 수요예측·재고이양 추천(/agent tool-use, 승인 게이트)</li>
|
||||
* <li>POST /api/mall/rag/feedback — 👍/👎 피드백(/rag/feedback, solution=mall 격리)</li>
|
||||
* <li>GET /api/mall/rag/toggles — 현재 기법/신뢰도 토글 스냅샷</li>
|
||||
* <li>PUT /api/mall/rag/toggles/{key} — 토글 변경(MANAGER+, mall_setting 격리 저장)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>기존 {@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<Map<String, Object>> recommend(@RequestBody Map<String, Object> req, Authentication auth) {
|
||||
return ApiResponse.ok(service.recommend(req, auth.getName()));
|
||||
}
|
||||
|
||||
/** 수요예측·재고이양 추천 — MANAGER+ (운영 의사결정). */
|
||||
@PostMapping("/demand-plan")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ApiResponse<Map<String, Object>> demandPlan(@RequestBody Map<String, Object> req, Authentication auth) {
|
||||
return ApiResponse.ok(service.demandPlan(req, auth.getName()));
|
||||
}
|
||||
|
||||
/** 피드백 — 인증 사용자. */
|
||||
@PostMapping("/feedback")
|
||||
public ApiResponse<Map<String, Object>> feedback(@RequestBody Map<String, Object> req, Authentication auth) {
|
||||
return ApiResponse.ok(service.feedback(req, auth.getName()));
|
||||
}
|
||||
|
||||
/** 토글 스냅샷 — 인증 사용자(읽기). */
|
||||
@GetMapping("/toggles")
|
||||
public ApiResponse<Map<String, Object>> toggles() {
|
||||
return ApiResponse.ok(service.currentToggles());
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 변경 — 기존 mall_setting upsert(감사로그 자동 기록) 재사용. MANAGER+.
|
||||
* 허용 키는 {@code rag.*} 네임스페이스로 한정.
|
||||
*/
|
||||
@PutMapping("/toggles/{key}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ApiResponse<Map<String, Object>> updateToggle(@PathVariable String key,
|
||||
@RequestBody Map<String, Object> 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());
|
||||
}
|
||||
}
|
||||
@ -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 전용).
|
||||
*
|
||||
* <p>보안 불변 규칙: base-url 은 <b>온프레미스(중앙 guardia-rag) 전용</b>이다.
|
||||
* 외부 엔드포인트를 가리키도록 설정해서는 안 된다. 기본값은 서버 내부 루프백이다.
|
||||
*
|
||||
* <p>application.yml:
|
||||
* <pre>
|
||||
* guardia:
|
||||
* rag:
|
||||
* base-url: http://127.0.0.1:8020
|
||||
* timeout-ms: 120000
|
||||
* enabled: true
|
||||
* solution: mall
|
||||
* </pre>
|
||||
*/
|
||||
@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";
|
||||
}
|
||||
134
backend/src/main/java/com/zioinfo/mall/rag/RagToggleService.java
Normal file
134
backend/src/main/java/com/zioinfo/mall/rag/RagToggleService.java
Normal file
@ -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 기법/신뢰도 토글 서비스.
|
||||
*
|
||||
* <p>기존 {@code mall_setting}(key-value) 테이블을 재사용해 RAG 토글을 격리 저장한다(신규 테이블
|
||||
* 신설 없음, admin 설정 화면과 자연 통합). 키 네임스페이스 {@code rag.*} 로 다른 설정과 충돌 회피.
|
||||
*
|
||||
* <p>무거운 기법(graphrag·rerank·tool_use/agent·stream)은 서버 RAM 제약상 <b>기본 off</b>.
|
||||
* 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<String, String> m = new LinkedHashMap<>();
|
||||
List<MallSetting> 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<String, Object> toMap() {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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() {
|
||||
<Route path="users" element={<UserManagement />} />
|
||||
<Route path="audit" element={<AuditLog />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="ai-techniques" element={<AiTechniques />} />
|
||||
<Route path="app" element={<AdminApp />} />
|
||||
{/* UIWS 이식 업무 모듈 (관리자 영역 병합) */}
|
||||
<Route path="uiws/worklog" element={<UiwsLayout><UiwsWorklog /></UiwsLayout>} />
|
||||
|
||||
@ -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() {
|
||||
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">{t('admin.system')}</div>
|
||||
{visibleAdmin.map(({ to, key, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{t(`admin.nav.${key}`)}</NavLink>)}
|
||||
</>)}
|
||||
{visibleAi.length > 0 && (<>
|
||||
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">AI</div>
|
||||
{visibleAi.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
|
||||
</>)}
|
||||
{visibleUiws.length > 0 && (<>
|
||||
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">업무 (UIWS)</div>
|
||||
{visibleUiws.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
|
||||
|
||||
139
frontend/src/admin/AiTechniques.tsx
Normal file
139
frontend/src/admin/AiTechniques.tsx
Normal file
@ -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<RagToggles | null>(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 <div className="text-slate-400 text-sm">Loading…</div>
|
||||
|
||||
const Switch = ({ on, k, disabled }: { on: boolean; k: string; disabled?: boolean }) => (
|
||||
<button
|
||||
disabled={!canEdit || busy === k || disabled}
|
||||
onClick={() => set(k, !on)}
|
||||
className={`relative w-11 h-6 rounded-full transition ${on ? 'bg-brand' : 'bg-edge'} ${(!canEdit || disabled) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 ${on ? 'left-6' : 'left-0.5'} w-5 h-5 rounded-full bg-white transition`} />
|
||||
</button>
|
||||
)
|
||||
|
||||
const Row = ({ icon: Icon, title, desc, children }: any) => (
|
||||
<div className="flex items-start justify-between gap-4 py-3 border-b border-edge last:border-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon size={16} className="text-brand mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h1 className="text-xl font-bold flex items-center gap-2"><Sparkles className="text-brand" size={20} /> AI Techniques</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full bg-card border border-edge ${t.ragAvailable ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
<Cpu size={13} /> central guardia-rag {t.ragAvailable ? 'available' : 'unavailable (Mall fallback)'}
|
||||
</span>
|
||||
<button onClick={load} className="p-2 rounded-lg bg-card border border-edge text-slate-300"><RefreshCw size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="mb-3 text-xs text-rose-400 bg-rose-950/30 border border-rose-900/50 rounded-lg px-3 py-2">{err}</div>}
|
||||
{!canEdit && <div className="mb-3 text-xs text-slate-400">Read-only — changes require MANAGER role or above.</div>}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 마스터 + 검색 기법 — 상품 추천·자연어 검색 경로에 적용 */}
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold mb-2">Retrieval & Generation</h3>
|
||||
<Row icon={ShieldCheck} title="Use RAG layer" desc="off → all AI falls back to existing Ollama/Java path">
|
||||
<Switch on={t.ragEnabled} k="rag.enabled" />
|
||||
</Row>
|
||||
<Row icon={Search} title="Hybrid search (retrieval_mode)" desc="vector → hybrid (BM25+vector) for recommend/search">
|
||||
<select disabled={!canEdit || busy === 'rag.retrieval_mode'} value={t.retrievalMode}
|
||||
onChange={e => set('rag.retrieval_mode', e.target.value)}
|
||||
className="bg-ink border border-edge rounded-lg text-xs px-2 py-1.5 outline-none">
|
||||
<option value="vector">vector</option>
|
||||
<option value="hybrid">hybrid</option>
|
||||
<option value="graph">graph</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row icon={Network} title="GraphRAG" desc="knowledge-graph search. Heavy → off by default (on ⇒ mode=graph)">
|
||||
<Switch on={t.graphrag} k="rag.graphrag" />
|
||||
</Row>
|
||||
<Row icon={ListTree} title="Rerank" desc="cross-encoder reorder. Heavy → off by default">
|
||||
<Switch on={t.rerank} k="rag.rerank" />
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 에이전틱·구조화·스트리밍 — 수요예측/재고이양·결정론 결과·응답 스트리밍 */}
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold mb-2">Agentic · Structured · Streaming</h3>
|
||||
<Row icon={Braces} title="Structured output" desc="recommend/decision results as /structured deterministic JSON. Default on">
|
||||
<Switch on={t.structured} k="rag.structured" />
|
||||
</Row>
|
||||
<Row icon={Wrench} title="Agentic tool-use (/agent)" desc="demand forecast / stock transfer via tool calls. Heavy → off by default">
|
||||
<Switch on={t.toolUse} k="rag.tool_use" />
|
||||
</Row>
|
||||
<Row icon={Radio} title="Token streaming (SSE)" desc="stream long answers (CS reply / card message). Default off">
|
||||
<Switch on={t.stream} k="rag.stream" />
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 파라미터 */}
|
||||
<div className="bg-card border border-edge rounded-xl p-5 lg:col-span-2">
|
||||
<h3 className="text-sm font-semibold mb-3">Parameters</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Num label="top_k (1–20)" value={t.topK} min={1} max={20} step={1} onSave={v => set('rag.top_k', v)} canEdit={canEdit} />
|
||||
<Num label="agent step cap (1–8)" value={t.agentMaxSteps} min={1} max={8} step={1} onSave={v => set('rag.agent_max_steps', v)} canEdit={canEdit} />
|
||||
<Num label="faithfulness threshold (0–1)" value={t.faithfulnessThreshold} min={0} max={1} step={0.05} onSave={v => set('rag.faithfulness_threshold', v)} canEdit={canEdit} />
|
||||
<Num label="temperature (0–1)" value={t.temperature} min={0} max={1} step={0.05} onSave={v => set('rag.temperature', v)} canEdit={canEdit} />
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-slate-400">
|
||||
Generation model: <span className="text-slate-200 font-mono">{t.generationModel}</span> · small model by default (server RAM). Heavy techniques are guarded by concurrency limits and fallback.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">{label}</label>
|
||||
<input type="number" min={min} max={max} step={step} value={v} disabled={!canEdit}
|
||||
onChange={e => 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" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<RagToggles>(api.get('/api/mall/rag/toggles'))
|
||||
export const updateRagToggle = (key: string, value: string | number | boolean) =>
|
||||
u<RagToggles>(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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user