기존 {@link MallAiService}(Ollama 우선 + Java 폴백)는 불변. 본 서비스는 중앙 guardia-rag 를
* 경유하는 별개 레이어로, 두 대표 기능을 전환한다:
*
*
상품 추천·자연어 상품검색 → {@code /rag/answer}(retrieval_mode=hybrid 근거) +
* {@code /rag/structured}(productId·score·reason 결정론 JSON). 매장 재고 ON/OFF·상태 필터 적용.
* 토글 off/미가용 시 기존 {@link MallAiService#recommend}/{@link MallAiService#nlSearch} 폴백(degraded:true).
*
수요예측·재고이양 추천 → {@code /rag/agent}(tool-use, 사람 승인 게이트, 제안만).
* tool_use 토글 off/미가용 시 기존 {@link MallAiService#demandForecast}/{@link MallAiService#transferRecommendations} 통계 폴백.
*
*
*
모든 응답에 적용 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