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

This commit is contained in:
GUARDiA 2026-06-21 11:39:10 +09:00
parent bb6bc69c68
commit f264524a87
16 changed files with 1332 additions and 1 deletions

View File

@ -48,6 +48,8 @@ public class SecurityConfig {
// (아래 일반 PUT /api/** 규칙보다 먼저 평가되도록 상단 배치) // (아래 일반 PUT /api/** 규칙보다 먼저 평가되도록 상단 배치)
.requestMatchers(HttpMethod.PUT, "/api/admin/settings/**").hasRole("ADMIN") .requestMatchers(HttpMethod.PUT, "/api/admin/settings/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "MANAGER") .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.DELETE, "/api/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER") .requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")
.requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER") .requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")

View File

@ -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 전용).
*
* <p><b>핵심 설계</b>: ESN LangChain·벡터 로직을 Java 재구현하지 않는다(13벌 재구현 금지).
* 중앙 Python guardia-rag 검색·기법·구조화·검증·에이전틱을 REST 호출하기만 한다.
* 미가용/타임아웃/RAM 부족 <b>절대 예외를 전파하지 않고</b> {@code degraded:true} 표시된
* 폴백 결과를 돌려준다(서비스 계층이 기존 OllamaClient/ 폴백 수행).
*
* <p>WebFlux 미의존 기존 {@code OllamaClient} 동일하게 {@code java.net.http.HttpClient} 사용.
*
* <p><b>보안 불변</b>: 온프레미스 전용(루프백). 응답에서 자격증명/PII/스택트레이스 미노출.
* 오류는 요약 1줄만 로깅.
*
* <p><b>테넌트 격리</b>: 모든 호출에 {@code solution=esn} + {@code collection=esn__<TENANT>}
* 실어 테넌트별 컬렉션을 분리한다.
*
* <p>호출 계약(중앙):
* <ul>
* <li>POST /rag/answer 검색(retrieval_mode)+근거생성+인용+guardrail</li>
* <li>POST /rag/verify 근거검증(faithfulness/grounded)</li>
* <li>POST /rag/structured format:json 결정론 JSON</li>
* <li>POST /rag/agent tool-use 에이전틱 루프</li>
* <li>POST /rag/feedback 👍/👎 피드백 (solution=esn 격리)</li>
* <li>POST /rag/ingest 색인 (테넌트 컬렉션)</li>
* </ul>
*/
@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<String> 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<String, Object> answer(String tenantCode, String query, String retrievalMode,
boolean rerank, int topK, String model, boolean verify) {
if (!props.isEnabled()) return degraded("rag_disabled");
try {
Map<String, Object> options = new LinkedHashMap<>();
options.put("rerank", rerank);
options.put("stream", false);
options.put("verify", verify);
if (model != null) options.put("model", model);
Map<String, Object> body = 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<String, Object> 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<String, Object> structured(String tenantCode, String prompt, Map<String, Object> schema,
String model, double temperature) {
if (!props.isEnabled()) return degraded("rag_disabled");
try {
Map<String, Object> 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<String, Object> 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<String, Object> agent(String tenantCode, String query, Object tools,
String retrievalMode, int maxSteps, String model) {
if (!props.isEnabled()) return degraded("rag_disabled");
try {
Map<String, Object> 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<String, Object> 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<String, Object> verify(String tenantCode, String answer, String context) {
if (!props.isEnabled()) return degraded("rag_disabled");
try {
Map<String, Object> body = base(tenantCode);
body.put("answer", answer);
if (context != null) body.put("context", context);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> res = post("/rag/feedback", body);
return res != null ? res : Map.of("stored", false, "degraded", true);
} catch (Exception e) {
log.warn("RAG /feedback 일시 불가: {}", summarize(e));
return Map.of("stored", false, "degraded", true);
}
}
/** POST /rag/ingest — 테넌트 컬렉션 색인. 색인 스크립트/훅에서 사용. */
public Map<String, Object> ingest(String tenantCode, Object documents) {
if (!props.isEnabled()) return degraded("rag_disabled");
try {
Map<String, Object> body = base(tenantCode);
body.put("documents", documents);
Map<String, Object> 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<String, Object> base(String tenantCode) {
Map<String, Object> 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<String, Object> post(String path, Map<String, Object> 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<String> 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<String, Object> degraded(String reason) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("degraded", true);
out.put("degraded_reason", reason);
return out;
}
/** 스택트레이스 미노출: 메시지 1줄만 요약. */
private String summarize(Exception e) {
String m = e.getMessage();
if (m == null) return e.getClass().getSimpleName();
return m.length() > 160 ? m.substring(0, 160) : m;
}
}

View File

@ -0,0 +1,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 고유 도구 디스크립터 레지스트리.
*
* <p>중앙 {@code /rag/agent} "AI 가 호출 가능한 도구 목록"으로 전달된다(이름·설명·파라미터
* 스키마). 실제 실행은 중앙이 ESN 콜백/MCP 위임하거나, 디스크립터를 컨텍스트 힌트로만
* 사용한다(서버 구현에 따름). 어느 쪽이든 ESN <b>읽기 전용 조회 도구</b> 노출하며,
* 자격증명/PII/SSH 노출 도구는 등록하지 않는다.
*
* <p>알람 도메인(esn-ai-alarm-agent) 다단계 진단 루프(증상연관조회가설검증)
* 완주하도록 알람·HCore·의존성·작업이력 조회 도구를 제공한다.
*/
@Component
public class EsnToolRegistry {
/** 알람 원인분석용 도구 셋(읽기전용). tenantCode 는 호출 시 컨텍스트로 고정. */
public List<Map<String, Object>> 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<String, Object> tool(String name, String desc, Map<String, Object>... params) {
Map<String, Object> properties = new LinkedHashMap<>();
for (Map<String, Object> p : params) {
String pn = (String) p.remove("__name");
properties.put(pn, p);
}
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
schema.put("properties", properties);
Map<String, Object> t = new LinkedHashMap<>();
t.put("name", name);
t.put("description", desc);
t.put("parameters", schema);
return t;
}
private Map<String, Object> param(String name, String type, String desc) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("__name", name);
p.put("type", type);
p.put("description", desc);
return p;
}
}

View File

@ -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 배선 레이어(순증·비파괴, 테넌트 격리).
*
* <p>기존 {@link OllamaClient}(폴백 내장) {@code AiController} <b>불변</b>. 서비스는
* 중앙 guardia-rag 경유하는 <b>별개 레이어</b>, 대표 기능을 전환한다:
* <ol>
* <li><b>알람 원인분석·우선순위</b> {@code /rag/agent}(tool-use 다단계 진단)
* + GraphRAG(retrieval_mode=graph 의존성 추적, graphrag 토글 on ) + {@code /rag/structured}
* (우선순위 결정론 JSON). 토글 off/미가용 기존 {@link OllamaClient#chat} 무손실 폴백.</li>
* <li><b>POS 데이터 자동 분류</b> {@code /rag/structured}(format:json 결정론).
* confidence 임계 미만이면 {@code needsReview:true}. 미가용 OllamaClient 폴백.</li>
* </ol>
*
* <p>모든 응답에 적용 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<String, Object> 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<String, Object> 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<String, Object> analyzeAlarm(String tenantCode, Map<String, Object> 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<String, Object> out = new LinkedHashMap<>();
if (!t.ragEnabled || !rag.available()) {
return alarmFallback(out, t, req);
}
// tool-use 토글 on /rag/agent 다단계 진단, off graph/answer 단발
Map<String, Object> 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<String, Object> sres = rag.structured(tenantCode,
query + "\n근거:\n" + evText + "\n위 근거로 priority(P1~P4)·rootCause·group·조치를 JSON 으로만 산출.",
ALARM_SCHEMA, t.generationModel, t.temperature);
Map<String, Object> data = sres.get("data") instanceof Map ? (Map<String, Object>) 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<String, Object> alarmFallback(Map<String, Object> out, RagToggles t, Map<String, Object> 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<String, Object> classifyPos(String tenantCode, Map<String, Object> 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<String, Object> 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<String, Object> sres = rag.structured(tenantCode, prompt, POS_SCHEMA, t.generationModel, t.temperature);
Map<String, Object> data = sres.get("data") instanceof Map ? (Map<String, Object>) 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<String, Object> posFallback(Map<String, Object> out, RagToggles t, Map<String, Object> 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<String, Object> feedback(String tenantCode, Map<String, Object> 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<String, Object> currentToggles(String tenantCode) {
Map<String, Object> m = toggles.current(tenantCode).toMap();
m.put("ragAvailable", rag.available());
return m;
}
public Map<String, Object> updateToggle(String tenantCode, String key, String value) {
Map<String, Object> m = toggles.update(tenantCode, key, value).toMap();
m.put("ragAvailable", rag.available());
return m;
}
// helpers
private Map<String, Object> meta(RagToggles t, String technique, boolean structured) {
Map<String, Object> m = new LinkedHashMap<>();
Map<String, Object> applied = new LinkedHashMap<>();
applied.put("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); }
}

View File

@ -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 별개 레이어, 테넌트 격리.
*
* <p>기존 {@code /api/ai/*}(AiController) <b>불변</b>. 컨트롤러는 중앙 경유 신규 경로만 추가한다.
*
* <ul>
* <li>POST /api/ai/rag/analyze-alarm ESL/HCore 알람 원인분석·우선순위(/agent+graph+structured)</li>
* <li>POST /api/ai/rag/classify-pos POS 데이터 자동 분류(/structured, 임계 미만 needsReview)</li>
* <li>POST /api/ai/rag/feedback 👍/👎 피드백(/rag/feedback, solution=esn 격리)</li>
* <li>GET /api/ai/rag/toggles 현재 테넌트 기법 토글 스냅샷</li>
* <li>PUT /api/ai/rag/toggles/{key} 테넌트 토글 변경(MANAGER+, esn_rag_setting 격리)</li>
* </ul>
*
* <p>테넌트는 {@code X-Tenant-Code} 헤더 또는 {@code tenantCode} 바디 필드로 식별(없으면 기본).
*/
@RestController
@RequestMapping("/api/ai/rag")
@RequiredArgsConstructor
public class RagController {
private final RagAiService service;
@PostMapping("/analyze-alarm")
public ApiResponse<Map<String, Object>> analyzeAlarm(
@RequestBody Map<String, Object> 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<Map<String, Object>> classifyPos(
@RequestBody Map<String, Object> 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<Map<String, Object>> feedback(
@RequestBody Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> updateToggle(
@PathVariable String key,
@RequestBody Map<String, Object> 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<String, Object> 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);
}
}

View File

@ -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 전용).
*
* <p><b>보안 불변</b>: {@code base-url} <b>온프레미스(중앙 guardia-rag) 전용</b>이다.
* 외부 엔드포인트를 가리키도록 설정해서는 된다 기본값은 서버 내부 루프백
* {@code http://127.0.0.1:8020}.
*
* <p>{@code @Value} 주입 방식(OllamaClient 패턴 일치) {@code @EnableConfigurationProperties}
* 등록 불필요. application.yml:
* <pre>
* guardia:
* rag:
* base-url: http://127.0.0.1:8020
* timeout-ms: 120000
* enabled: true
* solution: esn
* max-concurrent-streams: 2
* </pre>
*/
@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;
}

View File

@ -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}.
*
* <p>{@code @Mapper} 어노테이션 필수 (EsnApplication
* {@code @MapperScan(annotationClass = Mapper.class)} 패턴).
*
* <p>모든 조회/변경은 {@code tenant_code} 격리된다 테넌트가 다른 테넌트 토글을
* 읽거나 바꿀 없다.
*/
@Mapper
public interface RagSettingMapper {
/** 한 테넌트의 모든 RAG 토글(key,value). */
List<Map<String, Object>> 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);
}

View File

@ -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 기법 토글 서비스 <b>테넌트 격리</b>.
*
* <p>전용 {@code esn_rag_setting}(tenant_code, key, value) 테이블에서 테넌트별 토글을 읽는다.
* 테넌트가 다른 테넌트 설정을 읽거나 바꿀 없다. 네임스페이스 {@code rag.*}.
*
* <p>무거운 기법(graphrag·rerank·stream·tool_use) 서버 RAM 제약상 <b>기본 off</b>,
* 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<String> 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<String, String> m = new LinkedHashMap<>();
List<Map<String, Object>> rows = mapper.findByTenant(tc);
if (rows != null) {
for (Map<String, Object> 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<String, Object> toMap() {
Map<String, Object> 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;
}
}
}

View File

@ -22,6 +22,8 @@ spring:
schema-locations: schema-locations:
- classpath:db/schema.sql - classpath:db/schema.sql
- classpath:db/91_uiws_port.sql - classpath:db/91_uiws_port.sql
# AI 기법(중앙 guardia-rag) 토글 — 테넌트 격리 esn_rag_setting (멱등)
- classpath:db/92_esn_rag_setting.sql
web: web:
resources: resources:
static-locations: classpath:/static/ static-locations: classpath:/static/
@ -40,6 +42,14 @@ guardia:
ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b} ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
itsm-url: ${ITSM_URL:http://localhost:9001} 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: jwt:
secret: ${JWT_SECRET:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits} secret: ${JWT_SECRET:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits}
expiration: 86400000 expiration: 86400000

View File

@ -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;

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.rag.RagSettingMapper">
<!-- 테넌트 격리: 모든 쿼리에 tenant_code 조건 강제 -->
<select id="findByTenant" resultType="java.util.Map">
SELECT setting_key AS "settingKey", setting_value AS "settingValue"
FROM esn_rag_setting
WHERE tenant_code = #{tenantCode}
ORDER BY setting_key ASC
</select>
<select id="findValue" resultType="java.lang.String">
SELECT setting_value
FROM esn_rag_setting
WHERE tenant_code = #{tenantCode} AND setting_key = #{settingKey}
</select>
<update id="upsert">
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()
</update>
</mapper>

View File

@ -14,6 +14,7 @@ import UserList from './pages/UserList'
import ProductList from './pages/ProductList' import ProductList from './pages/ProductList'
import TenantAdmin from './pages/TenantAdmin' import TenantAdmin from './pages/TenantAdmin'
import AiAnalysis from './pages/AiAnalysis' import AiAnalysis from './pages/AiAnalysis'
import AiTechniqueSettings from './pages/AiTechniqueSettings'
import TagBindingList from './pages/TagBindingList' import TagBindingList from './pages/TagBindingList'
import UpdateQueueList from './pages/UpdateQueueList' import UpdateQueueList from './pages/UpdateQueueList'
import AuditLog from './pages/AuditLog' import AuditLog from './pages/AuditLog'
@ -49,6 +50,7 @@ export default function App() {
<Route path="/audit" element={<AuditLog />} /> <Route path="/audit" element={<AuditLog />} />
<Route path="/settings" element={<SystemSettings />} /> <Route path="/settings" element={<SystemSettings />} />
<Route path="/ai" element={<AiAnalysis />} /> <Route path="/ai" element={<AiAnalysis />} />
<Route path="/ai-techniques" element={<AiTechniqueSettings />} />
<Route path="/worklogs" element={<WorklogList />} /> <Route path="/worklogs" element={<WorklogList />} />
<Route path="/schedules" element={<ScheduleCalendar />} /> <Route path="/schedules" element={<ScheduleCalendar />} />
<Route path="/messages" element={<MessageBox />} /> <Route path="/messages" element={<MessageBox />} />

View File

@ -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 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 classifyPos = (d: object) => unwrap(api.post('/api/ai/classify-pos', d))
export const aiChat = (message: string) => unwrap(api.post('/api/ai/chat', { message })) 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))

View File

@ -3,7 +3,7 @@ import {
LayoutDashboard, Store, FileText, RefreshCw, LayoutDashboard, Store, FileText, RefreshCw,
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain, Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
Link2, ListOrdered, NotebookPen, CalendarDays, Mail, BarChart3, Link2, ListOrdered, NotebookPen, CalendarDays, Mail, BarChart3,
ScrollText, Settings, Smartphone ScrollText, Settings, Smartphone, Sparkles
} from 'lucide-react' } from 'lucide-react'
const nav = [ const nav = [
@ -23,6 +23,7 @@ const nav = [
{ to: '/audit', icon: ScrollText, label: '감사 로그' }, { to: '/audit', icon: ScrollText, label: '감사 로그' },
{ to: '/settings', icon: Settings, label: '시스템 설정' }, { to: '/settings', icon: Settings, label: '시스템 설정' },
{ to: '/ai', icon: Brain, label: 'AI 분석' }, { to: '/ai', icon: Brain, label: 'AI 분석' },
{ to: '/ai-techniques', icon: Sparkles, label: 'AI 기법 설정' },
{ to: '/worklogs', icon: NotebookPen, label: '업무일지' }, { to: '/worklogs', icon: NotebookPen, label: '업무일지' },
{ to: '/schedules', icon: CalendarDays, label: '일정 관리' }, { to: '/schedules', icon: CalendarDays, label: '일정 관리' },
{ to: '/messages', icon: Mail, label: '쪽지' }, { to: '/messages', icon: Mail, label: '쪽지' },

View File

@ -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<Record<string, string>>({})
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 <div className="text-gray-400 p-8"> ...</div>
const tg: any = t || {}
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-white flex items-center gap-2">
<Sparkles size={20} className="text-brand" /> AI ( guardia-rag)
</h1>
<p className="text-xs text-gray-500">
. (·GraphRAG·tool-use·) RAM OFF입니다.
MANAGER .
</p>
{/* 테넌트 선택 + 중앙 RAG 상태 */}
<div className="bg-card border border-edge rounded-lg p-5 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Server size={16} className="text-accent" />
<span className="text-sm text-gray-300"></span>
<select
value={tenant}
onChange={e => { 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 => <option key={tc} value={tc}>{tc}</option>)}
</select>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-400"> RAG:</span>
{tg.ragAvailable
? <span className="px-2 py-0.5 rounded bg-green-500/20 text-green-300 text-xs"></span>
: <span className="px-2 py-0.5 rounded bg-amber-500/20 text-amber-300 text-xs"> (degraded )</span>}
</div>
</div>
{/* 불리언 토글들 */}
<div className="bg-card border border-edge rounded-lg p-5 space-y-3">
<h2 className="text-sm font-medium text-white flex items-center gap-2 mb-2">
<Layers size={16} className="text-brand" /> On/Off
</h2>
{BOOL_TOGGLES.map(b => {
const on = !!tg[camel(b.key)]
return (
<div key={b.key} className="flex items-center justify-between border-b border-edge/60 pb-3">
<div>
<div className="text-sm text-gray-200 flex items-center gap-2">
{b.label}
{b.heavy && <span className="px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px]"></span>}
</div>
<div className="text-xs text-gray-500">{b.desc}</div>
</div>
<button
onClick={() => save.mutate({ key: b.key, value: !on })}
disabled={save.isPending}
className={`relative w-11 h-6 rounded-full transition ${on ? 'bg-brand' : 'bg-edge'}`}>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white transition ${on ? 'translate-x-5' : ''}`} />
</button>
</div>
)
})}
</div>
{/* 파라미터 (mode / top_k / step / model) */}
<div className="bg-card border border-edge rounded-lg p-5 space-y-4">
<h2 className="text-sm font-medium text-white flex items-center gap-2 mb-2">
<Sliders size={16} className="text-accent" />
</h2>
<Param label="검색 모드 (retrieval_mode)" hint="vector | hybrid | graph (GraphRAG on 이면 graph 강제)">
<select
value={draft['rag.retrieval_mode'] ?? tg.retrievalMode ?? 'vector'}
onChange={e => 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">
<option value="vector">vector</option>
<option value="hybrid">hybrid (BM25+)</option>
<option value="graph">graph (GraphRAG)</option>
</select>
</Param>
<NumParam label="top_k (검색 문서 수)" k="rag.top_k" cur={tg.topK} min={1} max={20} draft={draft} setDraft={setDraft} />
<NumParam label="에이전트 최대 스텝 (agent_max_steps)" k="rag.agent_max_steps" cur={tg.agentMaxSteps} min={1} max={8} draft={draft} setDraft={setDraft} />
<FloatParam label="temperature" k="rag.temperature" cur={tg.temperature} draft={draft} setDraft={setDraft} />
<FloatParam label="신뢰도 임계 (faithfulness_threshold)" k="rag.faithfulness_threshold" cur={tg.faithfulnessThreshold} draft={draft} setDraft={setDraft} />
<Param label="생성 모델 (generation_model)" hint="소형 모델 권장 (예: llama3.2:1b)">
<input
value={draft['rag.generation_model'] ?? tg.generationModel ?? ''}
onChange={e => 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" />
</Param>
<div className="pt-2 flex flex-wrap gap-2">
{Object.keys(draft).map(k => (
<button key={k}
onClick={() => 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.', '')}
</button>
))}
{Object.keys(draft).length > 0 && (
<button onClick={() => setDraft({})}
className="text-xs px-3 py-1.5 rounded bg-edge/60 text-gray-300 hover:bg-edge"> </button>
)}
</div>
</div>
</div>
)
}
function camel(key: string) {
const m: Record<string, string> = {
'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 (
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-200">{label}</div>
{hint && <div className="text-xs text-gray-500">{hint}</div>}
</div>
{children}
</div>
)
}
function NumParam({ label, k, cur, min, max, draft, setDraft }: any) {
return (
<Param label={label} hint={`범위 ${min}~${max}`}>
<input type="number" min={min} max={max}
value={draft[k] ?? cur ?? min}
onChange={e => 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" />
</Param>
)
}
function FloatParam({ label, k, cur, draft, setDraft }: any) {
return (
<Param label={label} hint="0.0 ~ 1.0">
<input type="number" step="0.1" min={0} max={1}
value={draft[k] ?? cur ?? 0}
onChange={e => 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" />
</Param>
)
}

122
scripts/rag_ingest_esn.py Normal file
View File

@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
GUARDiA ESN 중앙 guardia-rag 색인 (대표 도메인/운영 지식 esn 테넌트 컬렉션).
알람 원인분석(GraphRAG 의존성 근거)·POS 분류(스키마 grounding) 위해, ESN 대표
도메인 메타데이터(테이블/관계/운영 ) 중앙 RAG 테넌트별 컬렉션(esn__<TENANT>) 적재한다.
실제 데이터 ·자격증명·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__<TENANT> 분리. 테넌트 색인이 다른 테넌트에 섞이지 않음.
- 멱등: 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())