318 lines
16 KiB
Java
318 lines
16 KiB
Java
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 배선 레이어(순증·비파괴).
|
|
*
|
|
* <p>기존 {@link AiService}(8개 제조 AI, Ollama + 결정론 Java 폴백)는 <b>불변</b>. 본 서비스는
|
|
* 중앙 guardia-rag 를 경유하는 <b>별개 레이어</b>로, 두 대표 기능을 고급 기법으로 전환한다:
|
|
* <ol>
|
|
* <li><b>품질 분석(defectAnalysis)</b> — 불량 원인분석 + SPC 이상감지.
|
|
* SPC 수치(평균·관리한계 이탈·런 규칙)는 {@link AiService#spcAnomaly} 결정론 로직으로 산출하고,
|
|
* <b>원인 서술·분류·우선순위</b>만 중앙 {@code /rag/agent}(tool-use) + {@code /rag/structured}(JSON 강제)로 받는다.
|
|
* 토글 off/미가용 시 기존 {@link AiService#defectRootCause}/{@code spcAnomaly} 로 무손실 폴백(degraded:true).</li>
|
|
* <li><b>예측 분석(predictAnalysis)</b> — 설비 예지보전 + 수요/생산 예측.
|
|
* 예측 수치(이동평균·추세·위험점수)는 {@link AiService#forecast}/{@code predictiveMaintenance} 결정론 베이스라인으로 산출하고,
|
|
* <b>해석·권고 서술</b>만 {@code /rag/agent} 로 받는다. 토글 off/미가용 시 결정론 경로로 폴백(degraded:true).</li>
|
|
* </ol>
|
|
*
|
|
* <p><b>결정론 불변</b>: SPC/예측 수치는 절대 AI 가 만들지 않는다(재현 가능). AI 는 서술·분류·우선순위만.
|
|
* <p>모든 응답에 적용 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<String, Object> 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<String, Object> 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<Map<String, Object>> DEFECT_TOOLS = List.of(
|
|
tool("search_ncr", "부적합(NCR) 이력 검색 — 불량코드/LOT 기준 근거 수집(읽기전용)"),
|
|
tool("search_capa", "시정조치(CAPA) 이력 검색 — 유사 불량의 과거 조치(읽기전용)"),
|
|
tool("search_inspection", "검사 결과 검색 — 측정값·합부 판정 근거(읽기전용)")
|
|
);
|
|
private static final List<Map<String, Object>> 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<String, Object> defectAnalysis(Map<String, Object> req, String actor) {
|
|
String defectCode = str(req.get("defectCode"));
|
|
List<Map<String, Object>> context = req.get("context") instanceof List
|
|
? (List<Map<String, Object>>) req.get("context") : List.of();
|
|
|
|
// (A) SPC 수치 — 항상 결정론(재현 가능). AI 가 만들지 않는다.
|
|
Map<String, Object> 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<String, Object> out = new LinkedHashMap<>();
|
|
if (spc != null) out.put("spc", spc); // 결정론 수치 결과
|
|
|
|
// (B) 원인 서술 — 토글 off/미가용 시 기존 결정론 RCA 폴백
|
|
if (!t.ragEnabled || !rag.available()) {
|
|
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> defect = new LinkedHashMap<>();
|
|
if (res.get("data") instanceof Map) defect.putAll((Map<String, Object>) 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<String, Object> predictAnalysis(Map<String, Object> req, String actor) {
|
|
String kind = str(req.getOrDefault("kind", "forecast"));
|
|
RagToggles t = toggles.current();
|
|
Map<String, Object> out = new LinkedHashMap<>();
|
|
|
|
// (A) 수치 베이스라인 — 항상 결정론(재현 가능). AI 가 만들지 않는다.
|
|
Map<String, Object> 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<String, Object> 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<String, Object> interp = new LinkedHashMap<>();
|
|
if (res.get("data") instanceof Map) interp.putAll((Map<String, Object>) 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<String, Object> feedback(Map<String, Object> 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<String, Object> currentToggles() {
|
|
Map<String, Object> m = toggles.current().toMap();
|
|
m.put("ragAvailable", rag.available());
|
|
return m;
|
|
}
|
|
|
|
// ── helpers ─────────────────────────────────────────────────────────────
|
|
private static Map<String, Object> tool(String name, String desc) {
|
|
Map<String, Object> m = new LinkedHashMap<>();
|
|
m.put("name", name);
|
|
m.put("description", desc);
|
|
m.put("readonly", true);
|
|
return m;
|
|
}
|
|
|
|
private Map<String, Object> meta(RagToggles t, String technique) {
|
|
Map<String, Object> 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<String, Object> m = new LinkedHashMap<>();
|
|
m.put("applied", applied);
|
|
return m;
|
|
}
|
|
|
|
private List<Double> toDoubles(Object o) {
|
|
List<Double> 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); }
|
|
}
|