feat: 최신 AI 기법 적용 — 중앙 guardia-rag 경유 배선(근거검증·구조화출력·에이전틱) + 기법 토글
This commit is contained in:
parent
cc6e3b2c2a
commit
f945769c9e
189
backend/src/main/java/com/zioinfo/cms/rag/RagClient.java
Normal file
189
backend/src/main/java/com/zioinfo/cms/rag/RagClient.java
Normal file
@ -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 전용).
|
||||
*
|
||||
* <p><b>핵심 설계</b>: CMS 는 LangChain·벡터 로직을 Java 에 재구현하지 않는다. 중앙 Python
|
||||
* guardia-rag 의 검색·기법·구조화·검증·에이전틱을 REST 로 호출하기만 한다. 미가용/타임아웃/RAM
|
||||
* 부족 시 절대 예외를 전파하지 않고 {@code degraded:true} 가 표시된 빈/폴백 결과를 돌려준다(서비스
|
||||
* 계층이 CMS 로컬 {@code AiService} 폴백 수행).
|
||||
*
|
||||
* <p><b>보안 불변</b>: 온프레미스 전용(외부 API 0). 응답에서 자격증명/회원 PII/스택트레이스 미노출.
|
||||
* 오류는 요약만 로깅.
|
||||
*
|
||||
* <p>호출 계약(중앙, base http://127.0.0.1:8020):
|
||||
* <ul>
|
||||
* <li>POST /rag/answer — 검색+근거생성+인용+guardrail (콘텐츠 초안·SEO·번역·검색 용)</li>
|
||||
* <li>POST /rag/structured — format:json 결정론 JSON (UGC 모더레이션·감성 용)</li>
|
||||
* <li>POST /rag/agent — 에이전틱 tool-use 다단계 (콘텐츠 일괄 작업, 옵트인)</li>
|
||||
* <li>POST /rag/feedback — 👍/👎 피드백 (solution=cms 격리)</li>
|
||||
* <li>GET /rag/trust/settings — 가용성 핑</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String, Object> answer(String query, String retrievalMode, boolean rerank,
|
||||
int topK, String model, boolean verify) {
|
||||
if (!props.isEnabled()) return degraded("rag_disabled");
|
||||
try {
|
||||
Map<String, Object> options = new LinkedHashMap<>();
|
||||
options.put("rerank", rerank);
|
||||
options.put("stream", false);
|
||||
options.put("verify", verify);
|
||||
if (model != null) options.put("model", model);
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> structured(String prompt, Map<String, Object> schema,
|
||||
String model, double temperature) {
|
||||
if (!props.isEnabled()) return degraded("rag_disabled");
|
||||
try {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> agent(String task, List<String> tools, int maxSteps,
|
||||
String model, String retrievalMode) {
|
||||
if (!props.isEnabled()) return degraded("rag_disabled");
|
||||
try {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> post(String path, Map<String, Object> 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<String, Object>) m)
|
||||
.block();
|
||||
}
|
||||
|
||||
private Map<String, Object> degraded(String reason) {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
257
backend/src/main/java/com/zioinfo/cms/rag/RagCmsService.java
Normal file
257
backend/src/main/java/com/zioinfo/cms/rag/RagCmsService.java
Normal file
@ -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 배선 레이어(순증·비파괴, 별개 레이어).
|
||||
*
|
||||
* <p>기존 {@link AiService}(Ollama + Java 폴백)는 <b>불변</b>. 본 서비스는 중앙 guardia-rag 를
|
||||
* 경유하는 별개 레이어로, 두 대표 기능을 전환한다:
|
||||
* <ol>
|
||||
* <li><b>contentDraft</b> → {@code /rag/answer}(hybrid 근거+인용) 또는 tool_use 토글 시
|
||||
* {@code /rag/agent}(다단계 콘텐츠 작업). 근거 부족 시 보류 표기.
|
||||
* 토글 off/미가용 시 기존 {@link AiService#draft} 로 무손실 폴백(degraded:true).</li>
|
||||
* <li><b>moderate</b> → {@code /rag/structured}(format:json 결정론) 판정. 애매(저신뢰) 시
|
||||
* 자동 통과 금지 → REVIEW(hold). 토글 off/미가용 시 기존 {@link AiService#moderate}
|
||||
* (룰 기반) 폴백(degraded:true).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>모든 응답에 적용 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<String, Object> 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<String> 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<String, Object> contentDraft(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> moderate(Map<String, Object> req, String actor) {
|
||||
String text = str(req.get("text"));
|
||||
RagToggles t = toggles.current();
|
||||
Map<String, Object> 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<String, Object> sres = rag.structured(prompt, MODERATION_SCHEMA, t.generationModel, t.temperature);
|
||||
boolean degraded = bool(sres.get("degraded"));
|
||||
Map<String, Object> data = sres.get("data") instanceof Map ? (Map<String, Object>) 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<String, Object> moderateFallback(String text, RagToggles t, String reason) {
|
||||
Map<String, Object> legacy = aiService.moderate(text);
|
||||
Map<String, Object> 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<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;
|
||||
}
|
||||
|
||||
// ── 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("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);
|
||||
}
|
||||
}
|
||||
67
backend/src/main/java/com/zioinfo/cms/rag/RagController.java
Normal file
67
backend/src/main/java/com/zioinfo/cms/rag/RagController.java
Normal file
@ -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 — 별개 레이어.
|
||||
*
|
||||
* <ul>
|
||||
* <li>POST /api/cms/rag/content-draft — 콘텐츠 초안(/answer hybrid 또는 /agent, 근거+인용+보류)</li>
|
||||
* <li>POST /api/cms/rag/moderate — UGC 모더레이션(/structured 결정론 JSON, 애매 시 보류)</li>
|
||||
* <li>POST /api/cms/rag/feedback — 👍/👎 피드백(/rag/feedback, solution=cms 격리)</li>
|
||||
* <li>GET /api/cms/rag/toggles — 현재 기법/신뢰도 토글 스냅샷</li>
|
||||
* <li>PUT /api/cms/rag/toggles/{key} — 토글 변경(rag.* 키, cms_setting 격리 저장 + 감사로그)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>기존 {@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<Map<String, Object>> contentDraft(@RequestBody Map<String, Object> req, Authentication auth) {
|
||||
return ApiResponse.ok(service.contentDraft(req, auth == null ? null : auth.getName()));
|
||||
}
|
||||
|
||||
@PostMapping("/moderate")
|
||||
public ApiResponse<Map<String, Object>> moderate(@RequestBody Map<String, Object> req, Authentication auth) {
|
||||
return ApiResponse.ok(service.moderate(req, auth == null ? null : auth.getName()));
|
||||
}
|
||||
|
||||
@PostMapping("/feedback")
|
||||
public ApiResponse<Map<String, Object>> feedback(@RequestBody Map<String, Object> req, Authentication auth) {
|
||||
return ApiResponse.ok(service.feedback(req, auth == null ? null : auth.getName()));
|
||||
}
|
||||
|
||||
@GetMapping("/toggles")
|
||||
public ApiResponse<Map<String, Object>> toggles() {
|
||||
return ApiResponse.ok(service.currentToggles());
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 변경 — 기존 cms_setting upsert(감사로그 자동 기록) 재사용.
|
||||
* 허용 키는 {@code rag.*} 네임스페이스로 한정.
|
||||
*/
|
||||
@PutMapping("/toggles/{key}")
|
||||
public ApiResponse<Map<String, Object>> updateToggle(@PathVariable String key,
|
||||
@RequestBody Map<String, Object> 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());
|
||||
}
|
||||
}
|
||||
41
backend/src/main/java/com/zioinfo/cms/rag/RagProperties.java
Normal file
41
backend/src/main/java/com/zioinfo/cms/rag/RagProperties.java
Normal file
@ -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 전용).
|
||||
*
|
||||
* <p>보안 불변 규칙: base-url 은 <b>온프레미스(중앙 guardia-rag) 전용</b>이다.
|
||||
* 외부 엔드포인트를 가리키도록 설정해서는 안 된다. 기본값은 서버 내부 루프백이다.
|
||||
*
|
||||
* <p>application.yml:
|
||||
* <pre>
|
||||
* guardia:
|
||||
* rag:
|
||||
* base-url: http://127.0.0.1:8020
|
||||
* timeout-ms: 120000
|
||||
* enabled: true
|
||||
* solution: cms
|
||||
* </pre>
|
||||
*/
|
||||
@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";
|
||||
}
|
||||
131
backend/src/main/java/com/zioinfo/cms/rag/RagToggleService.java
Normal file
131
backend/src/main/java/com/zioinfo/cms/rag/RagToggleService.java
Normal file
@ -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 기법/신뢰도 토글 서비스.
|
||||
*
|
||||
* <p>기존 {@code cms_setting}(key-value) 테이블을 재사용해 RAG 토글을 격리 저장한다(신규 테이블
|
||||
* 신설 없음, admin 설정 화면과 자연 통합). 키 네임스페이스 {@code rag.*} 로 다른 설정과 충돌 회피.
|
||||
*
|
||||
* <p>무거운 기법(graphrag·rerank·tool_use)은 서버 RAM 제약상 <b>기본 off</b>. 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<String, String> m = new LinkedHashMap<>();
|
||||
List<CmsSetting> 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<String, Object> toMap() {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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() {
|
||||
<Route path="/members" element={<Members />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/ai-tools" element={<AiTools />} />
|
||||
<Route path="/ai-techniques" element={<AiTechniques />} />
|
||||
<Route path="/admin/users" element={
|
||||
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>
|
||||
} />
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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' },
|
||||
|
||||
188
frontend/src/pages/AiTechniques.tsx
Normal file
188
frontend/src/pages/AiTechniques.tsx
Normal file
@ -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<Toggles | null>(null)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [draftRes, setDraftRes] = useState<any>(null)
|
||||
const [modRes, setModRes] = useState<any>(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 <div className="text-slate-500">{err || '불러오는 중...'}</div>
|
||||
|
||||
const Badge = ({ on, label }: { on: boolean; label: string }) => (
|
||||
<span className={`px-2 py-0.5 rounded text-[11px] font-medium ${on ? 'bg-brand/15 text-brand' : 'bg-slate-500/15 text-slate-400'}`}>{label}</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl font-bold flex items-center gap-2"><Cpu className="text-brand" size={20} /> AI 기법 (중앙 guardia-rag)</h1>
|
||||
<span className={`px-2.5 py-1 rounded-lg text-xs font-medium ${t.ragAvailable ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'}`}>
|
||||
중앙 RAG {t.ragAvailable ? '가용' : '미가용 (로컬 폴백)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{msg && <div className="bg-brand/10 border border-brand/30 text-brand text-sm rounded-lg px-4 py-2.5 mb-4">{msg}</div>}
|
||||
{err && <div className="bg-rose-500/10 border border-rose-500/40 text-rose-300 text-sm rounded-lg px-4 py-2.5 mb-4">{err}</div>}
|
||||
{!editable && <div className="bg-amber-500/10 border border-amber-500/30 text-amber-300 text-sm rounded-lg px-4 py-2.5 mb-4">조회 전용입니다. 토글 변경은 Editor+ 권한이 필요합니다.</div>}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* 토글 설정 */}
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="font-semibold mb-3 flex items-center gap-2"><ShieldCheck size={16} className="text-brand" /> 기법 토글</h2>
|
||||
|
||||
{/* retrieval_mode */}
|
||||
<div className="flex items-center justify-between py-2.5 border-b border-edge/50">
|
||||
<div><div className="text-sm font-medium">검색 모드 (retrieval_mode)</div><div className="text-[11px] text-slate-500">vector(경량 기본) · hybrid(BM25+벡터) · graph</div></div>
|
||||
<select className="inp w-32" value={t.retrievalMode} disabled={!editable}
|
||||
onChange={e => save('rag.retrieval_mode', e.target.value)}>
|
||||
<option value="vector">vector</option><option value="hybrid">hybrid</option><option value="graph">graph</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{BOOL_KEYS.map(b => {
|
||||
const cur = (t as any)[b.key.replace('rag.', '').replace(/_(\w)/g, (_, c) => c.toUpperCase())] as boolean
|
||||
return (
|
||||
<div key={b.key} className="flex items-center justify-between py-2.5 border-b border-edge/50">
|
||||
<div>
|
||||
<div className="text-sm font-medium flex items-center gap-1.5">{b.label}{b.heavy && <span className="text-[10px] text-amber-400">RAM↑ 기본 off</span>}</div>
|
||||
<div className="text-[11px] text-slate-500">{b.hint}</div>
|
||||
</div>
|
||||
<button disabled={!editable} onClick={() => save(b.key, String(!cur))}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative ${cur ? 'bg-brand' : 'bg-slate-600'} ${!editable ? 'opacity-50' : ''}`}>
|
||||
<span className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all ${cur ? 'left-[22px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 숫자 파라미터 */}
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={p.key} className="flex items-center justify-between py-2.5 border-b border-edge/50">
|
||||
<div className="text-sm">{p.label}</div>
|
||||
<input type="number" className="inp w-24" defaultValue={p.val} min={p.min} max={p.max} step={(p as any).step || 1} disabled={!editable}
|
||||
onBlur={e => save(p.key, e.target.value)} />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<div className="text-sm">생성 모델</div>
|
||||
<input className="inp w-40" defaultValue={t.generationModel} disabled={!editable}
|
||||
onBlur={e => save('rag.generation_model', e.target.value)} />
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 mt-2">설정은 cms_setting(rag.*)에 격리 저장 · 변경 시 감사로그 기록 · 무거운 기법 기본 off</p>
|
||||
</div>
|
||||
|
||||
{/* 라이브 적용 테스트 */}
|
||||
<div className="space-y-5">
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="font-semibold mb-3 flex items-center gap-2"><Sparkles size={16} className="text-brand" /> 콘텐츠 초안 (/rag/answer · /agent)</h2>
|
||||
<input className="inp w-full mb-2" value={topic} onChange={e => setTopic(e.target.value)} placeholder="주제" />
|
||||
<button onClick={runDraft} disabled={busy === 'draft'} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand text-ink text-xs font-semibold">
|
||||
<Play size={13} /> {busy === 'draft' ? '생성 중...' : '초안 생성'}
|
||||
</button>
|
||||
{draftRes && (
|
||||
<div className="mt-3 text-sm">
|
||||
<div className="bg-panel rounded-lg p-3 whitespace-pre-wrap text-slate-200">{String(draftRes.draft ?? draftRes.error ?? '')}</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<Badge on={!draftRes.degraded} label={draftRes.degraded ? `폴백(${draftRes.degraded_reason || 'degraded'})` : `엔진:${draftRes.engine || 'RAG'}`} />
|
||||
{draftRes.applied && <>
|
||||
<Badge on label={`mode:${draftRes.applied.retrievalMode}`} />
|
||||
<Badge on={draftRes.applied.rerank} label="rerank" />
|
||||
<Badge on={draftRes.applied.graphrag} label="graph" />
|
||||
<Badge on={draftRes.applied.toolUse} label="tool-use" />
|
||||
</>}
|
||||
{draftRes.abstained && <Badge on={false} label="근거부족 보류" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="font-semibold mb-3 flex items-center gap-2"><ShieldCheck size={16} className="text-brand" /> UGC 모더레이션 (/rag/structured)</h2>
|
||||
<textarea className="inp w-full mb-2 h-16" value={ugc} onChange={e => setUgc(e.target.value)} placeholder="리뷰/댓글" />
|
||||
<button onClick={runMod} disabled={busy === 'mod'} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand text-ink text-xs font-semibold">
|
||||
<Play size={13} /> {busy === 'mod' ? '판정 중...' : '판정'}
|
||||
</button>
|
||||
{modRes && (
|
||||
<div className="mt-3 text-sm flex flex-wrap gap-1.5 items-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${modRes.verdict === 'APPROVE' ? 'bg-emerald-500/15 text-emerald-300' : modRes.verdict === 'REJECT' ? 'bg-rose-500/15 text-rose-300' : 'bg-amber-500/15 text-amber-300'}`}>
|
||||
{modRes.verdict || modRes.error}
|
||||
</span>
|
||||
{modRes.score != null && <Badge on label={`score:${modRes.score}`} />}
|
||||
{modRes.piiFlag && <Badge on={false} label="PII 감지" />}
|
||||
<Badge on={!modRes.degraded} label={modRes.degraded ? `폴백(${modRes.degraded_reason || 'rule'})` : '결정론 JSON'} />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-slate-500 mt-2">애매(저신뢰) 시 자동 통과 금지 → REVIEW(보류). 사람 검토 큐로.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
scripts/rag_ingest_cms.py
Normal file
142
scripts/rag_ingest_cms.py
Normal file
@ -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))
|
||||
Loading…
Reference in New Issue
Block a user