diff --git a/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java index bf3a2bd..359ad49 100644 --- a/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java +++ b/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java @@ -57,6 +57,9 @@ public class SecurityConfig { .requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER") .requestMatchers("/api/admin/**").hasRole("SUPERADMIN") + // RAG 기법 토글 변경 — MANAGER 이상(운영자 전용). 분석 트리거(POST)는 아래 WORKER+ 규칙 적용. + .requestMatchers(HttpMethod.PUT, "/api/mes/rag/toggles/**").hasAnyRole("SUPERADMIN", "MANAGER") + // 변경(실적·검사·입출고·재고이동) — WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드) .requestMatchers(HttpMethod.POST, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") .requestMatchers(HttpMethod.PUT, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") diff --git a/backend/src/main/java/com/zioinfo/mes/rag/RagClient.java b/backend/src/main/java/com/zioinfo/mes/rag/RagClient.java new file mode 100644 index 0000000..2fbb5fe --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/rag/RagClient.java @@ -0,0 +1,193 @@ +package com.zioinfo.mes.rag; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 얇은 중앙 guardia-rag REST 클라이언트 (MES 전용). + * + *

핵심 설계: MES 는 LangChain·검색·에이전틱 로직을 Java 에 재구현하지 않는다. 중앙 Python + * guardia-rag 의 검색·에이전틱·구조화·검증을 REST 로 호출하기만 한다. 미가용/타임아웃/RAM 부족 시 + * 절대 예외를 전파하지 않고 {@code degraded:true} 가 표시된 빈/폴백 결과를 돌려준다(서비스 계층이 + * MES 결정론 로컬 폴백 수행). + * + *

보안 불변: 온프레미스 전용. 응답에서 자격증명/거래처·인사 PII/스택트레이스 미노출. 오류는 요약만 로깅. + * + *

호출 계약(중앙, 8020): + *

+ */ +@Slf4j +@Component +public class RagClient { + + private final WebClient.Builder builder; + private final RagProperties props; + + public RagClient(WebClient.Builder builder, RagProperties props) { + this.builder = builder; + this.props = props; + } + + /** 중앙 RAG 사용 가능 여부(설정 enabled + trust/settings 도달). */ + public boolean available() { + if (!props.isEnabled()) return false; + try { + builder.baseUrl(props.getBaseUrl()).build() + .get().uri("/rag/trust/settings?solution=" + props.getSolution()) + .retrieve().bodyToMono(String.class) + .timeout(Duration.ofSeconds(3)).block(); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * POST /rag/structured — 스키마 강제 결정론 JSON. + * + * @return {@code {data:{...}, valid, fallback_used, degraded}} 형태. 미가용 시 {@code degraded:true}. + */ + public Map structured(String prompt, Map schema, + String model, double temperature) { + if (!props.isEnabled()) return degraded("rag_disabled"); + try { + Map body = new LinkedHashMap<>(); + body.put("solution", props.getSolution()); + body.put("prompt", prompt); + if (schema != null) body.put("schema", schema); + if (model != null) body.put("model", model); + body.put("temperature", temperature); + body.put("max_retries", 1); + Map res = post("/rag/structured", body); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("RAG /structured 일시 불가 — MES 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + /** + * POST /rag/answer — 검색(retrieval_mode)+생성+검증+guardrail. + * + * @return {@code {answer, grounded, faithfulness, citations[], guardrail{...}, sources[], answer_id, degraded}}. + * 미가용 시 {@code degraded:true}. + */ + public Map answer(String query, String retrievalMode, boolean rerank, + int topK, String model, boolean verify) { + if (!props.isEnabled()) return degraded("rag_disabled"); + try { + Map options = new LinkedHashMap<>(); + options.put("rerank", rerank); + options.put("stream", false); + options.put("verify", verify); + if (model != null) options.put("model", model); + + Map body = new LinkedHashMap<>(); + body.put("solution", props.getSolution()); + body.put("query", query); + body.put("retrieval_mode", retrievalMode == null ? "vector" : retrievalMode); + body.put("top_k", topK <= 0 ? 5 : topK); + body.put("options", options); + Map res = post("/rag/answer", body); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("RAG /answer 일시 불가 — MES 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + /** + * POST /rag/agent — 에이전틱 tool-use(멀티스텝 추론). MES 도메인 조회 매퍼를 도구로 노출해 + * 조회→집계→해석을 수행한다. {@code maxSteps} 상한으로 폭주/RAM 위협 차단. + * + * @param tools 읽기전용 조회 도구 정의 목록(이름·설명·파라미터 스키마). 쓰기/SSH 도구 금지. + * @return {@code {answer, steps[], tool_calls[], structured?, answer_id, degraded}}. 미가용 시 {@code degraded:true}. + */ + public Map agent(String task, Object tools, String retrievalMode, + int maxSteps, String model, Map structuredSchema) { + if (!props.isEnabled()) return degraded("rag_disabled"); + try { + Map options = new LinkedHashMap<>(); + options.put("max_steps", maxSteps <= 0 ? 4 : Math.min(maxSteps, 8)); // 상한 강제 + options.put("stream", false); + if (model != null) options.put("model", model); + if (retrievalMode != null) options.put("retrieval_mode", retrievalMode); + if (structuredSchema != null) options.put("schema", structuredSchema); // tool-use + 구조화 결합 + + Map body = new LinkedHashMap<>(); + body.put("solution", props.getSolution()); + body.put("task", task); + if (tools != null) body.put("tools", tools); + body.put("options", options); + Map res = post("/rag/agent", body); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("RAG /agent 일시 불가 — MES 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + /** + * POST /rag/feedback — 👍/👎 피드백(solution=mes 격리). + * + * @return {@code {feedback_id, stored}} 또는 미가용 시 {@code {stored:false, degraded:true}}. + */ + public Map feedback(String answerId, String query, String answer, + String verdict, String correction, String userRef) { + if (!props.isEnabled()) return Map.of("stored", false, "degraded", true); + try { + Map body = new LinkedHashMap<>(); + body.put("solution", props.getSolution()); + if (answerId != null) body.put("answer_id", answerId); + if (query != null) body.put("query", query); + if (answer != null) body.put("answer", answer); + body.put("verdict", verdict); + if (correction != null) body.put("correction", correction); + if (userRef != null) body.put("user_ref", userRef); + Map res = post("/rag/feedback", body); + return res != null ? res : Map.of("stored", false, "degraded", true); + } catch (Exception e) { + log.warn("RAG /feedback 일시 불가: {}", summarize(e)); + return Map.of("stored", false, "degraded", true); + } + } + + // ── helpers ─────────────────────────────────────────────────────────── + @SuppressWarnings("unchecked") + private Map post(String path, Map body) { + return builder.baseUrl(props.getBaseUrl()).build() + .post().uri(path) + .header("X-Solution-Key", props.getSolution()) + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofMillis(props.getTimeoutMs())) + .map(m -> (Map) m) + .block(); + } + + private Map degraded(String reason) { + Map out = new LinkedHashMap<>(); + out.put("degraded", true); + out.put("degraded_reason", reason); + return out; + } + + /** 스택트레이스 미노출: 메시지 1줄만 요약. */ + private String summarize(Exception e) { + String m = e.getMessage(); + if (m == null) return e.getClass().getSimpleName(); + return m.length() > 160 ? m.substring(0, 160) : m; + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/rag/RagController.java b/backend/src/main/java/com/zioinfo/mes/rag/RagController.java new file mode 100644 index 0000000..6d1ef79 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/rag/RagController.java @@ -0,0 +1,71 @@ +package com.zioinfo.mes.rag; + +import com.zioinfo.mes.admin.SettingService; +import com.zioinfo.mes.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * MES 최신 AI 기법(중앙 guardia-rag) 배선 API — 별개 레이어. + * + *

기존 {@code /api/mes/ai/*}(AiController, 8개 제조 AI)는 불변. 본 컨트롤러는 중앙 경유 + * 신규 경로만 추가한다. RBAC 은 {@code SecurityConfig} 가 통제(POST=Worker+, 토글 PUT=Manager+). + * + *

    + *
  • POST /api/mes/rag/defect-analysis — 불량 RCA + SPC 이상감지(/agent+/structured, SPC 수치는 결정론)
  • + *
  • POST /api/mes/rag/predict-analysis — 설비 예지보전 + 수요/생산 예측(/agent, 수치는 결정론 베이스라인)
  • + *
  • POST /api/mes/rag/feedback — 👍/👎 피드백(/rag/feedback, solution=mes 격리)
  • + *
  • GET /api/mes/rag/toggles — 현재 기법 토글 스냅샷
  • + *
  • PUT /api/mes/rag/toggles/{key} — 토글 변경(Manager+, mes_setting 격리 저장)
  • + *
+ */ +@RestController +@RequestMapping("/api/mes/rag") +@RequiredArgsConstructor +public class RagController { + + private final RagMesService service; + private final SettingService settingService; + + @PostMapping("/defect-analysis") + public ApiResponse> defectAnalysis(@RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.defectAnalysis(req, name(auth))); + } + + @PostMapping("/predict-analysis") + public ApiResponse> predictAnalysis(@RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.predictAnalysis(req, name(auth))); + } + + @PostMapping("/feedback") + public ApiResponse> feedback(@RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.feedback(req, name(auth))); + } + + @GetMapping("/toggles") + public ApiResponse> toggles() { + return ApiResponse.ok(service.currentToggles()); + } + + /** + * 토글 변경 — 기존 mes_setting upsert(감사로그 자동 기록) 재사용. RBAC 은 SecurityConfig 통제. + * 허용 키는 {@code rag.*} 네임스페이스로 한정(다른 설정 보호). + */ + @PutMapping("/toggles/{key}") + public ApiResponse> updateToggle(@PathVariable String key, + @RequestBody Map req) { + if (key == null || !key.startsWith("rag.")) { + return ApiResponse.fail("ERR-RAG-400: rag.* 키만 변경 가능"); + } + Object v = req.get("value"); + settingService.update(key, v == null ? "" : String.valueOf(v)); + return ApiResponse.ok(service.currentToggles()); + } + + private String name(Authentication auth) { + return auth == null ? "anon" : auth.getName(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/rag/RagMesService.java b/backend/src/main/java/com/zioinfo/mes/rag/RagMesService.java new file mode 100644 index 0000000..e61c055 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/rag/RagMesService.java @@ -0,0 +1,308 @@ +package com.zioinfo.mes.rag; + +import com.zioinfo.mes.ai.AiService; +import com.zioinfo.mes.rag.RagToggleService.RagToggles; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * MES 대표 AI 기능 RAG 배선 레이어(순증·비파괴). + * + *

기존 {@link AiService}(8개 제조 AI, Ollama + 결정론 Java 폴백)는 불변. 본 서비스는 + * 중앙 guardia-rag 를 경유하는 별개 레이어로, 두 대표 기능을 고급 기법으로 전환한다: + *

    + *
  1. 품질 분석(defectAnalysis) — 불량 원인분석 + SPC 이상감지. + * SPC 수치(평균·관리한계 이탈·런 규칙)는 {@link AiService#spcAnomaly} 결정론 로직으로 산출하고, + * 원인 서술·분류·우선순위만 중앙 {@code /rag/agent}(tool-use) + {@code /rag/structured}(JSON 강제)로 받는다. + * 토글 off/미가용 시 기존 {@link AiService#defectRootCause}/{@code spcAnomaly} 로 무손실 폴백(degraded:true).
  2. + *
  3. 예측 분석(predictAnalysis) — 설비 예지보전 + 수요/생산 예측. + * 예측 수치(이동평균·추세·위험점수)는 {@link AiService#forecast}/{@code predictiveMaintenance} 결정론 베이스라인으로 산출하고, + * 해석·권고 서술만 {@code /rag/agent} 로 받는다. 토글 off/미가용 시 결정론 경로로 폴백(degraded:true).
  4. + *
+ * + *

결정론 불변: SPC/예측 수치는 절대 AI 가 만들지 않는다(재현 가능). AI 는 서술·분류·우선순위만. + *

모든 응답에 적용 metadata(retrievalMode·rerank·toolUse·structured·degraded)를 표기해 토글 effect 가 관측 가능하게 한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RagMesService { + + private final RagClient rag; + private final RagToggleService toggles; + private final AiService ai; // 기존 결정론/Ollama 폴백 재사용(불변) + + /** 불량 RCA 결정론 결과 스키마(중앙 /structured/agent 강제). */ + private static final Map DEFECT_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "rootCauses", Map.of("type", "array", "items", Map.of( + "type", "object", "properties", Map.of( + "cause", Map.of("type", "string"), + "evidenceRef", Map.of("type", "string"), + "contribution", Map.of("type", "number") + ))), + "correctiveActions", Map.of("type", "array", "items", Map.of("type", "string")), + "priority", Map.of("type", "string"), + "confidence", Map.of("type", "number") + ), + "required", List.of("rootCauses") + ); + + /** 예측 해석 결과 스키마(중앙 /agent 구조화). */ + private static final Map PREDICT_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "interpretation", Map.of("type", "string"), + "drivers", Map.of("type", "array", "items", Map.of("type", "string")), + "recommendation", Map.of("type", "string"), + "confidence", Map.of("type", "number") + ), + "required", List.of("interpretation") + ); + + // 읽기전용 조회 도구 정의(이름·설명만 노출 — 실제 조회는 중앙에서 컬렉션 검색으로 수행. 쓰기/SSH 금지) + private static final List> DEFECT_TOOLS = List.of( + tool("search_ncr", "부적합(NCR) 이력 검색 — 불량코드/LOT 기준 근거 수집(읽기전용)"), + tool("search_capa", "시정조치(CAPA) 이력 검색 — 유사 불량의 과거 조치(읽기전용)"), + tool("search_inspection", "검사 결과 검색 — 측정값·합부 판정 근거(읽기전용)") + ); + private static final List> PREDICT_TOOLS = List.of( + tool("search_equipment", "설비 가동/비가동·MTBF 이력 검색(읽기전용)"), + tool("search_production", "생산실적·작업지시 시계열 검색(읽기전용)") + ); + + // ── 1) 품질 분석: 불량 RCA + SPC 이상감지 ────────────────────────────────── + /** + * 불량 원인분석 + SPC 이상감지. SPC 수치는 결정론, 원인 서술은 /rag/agent(+structured). + * @param req {defectCode, context[], values[], ucl, lcl, cl} + */ + @SuppressWarnings("unchecked") + public Map defectAnalysis(Map req, String actor) { + String defectCode = str(req.get("defectCode")); + List> context = req.get("context") instanceof List + ? (List>) req.get("context") : List.of(); + + // (A) SPC 수치 — 항상 결정론(재현 가능). AI 가 만들지 않는다. + Map spc = null; + if (req.get("values") instanceof List) { + spc = ai.spcAnomaly(toDoubles(req.get("values")), + dbl(req.get("ucl")), dbl(req.get("lcl")), dbl(req.get("cl"))); + } + + RagToggles t = toggles.current(); + Map out = new LinkedHashMap<>(); + if (spc != null) out.put("spc", spc); // 결정론 수치 결과 + + // (B) 원인 서술 — 토글 off/미가용 시 기존 결정론 RCA 폴백 + if (!t.ragEnabled || !rag.available()) { + Map legacy = ai.defectRootCause(defectCode, context); + out.put("defect", legacy); + out.put("degraded", true); + out.put("degraded_reason", t.ragEnabled ? "rag_unavailable" : "rag_disabled"); + out.put("source", "mes_local_fallback"); + out.putAll(meta(t, t.toolUse ? "agent" : "answer")); + return out; + } + + String mode = toggles.effectiveMode(t); + String task = + "당신은 제조 품질 엔지니어입니다. 불량코드 '" + defectCode + "' 의 근본원인을 4M·5Why·파레토 관점으로 " + + "추정하고 시정조치를 제시하라. 제공된 NCR/CAPA/검사 근거에 기반해야 한다. " + + "수치 판정(SPC 관리한계·Cp/Cpk)은 이미 결정론으로 계산되었으니 재계산하지 말고 해석만 하라. " + + "근거: " + truncate(context.toString(), 1200) + + (spc != null ? "\nSPC결과: " + truncate(spc.toString(), 400) : ""); + + Map res; + String technique; + if (t.toolUse) { + // 에이전틱 tool-use: 조회 매퍼 도구로 멀티스텝 + 구조화(structured on 시 스키마 결합) + res = rag.agent(task, DEFECT_TOOLS, mode, t.maxSteps, t.generationModel, + t.structured ? DEFECT_SCHEMA : null); + technique = "agent"; + } else if (t.structured) { + // tool-use off + structured on: 결정론 JSON + res = rag.structured(task, DEFECT_SCHEMA, t.generationModel, t.temperature); + technique = "structured"; + } else { + // 둘 다 off: 검색 근거 답변 + res = rag.answer(task, mode, t.rerank, t.topK, t.generationModel, true); + technique = "answer"; + } + + boolean degraded = bool(res.get("degraded")); + if (degraded) { + Map legacy = ai.defectRootCause(defectCode, context); + out.put("defect", legacy); + out.put("degraded", true); + out.put("degraded_reason", str(res.getOrDefault("degraded_reason", "rag_empty"))); + out.put("source", "mes_local_fallback"); + out.putAll(meta(t, technique)); + return out; + } + + // 정상 경유 — 구조화 데이터/근거/인용/answerId 표면화 + Map defect = new LinkedHashMap<>(); + if (res.get("data") instanceof Map) defect.putAll((Map) res.get("data")); + if (res.get("answer") != null) defect.put("narrative", res.get("answer")); + defect.put("citations", res.get("citations")); + defect.put("sources", res.get("sources")); + defect.put("steps", res.get("steps")); // 에이전트 추론 스텝(tool-use 시) + defect.put("answerId", res.get("answer_id")); + out.put("defect", defect); + out.put("engine", "RAG_" + technique.toUpperCase()); + out.put("degraded", false); + out.putAll(meta(t, technique)); + return out; + } + + // ── 2) 예측 분석: 설비 예지보전 + 수요/생산 예측 ─────────────────────────── + /** + * 설비 예지보전 + 수요/생산 예측. 예측 수치는 결정론 베이스라인, 해석/권고는 /rag/agent. + * @param req {mode:"pdm"|"forecast", equipmentCode, availability, downtimeCount, mtbfHours, series[], horizon} + */ + @SuppressWarnings("unchecked") + public Map predictAnalysis(Map req, String actor) { + String kind = str(req.getOrDefault("kind", "forecast")); + RagToggles t = toggles.current(); + Map out = new LinkedHashMap<>(); + + // (A) 수치 베이스라인 — 항상 결정론(재현 가능). AI 가 만들지 않는다. + Map baseline; + if ("pdm".equalsIgnoreCase(kind)) { + baseline = ai.predictiveMaintenance(str(req.get("equipmentCode")), + dbl(req.get("availability")), (int) Math.round(dbl(req.get("downtimeCount"))), + dbl(req.get("mtbfHours"))); + } else { + baseline = ai.forecast(toDoubles(req.get("series")), + (int) Math.round(dbl(req.get("horizon")))); + } + out.put("baseline", baseline); + + // (B) 해석/권고 — 토글 off/미가용 시 베이스라인만(degraded) + if (!t.ragEnabled || !rag.available()) { + out.put("interpretation", null); + out.put("degraded", true); + out.put("degraded_reason", t.ragEnabled ? "rag_unavailable" : "rag_disabled"); + out.put("source", "mes_local_baseline"); + out.putAll(meta(t, t.toolUse ? "agent" : "answer")); + return out; + } + + String mode = toggles.effectiveMode(t); + String task = + "당신은 제조 운영 분석가입니다. 아래 결정론 통계 결과를 해석하고 우선순위·권고를 제시하라. " + + "수치는 이미 계산되었으니 재계산하지 말고 의미·이상신호·조치만 서술하라. " + + ("pdm".equalsIgnoreCase(kind) ? "예지보전 결과" : "수요/생산 예측 결과") + ": " + + truncate(baseline.toString(), 800); + + Map res; + String technique; + if (t.toolUse) { + res = rag.agent(task, PREDICT_TOOLS, mode, t.maxSteps, t.generationModel, + t.structured ? PREDICT_SCHEMA : null); + technique = "agent"; + } else if (t.structured) { + res = rag.structured(task, PREDICT_SCHEMA, t.generationModel, t.temperature); + technique = "structured"; + } else { + res = rag.answer(task, mode, t.rerank, t.topK, t.generationModel, true); + technique = "answer"; + } + + boolean degraded = bool(res.get("degraded")); + if (degraded) { + out.put("interpretation", null); + out.put("degraded", true); + out.put("degraded_reason", str(res.getOrDefault("degraded_reason", "rag_empty"))); + out.put("source", "mes_local_baseline"); + out.putAll(meta(t, technique)); + return out; + } + + Map interp = new LinkedHashMap<>(); + if (res.get("data") instanceof Map) interp.putAll((Map) res.get("data")); + if (res.get("answer") != null) interp.put("narrative", res.get("answer")); + interp.put("citations", res.get("citations")); + interp.put("steps", res.get("steps")); + interp.put("answerId", res.get("answer_id")); + out.put("interpretation", interp); + out.put("engine", "RAG_" + technique.toUpperCase()); + out.put("degraded", false); + out.putAll(meta(t, technique)); + return out; + } + + // ── 3) 피드백 → 중앙 /rag/feedback (solution=mes 격리) ───────────────────── + public Map feedback(Map req, String actor) { + String userRef = "u_" + Integer.toHexString((actor == null ? "anon" : actor).hashCode()); + return rag.feedback( + str0(req.get("answerId")), + str0(req.get("query")), + str0(req.get("answer")), + str(req.getOrDefault("verdict", "down")), + str0(req.get("correction")), + userRef); + } + + /** 현재 토글 스냅샷(화면용). */ + public Map currentToggles() { + Map m = toggles.current().toMap(); + m.put("ragAvailable", rag.available()); + return m; + } + + // ── helpers ───────────────────────────────────────────────────────────── + private static Map tool(String name, String desc) { + Map m = new LinkedHashMap<>(); + m.put("name", name); + m.put("description", desc); + m.put("readonly", true); + return m; + } + + private Map meta(RagToggles t, String technique) { + Map applied = new LinkedHashMap<>(); + applied.put("retrievalMode", toggles.effectiveMode(t)); + applied.put("rerank", t.rerank); + applied.put("graphrag", t.graphrag); + applied.put("hybrid", t.hybrid); + applied.put("toolUse", t.toolUse); + applied.put("structured", t.structured); + applied.put("stream", t.stream); + applied.put("maxSteps", t.maxSteps); + applied.put("technique", technique); + Map m = new LinkedHashMap<>(); + m.put("applied", applied); + return m; + } + + private List toDoubles(Object o) { + List out = new ArrayList<>(); + if (o instanceof List list) { + for (Object v : list) { + try { out.add(Double.parseDouble(String.valueOf(v))); } catch (Exception ignore) {} + } + } + return out; + } + + private double dbl(Object o) { + try { return o == null ? 0.0 : Double.parseDouble(String.valueOf(o)); } catch (Exception e) { return 0.0; } + } + + private String truncate(String s, int max) { + if (s == null) return ""; + return s.length() > max ? s.substring(0, max) : s; + } + + private boolean bool(Object o) { return Boolean.TRUE.equals(o) || "true".equalsIgnoreCase(String.valueOf(o)); } + private String str(Object o) { return o == null ? "" : String.valueOf(o); } + private String str0(Object o) { return o == null ? null : String.valueOf(o); } +} diff --git a/backend/src/main/java/com/zioinfo/mes/rag/RagProperties.java b/backend/src/main/java/com/zioinfo/mes/rag/RagProperties.java new file mode 100644 index 0000000..776165d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/rag/RagProperties.java @@ -0,0 +1,41 @@ +package com.zioinfo.mes.rag; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 중앙 guardia-rag 연동 설정 (GUARDiA MES). + * + *

보안 불변: base-url 은 온프레미스(중앙 guardia-rag) 전용이다. + * 외부 엔드포인트를 가리키도록 설정해서는 안 된다. 기본값은 서버 내부 루프백이다. + * + *

application.yml: + *

+ * guardia:
+ *   rag:
+ *     base-url: http://127.0.0.1:8020
+ *     timeout-ms: 120000
+ *     enabled: true
+ *     solution: mes
+ * 
+ */ +@Component +@Getter +@Setter +@ConfigurationProperties(prefix = "guardia.rag") +public class RagProperties { + + /** 중앙 guardia-rag 베이스 URL (서버 내부 루프백 기본). */ + private String baseUrl = "http://127.0.0.1:8020"; + + /** 호출 타임아웃(ms). 소형모델 생성 고려 120s 기본. */ + private long timeoutMs = 120_000L; + + /** RAG 경유 마스터 스위치. false면 RagClient 가 항상 미가용으로 동작(MES 로컬 폴백). */ + private boolean enabled = true; + + /** 솔루션 식별자(컬렉션·토글·피드백 격리 키). */ + private String solution = "mes"; +} diff --git a/backend/src/main/java/com/zioinfo/mes/rag/RagToggleService.java b/backend/src/main/java/com/zioinfo/mes/rag/RagToggleService.java new file mode 100644 index 0000000..72ecded --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/rag/RagToggleService.java @@ -0,0 +1,139 @@ +package com.zioinfo.mes.rag; + +import com.zioinfo.mes.admin.dto.MesSetting; +import com.zioinfo.mes.admin.mapper.SettingMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * MES RAG 기법 토글 서비스. + * + *

기존 {@code mes_setting}(key-value) 테이블을 재사용해 RAG 토글을 격리 저장한다(신규 설정 테이블 + * 신설 없음, admin 설정 화면과 자연 통합). 키 네임스페이스 {@code rag.*} 로 다른 설정과 충돌 회피. + * + *

서버 RAM 제약: 무거운 기법(graphrag·rerank·tool_use·stream)은 기본 off. + * retrieval_mode 기본 vector. step 상한·top_k 상한을 강제(폭주 차단). + */ +@Service +@RequiredArgsConstructor +public class RagToggleService { + + private final SettingMapper settingMapper; + + public static final String K_ENABLED = "rag.enabled"; + public static final String K_MODE = "rag.retrieval_mode"; // vector|hybrid|graph + public static final String K_RERANK = "rag.rerank"; // true|false (무거움→기본 off) + public static final String K_GRAPHRAG = "rag.graphrag"; // true|false (무거움→기본 off) + public static final String K_HYBRID = "rag.hybrid"; // true|false (mode 보조 스위치) + public static final String K_TOOL_USE = "rag.tool_use"; // true|false (에이전틱 /agent, 무거움→off) + public static final String K_STRUCTURED = "rag.structured"; // true|false (구조화 출력, 기본 on) + public static final String K_STREAM = "rag.stream"; // true|false (SSE, 무거움→기본 off) + public static final String K_TOP_K = "rag.top_k"; + public static final String K_MAX_STEPS = "rag.max_steps"; // 에이전트 step 상한 + public static final String K_TEMPERATURE = "rag.temperature"; + public static final String K_GEN_MODEL = "rag.generation_model"; + + /** 현재 토글 전부 조회(기본값 머지). 화면/배선 공용. */ + public RagToggles current() { + Map m = new LinkedHashMap<>(); + List all = settingMapper.findAll(); + if (all != null) for (MesSetting s : all) m.put(s.getKey(), s.getValue()); + + RagToggles t = new RagToggles(); + t.ragEnabled = bool(m.get(K_ENABLED), true); + t.retrievalMode = mode(m.get(K_MODE)); + t.rerank = bool(m.get(K_RERANK), false); // 무거움→off 기본 + t.graphrag = bool(m.get(K_GRAPHRAG), false); // 무거움→off 기본 + t.hybrid = bool(m.get(K_HYBRID), false); // 무거움→off 기본 + t.toolUse = bool(m.get(K_TOOL_USE), false); // 무거움→off 기본(에이전틱) + t.structured = bool(m.get(K_STRUCTURED), true); // 결정론 출력→on 기본(경량) + t.stream = bool(m.get(K_STREAM), false); // 무거움→off 기본 + t.topK = clampInt(m.get(K_TOP_K), 5, 1, 20); + t.maxSteps = clampInt(m.get(K_MAX_STEPS), 4, 1, 8); + t.temperature = clampDouble(m.get(K_TEMPERATURE), 0.1, 0.0, 1.0); + t.generationModel = blankTo(m.get(K_GEN_MODEL), "llama3.2:1b"); + return t; + } + + /** + * retrieval_mode 결정 우선순위: graphrag 토글 on → graph, 아니면 hybrid 토글 on → hybrid, + * 아니면 설정된 mode(기본 vector). + */ + public String effectiveMode(RagToggles t) { + if (t.graphrag) return "graph"; + if (t.hybrid) return "hybrid"; + return t.retrievalMode; + } + + // ── 정규화/클램프 (상한 강제 — 폭주·RAM 위협 차단) ──────────────────────── + private boolean bool(String v, boolean def) { + if (v == null) return def; + String s = v.trim(); + return "true".equalsIgnoreCase(s) || "1".equals(s) || "on".equalsIgnoreCase(s); + } + + private String mode(String v) { + if (v == null) return "vector"; + String s = v.trim().toLowerCase(); + return (s.equals("hybrid") || s.equals("graph") || s.equals("vector")) ? s : "vector"; + } + + private int clampInt(String v, int def, int min, int max) { + try { + int n = Integer.parseInt(v.trim()); + return Math.max(min, Math.min(max, n)); + } catch (Exception e) { + return def; + } + } + + private double clampDouble(String v, double def, double min, double max) { + try { + double n = Double.parseDouble(v.trim()); + return Math.max(min, Math.min(max, n)); + } catch (Exception e) { + return def; + } + } + + private String blankTo(String v, String def) { + return (v == null || v.isBlank()) ? def : v.trim(); + } + + /** 토글 스냅샷 DTO(응답 metadata·화면 공용). */ + public static class RagToggles { + public boolean ragEnabled = true; + public String retrievalMode = "vector"; + public boolean rerank = false; + public boolean graphrag = false; + public boolean hybrid = false; + public boolean toolUse = false; + public boolean structured = true; + public boolean stream = false; + public int topK = 5; + public int maxSteps = 4; + public double temperature = 0.1; + public String generationModel = "llama3.2:1b"; + + public Map toMap() { + Map m = new LinkedHashMap<>(); + m.put("ragEnabled", ragEnabled); + m.put("retrievalMode", retrievalMode); + m.put("rerank", rerank); + m.put("graphrag", graphrag); + m.put("hybrid", hybrid); + m.put("toolUse", toolUse); + m.put("structured", structured); + m.put("stream", stream); + m.put("topK", topK); + m.put("maxSteps", maxSteps); + m.put("temperature", temperature); + m.put("generationModel", generationModel); + return m; + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b36dff0..8862638 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -55,6 +55,13 @@ guardia: itsm-url: ${ITSM_URL:http://localhost:9001} ocr-url: ${OCR_URL:http://localhost:8005} bi-url: ${BI_URL:http://localhost:8006} + # 중앙 guardia-rag (최신 AI 기법: 하이브리드/그래프/리랭크 검색·에이전틱 tool-use·구조화·스트리밍) + # 보안 불변: 온프레미스 루프백 전용. 미가용/타임아웃 시 MES 결정론 로컬 폴백(degraded:true). + rag: + base-url: ${RAG_URL:http://127.0.0.1:8020} + timeout-ms: ${RAG_TIMEOUT_MS:120000} + enabled: ${RAG_ENABLED:true} + solution: mes # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지. ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b} diff --git a/backend/src/main/resources/db/91_uiws_port.sql b/backend/src/main/resources/db/91_uiws_port.sql index c6b6a91..5077e6f 100644 --- a/backend/src/main/resources/db/91_uiws_port.sql +++ b/backend/src/main/resources/db/91_uiws_port.sql @@ -248,4 +248,24 @@ ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS otp_secret VARCHAR(255); -- "업무 (UIWS)" 메뉴는 프론트 Sidebar/Route 에 추가(코드). 여기서는 주석으로만 명시. -- ─────────────────────────────────────────────────────────────────────────── +-- ─────────────────────────────────────────────────────────────────────────── +-- [RAG 기법 토글] 중앙 guardia-rag 최신 AI 기법 적용 토글 (mes_setting 재사용). +-- mes-ai-applier 배선용. 무거운 기법(graphrag·rerank·tool_use·stream)은 서버 RAM 제약상 기본 off. +-- ON CONFLICT DO NOTHING 멱등 — 운영자가 화면에서 변경한 값은 보존(재시드 덮어쓰기 없음). +-- ─────────────────────────────────────────────────────────────────────────── +INSERT INTO mes_setting (key, value) VALUES +('rag.enabled', 'true'), +('rag.retrieval_mode', 'vector'), +('rag.rerank', 'false'), +('rag.graphrag', 'false'), +('rag.hybrid', 'false'), +('rag.tool_use', 'false'), +('rag.structured', 'true'), +('rag.stream', 'false'), +('rag.top_k', '5'), +('rag.max_steps', '4'), +('rag.temperature', '0.1'), +('rag.generation_model', 'llama3.2:1b') +ON CONFLICT (key) DO NOTHING; + -- end 91_uiws_port.sql diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac18c43..1c7366b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ import Warehouses from './pages/Warehouses' // 공통 import Analytics from './pages/Analytics' import AiTools from './pages/AiTools' +import RagSettings from './pages/RagSettings' import UserManagement from './pages/UserManagement' import AuditLog from './pages/AuditLog' import SystemSettings from './pages/SystemSettings' @@ -88,6 +89,9 @@ export default function App() { {/* 공통 */} } /> } /> + + } /> {/* 업무 (UIWS 이식) — 기존 라우트 보존, 추가만 */} } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a8d425d..68ac980 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -300,6 +300,18 @@ export const aiInspectionJudge = (d: object) => unwrap(api.post('/api/mes/ai/ins export const aiSafetyStock = (d: object) => unwrap(api.post('/api/mes/ai/safety-stock', d)) export const aiScheduleOptimize = (d: object) => unwrap(api.post('/api/mes/ai/schedule-optimize', d)) +// ── RAG 최신 AI 기법 (중앙 guardia-rag 경유, 별개 레이어 — 항상 200, 폴백 degraded) ── +// 불량 RCA + SPC 이상감지: SPC 수치는 결정론, 원인 서술은 /rag/agent(+structured) +export const ragDefectAnalysis = (d: object) => unwrap(api.post('/api/mes/rag/defect-analysis', d)) +// 설비 예지보전 + 수요/생산 예측: 수치는 결정론 베이스라인, 해석은 /rag/agent +export const ragPredictAnalysis = (d: object) => unwrap(api.post('/api/mes/rag/predict-analysis', d)) +// 👍/👎 피드백 (solution=mes 격리) +export const ragFeedback = (d: object) => unwrap(api.post('/api/mes/rag/feedback', d)) +// 기법 토글 스냅샷 / 변경(MANAGER+) +export const getRagToggles = () => unwrap(api.get('/api/mes/rag/toggles')) +export const updateRagToggle = (key: string, value: any) => + unwrap(api.put(`/api/mes/rag/toggles/${key}`, { value })) + // ── Admin (SUPERADMIN / 감사로그·설정 GET 은 MANAGER+) ──────────────── export const getUsers = () => api.get('/api/admin/users') export const createUser = (data: { username: string; password: string; displayName?: string; role?: string }) => diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index c495916..ba6da22 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -50,6 +50,7 @@ const masterLinks: Link[] = [ const commonLinks: Link[] = [ { to: '/analytics', label: '분석 / KPI', icon: BarChart3 }, { to: '/ai-tools', label: 'AI 도구', icon: Sparkles }, + { to: '/ai-techniques', label: 'AI 기법 설정', icon: Cpu, min: 'MANAGER' }, ] // 업무 (UIWS 이식) — 업무일지/일정/쪽지/업무통계 const uiwsLinks: Link[] = [ diff --git a/frontend/src/pages/AiTools.tsx b/frontend/src/pages/AiTools.tsx index 85a769f..8ab9c74 100644 --- a/frontend/src/pages/AiTools.tsx +++ b/frontend/src/pages/AiTools.tsx @@ -1,13 +1,54 @@ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' import { Sparkles, AlertTriangle, TrendingUp, Wrench, LineChart, Search, Boxes, CalendarClock, + Cpu, ThumbsUp, ThumbsDown, } from 'lucide-react' import { Card, Btn, Field } from '../components/ui' import { getAiStatus, aiDefectRootCause, aiForecast, aiPredictiveMaintenance, aiSpcAnomaly, aiParseQuery, aiSafetyStock, aiScheduleOptimize, + ragDefectAnalysis, ragPredictAnalysis, ragFeedback, } from '../api/client' +/** 적용된 기법 배지 — 응답의 applied metadata 를 시각화(토글 effect 관측). */ +function AppliedBadge({ data }: { data: any }) { + const a = data?.applied + if (!a) return null + const chips: string[] = [`mode:${a.retrievalMode}`, `기법:${a.technique}`] + if (a.rerank) chips.push('rerank') + if (a.graphrag) chips.push('graphrag') + if (a.hybrid) chips.push('hybrid') + if (a.toolUse) chips.push(`agent(${a.maxSteps})`) + if (a.structured) chips.push('structured') + if (a.stream) chips.push('stream') + if (data?.degraded) chips.push('⚠ degraded(폴백)') + return ( +

+ {chips.map((c, i) => ( + {c} + ))} +
+ ) +} + +/** 👍/👎 피드백 (중앙 /rag/feedback, solution=mes 격리). answerId 가 있을 때만 노출. */ +function FeedbackBar({ answerId, query }: { answerId?: string; query?: string }) { + const [done, setDone] = useState('') + if (!answerId) return null + const send = async (verdict: 'up' | 'down') => { + try { await ragFeedback({ answerId, query, verdict }); setDone(verdict === 'up' ? '👍 반영됨' : '👎 반영됨') } catch { setDone('전송 실패') } + } + return ( +
+ 이 답변이 도움이 됐나요? + + + {done && {done}} +
+ ) +} + function Output({ data }: { data: any }) { if (data == null) return null if (typeof data === 'string') return
{data}
@@ -31,6 +72,17 @@ export default function AiTools() { + {/* ── 최신 기법 (중앙 guardia-rag 경유) — 대표 2개 기능 ───────────── */} +
+

최신 AI 기법 (RAG · 에이전틱 · 구조화)

+ 기법 토글 설정 → +
+
+ +
+ + {/* ── 기존 AI 도구 (불변, Ollama + Java 폴백) ───────────────────── */} +

기본 AI 도구

@@ -39,6 +91,85 @@ export default function AiTools() { ) } +/** 대표 1: 불량 원인분석 + SPC 이상감지 (SPC 수치=결정론, 원인서술=/rag/agent+structured). */ +function RagDefectTool() { + const [code, setCode] = useState(''); const [ctx, setCtx] = useState('') + const [values, setValues] = useState(''); const [ucl, setUcl] = useState(''); const [lcl, setLcl] = useState(''); const [cl, setCl] = useState('') + const [out, setOut] = useState(null); const [busy, setBusy] = useState(false) + const run = async () => { + setBusy(true) + try { + const vals = values.split(',').map(s => s.trim()).filter(Boolean).map(Number) + const req: any = { defectCode: code, context: ctx ? [{ note: ctx }] : [] } + if (vals.length) { req.values = vals; req.ucl = Number(ucl) || 0; req.lcl = Number(lcl) || 0; req.cl = Number(cl) || 0 } + setOut(await ragDefectAnalysis(req)) + } finally { setBusy(false) } + } + return ( + }> + setCode(e.target.value)} placeholder="예: D-DIM-01" /> +