feat: 최신 AI 기법 적용 — 중앙 guardia-rag 경유 배선(근거검증·구조화출력·에이전틱) + 기법 토글
This commit is contained in:
parent
bb6bc69c68
commit
f264524a87
@ -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")
|
||||
|
||||
248
backend/src/main/java/com/zioinfo/esn/rag/EsnRagClient.java
Normal file
248
backend/src/main/java/com/zioinfo/esn/rag/EsnRagClient.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
254
backend/src/main/java/com/zioinfo/esn/rag/RagAiService.java
Normal file
254
backend/src/main/java/com/zioinfo/esn/rag/RagAiService.java
Normal 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); }
|
||||
}
|
||||
98
backend/src/main/java/com/zioinfo/esn/rag/RagController.java
Normal file
98
backend/src/main/java/com/zioinfo/esn/rag/RagController.java
Normal 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);
|
||||
}
|
||||
}
|
||||
49
backend/src/main/java/com/zioinfo/esn/rag/RagProperties.java
Normal file
49
backend/src/main/java/com/zioinfo/esn/rag/RagProperties.java
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
161
backend/src/main/java/com/zioinfo/esn/rag/RagToggleService.java
Normal file
161
backend/src/main/java/com/zioinfo/esn/rag/RagToggleService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
53
backend/src/main/resources/db/92_esn_rag_setting.sql
Normal file
53
backend/src/main/resources/db/92_esn_rag_setting.sql
Normal 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;
|
||||
28
backend/src/main/resources/mapper/RagSettingMapper.xml
Normal file
28
backend/src/main/resources/mapper/RagSettingMapper.xml
Normal 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>
|
||||
@ -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() {
|
||||
<Route path="/audit" element={<AuditLog />} />
|
||||
<Route path="/settings" element={<SystemSettings />} />
|
||||
<Route path="/ai" element={<AiAnalysis />} />
|
||||
<Route path="/ai-techniques" element={<AiTechniqueSettings />} />
|
||||
<Route path="/worklogs" element={<WorklogList />} />
|
||||
<Route path="/schedules" element={<ScheduleCalendar />} />
|
||||
<Route path="/messages" element={<MessageBox />} />
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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: '쪽지' },
|
||||
|
||||
183
frontend/src/pages/AiTechniqueSettings.tsx
Normal file
183
frontend/src/pages/AiTechniqueSettings.tsx
Normal 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
122
scripts/rag_ingest_esn.py
Normal 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())
|
||||
Loading…
Reference in New Issue
Block a user