215 lines
11 KiB
Java
215 lines
11 KiB
Java
package com.zioinfo.mro.ai;
|
|
|
|
import com.zioinfo.mro.ai.service.AiTextRouter;
|
|
import com.zioinfo.mro.common.ai.TextAiClient.GenResult;
|
|
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;
|
|
|
|
/**
|
|
* GUARDiA MRO AI 서비스 — 6개 설비보전·MRO 자재 AI 기능. AiTextRouter(Claude→Ollama 폴백) 경유 + Java 폴백.
|
|
*
|
|
* <p>보안 불변 규칙: AI 호출은 AiTextRouter 경유(외부 API 는 anthropic 예외 외 금지).
|
|
* 라우터 degraded 시 모든 메서드가 규칙기반/통계 폴백으로 동작한다(예외 없음).
|
|
* <ol>
|
|
* <li>고장 원인 분석 — 증상/설비/이력 상관 → 추정 원인·조치</li>
|
|
* <li>예지보전 — 가동률/다운타임/MTBF 패턴 이상 → 정비 권고(위험도)</li>
|
|
* <li>자재 수요 예측 — 소비 추세(가중이동평균+추세) 폴백</li>
|
|
* <li>자연어 조회 — Text→검색필터 추출</li>
|
|
* <li>안전재고·재발주점 추천 — 수요 변동/리드타임 기반</li>
|
|
* <li>신뢰성 지표 이상 감지 — MTBF/MTTR/가동률 임계 판정</li>
|
|
* </ol>
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class AiService {
|
|
|
|
private final OllamaClient ollama;
|
|
/** provider 라우팅(Claude→Ollama 폴백) + 로컬 학습 로그 경유. */
|
|
private final AiTextRouter aiTextRouter;
|
|
|
|
public boolean ollamaAvailable() {
|
|
return ollama.available();
|
|
}
|
|
|
|
/** 라우터 경유 텍스트 생성 — degraded 면 null(호출부가 Java 폴백 수행). */
|
|
private String routeGenerate(String prompt) {
|
|
GenResult r = aiTextRouter.generate(prompt);
|
|
return (r != null && !r.degraded()) ? r.text() : null;
|
|
}
|
|
|
|
// 1. 고장 원인 분석 (증상/설비/정비이력 상관)
|
|
public Map<String, Object> faultRootCause(String equipmentCode, String symptom,
|
|
List<Map<String, Object>> history) {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
String ctx = history == null ? "" : truncate(history.toString(), 1200);
|
|
String prompt = String.format(
|
|
"당신은 설비보전(CMMS) 신뢰성 엔지니어입니다. 설비 '%s' 의 고장 증상 '%s' 와 최근 정비이력: %s. "
|
|
+ "가장 가능성 높은 추정 원인 1가지와 권고 정비 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
|
|
equipmentCode, symptom, ctx);
|
|
String out = routeGenerate(prompt);
|
|
if (out != null && !out.isBlank()) {
|
|
result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out));
|
|
result.put("action", extractLine(out, "ACTION:"));
|
|
result.put("source", "ai");
|
|
return result;
|
|
}
|
|
// Java 폴백: 증상 키워드 규칙
|
|
String s = symptom == null ? "" : symptom.toLowerCase();
|
|
String cause; String action;
|
|
if (s.contains("진동") || s.contains("vib")) {
|
|
cause = "베어링 마모/정렬 불량(진동 이상)"; action = "베어링 점검·교체 + 축 정렬 재조정";
|
|
} else if (s.contains("과열") || s.contains("온도") || s.contains("heat")) {
|
|
cause = "윤활 부족 또는 냉각계통 이상(과열)"; action = "윤활유 보충·교환 + 냉각팬/필터 점검";
|
|
} else if (s.contains("소음") || s.contains("noise")) {
|
|
cause = "기어/체인 마모 또는 이물 혼입(이상 소음)"; action = "구동부 분해 점검 + 소모품 교체";
|
|
} else if (s.contains("누유") || s.contains("leak")) {
|
|
cause = "씰/개스킷 열화(누유)"; action = "씰·개스킷 교체 + 체결 토크 재점검";
|
|
} else {
|
|
cause = "원인 미상(상관 데이터 부족 — 추가 점검 필요)"; action = "정비이력·다운타임 추이 확인 + 계측 점검";
|
|
}
|
|
result.put("cause", cause);
|
|
result.put("action", action);
|
|
result.put("source", "fallback");
|
|
return result;
|
|
}
|
|
|
|
// 2. 예지보전 (가동률/다운타임/MTBF 이상 → 정비 권고)
|
|
public Map<String, Object> predictiveMaintenance(String equipmentCode, double availability,
|
|
int downtimeCount, double mtbfHours) {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
double risk = (availability < 0.7 ? 0.4 : availability < 0.85 ? 0.2 : 0.0)
|
|
+ Math.min(0.3, downtimeCount * 0.05)
|
|
+ (mtbfHours > 0 && mtbfHours < 100 ? 0.3 : mtbfHours < 300 ? 0.15 : 0.0);
|
|
risk = Math.min(1.0, risk);
|
|
String level = risk >= 0.6 ? "HIGH" : risk >= 0.3 ? "MEDIUM" : "LOW";
|
|
String recommend = switch (level) {
|
|
case "HIGH" -> "즉시 예방 정비 권고 — 가동률 저하 + 잦은 다운타임. ITSM SR 자동생성 대상.";
|
|
case "MEDIUM" -> "정비 주기 단축 검토 권고.";
|
|
default -> "정상 — 정기 점검(PM 계획) 유지.";
|
|
};
|
|
result.put("equipmentCode", equipmentCode);
|
|
result.put("risk", round2(risk));
|
|
result.put("level", level);
|
|
result.put("recommendation", recommend);
|
|
result.put("source", "fallback");
|
|
return result;
|
|
}
|
|
|
|
// 3. 자재 수요 예측 (가중이동평균 + 선형추세 폴백)
|
|
public Map<String, Object> demandForecast(List<Double> series, int horizon) {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
int h = horizon <= 0 ? 7 : Math.min(horizon, 90);
|
|
List<Double> preds = new ArrayList<>();
|
|
if (series == null || series.isEmpty()) {
|
|
for (int i = 0; i < h; i++) preds.add(0.0);
|
|
result.put("method", "empty");
|
|
} else {
|
|
int n = series.size();
|
|
double avg = series.stream().mapToDouble(Double::doubleValue).average().orElse(0);
|
|
double slope = n >= 2 ? (series.get(n - 1) - series.get(0)) / (n - 1) : 0;
|
|
double last = series.get(n - 1);
|
|
for (int i = 1; i <= h; i++) {
|
|
double p = Math.max(0, (last + slope * i) * 0.6 + avg * 0.4);
|
|
preds.add(round2(p));
|
|
}
|
|
result.put("method", "weighted-moving-average+trend");
|
|
}
|
|
result.put("horizon", h);
|
|
result.put("predictions", preds);
|
|
result.put("source", "fallback");
|
|
return result;
|
|
}
|
|
|
|
// 4. 자연어 조회 → 검색 필터 추출
|
|
public Map<String, Object> parseQuery(String naturalQuery) {
|
|
Map<String, Object> filter = new LinkedHashMap<>();
|
|
if (naturalQuery == null || naturalQuery.isBlank()) return filter;
|
|
String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nEQUIP:..\\nMATERIAL:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery;
|
|
String out = routeGenerate(prompt);
|
|
if (out != null && !out.isBlank()) {
|
|
putIf(filter, "status", extractLine(out, "STATUS:"));
|
|
putIf(filter, "equipment", extractLine(out, "EQUIP:"));
|
|
putIf(filter, "material", extractLine(out, "MATERIAL:"));
|
|
putIf(filter, "date", extractLine(out, "DATE:"));
|
|
if (!filter.isEmpty()) { filter.put("source", "ai"); return filter; }
|
|
}
|
|
// Java 폴백: 키워드 매칭
|
|
String q = naturalQuery.toLowerCase();
|
|
if (q.contains("진행") || q.contains("배정") || q.contains("assigned")) filter.put("status", "ASSIGNED");
|
|
else if (q.contains("완료") || q.contains("done")) filter.put("status", "DONE");
|
|
else if (q.contains("대기") || q.contains("접수") || q.contains("created")) filter.put("status", "CREATED");
|
|
if (q.contains("부족") || q.contains("안전재고")) filter.put("belowSafety", true);
|
|
if (q.contains("고장") || q.contains("정지") || q.contains("down")) filter.put("equipment", "DOWN");
|
|
filter.put("source", "fallback");
|
|
return filter;
|
|
}
|
|
|
|
// 5. 안전재고·재발주점 추천 (수요 변동/리드타임 기반)
|
|
public Map<String, Object> safetyStock(double avgDailyDemand, double demandStdDev,
|
|
double leadTimeDays, double serviceZ) {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
double z = serviceZ <= 0 ? 1.65 : serviceZ; // 95% 서비스 수준 기본
|
|
double safety = z * demandStdDev * Math.sqrt(Math.max(0, leadTimeDays));
|
|
double reorderPoint = avgDailyDemand * leadTimeDays + safety;
|
|
result.put("safetyStock", round2(Math.max(0, safety)));
|
|
result.put("reorderPoint", round2(Math.max(0, reorderPoint)));
|
|
result.put("serviceZ", z);
|
|
result.put("source", "fallback");
|
|
return result;
|
|
}
|
|
|
|
// 6. 신뢰성 지표 이상 감지 (MTBF/MTTR/가동률 임계 판정)
|
|
public Map<String, Object> reliabilityAnomaly(double mtbfHours, double mttrHours,
|
|
double availability, Double mtbfTarget) {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
List<String> flags = new ArrayList<>();
|
|
if (availability < 0.85) flags.add("가동률 저하(" + round2(availability * 100) + "% < 85%)");
|
|
if (mttrHours > 8) flags.add("MTTR 과다(" + round2(mttrHours) + "h > 8h)");
|
|
double target = mtbfTarget != null && mtbfTarget > 0 ? mtbfTarget : 300;
|
|
if (mtbfHours > 0 && mtbfHours < target) flags.add("MTBF 목표 미달(" + round2(mtbfHours) + "h < " + round2(target) + "h)");
|
|
result.put("anomaly", !flags.isEmpty());
|
|
result.put("flags", flags);
|
|
result.put("availability", round2(availability));
|
|
result.put("mtbfHours", round2(mtbfHours));
|
|
result.put("mttrHours", round2(mttrHours));
|
|
result.put("source", "fallback");
|
|
return result;
|
|
}
|
|
|
|
// ── helpers ──
|
|
private void putIf(Map<String, Object> m, String k, String v) {
|
|
if (v != null && !v.isBlank()) m.put(k, v.trim());
|
|
}
|
|
|
|
private String extractLine(String out, String tag) {
|
|
if (out == null) return "";
|
|
for (String line : out.split("[\\n\\r]+")) {
|
|
String l = line.trim();
|
|
if (l.toUpperCase().startsWith(tag.toUpperCase())) {
|
|
return l.substring(tag.length()).trim();
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
private String firstNonBlank(String a, String b) {
|
|
return (a != null && !a.isBlank()) ? a : b;
|
|
}
|
|
|
|
private String truncate(String s, int max) {
|
|
if (s == null) return "";
|
|
return s.length() <= max ? s : s.substring(0, max);
|
|
}
|
|
|
|
private double round2(double d) {
|
|
return Math.round(d * 100.0) / 100.0;
|
|
}
|
|
}
|