410 lines
21 KiB
Java
410 lines
21 KiB
Java
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;
|
|
}
|
|
|
|
// ── ①-b WISE AI 일반 지식 질의 (중앙 /answer 근거·인용·보류) ──────────────
|
|
/**
|
|
* WISE AI 일반 Q&A. 상품 프레이밍 없이 중앙 {@code /rag/answer}(rag_mall 컬렉션) 근거 답변을
|
|
* 그대로 전달한다. 근거 미달이면 {@code abstained:true}(환각 차단), 중앙 미가용이면
|
|
* {@code degraded:true}. 기존 recommend 로직·필드는 불변(순증 메서드).
|
|
*/
|
|
@SuppressWarnings("unchecked")
|
|
public Map<String, Object> ask(Map<String, Object> req, String actor) {
|
|
String query = str(req.get("query"));
|
|
if (query.isBlank()) query = str(req.get("q"));
|
|
RagToggles t = toggles.current();
|
|
Map<String, Object> out = new LinkedHashMap<>();
|
|
|
|
if (query.isBlank()) {
|
|
out.put("answer", null);
|
|
out.put("sources", List.of());
|
|
out.put("citations", List.of());
|
|
out.put("abstained", false);
|
|
out.put("degraded", true);
|
|
out.put("degraded_reason", "empty_query");
|
|
out.putAll(meta(t, "answer", false));
|
|
return out;
|
|
}
|
|
|
|
// RAG 미가용/비활성 → degraded (AI 서비스 일시 불가). 온프레미스 폴백 없음(일반 지식 검색).
|
|
if (!t.ragEnabled || !rag.available()) {
|
|
out.put("answer", null);
|
|
out.put("sources", List.of());
|
|
out.put("citations", List.of());
|
|
out.put("abstained", false);
|
|
out.put("degraded", true);
|
|
out.put("degraded_reason", t.ragEnabled ? "rag_unavailable" : "rag_disabled");
|
|
out.put("engine", "NONE");
|
|
out.putAll(meta(t, "answer", false));
|
|
return out;
|
|
}
|
|
|
|
String mode = toggles.effectiveMode(t);
|
|
Map<String, Object> ares = rag.answer(mask(query), mode, t.rerank, t.topK, t.generationModel, true);
|
|
if (bool(ares.get("degraded")) || ares.get("answer") == null) {
|
|
out.put("answer", null);
|
|
out.put("sources", List.of());
|
|
out.put("citations", List.of());
|
|
out.put("abstained", ares.getOrDefault("abstained", Boolean.FALSE));
|
|
out.put("degraded", true);
|
|
out.put("degraded_reason", str(ares.getOrDefault("degraded_reason", "answer_empty")));
|
|
out.put("engine", "RAG_ANSWER");
|
|
out.putAll(meta(t, "answer", false));
|
|
return out;
|
|
}
|
|
|
|
out.put("answer", 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"));
|
|
out.put("engine", "RAG_ANSWER");
|
|
out.put("degraded", false);
|
|
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); }
|
|
}
|