diff --git a/backend/src/main/java/com/zioinfo/esn/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/esn/config/SecurityConfig.java
index e854de9..baff45c 100644
--- a/backend/src/main/java/com/zioinfo/esn/config/SecurityConfig.java
+++ b/backend/src/main/java/com/zioinfo/esn/config/SecurityConfig.java
@@ -48,6 +48,8 @@ public class SecurityConfig {
// (아래 일반 PUT /api/** 규칙보다 먼저 평가되도록 상단 배치)
.requestMatchers(HttpMethod.PUT, "/api/admin/settings/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "MANAGER")
+ // AI 기법 토글 변경 = MANAGER+ (조회/실행은 일반 인증 사용자) — 상단 배치로 우선 평가
+ .requestMatchers(HttpMethod.PUT, "/api/ai/rag/toggles/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")
.requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")
diff --git a/backend/src/main/java/com/zioinfo/esn/rag/EsnRagClient.java b/backend/src/main/java/com/zioinfo/esn/rag/EsnRagClient.java
new file mode 100644
index 0000000..acdcd7b
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/esn/rag/EsnRagClient.java
@@ -0,0 +1,248 @@
+package com.zioinfo.esn.rag;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 얇은 중앙 guardia-rag REST 클라이언트 (ESN 전용).
+ *
+ *
핵심 설계 : ESN 은 LangChain·벡터 로직을 Java 에 재구현하지 않는다(13벌 재구현 금지).
+ * 중앙 Python guardia-rag 의 검색·기법·구조화·검증·에이전틱을 REST 로 호출하기만 한다.
+ * 미가용/타임아웃/RAM 부족 시 절대 예외를 전파하지 않고 {@code degraded:true} 가 표시된
+ * 폴백 결과를 돌려준다(서비스 계층이 기존 OllamaClient/룰 폴백 수행).
+ *
+ *
WebFlux 미의존 — 기존 {@code OllamaClient} 와 동일하게 {@code java.net.http.HttpClient} 사용.
+ *
+ *
보안 불변 : 온프레미스 전용(루프백). 응답에서 자격증명/PII/스택트레이스 미노출.
+ * 오류는 요약 1줄만 로깅.
+ *
+ *
테넌트 격리 : 모든 호출에 {@code solution=esn} + {@code collection=esn__} 을
+ * 실어 테넌트별 컬렉션을 분리한다.
+ *
+ * 호출 계약(중앙):
+ *
+ * POST /rag/answer — 검색(retrieval_mode)+근거생성+인용+guardrail
+ * POST /rag/verify — 근거검증(faithfulness/grounded)
+ * POST /rag/structured — format:json 결정론 JSON
+ * POST /rag/agent — tool-use 에이전틱 루프
+ * POST /rag/feedback — 👍/👎 피드백 (solution=esn 격리)
+ * POST /rag/ingest — 색인 (테넌트 컬렉션)
+ *
+ */
+@Slf4j
+@Component
+public class EsnRagClient {
+
+ private final RagProperties props;
+ private final ObjectMapper json = new ObjectMapper();
+ private final HttpClient http = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(4))
+ .build();
+
+ public EsnRagClient(RagProperties props) {
+ this.props = props;
+ }
+
+ /** 테넌트별 컬렉션 키: {@code esn__LGINNOTEK} 등. null/blank → 솔루션 공용. */
+ public String collectionFor(String tenantCode) {
+ if (tenantCode == null || tenantCode.isBlank()) return props.getSolution();
+ return props.getSolution() + "__" + tenantCode.trim().toUpperCase();
+ }
+
+ /** 중앙 RAG 사용 가능 여부(설정 enabled + trust/settings 도달). */
+ public boolean available() {
+ if (!props.isEnabled()) return false;
+ try {
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create(props.getBaseUrl() + "/rag/trust/settings?solution=" + props.getSolution()))
+ .GET()
+ .timeout(Duration.ofSeconds(3))
+ .build();
+ HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString());
+ return resp.statusCode() >= 200 && resp.statusCode() < 500;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * POST /rag/answer — 검색(retrieval_mode: vector|hybrid|graph)+생성+검증+guardrail.
+ *
+ * @return {@code {answer, grounded, faithfulness, citations[], sources[], guardrail{...}, answer_id, degraded}}.
+ * 미가용 시 {@code degraded:true}.
+ */
+ public Map answer(String tenantCode, 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 = base(tenantCode);
+ 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 일시 불가 — ESN 폴백: {}", summarize(e));
+ return degraded("rag_unavailable");
+ }
+ }
+
+ /**
+ * POST /rag/structured — 스키마 강제 결정론 JSON.
+ *
+ * @return {@code {data:{...}, valid, fallback_used, degraded}}. 미가용 시 {@code degraded:true}.
+ */
+ public Map structured(String tenantCode, String prompt, Map schema,
+ String model, double temperature) {
+ if (!props.isEnabled()) return degraded("rag_disabled");
+ try {
+ Map body = base(tenantCode);
+ body.put("prompt", prompt);
+ if (schema != null) body.put("schema", schema);
+ body.put("format", "json");
+ 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 일시 불가 — ESN 폴백: {}", summarize(e));
+ return degraded("rag_unavailable");
+ }
+ }
+
+ /**
+ * POST /rag/agent — 에이전틱 tool-use 루프(다단계 진단).
+ *
+ * @param tools ESN 도구 디스크립터 목록(이름·설명·파라미터 스키마).
+ * @param maxSteps 루프 상한(서버 RAM/시간 보호).
+ * @return {@code {answer, steps[], tool_calls[], citations[], answer_id, degraded}}.
+ */
+ public Map agent(String tenantCode, String query, Object tools,
+ String retrievalMode, int maxSteps, String model) {
+ if (!props.isEnabled()) return degraded("rag_disabled");
+ try {
+ Map body = base(tenantCode);
+ body.put("query", query);
+ if (tools != null) body.put("tools", tools);
+ body.put("retrieval_mode", retrievalMode == null ? "vector" : retrievalMode);
+ body.put("max_steps", maxSteps <= 0 ? 4 : Math.min(maxSteps, 8));
+ if (model != null) body.put("model", model);
+ Map res = post("/rag/agent", body);
+ return res != null ? res : degraded("rag_no_response");
+ } catch (Exception e) {
+ log.warn("RAG /agent 일시 불가 — ESN 폴백: {}", summarize(e));
+ return degraded("rag_unavailable");
+ }
+ }
+
+ /** POST /rag/verify — 근거검증(grounded/faithfulness). */
+ public Map verify(String tenantCode, String answer, String context) {
+ if (!props.isEnabled()) return degraded("rag_disabled");
+ try {
+ Map body = base(tenantCode);
+ body.put("answer", answer);
+ if (context != null) body.put("context", context);
+ Map res = post("/rag/verify", body);
+ return res != null ? res : degraded("rag_no_response");
+ } catch (Exception e) {
+ log.warn("RAG /verify 일시 불가: {}", summarize(e));
+ return degraded("rag_unavailable");
+ }
+ }
+
+ /** POST /rag/feedback — 👍/👎 피드백(solution=esn 격리). */
+ public Map feedback(String tenantCode, 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 = base(tenantCode);
+ 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);
+ }
+ }
+
+ /** POST /rag/ingest — 테넌트 컬렉션 색인. 색인 스크립트/훅에서 사용. */
+ public Map ingest(String tenantCode, Object documents) {
+ if (!props.isEnabled()) return degraded("rag_disabled");
+ try {
+ Map body = base(tenantCode);
+ body.put("documents", documents);
+ Map res = post("/rag/ingest", body);
+ return res != null ? res : degraded("rag_no_response");
+ } catch (Exception e) {
+ log.warn("RAG /ingest 일시 불가: {}", summarize(e));
+ return degraded("rag_unavailable");
+ }
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────
+ private Map base(String tenantCode) {
+ Map m = new LinkedHashMap<>();
+ m.put("solution", props.getSolution());
+ m.put("collection", collectionFor(tenantCode));
+ if (tenantCode != null && !tenantCode.isBlank()) {
+ m.put("tenant_code", tenantCode.trim().toUpperCase());
+ }
+ return m;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map post(String path, Map body) throws Exception {
+ String payload = json.writeValueAsString(body);
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create(props.getBaseUrl() + path))
+ .header("Content-Type", "application/json")
+ .header("X-Solution-Key", props.getSolution())
+ .POST(HttpRequest.BodyPublishers.ofString(payload))
+ .timeout(Duration.ofMillis(props.getTimeoutMs()))
+ .build();
+ HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString());
+ if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
+ log.warn("RAG {} 비정상 status={} — 폴백", path, resp.statusCode());
+ return null;
+ }
+ String b = resp.body();
+ if (b == null || b.isBlank()) return null;
+ return json.readValue(b, Map.class);
+ }
+
+ 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/esn/rag/EsnToolRegistry.java b/backend/src/main/java/com/zioinfo/esn/rag/EsnToolRegistry.java
new file mode 100644
index 0000000..0b8e5b8
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/esn/rag/EsnToolRegistry.java
@@ -0,0 +1,71 @@
+package com.zioinfo.esn.rag;
+
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 에이전틱 tool-use 용 ESN 고유 도구 디스크립터 레지스트리.
+ *
+ * 중앙 {@code /rag/agent} 에 "AI 가 호출 가능한 도구 목록"으로 전달된다(이름·설명·파라미터
+ * 스키마). 실제 실행은 중앙이 ESN 콜백/MCP 로 위임하거나, 본 디스크립터를 컨텍스트 힌트로만
+ * 사용한다(서버 구현에 따름). 어느 쪽이든 ESN 은 읽기 전용 조회 도구 만 노출하며,
+ * 자격증명/PII/SSH 노출 도구는 등록하지 않는다.
+ *
+ *
알람 도메인(esn-ai-alarm-agent)이 다단계 진단 루프(증상→연관조회→가설→검증)를
+ * 완주하도록 알람·HCore·의존성·작업이력 조회 도구를 제공한다.
+ */
+@Component
+public class EsnToolRegistry {
+
+ /** 알람 원인분석용 도구 셋(읽기전용). tenantCode 는 호출 시 컨텍스트로 고정. */
+ public List> alarmDiagnosisTools() {
+ return List.of(
+ tool("esn_get_alarm",
+ "알람 ID로 단일 알람 상세(유형·심각도·메시지·발생시각·디바이스)를 조회한다.",
+ param("alarmId", "string", "조회할 알람 ID")),
+ tool("esn_list_related_alarms",
+ "동일 매장/허브/시간창의 연관 알람 목록을 조회한다(연쇄 장애 식별용).",
+ param("storeCode", "string", "매장 코드"),
+ param("windowMinutes", "integer", "시간창(분), 기본 30")),
+ tool("esn_get_hcore_status",
+ "HCore 게이트웨이/허브의 현재 상태(온라인·하위 단말 수·마지막 통신)를 조회한다.",
+ param("hcoreId", "string", "HCore 장치 ID")),
+ tool("esn_get_dependency",
+ "HCore↔매장↔ESL 단말↔태그바인딩 의존성 그래프를 조회한다(근본원인 수렴용).",
+ param("rootType", "string", "시작 노드 유형: HCORE|STORE|DEVICE"),
+ param("rootId", "string", "시작 노드 ID")),
+ tool("esn_get_work_history",
+ "디바이스/매장의 최근 작업 이력(펌웨어·교체·점검)을 조회한다(변경 연관 분석용).",
+ param("targetType", "string", "대상 유형: STORE|DEVICE|HCORE"),
+ param("targetId", "string", "대상 ID"))
+ );
+ }
+
+ private Map tool(String name, String desc, Map... params) {
+ Map properties = new LinkedHashMap<>();
+ for (Map p : params) {
+ String pn = (String) p.remove("__name");
+ properties.put(pn, p);
+ }
+ Map schema = new LinkedHashMap<>();
+ schema.put("type", "object");
+ schema.put("properties", properties);
+
+ Map t = new LinkedHashMap<>();
+ t.put("name", name);
+ t.put("description", desc);
+ t.put("parameters", schema);
+ return t;
+ }
+
+ private Map param(String name, String type, String desc) {
+ Map p = new LinkedHashMap<>();
+ p.put("__name", name);
+ p.put("type", type);
+ p.put("description", desc);
+ return p;
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/esn/rag/RagAiService.java b/backend/src/main/java/com/zioinfo/esn/rag/RagAiService.java
new file mode 100644
index 0000000..c5b1903
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/esn/rag/RagAiService.java
@@ -0,0 +1,254 @@
+package com.zioinfo.esn.rag;
+
+import com.zioinfo.esn.config.OllamaClient;
+import com.zioinfo.esn.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;
+
+/**
+ * ESN 대표 AI 기능 RAG 배선 레이어(순증·비파괴, 테넌트 격리).
+ *
+ * 기존 {@link OllamaClient}(폴백 내장) 및 {@code AiController} 는 불변 . 본 서비스는
+ * 중앙 guardia-rag 를 경유하는 별개 레이어 로, 두 대표 기능을 전환한다:
+ *
+ * 알람 원인분석·우선순위 → {@code /rag/agent}(tool-use 다단계 진단)
+ * + GraphRAG(retrieval_mode=graph 의존성 추적, graphrag 토글 on 시) + {@code /rag/structured}
+ * (우선순위 결정론 JSON). 토글 off/미가용 시 기존 {@link OllamaClient#chat} 로 무손실 폴백.
+ * POS 데이터 자동 분류 → {@code /rag/structured}(format:json 결정론).
+ * confidence 임계 미만이면 {@code needsReview:true}. 미가용 시 OllamaClient 폴백.
+ *
+ *
+ * 모든 응답에 적용 metadata(retrievalMode·rerank·structured·toolUse·degraded)를 표기해
+ * 토글 effect 를 관측 가능하게 한다.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RagAiService {
+
+ private final EsnRagClient rag;
+ private final RagToggleService toggles;
+ private final EsnToolRegistry tools;
+ private final OllamaClient ollama; // 기존 폴백 재사용(불변)
+
+ /** 알람 우선순위·근본원인 결정론 JSON 스키마. */
+ private static final Map ALARM_SCHEMA = Map.of(
+ "type", "object",
+ "properties", Map.of(
+ "rootCauseHypothesis", Map.of("type", "string"),
+ "priority", Map.of("type", "string"), // P1~P4
+ "groupKey", Map.of("type", "string"),
+ "affectedDeviceCount", Map.of("type", "integer"),
+ "recommendedActions", Map.of("type", "array", "items", Map.of("type", "string")),
+ "confidence", Map.of("type", "number")
+ ),
+ "required", List.of("priority", "rootCauseHypothesis")
+ );
+
+ /** POS 분류 결정론 JSON 스키마. */
+ private static final Map POS_SCHEMA = Map.of(
+ "type", "object",
+ "properties", Map.of(
+ "classification", Map.of("type", "string"), // NORMAL|ABNORMAL
+ "category", Map.of("type", "string"), // PRICE_OK|PRICE_MISMATCH|MISSING|OUTLIER ...
+ "reason", Map.of("type", "string"),
+ "confidence", Map.of("type", "number")
+ ),
+ "required", List.of("classification", "confidence")
+ );
+
+ // ── ① ESL/HCore 알람 원인분석·우선순위 ───────────────────────────────────
+ @SuppressWarnings("unchecked")
+ public Map analyzeAlarm(String tenantCode, Map req, String actor) {
+ RagToggles t = toggles.current(tenantCode);
+ String alarmType = str(req.get("alarmType"));
+ String severity = str(req.getOrDefault("severity", "LOW"));
+ String message = str(req.get("message"));
+ String storeCode = str(req.get("storeCode"));
+ String query = String.format(
+ "ESL/HCore 알람의 근본원인과 우선순위를 진단하라. 유형=%s, 심각도=%s, 매장=%s, 메시지=%s. "
+ + "허브 다운이 하위 단말 다중 알람을 유발하는 연쇄(cascading) 가능성을 의존성 그래프로 검토하고, "
+ + "같은 근본원인의 파생 알람은 하나로 그룹핑하라.",
+ alarmType, severity, storeCode, message);
+
+ Map out = new LinkedHashMap<>();
+
+ if (!t.ragEnabled || !rag.available()) {
+ return alarmFallback(out, t, req);
+ }
+
+ // tool-use 토글 on → /rag/agent 다단계 진단, off → graph/answer 단발
+ Map evidence;
+ if (t.toolUse) {
+ evidence = rag.agent(tenantCode, query, tools.alarmDiagnosisTools(),
+ toggles.effectiveMode(t), t.agentMaxSteps, t.generationModel);
+ } else {
+ evidence = rag.answer(tenantCode, query, toggles.effectiveMode(t),
+ t.rerank, t.topK, t.generationModel, true);
+ }
+ if (bool(evidence.get("degraded"))) {
+ return alarmFallback(out, t, req);
+ }
+
+ // 우선순위·그룹핑은 결정론 JSON(/structured)으로 수렴 — 파싱 실패 차단
+ String evText = str(evidence.getOrDefault("answer", ""));
+ Map sres = rag.structured(tenantCode,
+ query + "\n근거:\n" + evText + "\n위 근거로 priority(P1~P4)·rootCause·group·조치를 JSON 으로만 산출.",
+ ALARM_SCHEMA, t.generationModel, t.temperature);
+
+ Map data = sres.get("data") instanceof Map ? (Map) sres.get("data") : Map.of();
+ if (bool(sres.get("degraded")) || data.isEmpty()) {
+ return alarmFallback(out, t, req);
+ }
+
+ out.put("rootCauseHypothesis", data.get("rootCauseHypothesis"));
+ out.put("priority", data.getOrDefault("priority", "P3"));
+ out.put("groupKey", data.get("groupKey"));
+ out.put("affectedDeviceCount", data.get("affectedDeviceCount"));
+ out.put("recommendedActions", data.get("recommendedActions"));
+ out.put("confidence", data.get("confidence"));
+ out.put("citations", evidence.get("citations"));
+ out.put("answerId", evidence.get("answer_id"));
+ out.put("engine", t.toolUse ? "RAG_AGENT" : "RAG_GRAPH");
+ out.put("degraded", false);
+ out.putAll(meta(t, t.toolUse ? "agent" : "answer", true));
+ return out;
+ }
+
+ private Map alarmFallback(Map out, RagToggles t, Map req) {
+ // 비파괴 폴백: 기존 OllamaClient 룰/모델 + 규칙 기반 우선순위
+ String prompt = String.format("ESL 알람 분석: 유형=%s 심각도=%s 메시지=%s. 원인·조치 3줄.",
+ str(req.get("alarmType")), str(req.getOrDefault("severity", "LOW")), str(req.get("message")));
+ String analysis = ollama.chat(prompt, "ESL 시스템 전문 AI 엔지니어. 실용 조치 제안.");
+ out.put("rootCauseHypothesis", analysis);
+ out.put("priority", rulePriority(str(req.getOrDefault("severity", "LOW"))));
+ out.put("recommendedActions", List.of("디바이스 연결 상태 확인", "허브 통신 점검", "지속 시 현장 엔지니어 파견"));
+ out.put("engine", "OLLAMA_FALLBACK");
+ out.put("degraded", true);
+ out.put("degraded_reason", t.ragEnabled ? "rag_unavailable" : "rag_disabled");
+ out.putAll(meta(t, "fallback", false));
+ return out;
+ }
+
+ private String rulePriority(String severity) {
+ switch (severity == null ? "" : severity.toUpperCase()) {
+ case "CRITICAL": return "P1";
+ case "HIGH": return "P2";
+ case "MEDIUM": return "P3";
+ default: return "P4";
+ }
+ }
+
+ // ── ② POS 데이터 자동 분류 (결정론) ─────────────────────────────────────
+ @SuppressWarnings("unchecked")
+ public Map classifyPos(String tenantCode, Map req, String actor) {
+ RagToggles t = toggles.current(tenantCode);
+ String productCode = str(req.get("productCode"));
+ String price = str(req.getOrDefault("price", "0"));
+ String salePrice = str(req.getOrDefault("salePrice", "0"));
+ Map out = new LinkedHashMap<>();
+
+ if (!t.ragEnabled || !rag.available() || !t.structured) {
+ return posFallback(out, t, req);
+ }
+
+ String prompt = String.format(
+ "POS 가격 데이터를 NORMAL/ABNORMAL 로 분류하라. 상품코드=%s, 정가=%s, 판매가=%s. "
+ + "판매가>정가, 음수, 비현실적 값, 누락을 ABNORMAL 로 판정하고 category·reason·confidence(0~1)를 JSON 으로만.",
+ productCode, price, salePrice);
+
+ Map sres = rag.structured(tenantCode, prompt, POS_SCHEMA, t.generationModel, t.temperature);
+ Map data = sres.get("data") instanceof Map ? (Map) sres.get("data") : Map.of();
+
+ if (bool(sres.get("degraded")) || data.isEmpty() || data.get("classification") == null) {
+ return posFallback(out, t, req);
+ }
+
+ double conf = num(data.get("confidence"), 0.0);
+ boolean needsReview = conf < t.faithfulnessThreshold;
+ out.put("classification", String.valueOf(data.get("classification")).toUpperCase());
+ out.put("category", data.get("category"));
+ out.put("reason", data.get("reason"));
+ out.put("confidence", conf);
+ out.put("needsReview", needsReview); // 임계 미만 → 수동 검토
+ out.put("productCode", productCode);
+ out.put("engine", "RAG_STRUCTURED");
+ out.put("degraded", false);
+ out.putAll(meta(t, "structured", true));
+ return out;
+ }
+
+ private Map posFallback(Map out, RagToggles t, Map req) {
+ String price = str(req.getOrDefault("price", "0"));
+ String salePrice = str(req.getOrDefault("salePrice", "0"));
+ // 규칙 우선(결정론) — 판매가>정가 또는 음수면 ABNORMAL
+ String cls = "NORMAL";
+ String reason = "규칙 기반: 가격 범위 정상";
+ try {
+ double p = Double.parseDouble(price), s = Double.parseDouble(salePrice);
+ if (p < 0 || s < 0 || s > p) { cls = "ABNORMAL"; reason = "규칙 기반: 음수 또는 판매가>정가"; }
+ } catch (Exception ignore) {
+ // 숫자 파싱 실패 시 모델 보조
+ String r = ollama.chat("POS 가격 검증: 정가=" + price + " 판매가=" + salePrice, "POS 데이터 품질 관리 AI.");
+ cls = r.toUpperCase().contains("ABNORMAL") ? "ABNORMAL" : "NORMAL";
+ reason = "모델 폴백 판정";
+ }
+ out.put("classification", cls);
+ out.put("reason", reason);
+ out.put("confidence", 0.0);
+ out.put("needsReview", true); // 폴백은 항상 검토 권고
+ out.put("productCode", str(req.get("productCode")));
+ out.put("engine", "RULE_FALLBACK");
+ out.put("degraded", true);
+ out.put("degraded_reason", t.ragEnabled ? "rag_unavailable" : "rag_disabled");
+ out.putAll(meta(t, "fallback", false));
+ return out;
+ }
+
+ // ── 피드백 / 토글 ────────────────────────────────────────────────────────
+ public Map feedback(String tenantCode, Map req, String actor) {
+ String userRef = "u_" + Integer.toHexString((actor == null ? "anon" : actor).hashCode());
+ return rag.feedback(tenantCode,
+ 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(String tenantCode) {
+ Map m = toggles.current(tenantCode).toMap();
+ m.put("ragAvailable", rag.available());
+ return m;
+ }
+
+ public Map updateToggle(String tenantCode, String key, String value) {
+ Map m = toggles.update(tenantCode, key, value).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("tenantCode", t.tenantCode);
+ 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 boolean bool(Object o) { return Boolean.TRUE.equals(o) || "true".equalsIgnoreCase(String.valueOf(o)); }
+ private double num(Object o, double def) {
+ 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); }
+}
diff --git a/backend/src/main/java/com/zioinfo/esn/rag/RagController.java b/backend/src/main/java/com/zioinfo/esn/rag/RagController.java
new file mode 100644
index 0000000..f94672b
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/esn/rag/RagController.java
@@ -0,0 +1,98 @@
+package com.zioinfo.esn.rag;
+
+import com.zioinfo.esn.common.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * ESN 최신 AI 기법(중앙 guardia-rag) 배선 API — 별개 레이어, 테넌트 격리.
+ *
+ * 기존 {@code /api/ai/*}(AiController) 는 불변 . 본 컨트롤러는 중앙 경유 신규 경로만 추가한다.
+ *
+ *
+ * POST /api/ai/rag/analyze-alarm — ESL/HCore 알람 원인분석·우선순위(/agent+graph+structured)
+ * POST /api/ai/rag/classify-pos — POS 데이터 자동 분류(/structured, 임계 미만 needsReview)
+ * POST /api/ai/rag/feedback — 👍/👎 피드백(/rag/feedback, solution=esn 격리)
+ * GET /api/ai/rag/toggles — 현재 테넌트 기법 토글 스냅샷
+ * PUT /api/ai/rag/toggles/{key} — 테넌트 토글 변경(MANAGER+, esn_rag_setting 격리)
+ *
+ *
+ * 테넌트는 {@code X-Tenant-Code} 헤더 또는 {@code tenantCode} 바디 필드로 식별(없으면 기본).
+ */
+@RestController
+@RequestMapping("/api/ai/rag")
+@RequiredArgsConstructor
+public class RagController {
+
+ private final RagAiService service;
+
+ @PostMapping("/analyze-alarm")
+ public ApiResponse> analyzeAlarm(
+ @RequestBody Map req,
+ @RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader,
+ Authentication auth) {
+ return ApiResponse.ok(service.analyzeAlarm(tenant(tenantHeader, req), req, name(auth)));
+ }
+
+ @PostMapping("/classify-pos")
+ public ApiResponse> classifyPos(
+ @RequestBody Map req,
+ @RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader,
+ Authentication auth) {
+ return ApiResponse.ok(service.classifyPos(tenant(tenantHeader, req), req, name(auth)));
+ }
+
+ @PostMapping("/feedback")
+ public ApiResponse> feedback(
+ @RequestBody Map req,
+ @RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader,
+ Authentication auth) {
+ return ApiResponse.ok(service.feedback(tenant(tenantHeader, req), req, name(auth)));
+ }
+
+ @GetMapping("/toggles")
+ public ApiResponse> toggles(
+ @RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader,
+ @RequestParam(value = "tenantCode", required = false) String tenantParam) {
+ String tc = tenantHeader != null ? tenantHeader : tenantParam;
+ return ApiResponse.ok(service.currentToggles(tc));
+ }
+
+ /**
+ * 토글 변경 — rag.* 화이트리스트 키만, 테넌트 격리. RBAC(MANAGER+)은 SecurityConfig 통제.
+ */
+ @PutMapping("/toggles/{key}")
+ public ApiResponse> updateToggle(
+ @PathVariable String key,
+ @RequestBody Map req,
+ @RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader) {
+ if (key == null || !key.startsWith("rag.")) {
+ return ApiResponse.fail("ERR-RAG-400: rag.* 키만 변경 가능");
+ }
+ String tc = tenantHeader != null ? tenantHeader : str(req.get("tenantCode"));
+ Object v = req.get("value");
+ try {
+ return ApiResponse.ok(service.updateToggle(tc, key, v == null ? "" : String.valueOf(v)));
+ } catch (IllegalArgumentException e) {
+ return ApiResponse.fail(e.getMessage());
+ }
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+ private String tenant(String header, Map req) {
+ if (header != null && !header.isBlank()) return header;
+ Object t = req == null ? null : req.get("tenantCode");
+ return t == null ? null : String.valueOf(t);
+ }
+
+ private String name(Authentication auth) {
+ return auth == null ? "anon" : auth.getName();
+ }
+
+ private String str(Object o) {
+ return o == null ? null : String.valueOf(o);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/esn/rag/RagProperties.java b/backend/src/main/java/com/zioinfo/esn/rag/RagProperties.java
new file mode 100644
index 0000000..a4db52e
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/esn/rag/RagProperties.java
@@ -0,0 +1,49 @@
+package com.zioinfo.esn.rag;
+
+import lombok.Getter;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 중앙 guardia-rag 연동 설정 (ESN 전용).
+ *
+ * 보안 불변 : {@code base-url} 은 온프레미스(중앙 guardia-rag) 전용 이다.
+ * 외부 엔드포인트를 가리키도록 설정해서는 안 된다 — 기본값은 서버 내부 루프백
+ * {@code http://127.0.0.1:8020}.
+ *
+ *
{@code @Value} 주입 방식(OllamaClient 패턴 일치) — {@code @EnableConfigurationProperties}
+ * 등록 불필요. application.yml:
+ *
+ * guardia:
+ * rag:
+ * base-url: http://127.0.0.1:8020
+ * timeout-ms: 120000
+ * enabled: true
+ * solution: esn
+ * max-concurrent-streams: 2
+ *
+ */
+@Component
+@Getter
+public class RagProperties {
+
+ /** 중앙 guardia-rag 베이스 URL (서버 내부 루프백 기본). */
+ @Value("${guardia.rag.base-url:http://127.0.0.1:8020}")
+ private String baseUrl;
+
+ /** 호출 타임아웃(ms). 소형모델 생성 콜드로드 고려 120s 기본. */
+ @Value("${guardia.rag.timeout-ms:120000}")
+ private long timeoutMs;
+
+ /** RAG 경유 마스터 스위치. false면 RagClient 가 항상 미가용으로 동작(ESN 로컬 폴백). */
+ @Value("${guardia.rag.enabled:true}")
+ private boolean enabled;
+
+ /** 솔루션 식별자(컬렉션·토글·피드백 격리 키). */
+ @Value("${guardia.rag.solution:esn}")
+ private String solution;
+
+ /** 동시 SSE 스트림 상한 — 서버 RAM 보호. */
+ @Value("${guardia.rag.max-concurrent-streams:2}")
+ private int maxConcurrentStreams;
+}
diff --git a/backend/src/main/java/com/zioinfo/esn/rag/RagSettingMapper.java b/backend/src/main/java/com/zioinfo/esn/rag/RagSettingMapper.java
new file mode 100644
index 0000000..93ea14f
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/esn/rag/RagSettingMapper.java
@@ -0,0 +1,32 @@
+package com.zioinfo.esn.rag;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * ESN RAG 기법 토글 매퍼 — 테넌트 격리 {@code esn_rag_setting}.
+ *
+ * {@code @Mapper} 어노테이션 필수 (EsnApplication 의
+ * {@code @MapperScan(annotationClass = Mapper.class)} 패턴).
+ *
+ *
모든 조회/변경은 {@code tenant_code} 로 격리된다 — 한 테넌트가 다른 테넌트 토글을
+ * 읽거나 바꿀 수 없다.
+ */
+@Mapper
+public interface RagSettingMapper {
+
+ /** 한 테넌트의 모든 RAG 토글(key,value). */
+ List> findByTenant(@Param("tenantCode") String tenantCode);
+
+ /** 단건 조회. */
+ String findValue(@Param("tenantCode") String tenantCode,
+ @Param("settingKey") String settingKey);
+
+ /** upsert (ON CONFLICT (tenant_code, setting_key) DO UPDATE). */
+ int upsert(@Param("tenantCode") String tenantCode,
+ @Param("settingKey") String settingKey,
+ @Param("settingValue") String settingValue);
+}
diff --git a/backend/src/main/java/com/zioinfo/esn/rag/RagToggleService.java b/backend/src/main/java/com/zioinfo/esn/rag/RagToggleService.java
new file mode 100644
index 0000000..e76a354
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/esn/rag/RagToggleService.java
@@ -0,0 +1,161 @@
+package com.zioinfo.esn.rag;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * ESN RAG 기법 토글 서비스 — 테넌트 격리 .
+ *
+ * 전용 {@code esn_rag_setting}(tenant_code, key, value) 테이블에서 테넌트별 토글을 읽는다.
+ * 한 테넌트가 다른 테넌트 설정을 읽거나 바꿀 수 없다. 키 네임스페이스 {@code rag.*}.
+ *
+ *
무거운 기법(graphrag·rerank·stream·tool_use)은 서버 RAM 제약상 기본 off ,
+ * retrieval_mode 기본 vector, 소형모델 기본.
+ */
+@Service
+@RequiredArgsConstructor
+public class RagToggleService {
+
+ private final RagSettingMapper mapper;
+
+ 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"; // 무거움→기본 off
+ public static final String K_GRAPHRAG = "rag.graphrag"; // 무거움→기본 off
+ public static final String K_TOOL_USE = "rag.tool_use"; // 에이전틱→기본 off
+ public static final String K_STRUCTURED = "rag.structured"; // 결정론 JSON→기본 on
+ public static final String K_STREAM = "rag.stream"; // SSE→기본 off
+ public static final String K_TOP_K = "rag.top_k";
+ public static final String K_AGENT_STEPS = "rag.agent_max_steps";
+ public static final String K_TEMPERATURE = "rag.temperature";
+ public static final String K_FAITHFULNESS = "rag.faithfulness_threshold";
+ public static final String K_GEN_MODEL = "rag.generation_model";
+
+ /** 변경 허용 키 화이트리스트 — rag.* 외 임의 키 주입 차단. */
+ private static final java.util.Set ALLOWED = java.util.Set.of(
+ K_ENABLED, K_MODE, K_RERANK, K_GRAPHRAG, K_TOOL_USE, K_STRUCTURED, K_STREAM,
+ K_TOP_K, K_AGENT_STEPS, K_TEMPERATURE, K_FAITHFULNESS, K_GEN_MODEL);
+
+ /** 테넌트 토글 전부 조회(DB 누락 키는 안전 기본값 머지). */
+ public RagToggles current(String tenantCode) {
+ String tc = normTenant(tenantCode);
+ Map m = new LinkedHashMap<>();
+ List> rows = mapper.findByTenant(tc);
+ if (rows != null) {
+ for (Map r : rows) {
+ Object k = r.get("settingKey");
+ Object v = r.get("settingValue");
+ if (k != null) m.put(String.valueOf(k), v == null ? null : String.valueOf(v));
+ }
+ }
+
+ RagToggles t = new RagToggles();
+ t.tenantCode = tc;
+ t.ragEnabled = bool(m.get(K_ENABLED), true);
+ t.retrievalMode = mode(m.get(K_MODE));
+ t.rerank = bool(m.get(K_RERANK), false);
+ t.graphrag = bool(m.get(K_GRAPHRAG), false);
+ t.toolUse = bool(m.get(K_TOOL_USE), false);
+ t.structured = bool(m.get(K_STRUCTURED), true);
+ t.stream = bool(m.get(K_STREAM), false);
+ t.topK = clampInt(m.get(K_TOP_K), 5, 1, 20);
+ t.agentMaxSteps = clampInt(m.get(K_AGENT_STEPS), 4, 1, 8);
+ t.temperature = clampDouble(m.get(K_TEMPERATURE), 0.1, 0.0, 1.0);
+ t.faithfulnessThreshold = clampDouble(m.get(K_FAITHFULNESS), 0.5, 0.0, 1.0);
+ t.generationModel = blankTo(m.get(K_GEN_MODEL), "llama3.2:1b");
+ return t;
+ }
+
+ /** 토글 변경(테넌트 격리, 화이트리스트 키만). 반환=변경 후 전체 스냅샷. */
+ public RagToggles update(String tenantCode, String key, String value) {
+ if (key == null || !ALLOWED.contains(key)) {
+ throw new IllegalArgumentException("ERR-RAG-400: 허용되지 않은 토글 키");
+ }
+ String tc = normTenant(tenantCode);
+ mapper.upsert(tc, key, value == null ? "" : value);
+ return current(tc);
+ }
+
+ /** retrieval_mode 결정: graphrag 토글 on 이면 graph 우선, 아니면 설정 mode. */
+ public String effectiveMode(RagToggles t) {
+ if (t.graphrag) return "graph";
+ return t.retrievalMode;
+ }
+
+ // ── 정규화/클램프 (상한 강제 — 폭주 차단) ───────────────────────────────
+ private String normTenant(String tc) {
+ if (tc == null || tc.isBlank()) return "ZIOINFO"; // 기본 테넌트
+ return tc.trim().toUpperCase();
+ }
+
+ 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 {
+ return Math.max(min, Math.min(max, Integer.parseInt(v.trim())));
+ } catch (Exception e) {
+ return def;
+ }
+ }
+
+ private double clampDouble(String v, double def, double min, double max) {
+ try {
+ return Math.max(min, Math.min(max, Double.parseDouble(v.trim())));
+ } 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 String tenantCode = "ZIOINFO";
+ public boolean ragEnabled = true;
+ public String retrievalMode = "vector";
+ public boolean rerank = false;
+ public boolean graphrag = false;
+ public boolean toolUse = false;
+ public boolean structured = true;
+ public boolean stream = false;
+ public int topK = 5;
+ public int agentMaxSteps = 4;
+ public double temperature = 0.1;
+ public double faithfulnessThreshold = 0.5;
+ public String generationModel = "llama3.2:1b";
+
+ public Map toMap() {
+ Map m = new LinkedHashMap<>();
+ m.put("tenantCode", tenantCode);
+ m.put("ragEnabled", ragEnabled);
+ m.put("retrievalMode", retrievalMode);
+ m.put("rerank", rerank);
+ m.put("graphrag", graphrag);
+ m.put("toolUse", toolUse);
+ m.put("structured", structured);
+ m.put("stream", stream);
+ m.put("topK", topK);
+ m.put("agentMaxSteps", agentMaxSteps);
+ m.put("temperature", temperature);
+ m.put("faithfulnessThreshold", faithfulnessThreshold);
+ m.put("generationModel", generationModel);
+ return m;
+ }
+ }
+}
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index fd4c915..9d64e08 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -22,6 +22,8 @@ spring:
schema-locations:
- classpath:db/schema.sql
- classpath:db/91_uiws_port.sql
+ # AI 기법(중앙 guardia-rag) 토글 — 테넌트 격리 esn_rag_setting (멱등)
+ - classpath:db/92_esn_rag_setting.sql
web:
resources:
static-locations: classpath:/static/
@@ -40,6 +42,14 @@ guardia:
ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
itsm-url: ${ITSM_URL:http://localhost:9001}
+ # 중앙 guardia-rag (온프레미스 루프백 전용 — 외부 엔드포인트 금지).
+ # 미가용/타임아웃 시 EsnRagClient 가 degraded 폴백, 기존 OllamaClient/룰로 무손실 동작.
+ rag:
+ base-url: ${RAG_URL:http://127.0.0.1:8020}
+ timeout-ms: ${RAG_TIMEOUT_MS:120000}
+ enabled: ${RAG_ENABLED:true}
+ solution: esn
+ max-concurrent-streams: 2
jwt:
secret: ${JWT_SECRET:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits}
expiration: 86400000
diff --git a/backend/src/main/resources/db/92_esn_rag_setting.sql b/backend/src/main/resources/db/92_esn_rag_setting.sql
new file mode 100644
index 0000000..004ebce
--- /dev/null
+++ b/backend/src/main/resources/db/92_esn_rag_setting.sql
@@ -0,0 +1,53 @@
+-- ============================================================================
+-- 92_esn_rag_setting.sql — ESN 최신 AI 기법(중앙 guardia-rag) 토글 설정 (멱등)
+-- ----------------------------------------------------------------------------
+-- 기존 esn_setting 은 전역(테넌트 무차원)이라 RAG 기법 토글의 *테넌트 격리* 요건을
+-- 만족하지 못한다. 그래서 (tenant_code, setting_key) 복합 PK 의 전용 테이블을 신설한다.
+-- sql.init.mode=always + continue-on-error 환경에서 재실행 안전(IF NOT EXISTS / ON CONFLICT).
+-- 무거운 기법(rerank·graphrag·stream)은 서버 RAM 제약상 기본 OFF, retrieval_mode 기본 vector.
+-- 자격증명/PII 미저장 — 운영 토글 값만.
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS esn_rag_setting (
+ tenant_code VARCHAR(20) NOT NULL,
+ setting_key VARCHAR(100) NOT NULL,
+ setting_value TEXT,
+ updated_at TIMESTAMP DEFAULT NOW(),
+ PRIMARY KEY (tenant_code, setting_key)
+);
+
+-- 테넌트별 기본 토글 시드 (멱등). 4개 테넌트 × 토글 키.
+-- 무거운 기법 기본 off, 소형모델, vector 모드, top_k=5.
+INSERT INTO esn_rag_setting (tenant_code, setting_key, setting_value) VALUES
+ ('LGINNOTEK','rag.enabled','true'), ('LGINNOTEK','rag.retrieval_mode','vector'),
+ ('LGINNOTEK','rag.rerank','false'), ('LGINNOTEK','rag.graphrag','false'),
+ ('LGINNOTEK','rag.tool_use','false'), ('LGINNOTEK','rag.structured','true'),
+ ('LGINNOTEK','rag.stream','false'), ('LGINNOTEK','rag.top_k','5'),
+ ('LGINNOTEK','rag.agent_max_steps','4'), ('LGINNOTEK','rag.temperature','0.1'),
+ ('LGINNOTEK','rag.faithfulness_threshold','0.5'),
+ ('LGINNOTEK','rag.generation_model','llama3.2:1b'),
+
+ ('LGIT','rag.enabled','true'), ('LGIT','rag.retrieval_mode','vector'),
+ ('LGIT','rag.rerank','false'), ('LGIT','rag.graphrag','false'),
+ ('LGIT','rag.tool_use','false'), ('LGIT','rag.structured','true'),
+ ('LGIT','rag.stream','false'), ('LGIT','rag.top_k','5'),
+ ('LGIT','rag.agent_max_steps','4'), ('LGIT','rag.temperature','0.1'),
+ ('LGIT','rag.faithfulness_threshold','0.5'),
+ ('LGIT','rag.generation_model','llama3.2:1b'),
+
+ ('EMART','rag.enabled','true'), ('EMART','rag.retrieval_mode','vector'),
+ ('EMART','rag.rerank','false'), ('EMART','rag.graphrag','false'),
+ ('EMART','rag.tool_use','false'), ('EMART','rag.structured','true'),
+ ('EMART','rag.stream','false'), ('EMART','rag.top_k','5'),
+ ('EMART','rag.agent_max_steps','4'), ('EMART','rag.temperature','0.1'),
+ ('EMART','rag.faithfulness_threshold','0.5'),
+ ('EMART','rag.generation_model','llama3.2:1b'),
+
+ ('ZIOINFO','rag.enabled','true'), ('ZIOINFO','rag.retrieval_mode','vector'),
+ ('ZIOINFO','rag.rerank','false'), ('ZIOINFO','rag.graphrag','false'),
+ ('ZIOINFO','rag.tool_use','false'), ('ZIOINFO','rag.structured','true'),
+ ('ZIOINFO','rag.stream','false'), ('ZIOINFO','rag.top_k','5'),
+ ('ZIOINFO','rag.agent_max_steps','4'), ('ZIOINFO','rag.temperature','0.1'),
+ ('ZIOINFO','rag.faithfulness_threshold','0.5'),
+ ('ZIOINFO','rag.generation_model','llama3.2:1b')
+ON CONFLICT (tenant_code, setting_key) DO NOTHING;
diff --git a/backend/src/main/resources/mapper/RagSettingMapper.xml b/backend/src/main/resources/mapper/RagSettingMapper.xml
new file mode 100644
index 0000000..e3e4bcd
--- /dev/null
+++ b/backend/src/main/resources/mapper/RagSettingMapper.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ SELECT setting_key AS "settingKey", setting_value AS "settingValue"
+ FROM esn_rag_setting
+ WHERE tenant_code = #{tenantCode}
+ ORDER BY setting_key ASC
+
+
+
+ SELECT setting_value
+ FROM esn_rag_setting
+ WHERE tenant_code = #{tenantCode} AND setting_key = #{settingKey}
+
+
+
+ INSERT INTO esn_rag_setting (tenant_code, setting_key, setting_value, updated_at)
+ VALUES (#{tenantCode}, #{settingKey}, #{settingValue}, NOW())
+ ON CONFLICT (tenant_code, setting_key) DO UPDATE
+ SET setting_value = EXCLUDED.setting_value,
+ updated_at = NOW()
+
+
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3f116b4..ed9c065 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -14,6 +14,7 @@ import UserList from './pages/UserList'
import ProductList from './pages/ProductList'
import TenantAdmin from './pages/TenantAdmin'
import AiAnalysis from './pages/AiAnalysis'
+import AiTechniqueSettings from './pages/AiTechniqueSettings'
import TagBindingList from './pages/TagBindingList'
import UpdateQueueList from './pages/UpdateQueueList'
import AuditLog from './pages/AuditLog'
@@ -49,6 +50,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 1848798..44842ba 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -117,3 +117,20 @@ export const setUserActive = (id: number, active: boolean) =>
export const analyzeAlarm = (d: object) => unwrap(api.post('/api/ai/analyze-alarm', d))
export const classifyPos = (d: object) => unwrap(api.post('/api/ai/classify-pos', d))
export const aiChat = (message: string) => unwrap(api.post('/api/ai/chat', { message }))
+
+// ── AI 기법 (중앙 guardia-rag 경유 — 테넌트 격리) ─────────────────────────────
+// X-Tenant-Code 헤더로 테넌트 격리. 토글 조회/변경 + 대표 기능(/agent·/structured) 경유.
+export const ragToggles = (tenantCode?: string) =>
+ unwrap(api.get('/api/ai/rag/toggles', { params: { tenantCode } }))
+export const ragUpdateToggle = (key: string, value: string | number | boolean, tenantCode?: string) =>
+ unwrap(api.put(`/api/ai/rag/toggles/${key}`, { value, tenantCode },
+ tenantCode ? { headers: { 'X-Tenant-Code': tenantCode } } : undefined))
+export const ragAnalyzeAlarm = (d: object, tenantCode?: string) =>
+ unwrap(api.post('/api/ai/rag/analyze-alarm', d,
+ tenantCode ? { headers: { 'X-Tenant-Code': tenantCode } } : undefined))
+export const ragClassifyPos = (d: object, tenantCode?: string) =>
+ unwrap(api.post('/api/ai/rag/classify-pos', d,
+ tenantCode ? { headers: { 'X-Tenant-Code': tenantCode } } : undefined))
+export const ragFeedback = (d: object, tenantCode?: string) =>
+ unwrap(api.post('/api/ai/rag/feedback', d,
+ tenantCode ? { headers: { 'X-Tenant-Code': tenantCode } } : undefined))
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 54afe7f..b01574c 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -3,7 +3,7 @@ import {
LayoutDashboard, Store, FileText, RefreshCw,
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
Link2, ListOrdered, NotebookPen, CalendarDays, Mail, BarChart3,
- ScrollText, Settings, Smartphone
+ ScrollText, Settings, Smartphone, Sparkles
} from 'lucide-react'
const nav = [
@@ -23,6 +23,7 @@ const nav = [
{ to: '/audit', icon: ScrollText, label: '감사 로그' },
{ to: '/settings', icon: Settings, label: '시스템 설정' },
{ to: '/ai', icon: Brain, label: 'AI 분석' },
+ { to: '/ai-techniques', icon: Sparkles, label: 'AI 기법 설정' },
{ to: '/worklogs', icon: NotebookPen, label: '업무일지' },
{ to: '/schedules', icon: CalendarDays, label: '일정 관리' },
{ to: '/messages', icon: Mail, label: '쪽지' },
diff --git a/frontend/src/pages/AiTechniqueSettings.tsx b/frontend/src/pages/AiTechniqueSettings.tsx
new file mode 100644
index 0000000..538c708
--- /dev/null
+++ b/frontend/src/pages/AiTechniqueSettings.tsx
@@ -0,0 +1,183 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { ragToggles, ragUpdateToggle } from '../api/client'
+import { Sparkles, Server, Layers, Sliders } from 'lucide-react'
+
+const TENANTS = ['LGINNOTEK', 'LGIT', 'EMART', 'ZIOINFO']
+
+// 토글 메타: key, 라벨, 타입, 무거운 기법 여부(경고 배지), 옵션
+const BOOL_TOGGLES: { key: string; label: string; desc: string; heavy?: boolean }[] = [
+ { key: 'rag.enabled', label: 'RAG 경유 사용', desc: '끄면 기존 Ollama/룰 경로로 동작' },
+ { key: 'rag.rerank', label: '리랭킹 (rerank)', desc: '검색 결과 재정렬 — 품질↑·비용↑', heavy: true },
+ { key: 'rag.graphrag', label: 'GraphRAG (의존성 추적)', desc: '허브→단말 연쇄 원인 추적 — 무거움', heavy: true },
+ { key: 'rag.tool_use', label: '에이전틱 tool-use (/agent)', desc: '다단계 진단 루프 — 무거움', heavy: true },
+ { key: 'rag.structured', label: '구조화 출력 (/structured)', desc: '결정론 JSON — POS 분류 권장 on' },
+ { key: 'rag.stream', label: '토큰 스트리밍 (SSE)', desc: '긴 답변 점진 표시 — 동시 스트림 제한', heavy: true },
+]
+
+export default function AiTechniqueSettings() {
+ const qc = useQueryClient()
+ const [tenant, setTenant] = useState(TENANTS[3]) // ZIOINFO 기본
+ const [draft, setDraft] = useState>({})
+
+ const { data: t, isLoading } = useQuery({
+ queryKey: ['rag-toggles', tenant],
+ queryFn: () => ragToggles(tenant),
+ })
+
+ const save = useMutation({
+ mutationFn: ({ key, value }: { key: string; value: string | number | boolean }) =>
+ ragUpdateToggle(key, value, tenant),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['rag-toggles', tenant] }),
+ })
+
+ if (isLoading) return 로딩 중...
+
+ const tg: any = t || {}
+
+ return (
+
+
+ AI 기법 설정 (중앙 guardia-rag)
+
+
+ 테넌트별로 격리되어 저장됩니다. 무거운 기법(리랭킹·GraphRAG·tool-use·스트리밍)은 서버 RAM 보호를 위해 기본 OFF입니다.
+ 변경은 MANAGER 이상 권한이 필요합니다.
+
+
+ {/* 테넌트 선택 + 중앙 RAG 상태 */}
+
+
+
+ 테넌트
+ { setTenant(e.target.value); setDraft({}) }}
+ className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-100">
+ {TENANTS.map(tc => {tc} )}
+
+
+
+ 중앙 RAG:
+ {tg.ragAvailable
+ ? 가용
+ : 미가용 (degraded 폴백) }
+
+
+
+ {/* 불리언 토글들 */}
+
+
+ 기법 On/Off
+
+ {BOOL_TOGGLES.map(b => {
+ const on = !!tg[camel(b.key)]
+ return (
+
+
+
+ {b.label}
+ {b.heavy && 무거움 }
+
+
{b.desc}
+
+
save.mutate({ key: b.key, value: !on })}
+ disabled={save.isPending}
+ className={`relative w-11 h-6 rounded-full transition ${on ? 'bg-brand' : 'bg-edge'}`}>
+
+
+
+ )
+ })}
+
+
+ {/* 파라미터 (mode / top_k / step / model) */}
+
+
+ 파라미터
+
+
+
+
setDraft(d => ({ ...d, 'rag.retrieval_mode': e.target.value }))}
+ className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-100">
+ vector
+ hybrid (BM25+벡터)
+ graph (GraphRAG)
+
+
+
+
+
+
+
+
+
+
setDraft(d => ({ ...d, 'rag.generation_model': e.target.value }))}
+ className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-100 w-48" />
+
+
+
+ {Object.keys(draft).map(k => (
+ save.mutate({ key: k, value: draft[k] })}
+ disabled={save.isPending}
+ className="text-xs px-3 py-1.5 rounded bg-brand/80 text-white hover:bg-brand">
+ 저장: {k.replace('rag.', '')}
+
+ ))}
+ {Object.keys(draft).length > 0 && (
+ setDraft({})}
+ className="text-xs px-3 py-1.5 rounded bg-edge/60 text-gray-300 hover:bg-edge">변경 취소
+ )}
+
+
+
+ )
+}
+
+function camel(key: string) {
+ const m: Record = {
+ 'rag.enabled': 'ragEnabled', 'rag.rerank': 'rerank', 'rag.graphrag': 'graphrag',
+ 'rag.tool_use': 'toolUse', 'rag.structured': 'structured', 'rag.stream': 'stream',
+ }
+ return m[key] || key
+}
+
+function Param({ label, hint, children }: { label: string; hint?: string; children: any }) {
+ return (
+
+
+
{label}
+ {hint &&
{hint}
}
+
+ {children}
+
+ )
+}
+
+function NumParam({ label, k, cur, min, max, draft, setDraft }: any) {
+ return (
+
+ setDraft((d: any) => ({ ...d, [k]: e.target.value }))}
+ className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-100 w-24" />
+
+ )
+}
+
+function FloatParam({ label, k, cur, draft, setDraft }: any) {
+ return (
+
+ setDraft((d: any) => ({ ...d, [k]: e.target.value }))}
+ className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-100 w-24" />
+
+ )
+}
diff --git a/scripts/rag_ingest_esn.py b/scripts/rag_ingest_esn.py
new file mode 100644
index 0000000..4493615
--- /dev/null
+++ b/scripts/rag_ingest_esn.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""
+GUARDiA ESN — 중앙 guardia-rag 색인 훅 (대표 도메인/운영 지식 → esn 테넌트 컬렉션).
+
+알람 원인분석(GraphRAG 의존성 근거)·POS 분류(스키마 grounding)를 위해, ESN 의 대표
+도메인 메타데이터(테이블/관계/운영 룰)만 중앙 RAG 의 테넌트별 컬렉션(esn__)에 적재한다.
+실제 데이터 행·자격증명·PII(passwordHash·ssh_*)는 색인하지 않는다(보안 불변).
+
+사용:
+ python scripts/rag_ingest_esn.py # 4개 테넌트 전부
+ python scripts/rag_ingest_esn.py LGINNOTEK # 특정 테넌트만
+ RAG_URL=http://127.0.0.1:8020 python scripts/rag_ingest_esn.py
+
+설계 메모:
+- 외부 API 0. 중앙 guardia-rag(온프레미스 루프백)만 호출.
+- 테넌트 격리: 컬렉션 esn__ 로 분리. 한 테넌트 색인이 다른 테넌트에 섞이지 않음.
+- 멱등: doc_id 안정 → 재실행 시 upsert(중앙 처리). 실패해도 ESN 동작 영향 없음(보조 스크립트).
+"""
+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("/")
+SOLUTION = "esn"
+TENANTS = ["LGINNOTEK", "LGIT", "EMART", "ZIOINFO"]
+
+# 대표 도메인/운영 지식 문서 — 알람 의존성 추적·POS 분류 grounding 용.
+# (자격증명·PII 제외. 관계·룰·물리 테이블 정의 중심. 테넌트 공통 지식 → 각 테넌트 컬렉션에 적재.)
+COMMON_DOCS = [
+ {
+ "doc_id": "esn_dep_graph",
+ "source": "ESN-DEPENDENCY",
+ "text": (
+ "의존성 관계: esn_hcore_device(게이트웨이/허브) → esn_store(매장) → ESL 단말 → "
+ "esn_tag_binding(ESL↔상품 바인딩). 허브 1대 장애 시 하위 매장의 다수 ESL 단말에서 "
+ "연쇄(cascading) 알람이 발생한다. 동일 hcore 하위 단말의 동시 알람은 단일 근본원인(허브)으로 "
+ "수렴하여 1건으로 그룹핑한다."
+ ),
+ },
+ {
+ "doc_id": "esn_alarm_schema",
+ "source": "ESN-SCHEMA",
+ "text": (
+ "테이블 esn_alarm: 장치 알람. 컬럼 - id, tenant_code, store_code, device_id, "
+ "alarm_type(통신단절/배터리/표시오류 등), severity(CRITICAL/HIGH/MEDIUM/LOW), message, "
+ "occurred_at. 우선순위 산정: 영향 단말 수 × 테넌트 중요도 × SLA 잔여시간 × 중복도 가중합 → P1~P4."
+ ),
+ },
+ {
+ "doc_id": "esn_priority_rule",
+ "source": "ESN-RULE",
+ "text": (
+ "우선순위 규칙(폴백): severity CRITICAL→P1, HIGH→P2, MEDIUM→P3, LOW→P4. "
+ "단, 영향 단말 수가 많거나 핵심 매장이면 한 단계 상향. 같은 groupKey(동일 근본원인) 파생 "
+ "알람은 1건으로 집계한다."
+ ),
+ },
+ {
+ "doc_id": "esn_poscvt_schema",
+ "source": "ESN-SCHEMA",
+ "text": (
+ "테이블 esn_pos_cvt: POS→ESL 가격 변환. 컬럼 - id, tenant_code, product_code, price(정가), "
+ "sale_price(판매가), status. 분류 룰 — ABNORMAL 판정: 판매가>정가, 음수 가격, 누락, "
+ "비현실적 값(예: 0 또는 과대). 그 외 NORMAL. confidence 임계 미만은 수동 검토(needsReview)."
+ ),
+ },
+ {
+ "doc_id": "esn_hcore_schema",
+ "source": "ESN-SCHEMA",
+ "text": (
+ "테이블 esn_hcore_device: 게이트웨이/허브. 컬럼 - id, tenant_code, store_code, status"
+ "(ONLINE/OFFLINE), child_device_count(하위 단말 수), last_seen_at(마지막 통신). "
+ "OFFLINE + 하위 단말 다수 알람 = 허브 장애 시그니처."
+ ),
+ },
+]
+
+
+def ingest_tenant(tenant: str) -> int:
+ collection = f"{SOLUTION}__{tenant}"
+ # 문서에 테넌트 메타 부여(컬렉션 격리)
+ docs = [{**d, "tenant_code": tenant} for d in COMMON_DOCS]
+ payload = {"solution": SOLUTION, "collection": collection, "tenant_code": tenant, "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 '{collection}'")
+ print(f" response: {body[:200]}")
+ return 0
+ except urllib.error.HTTPError as e:
+ print(f"[WARN] ingest HTTP {e.code} ({collection}) — 중앙 RAG 응답 이상(보조 스크립트). ESN 동작 영향 없음.")
+ return 1
+ except urllib.error.URLError as e:
+ print(f"[WARN] 중앙 guardia-rag 미가용({RAG_URL}) — 색인 보류. 라이브 가동 후 재실행. (요약: {str(e.reason)[:80]})")
+ return 1
+ except Exception:
+ print(f"[WARN] 색인 중 예기치 못한 오류({collection}) — 보류(ESN 동작 영향 없음).")
+ return 1
+
+
+def main() -> int:
+ targets = [t.upper() for t in sys.argv[1:]] or TENANTS
+ rc = 0
+ for t in targets:
+ if t not in TENANTS:
+ print(f"[SKIP] 알 수 없는 테넌트: {t} (허용: {', '.join(TENANTS)})")
+ continue
+ rc |= ingest_tenant(t)
+ return rc
+
+
+if __name__ == "__main__":
+ sys.exit(main())