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")); // 환각차단·근거검증 통과 필드(계약 추가·기존 불변): WISE AI 화면 인용/보류 UX 용 defect.put("abstained", res.get("abstained")); defect.put("grounded", res.get("grounded")); defect.put("faithfulness", res.get("faithfulness")); 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("sources", res.get("sources")); interp.put("steps", res.get("steps")); interp.put("answerId", res.get("answer_id")); // 환각차단·근거검증 통과 필드(계약 추가·기존 불변): WISE AI 화면 인용/보류 UX 용 interp.put("abstained", res.get("abstained")); interp.put("grounded", res.get("grounded")); interp.put("faithfulness", res.get("faithfulness")); 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); } }