From f945769c9e4092220f9d367c9d9af084ed9992ec Mon Sep 17 00:00:00 2001 From: GUARDiA Date: Sun, 21 Jun 2026 11:38:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B5=9C=EC=8B=A0=20AI=20=EA=B8=B0?= =?UTF-8?q?=EB=B2=95=20=EC=A0=81=EC=9A=A9=20=E2=80=94=20=EC=A4=91=EC=95=99?= =?UTF-8?q?=20guardia-rag=20=EA=B2=BD=EC=9C=A0=20=EB=B0=B0=EC=84=A0(?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=EA=B2=80=EC=A6=9D=C2=B7=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=ED=99=94=EC=B6=9C=EB=A0=A5=C2=B7=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8B=B1)=20+=20=EA=B8=B0=EB=B2=95=20=ED=86=A0=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/zioinfo/cms/rag/RagClient.java | 189 +++++++++++++ .../com/zioinfo/cms/rag/RagCmsService.java | 257 ++++++++++++++++++ .../com/zioinfo/cms/rag/RagController.java | 67 +++++ .../com/zioinfo/cms/rag/RagProperties.java | 41 +++ .../com/zioinfo/cms/rag/RagToggleService.java | 131 +++++++++ backend/src/main/resources/application.yml | 7 + frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 10 + frontend/src/components/Sidebar.tsx | 3 +- frontend/src/pages/AiTechniques.tsx | 188 +++++++++++++ scripts/rag_ingest_cms.py | 142 ++++++++++ 11 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/zioinfo/cms/rag/RagClient.java create mode 100644 backend/src/main/java/com/zioinfo/cms/rag/RagCmsService.java create mode 100644 backend/src/main/java/com/zioinfo/cms/rag/RagController.java create mode 100644 backend/src/main/java/com/zioinfo/cms/rag/RagProperties.java create mode 100644 backend/src/main/java/com/zioinfo/cms/rag/RagToggleService.java create mode 100644 frontend/src/pages/AiTechniques.tsx create mode 100644 scripts/rag_ingest_cms.py diff --git a/backend/src/main/java/com/zioinfo/cms/rag/RagClient.java b/backend/src/main/java/com/zioinfo/cms/rag/RagClient.java new file mode 100644 index 0000000..ab7ae78 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/cms/rag/RagClient.java @@ -0,0 +1,189 @@ +package com.zioinfo.cms.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 클라이언트 (CMS 전용). + * + *

핵심 설계: CMS 는 LangChain·벡터 로직을 Java 에 재구현하지 않는다. 중앙 Python + * guardia-rag 의 검색·기법·구조화·검증·에이전틱을 REST 로 호출하기만 한다. 미가용/타임아웃/RAM + * 부족 시 절대 예외를 전파하지 않고 {@code degraded:true} 가 표시된 빈/폴백 결과를 돌려준다(서비스 + * 계층이 CMS 로컬 {@code AiService} 폴백 수행). + * + *

보안 불변: 온프레미스 전용(외부 API 0). 응답에서 자격증명/회원 PII/스택트레이스 미노출. + * 오류는 요약만 로깅. + * + *

호출 계약(중앙, base http://127.0.0.1:8020): + *

+ */ +@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/answer — 검색+생성+검증+guardrail. + * + * @return {@code {answer, grounded, faithfulness, citations[], guardrail{...}, sources[], answer_id, degraded}}. + * 미가용 시 {@code degraded:true}. + */ + public Map answer(String query, String retrievalMode, boolean rerank, + int topK, String model, boolean verify) { + if (!props.isEnabled()) return degraded("rag_disabled"); + try { + Map options = new LinkedHashMap<>(); + options.put("rerank", rerank); + options.put("stream", false); + options.put("verify", verify); + if (model != null) options.put("model", model); + + Map 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 res = post("/rag/answer", body); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("RAG /answer 일시 불가 — CMS 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + /** + * POST /rag/structured — 스키마 강제 결정론 JSON (UGC 모더레이션). + * + * @return {@code {data:{...}, valid, fallback_used, degraded}} 형태. 미가용 시 {@code degraded:true}. + */ + public Map structured(String prompt, Map schema, + String model, double temperature) { + if (!props.isEnabled()) return degraded("rag_disabled"); + try { + Map 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 res = post("/rag/structured", body); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("RAG /structured 일시 불가 — CMS 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + /** + * POST /rag/agent — 에이전틱 tool-use 다단계 (CMS 콘텐츠 도구 활용, 옵트인). + * + * @param tools 등록할 CMS 도구 이름 목록(중앙 도구 레지스트리 매칭). null 가능. + * @return {@code {answer, steps[], tools_used[], degraded}}. 미가용 시 {@code degraded:true}. + */ + public Map agent(String task, List tools, int maxSteps, + String model, String retrievalMode) { + if (!props.isEnabled()) return degraded("rag_disabled"); + try { + Map body = new LinkedHashMap<>(); + body.put("solution", props.getSolution()); + body.put("task", task); + if (tools != null) body.put("tools", tools); + body.put("max_steps", maxSteps <= 0 ? 4 : Math.min(maxSteps, 8)); // 상한 강제(폭주 차단) + if (model != null) body.put("model", model); + if (retrievalMode != null) body.put("retrieval_mode", retrievalMode); + Map res = post("/rag/agent", body); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("RAG /agent 일시 불가 — CMS 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + /** + * POST /rag/feedback — 👍/👎 피드백(solution=cms 격리). + * + * @return {@code {feedback_id, stored}} 또는 미가용 시 {@code {stored:false, degraded:true}}. + */ + public Map 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 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 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 post(String path, Map 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) m) + .block(); + } + + private Map degraded(String reason) { + Map 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 를 + * 경유하는 별개 레이어로, 두 대표 기능을 전환한다: + *

    + *
  1. contentDraft → {@code /rag/answer}(hybrid 근거+인용) 또는 tool_use 토글 시 + * {@code /rag/agent}(다단계 콘텐츠 작업). 근거 부족 시 보류 표기. + * 토글 off/미가용 시 기존 {@link AiService#draft} 로 무손실 폴백(degraded:true).
  2. + *
  3. moderate → {@code /rag/structured}(format:json 결정론) 판정. 애매(저신뢰) 시 + * 자동 통과 금지 → REVIEW(hold). 토글 off/미가용 시 기존 {@link AiService#moderate} + * (룰 기반) 폴백(degraded:true).
  4. + *
+ * + *

모든 응답에 적용 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
+ +
+ + {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}
+
+ +
+ ) + })} + + {/* 숫자 파라미터 */} + {[ + { 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="주제" /> + + {draftRes && ( +
+
{String(draftRes.draft ?? draftRes.error ?? '')}
+
+ + {draftRes.applied && <> + + + + + } + {draftRes.abstained && } +
+
+ )} +
+ +
+

UGC 모더레이션 (/rag/structured)

+