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/cms/rag/RagCmsService.java b/backend/src/main/java/com/zioinfo/cms/rag/RagCmsService.java
new file mode 100644
index 0000000..f4e6c84
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/rag/RagCmsService.java
@@ -0,0 +1,257 @@
+package com.zioinfo.cms.rag;
+
+import com.zioinfo.cms.ai.AiService;
+import com.zioinfo.cms.rag.RagToggleService.RagToggles;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * CMS 대표 AI 기능 RAG 배선 레이어(순증·비파괴, 별개 레이어).
+ *
+ * 기존 {@link AiService}(Ollama + Java 폴백)는 불변 . 본 서비스는 중앙 guardia-rag 를
+ * 경유하는 별개 레이어로, 두 대표 기능을 전환한다:
+ *
+ * contentDraft → {@code /rag/answer}(hybrid 근거+인용) 또는 tool_use 토글 시
+ * {@code /rag/agent}(다단계 콘텐츠 작업). 근거 부족 시 보류 표기.
+ * 토글 off/미가용 시 기존 {@link AiService#draft} 로 무손실 폴백(degraded:true).
+ * moderate → {@code /rag/structured}(format:json 결정론) 판정. 애매(저신뢰) 시
+ * 자동 통과 금지 → REVIEW(hold). 토글 off/미가용 시 기존 {@link AiService#moderate}
+ * (룰 기반) 폴백(degraded:true).
+ *
+ *
+ * 모든 응답에 적용 metadata(retrievalMode·rerank·graphrag·toolUse·structured·degraded)를
+ * 표기해 토글 effect 가 관측 가능하게 한다. 보안 불변: 외부 API 0, PII/자격증명/스택트레이스 미노출.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RagCmsService {
+
+ private final RagClient rag;
+ private final RagToggleService toggles;
+ private final AiService aiService; // 기존 Ollama+Java 폴백 재사용(불변)
+
+ /** UGC 모더레이션 결정론 JSON 스키마(중앙 /structured 강제). */
+ private static final Map MODERATION_SCHEMA = Map.of(
+ "type", "object",
+ "properties", Map.of(
+ "verdict", Map.of("type", "string", "enum", List.of("APPROVE", "REJECT", "REVIEW")),
+ "categories", Map.of("type", "array", "items", Map.of("type", "string")),
+ "score", Map.of("type", "number"),
+ "reasons", Map.of("type", "array", "items", Map.of("type", "string")),
+ "pii_flag", Map.of("type", "boolean")
+ ),
+ "required", List.of("verdict", "score")
+ );
+
+ /** CMS 콘텐츠 도구 — /agent 토글 on 시 중앙 도구 레지스트리에서 매칭. */
+ private static final List CMS_TOOLS = List.of(
+ "cms_content_search", // 기존 페이지/포스트/블록 조회
+ "cms_category_tree", // 카테고리 트리 lookup
+ "cms_media_lookup", // 미디어 자산 조회(alt/태그)
+ "cms_glossary" // 용어집(번역 일관성)
+ );
+
+ /**
+ * AI 콘텐츠 초안 (중앙 /answer hybrid 또는 /agent 경유, 근거+인용, 근거 부족 시 보류).
+ * 미가용/off → 기존 AiService.draft 폴백.
+ */
+ @SuppressWarnings("unchecked")
+ public Map contentDraft(Map req, String actor) {
+ String kind = str(req.get("kind"));
+ String topic = str(req.get("topic"));
+ String tone = str(req.get("tone"));
+ RagToggles t = toggles.current();
+ Map out = new LinkedHashMap<>();
+
+ if (!t.ragEnabled || !rag.available()) {
+ String legacy = aiService.draft(kind, topic, tone.isBlank() ? null : tone);
+ out.put("draft", legacy);
+ out.put("degraded", true);
+ out.put("degraded_reason", t.ragEnabled ? "rag_unavailable" : "rag_disabled");
+ out.put("source", "cms_local_fallback");
+ out.putAll(meta(t, "answer", false));
+ return out;
+ }
+
+ String query = String.format(
+ "GUARDiA CMS 콘텐츠 초안을 작성하라. 유형: %s, 주제: %s, 톤: %s. "
+ + "기존 사이트 콘텐츠·용어집·브랜드 톤을 근거로 일관되게 한국어 마케팅 초안을 작성하라. "
+ + "근거가 부족하면 추측하지 말고 축소하라.",
+ kind.isBlank() ? "콘텐츠" : kind, topic.isBlank() ? "신규" : topic,
+ tone.isBlank() ? "친근함" : tone);
+
+ Map res;
+ boolean usedAgent = false;
+ if (t.toolUse) {
+ // 에이전틱 다단계(옵트인): CMS 도구 등록 → /agent
+ res = rag.agent(query, CMS_TOOLS, t.stepLimit, t.generationModel, toggles.effectiveMode(t));
+ usedAgent = true;
+ } else {
+ res = rag.answer(query, toggles.effectiveMode(t), t.rerank, t.topK, t.generationModel, true);
+ }
+
+ boolean degraded = bool(res.get("degraded"));
+ Object answer = res.get("answer");
+ if (degraded || answer == null) {
+ String legacy = aiService.draft(kind, topic, tone.isBlank() ? null : tone);
+ out.put("draft", legacy);
+ out.put("degraded", true);
+ out.put("degraded_reason", str(res.getOrDefault("degraded_reason",
+ usedAgent ? "agent_empty" : "answer_empty")));
+ out.put("source", "cms_local_fallback");
+ out.putAll(meta(t, usedAgent ? "agent" : "answer", false));
+ return out;
+ }
+
+ // 정상 경유: 초안은 항상 draft 상태(published 직행 금지 — 사람 승인)
+ out.put("draft", answer);
+ out.put("status", "draft");
+ out.put("grounded", res.get("grounded"));
+ out.put("faithfulness", res.get("faithfulness"));
+ out.put("citations", res.get("citations"));
+ out.put("sources", res.get("sources"));
+ out.put("guardrail", res.get("guardrail"));
+ out.put("abstained", res.getOrDefault("abstained", Boolean.FALSE.equals(res.get("grounded"))));
+ if (usedAgent) {
+ out.put("steps", res.get("steps"));
+ out.put("toolsUsed", res.get("tools_used"));
+ }
+ out.put("answerId", res.get("answer_id")); // /feedback 연계
+ out.put("engine", usedAgent ? "RAG_AGENT" : "RAG_ANSWER");
+ out.put("degraded", false);
+ out.putAll(meta(t, usedAgent ? "agent" : "answer", false));
+ return out;
+ }
+
+ /**
+ * UGC 모더레이션 (중앙 /structured 결정론 JSON, 애매 시 hold=REVIEW).
+ * 미가용/off → 기존 AiService.moderate(룰 기반) 폴백.
+ */
+ @SuppressWarnings("unchecked")
+ public Map moderate(Map req, String actor) {
+ String text = str(req.get("text"));
+ RagToggles t = toggles.current();
+ Map out = new LinkedHashMap<>();
+
+ if (text.isBlank()) {
+ out.put("verdict", "APPROVE");
+ out.put("score", 0.0);
+ out.put("reason", "empty");
+ out.put("degraded", false);
+ out.putAll(meta(t, "structured", true));
+ return out;
+ }
+
+ if (!t.ragEnabled || !rag.available()) {
+ return moderateFallback(text, t, t.ragEnabled ? "rag_unavailable" : "rag_disabled");
+ }
+
+ String prompt =
+ "다음 UGC(리뷰/댓글/문의)가 유해/스팸/욕설/금칙어/정책위반인지 판정하라. "
+ + "개인정보(전화/이메일/주소/카드)가 섞였으면 pii_flag=true. "
+ + "확신이 낮으면 자동 통과시키지 말고 verdict=REVIEW(보류). "
+ + "JSON 스키마(verdict, categories, score, reasons, pii_flag)로만 응답:\n" + truncate(text, 800);
+
+ Map sres = rag.structured(prompt, MODERATION_SCHEMA, t.generationModel, t.temperature);
+ boolean degraded = bool(sres.get("degraded"));
+ Map data = sres.get("data") instanceof Map ? (Map) sres.get("data") : null;
+
+ if (degraded || data == null || data.get("verdict") == null) {
+ // 스키마 위반/파싱 실패/미가용 → 룰 기반 폴백 + 안전측(REVIEW 강제 가능)
+ return moderateFallback(text, t, str(sres.getOrDefault("degraded_reason", "structured_empty")));
+ }
+
+ String verdict = normVerdict(str(data.get("verdict")));
+ double score = num(data.get("score"), 0.5);
+ // 안전측 게이트: 저신뢰(애매)면 자동 APPROVE/REJECT 금지 → REVIEW
+ if ("APPROVE".equals(verdict) && score >= t.faithfulnessThreshold) {
+ verdict = "REVIEW";
+ }
+
+ out.put("verdict", verdict);
+ out.put("categories", data.getOrDefault("categories", List.of()));
+ out.put("score", score);
+ out.put("reasons", data.getOrDefault("reasons", List.of()));
+ out.put("piiFlag", bool(data.get("pii_flag")));
+ out.put("engine", "RAG_STRUCTURED");
+ out.put("degraded", false);
+ out.putAll(meta(t, "structured", true));
+ return out;
+ }
+
+ /** 모더레이션 폴백 — 기존 룰 기반 AiService.moderate + 안전측 표기. */
+ private Map moderateFallback(String text, RagToggles t, String reason) {
+ Map legacy = aiService.moderate(text);
+ Map out = new LinkedHashMap<>();
+ // AiService.moderate → {decision, risk, reason, source}
+ String decision = normVerdict(str(legacy.getOrDefault("decision", "REVIEW")));
+ out.put("verdict", decision);
+ out.put("score", num(legacy.get("risk"), 0.5));
+ out.put("reasons", List.of(str(legacy.getOrDefault("reason", "rule_based"))));
+ out.put("piiFlag", false);
+ out.put("degraded", true);
+ out.put("degraded_reason", reason);
+ out.put("source", "cms_local_fallback");
+ out.putAll(meta(t, "structured", false));
+ return out;
+ }
+
+ /** 👍/👎 피드백 → 중앙 /rag/feedback (solution=cms 격리). */
+ 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;
+ }
+
+ // ── 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("technique", technique);
+ m.put("applied", applied);
+ return m;
+ }
+
+ private String normVerdict(String v) {
+ String s = v == null ? "" : v.trim().toUpperCase();
+ if (s.contains("REJECT")) return "REJECT";
+ if (s.contains("REVIEW")) return "REVIEW";
+ if (s.contains("APPROVE")) return "APPROVE";
+ return "REVIEW"; // 알 수 없으면 안전측 보류
+ }
+
+ private boolean bool(Object o) { return Boolean.TRUE.equals(o) || "true".equalsIgnoreCase(String.valueOf(o)); }
+ private double num(Object o, double def) {
+ if (o instanceof Number n) return n.doubleValue();
+ try { return Double.parseDouble(String.valueOf(o)); } catch (Exception e) { return def; }
+ }
+ private String str(Object o) { return o == null ? "" : String.valueOf(o); }
+ private String str0(Object o) { return o == null ? null : String.valueOf(o); }
+ private String truncate(String s, int max) {
+ if (s == null) return "";
+ return s.length() <= max ? s : s.substring(0, max);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/cms/rag/RagController.java b/backend/src/main/java/com/zioinfo/cms/rag/RagController.java
new file mode 100644
index 0000000..a7c58ef
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/rag/RagController.java
@@ -0,0 +1,67 @@
+package com.zioinfo.cms.rag;
+
+import com.zioinfo.cms.admin.SettingService;
+import com.zioinfo.cms.common.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * CMS 최신 AI 기법(중앙 guardia-rag) 배선 API — 별개 레이어.
+ *
+ *
+ * POST /api/cms/rag/content-draft — 콘텐츠 초안(/answer hybrid 또는 /agent, 근거+인용+보류)
+ * POST /api/cms/rag/moderate — UGC 모더레이션(/structured 결정론 JSON, 애매 시 보류)
+ * POST /api/cms/rag/feedback — 👍/👎 피드백(/rag/feedback, solution=cms 격리)
+ * GET /api/cms/rag/toggles — 현재 기법/신뢰도 토글 스냅샷
+ * PUT /api/cms/rag/toggles/{key} — 토글 변경(rag.* 키, cms_setting 격리 저장 + 감사로그)
+ *
+ *
+ * 기존 {@code /api/cms/ai/*} 는 불변. 본 컨트롤러는 중앙 경유 신규 경로만 추가한다.
+ * RBAC 은 SecurityConfig 통제(GET=Viewer+, POST/PUT=Author+).
+ */
+@RestController
+@RequestMapping("/api/cms/rag")
+@RequiredArgsConstructor
+public class RagController {
+
+ private final RagCmsService service;
+ private final SettingService settingService;
+
+ @PostMapping("/content-draft")
+ public ApiResponse> contentDraft(@RequestBody Map req, Authentication auth) {
+ return ApiResponse.ok(service.contentDraft(req, auth == null ? null : auth.getName()));
+ }
+
+ @PostMapping("/moderate")
+ public ApiResponse> moderate(@RequestBody Map req, Authentication auth) {
+ return ApiResponse.ok(service.moderate(req, auth == null ? null : auth.getName()));
+ }
+
+ @PostMapping("/feedback")
+ public ApiResponse> feedback(@RequestBody Map req, Authentication auth) {
+ return ApiResponse.ok(service.feedback(req, auth == null ? null : auth.getName()));
+ }
+
+ @GetMapping("/toggles")
+ public ApiResponse> toggles() {
+ return ApiResponse.ok(service.currentToggles());
+ }
+
+ /**
+ * 토글 변경 — 기존 cms_setting upsert(감사로그 자동 기록) 재사용.
+ * 허용 키는 {@code rag.*} 네임스페이스로 한정.
+ */
+ @PutMapping("/toggles/{key}")
+ 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/cms/rag/RagProperties.java b/backend/src/main/java/com/zioinfo/cms/rag/RagProperties.java
new file mode 100644
index 0000000..d5d0c26
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/rag/RagProperties.java
@@ -0,0 +1,41 @@
+package com.zioinfo.cms.rag;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 중앙 guardia-rag 연동 설정 (CMS 전용).
+ *
+ * 보안 불변 규칙: base-url 은 온프레미스(중앙 guardia-rag) 전용 이다.
+ * 외부 엔드포인트를 가리키도록 설정해서는 안 된다. 기본값은 서버 내부 루프백이다.
+ *
+ *
application.yml:
+ *
+ * guardia:
+ * rag:
+ * base-url: http://127.0.0.1:8020
+ * timeout-ms: 120000
+ * enabled: true
+ * solution: cms
+ *
+ */
+@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 가 항상 미가용으로 동작(CMS 로컬 폴백). */
+ private boolean enabled = true;
+
+ /** 솔루션 식별자(컬렉션·토글·피드백 격리 키). */
+ private String solution = "cms";
+}
diff --git a/backend/src/main/java/com/zioinfo/cms/rag/RagToggleService.java b/backend/src/main/java/com/zioinfo/cms/rag/RagToggleService.java
new file mode 100644
index 0000000..a2d77c3
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/cms/rag/RagToggleService.java
@@ -0,0 +1,131 @@
+package com.zioinfo.cms.rag;
+
+import com.zioinfo.cms.admin.dto.CmsSetting;
+import com.zioinfo.cms.admin.mapper.SettingMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * CMS RAG 기법/신뢰도 토글 서비스.
+ *
+ * 기존 {@code cms_setting}(key-value) 테이블을 재사용해 RAG 토글을 격리 저장한다(신규 테이블
+ * 신설 없음, admin 설정 화면과 자연 통합). 키 네임스페이스 {@code rag.*} 로 다른 설정과 충돌 회피.
+ *
+ *
무거운 기법(graphrag·rerank·tool_use)은 서버 RAM 제약상 기본 off . retrieval_mode
+ * 기본 vector. stream 도 기본 off(설정 on 시 SSE).
+ */
+@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 (에이전틱→기본 off, 옵트인)
+ 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_STEP_LIMIT = "rag.step_limit"; // /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 (CmsSetting 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.stream = bool(m.get(K_STREAM), false); // SSE→off 기본
+ t.topK = clampInt(m.get(K_TOP_K), 5, 1, 20);
+ t.stepLimit = clampInt(m.get(K_STEP_LIMIT), 4, 1, 8);
+ t.faithfulnessThreshold = clampDouble(m.get(K_FAITHFULNESS), 0.5, 0.0, 1.0);
+ t.temperature = clampDouble(m.get(K_TEMPERATURE), 0.1, 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;
+ String s = v.trim();
+ return "true".equalsIgnoreCase(s) || "1".equals(s) || "on".equalsIgnoreCase(s);
+ }
+
+ 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 stream = false;
+ public int topK = 5;
+ public int stepLimit = 4;
+ public double faithfulnessThreshold = 0.5;
+ public double temperature = 0.1;
+ 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("stream", stream);
+ m.put("topK", topK);
+ m.put("stepLimit", stepLimit);
+ 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 ba3220f..5a0ec5c 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -44,6 +44,13 @@ guardia:
ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream}
+ # 중앙 guardia-rag 연동 (최신 AI 기법 배선 — 별개 레이어, 미가용 시 CMS 로컬 폴백).
+ # 보안 불변: 온프레미스(서버 내부 루프백) 전용. 외부 엔드포인트 지정 금지.
+ rag:
+ base-url: ${RAG_URL:http://127.0.0.1:8020}
+ timeout-ms: ${RAG_TIMEOUT_MS:120000}
+ enabled: ${RAG_ENABLED:true}
+ solution: cms
crypto:
secret: ${CMS_CRYPTO_SECRET:guardia-cms-aes-256-gcm-master-key-2026-zioinfo}
jwt:
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a28c289..19cbb32 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -19,6 +19,7 @@ import Channels from './pages/Channels'
import Members from './pages/Members'
import Analytics from './pages/Analytics'
import AiTools from './pages/AiTools'
+import AiTechniques from './pages/AiTechniques'
import UserManagement from './pages/UserManagement'
import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings'
@@ -56,6 +57,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 8d3dd0c..7903f64 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -188,6 +188,16 @@ export const aiReviewSummary = (reviews: string[]) => unwrap(api.post('/api/cms/
export const aiSearchKeywords = (query: string) => unwrap(api.post('/api/cms/ai/search-keywords', { query }))
export const aiModerate = (text: string) => unwrap(api.post('/api/cms/ai/moderate', { text }))
+// ── RAG (중앙 guardia-rag 최신 AI 기법 배선 — 별개 레이어, 폴백 degraded) ──
+export const getRagToggles = () => unwrap(api.get('/api/cms/rag/toggles'))
+export const updateRagToggle = (key: string, value: string) =>
+ unwrap(api.put(`/api/cms/rag/toggles/${encodeURIComponent(key)}`, { value }))
+export const ragContentDraft = (kind: string, topic: string, tone: string) =>
+ unwrap(api.post('/api/cms/rag/content-draft', { kind, topic, tone }))
+export const ragModerate = (text: string) => unwrap(api.post('/api/cms/rag/moderate', { text }))
+export const ragFeedback = (d: { answerId?: string; query?: string; answer?: string; verdict: 'up' | 'down'; correction?: string }) =>
+ unwrap(api.post('/api/cms/rag/feedback', d))
+
// ── Integration ───────────────────────────────────────────────────────
export const getIntegrationStatus = () => unwrap(api.get('/api/cms/integration/status'))
export const getMallProducts = () => unwrap(api.get('/api/cms/integration/mall/products'))
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index c5bb579..b147663 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -4,7 +4,7 @@ import {
LayoutDashboard, FileText, Image, Package, Megaphone, ListTree, Palette,
Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound,
Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack,
- BookOpen, CalendarDays, Mail, Smartphone,
+ BookOpen, CalendarDays, Mail, Smartphone, Cpu,
} from 'lucide-react'
import { currentRole, hasRole } from './rbac'
import ThemeToggle from './uiws/ThemeToggle'
@@ -31,6 +31,7 @@ const engageLinks: Link[] = [
{ to: '/members', label: '회원', icon: UsersRound },
{ to: '/analytics', label: '콘텐츠 분석', icon: BarChart3 },
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
+ { to: '/ai-techniques', label: 'AI 기법 (RAG)', icon: Cpu },
]
const adminLinks: Link[] = [
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
diff --git a/frontend/src/pages/AiTechniques.tsx b/frontend/src/pages/AiTechniques.tsx
new file mode 100644
index 0000000..3b89496
--- /dev/null
+++ b/frontend/src/pages/AiTechniques.tsx
@@ -0,0 +1,188 @@
+import { useEffect, useState } from 'react'
+import { Cpu, Save, Play, Sparkles, ShieldCheck } from 'lucide-react'
+import { getRagToggles, updateRagToggle, ragContentDraft, ragModerate } from '../api/client'
+import { hasRole, currentRole } from '../components/rbac'
+
+/**
+ * CMS 최신 AI 기법(중앙 guardia-rag) 토글 설정 + 라이브 적용 테스트.
+ *
+ * - 토글: hybrid/graphrag(검색)·rerank·tool_use(에이전틱)·structured(결정론)·stream + 파라미터(top_k·step 상한·신뢰도·온도·모델)
+ * - 무거운 기법(graphrag·rerank·tool_use)은 기본 off (서버 RAM 제약)
+ * - 적용 metadata(applied)·degraded 를 응답에 표기 → 토글 effect 관측
+ * - 외부 API 0 (중앙 guardia-rag = 온프레미스). 미가용 시 CMS 로컬 폴백(degraded:true)
+ */
+interface Toggles {
+ ragEnabled: boolean
+ retrievalMode: string
+ rerank: boolean
+ graphrag: boolean
+ toolUse: boolean
+ stream: boolean
+ topK: number
+ stepLimit: number
+ faithfulnessThreshold: number
+ temperature: number
+ generationModel: string
+ ragAvailable?: boolean
+}
+
+const BOOL_KEYS: { key: string; label: string; hint: string; heavy?: boolean }[] = [
+ { key: 'rag.enabled', label: 'RAG 경유 (마스터)', hint: 'off 시 모든 AI 호출이 CMS 로컬 폴백' },
+ { key: 'rag.rerank', label: '리랭킹 (rerank)', hint: 'cross-encoder 재정렬 — 정확도↑·RAM↑', heavy: true },
+ { key: 'rag.graphrag', label: 'GraphRAG (graph)', hint: '지식그래프 검색 — 관계 추론·RAM↑', heavy: true },
+ { key: 'rag.tool_use', label: '에이전틱 tool-use', hint: '다단계 콘텐츠 작업(/agent) — 옵트인·RAM↑', heavy: true },
+ { key: 'rag.stream', label: 'SSE 스트리밍', hint: '긴 초안 토큰 스트리밍' },
+]
+
+export default function AiTechniques() {
+ const [t, setT] = useState(null)
+ const [msg, setMsg] = useState('')
+ const [err, setErr] = useState('')
+ const [draftRes, setDraftRes] = useState(null)
+ const [modRes, setModRes] = useState(null)
+ const [topic, setTopic] = useState('봄맞이 신상 화분')
+ const [ugc, setUgc] = useState('이 상품 정말 최고예요! 강추합니다')
+ const [busy, setBusy] = useState('')
+ const editable = hasRole('EDITOR', currentRole())
+
+ const load = () =>
+ getRagToggles()
+ .then((d: Toggles) => { setT(d); setErr('') })
+ .catch(() => setErr('AI 기법 토글을 불러오지 못했습니다.'))
+ useEffect(() => { load() }, [])
+
+ const save = async (key: string, value: string) => {
+ setMsg(''); setErr('')
+ try { const d = await updateRagToggle(key, value); setT(d); setMsg(`'${key}' 저장됨`) }
+ catch (e: any) { setErr(e?.response?.status === 403 ? '권한이 없습니다 (Editor+).' : '저장 실패') }
+ }
+
+ const runDraft = async () => {
+ setBusy('draft'); setDraftRes(null)
+ try { setDraftRes(await ragContentDraft('product', topic, '친근함')) }
+ catch { setDraftRes({ error: '호출 실패' }) } finally { setBusy('') }
+ }
+ const runMod = async () => {
+ setBusy('mod'); setModRes(null)
+ try { setModRes(await ragModerate(ugc)) }
+ catch { setModRes({ error: '호출 실패' }) } finally { setBusy('') }
+ }
+
+ if (!t) return {err || '불러오는 중...'}
+
+ const Badge = ({ on, label }: { on: boolean; label: string }) => (
+ {label}
+ )
+
+ return (
+
+
+
AI 기법 (중앙 guardia-rag)
+
+ 중앙 RAG {t.ragAvailable ? '가용' : '미가용 (로컬 폴백)'}
+
+
+
+ {msg &&
{msg}
}
+ {err &&
{err}
}
+ {!editable &&
조회 전용입니다. 토글 변경은 Editor+ 권한이 필요합니다.
}
+
+
+ {/* 토글 설정 */}
+
+
기법 토글
+
+ {/* retrieval_mode */}
+
+
검색 모드 (retrieval_mode)
vector(경량 기본) · hybrid(BM25+벡터) · graph
+
save('rag.retrieval_mode', e.target.value)}>
+ vector hybrid graph
+
+
+
+ {BOOL_KEYS.map(b => {
+ const cur = (t as any)[b.key.replace('rag.', '').replace(/_(\w)/g, (_, c) => c.toUpperCase())] as boolean
+ return (
+
+
+
{b.label}{b.heavy && RAM↑ 기본 off }
+
{b.hint}
+
+
save(b.key, String(!cur))}
+ className={`w-11 h-6 rounded-full transition-colors relative ${cur ? 'bg-brand' : 'bg-slate-600'} ${!editable ? 'opacity-50' : ''}`}>
+
+
+
+ )
+ })}
+
+ {/* 숫자 파라미터 */}
+ {[
+ { key: 'rag.top_k', label: 'top_k (검색 결과 수)', val: t.topK, min: 1, max: 20 },
+ { key: 'rag.step_limit', label: 'step 상한 (/agent)', val: t.stepLimit, min: 1, max: 8 },
+ { key: 'rag.faithfulness_threshold', label: '신뢰도 임계값', val: t.faithfulnessThreshold, min: 0, max: 1, step: 0.05 },
+ { key: 'rag.temperature', label: 'temperature', val: t.temperature, min: 0, max: 1, step: 0.05 },
+ ].map(p => (
+
+
{p.label}
+
save(p.key, e.target.value)} />
+
+ ))}
+
+
생성 모델
+
save('rag.generation_model', e.target.value)} />
+
+
설정은 cms_setting(rag.*)에 격리 저장 · 변경 시 감사로그 기록 · 무거운 기법 기본 off
+
+
+ {/* 라이브 적용 테스트 */}
+
+
+
콘텐츠 초안 (/rag/answer · /agent)
+
setTopic(e.target.value)} placeholder="주제" />
+
+ {busy === 'draft' ? '생성 중...' : '초안 생성'}
+
+ {draftRes && (
+
+
{String(draftRes.draft ?? draftRes.error ?? '')}
+
+
+ {draftRes.applied && <>
+
+
+
+
+ >}
+ {draftRes.abstained && }
+
+
+ )}
+
+
+
+
UGC 모더레이션 (/rag/structured)
+
+
+
+
+ )
+}
diff --git a/scripts/rag_ingest_cms.py b/scripts/rag_ingest_cms.py
new file mode 100644
index 0000000..b6c3944
--- /dev/null
+++ b/scripts/rag_ingest_cms.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+"""
+GUARDiA CMS — 중앙 guardia-rag 색인 훅 (브랜드 톤·용어집·콘텐츠 가이드 → cms 컬렉션).
+
+콘텐츠 초안 생성(/answer hybrid·/agent)의 근거 회수와 용어 일관성을 위해, CMS 의 **브랜드 톤·
+용어집·콘텐츠 유형 가이드·SEO/모더레이션 정책** 메타만 중앙 RAG 의 `cms` 컬렉션에 적재한다.
+회원 PII·자격증명·실데이터 행은 색인하지 않는다(보안 불변).
+
+선택: 라이브 published 콘텐츠 본문도 색인하려면 --with-content 로 공개 delivery API(GET, 인증불요)
+에서 published 만 가져와 색인한다(초안/비공개 제외, PII 필드 제외).
+
+사용:
+ python scripts/rag_ingest_cms.py # 기본 메타 docs (http://127.0.0.1:8020)
+ RAG_URL=http://127.0.0.1:8020 python scripts/rag_ingest_cms.py
+ CMS_URL=http://127.0.0.1:8012 python scripts/rag_ingest_cms.py --with-content
+
+설계 메모:
+- 외부 API 0. 중앙 guardia-rag(온프레미스)만 호출.
+- 멱등: doc_id 안정 → 재실행 시 upsert(중앙이 처리). 실패해도 CMS 동작에 영향 없음(보조 스크립트).
+- PII 미색인: 회원 email/phone/주소·password_enc·작성자 식별정보는 문서에 포함하지 않는다.
+"""
+import json
+import os
+import sys
+import urllib.request
+import urllib.error
+
+RAG_URL = os.environ.get("RAG_URL", "http://127.0.0.1:8020").rstrip("/")
+CMS_URL = os.environ.get("CMS_URL", "http://127.0.0.1:8012").rstrip("/")
+SOLUTION = "cms"
+
+# 브랜드 톤·용어집·콘텐츠 정책 메타 — 콘텐츠 초안 grounding·용어 일관성용.
+DOCS = [
+ {
+ "doc_id": "cms_brand_tone",
+ "source": "CMS-BRAND",
+ "text": (
+ "GUARDiA CMS 브랜드 톤 가이드: 친근하지만 신뢰감 있는 한국어. 과장·허위광고 금지, "
+ "구체적 가치(품질·배송·혜택)를 명료하게. 이모지는 절제. 마케팅 카피는 200자 내외 권장."
+ ),
+ },
+ {
+ "doc_id": "cms_content_types",
+ "source": "CMS-GUIDE",
+ "text": (
+ "콘텐츠 유형(content_type): PAGE(정적 페이지), POST(블로그/공지), BLOCK(재사용 블록), "
+ "PRODUCT(상품 상세). 게시 워크플로우: draft→review→approved→published. "
+ "AI 초안은 항상 draft 로만 적재하고 published 직행 금지(사람 승인)."
+ ),
+ },
+ {
+ "doc_id": "cms_glossary",
+ "source": "CMS-GLOSSARY",
+ "text": (
+ "도메인 용어집(번역·작성 시 보존): 헤드리스(headless)=콘텐츠와 표현 분리, "
+ "delivery API=published 콘텐츠 제공 API, UGC=사용자생성콘텐츠(리뷰/댓글/문의), "
+ "기획전=프로모션 캠페인. 직역 금지, 용어 일관 유지."
+ ),
+ },
+ {
+ "doc_id": "cms_seo_policy",
+ "source": "CMS-SEO",
+ "text": (
+ "SEO 가이드: meta title 60자·description 150자 내외, 키워드 3~8개, "
+ "본문 길이 300~1500자 가독성 우수. 중복 콘텐츠·키워드 스터핑 금지."
+ ),
+ },
+ {
+ "doc_id": "cms_moderation_policy",
+ "source": "CMS-MODERATION",
+ "text": (
+ "UGC 모더레이션 정책: 욕설/스팸(외부링크·도박·대출·주식리딩)/금칙어/개인정보 노출은 차단 또는 보류. "
+ "판정 verdict: APPROVE/REJECT/REVIEW. 확신이 낮으면 자동 통과 금지 → REVIEW(사람 검토). "
+ "개인정보(전화/이메일/주소/카드)는 pii_flag 로 표시하고 응답·색인에서 제외."
+ ),
+ },
+]
+
+
+def fetch_published_content():
+ """공개 delivery API에서 published 콘텐츠 본문 일부를 docs 로 변환(옵션). PII/비공개 제외."""
+ out = []
+ try:
+ req = urllib.request.Request(f"{CMS_URL}/api/cms/delivery/content?status=PUBLISHED",
+ headers={"Accept": "application/json"}, method="GET")
+ with urllib.request.urlopen(req, timeout=20) as resp:
+ payload = json.loads(resp.read().decode("utf-8"))
+ items = payload.get("data") if isinstance(payload, dict) else payload
+ if not isinstance(items, list):
+ return out
+ for it in items[:200]:
+ cid = it.get("id")
+ title = (it.get("title") or "").strip()
+ body = (it.get("body") or it.get("content") or "").strip()
+ ctype = it.get("contentType") or it.get("content_type") or "CONTENT"
+ if not title and not body:
+ continue
+ out.append({
+ "doc_id": f"cms_content_{cid}",
+ "source": f"CMS-{ctype}",
+ "text": f"[{ctype}] {title}\n{body[:2000]}",
+ })
+ except Exception:
+ # 보조 기능 — 라이브 미가용/형식 상이 시 조용히 스킵
+ pass
+ return out
+
+
+def ingest(docs):
+ payload = {"solution": SOLUTION, "documents": docs}
+ data = json.dumps(payload).encode("utf-8")
+ req = urllib.request.Request(
+ f"{RAG_URL}/rag/ingest",
+ data=data,
+ headers={"Content-Type": "application/json", "X-Solution-Key": SOLUTION},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=120) as resp:
+ body = resp.read().decode("utf-8")
+ print(f"[OK] ingested {len(docs)} docs into '{SOLUTION}' collection")
+ print(f" response: {body[:300]}")
+ return 0
+ except urllib.error.HTTPError as e:
+ print(f"[WARN] ingest HTTP {e.code} — 중앙 RAG 응답 이상(스크립트는 보조). 본 CMS 동작 영향 없음.")
+ return 1
+ except urllib.error.URLError as e:
+ print(f"[WARN] 중앙 guardia-rag 미가용({RAG_URL}) — 색인 보류. 라이브 가동 후 재실행. "
+ f"(사유 요약: {str(e.reason)[:80]})")
+ return 1
+ except Exception:
+ print("[WARN] 색인 중 예기치 못한 오류 — 보류(CMS 동작 영향 없음).")
+ return 1
+
+
+if __name__ == "__main__":
+ docs = list(DOCS)
+ if "--with-content" in sys.argv:
+ extra = fetch_published_content()
+ print(f"[INFO] published 콘텐츠 {len(extra)}건 추가 색인 대상")
+ docs += extra
+ sys.exit(ingest(docs))