feat: 최신 AI 기법 적용 — 중앙 guardia-rag 경유 배선(근거검증·구조화출력·에이전틱) + 기법 토글

This commit is contained in:
GUARDiA 2026-06-21 11:38:01 +09:00
parent cc6e3b2c2a
commit f945769c9e
11 changed files with 1036 additions and 1 deletions

View 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;
}
}

View 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);
}
}

View 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());
}
}

View 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";
}

View 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); // SSEoff 기본
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;
}
}
}

View File

@ -44,6 +44,13 @@ guardia:
ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b} ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream} 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: crypto:
secret: ${CMS_CRYPTO_SECRET:guardia-cms-aes-256-gcm-master-key-2026-zioinfo} secret: ${CMS_CRYPTO_SECRET:guardia-cms-aes-256-gcm-master-key-2026-zioinfo}
jwt: jwt:

View File

@ -19,6 +19,7 @@ import Channels from './pages/Channels'
import Members from './pages/Members' import Members from './pages/Members'
import Analytics from './pages/Analytics' import Analytics from './pages/Analytics'
import AiTools from './pages/AiTools' import AiTools from './pages/AiTools'
import AiTechniques from './pages/AiTechniques'
import UserManagement from './pages/UserManagement' import UserManagement from './pages/UserManagement'
import AuditLog from './pages/AuditLog' import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings' import SystemSettings from './pages/SystemSettings'
@ -56,6 +57,7 @@ export default function App() {
<Route path="/members" element={<Members />} /> <Route path="/members" element={<Members />} />
<Route path="/analytics" element={<Analytics />} /> <Route path="/analytics" element={<Analytics />} />
<Route path="/ai-tools" element={<AiTools />} /> <Route path="/ai-tools" element={<AiTools />} />
<Route path="/ai-techniques" element={<AiTechniques />} />
<Route path="/admin/users" element={ <Route path="/admin/users" element={
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute> <ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>
} /> } />

View File

@ -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 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 })) 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 ─────────────────────────────────────────────────────── // ── Integration ───────────────────────────────────────────────────────
export const getIntegrationStatus = () => unwrap(api.get('/api/cms/integration/status')) export const getIntegrationStatus = () => unwrap(api.get('/api/cms/integration/status'))
export const getMallProducts = () => unwrap(api.get('/api/cms/integration/mall/products')) export const getMallProducts = () => unwrap(api.get('/api/cms/integration/mall/products'))

View File

@ -4,7 +4,7 @@ import {
LayoutDashboard, FileText, Image, Package, Megaphone, ListTree, Palette, LayoutDashboard, FileText, Image, Package, Megaphone, ListTree, Palette,
Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound, Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound,
Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack, Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack,
BookOpen, CalendarDays, Mail, Smartphone, BookOpen, CalendarDays, Mail, Smartphone, Cpu,
} from 'lucide-react' } from 'lucide-react'
import { currentRole, hasRole } from './rbac' import { currentRole, hasRole } from './rbac'
import ThemeToggle from './uiws/ThemeToggle' import ThemeToggle from './uiws/ThemeToggle'
@ -31,6 +31,7 @@ const engageLinks: Link[] = [
{ to: '/members', label: '회원', icon: UsersRound }, { to: '/members', label: '회원', icon: UsersRound },
{ to: '/analytics', label: '콘텐츠 분석', icon: BarChart3 }, { to: '/analytics', label: '콘텐츠 분석', icon: BarChart3 },
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles }, { to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
{ to: '/ai-techniques', label: 'AI 기법 (RAG)', icon: Cpu },
] ]
const adminLinks: Link[] = [ const adminLinks: Link[] = [
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' }, { to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },

View 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
View 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))