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__} 을 + * 실어 테넌트별 컬렉션을 분리한다. + * + *

호출 계약(중앙): + *

+ */ +@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 를 경유하는 별개 레이어로, 두 대표 기능을 전환한다: + *

    + *
  1. 알람 원인분석·우선순위 → {@code /rag/agent}(tool-use 다단계 진단) + * + GraphRAG(retrieval_mode=graph 의존성 추적, graphrag 토글 on 시) + {@code /rag/structured} + * (우선순위 결정론 JSON). 토글 off/미가용 시 기존 {@link OllamaClient#chat} 로 무손실 폴백.
  2. + *
  3. POS 데이터 자동 분류 → {@code /rag/structured}(format:json 결정론). + * confidence 임계 미만이면 {@code needsReview:true}. 미가용 시 OllamaClient 폴백.
  4. + *
+ * + *

모든 응답에 적용 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 @@ + + + + + + + + + + + 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 상태 */} +
+
+ + 테넌트 + +
+
+ 중앙 RAG: + {tg.ragAvailable + ? 가용 + : 미가용 (degraded 폴백)} +
+
+ + {/* 불리언 토글들 */} +
+

+ 기법 On/Off +

+ {BOOL_TOGGLES.map(b => { + const on = !!tg[camel(b.key)] + return ( +
+
+
+ {b.label} + {b.heavy && 무거움} +
+
{b.desc}
+
+ +
+ ) + })} +
+ + {/* 파라미터 (mode / top_k / step / model) */} +
+

+ 파라미터 +

+ + + + + + + + + + + + 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 => ( + + ))} + {Object.keys(draft).length > 0 && ( + + )} +
+
+
+ ) +} + +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())