guardia-mall/backend/src/main/java/com/zioinfo/mall/rag/MallRagAiService.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); }
}