259 lines
12 KiB
Java
259 lines
12 KiB
Java
package com.zioinfo.mes.ai;
|
|
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
/**
|
|
* GUARDiA MES AI 서비스 — 8개 제조 AI 기능, 전부 Ollama 온프레미스 + Java 폴백.
|
|
*
|
|
* <p>보안 불변 규칙: 외부 AI API 금지. Ollama 미가용 시 모든 메서드가 규칙기반/통계 폴백으로 동작한다.
|
|
* <ol>
|
|
* <li>불량 원인 분석 — 공정/설비/자재 상관 → 추정 원인</li>
|
|
* <li>수요·생산량 예측 — 실적/계획 추세(이동평균 폴백)</li>
|
|
* <li>설비 예지보전 — 가동/비가동 패턴 이상 → 정비 권고</li>
|
|
* <li>SPC 이상 감지 — 관리한계 이탈·연속런·추세</li>
|
|
* <li>작업지시/재고 자연어 조회 — Text→필터</li>
|
|
* <li>검사 판정 보조 — 측정값 vs 스펙 자동 판정</li>
|
|
* <li>재고 최적화·안전재고 추천</li>
|
|
* <li>생산 일정 최적화 제안</li>
|
|
* </ol>
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class AiService {
|
|
|
|
private final OllamaClient ollama;
|
|
|
|
public boolean ollamaAvailable() {
|
|
return ollama.available();
|
|
}
|
|
|
|
// 1. 불량 원인 분석 (공정/설비/자재 상관)
|
|
public Map<String, Object> defectRootCause(String defectCode, List<Map<String, Object>> context) {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
String ctx = context == null ? "" : truncate(context.toString(), 1200);
|
|
String prompt = String.format(
|
|
"당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. "
|
|
+ "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
|
|
defectCode, ctx);
|
|
String out = ollama.generate(prompt);
|
|
if (out != null && !out.isBlank()) {
|
|
result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out));
|
|
result.put("action", extractLine(out, "ACTION:"));
|
|
result.put("source", "ollama");
|
|
return result;
|
|
}
|
|
// Java 폴백: 불량코드 분류 규칙
|
|
String code = defectCode == null ? "" : defectCode.toUpperCase();
|
|
String cause; String action;
|
|
if (code.contains("DIM") || code.contains("치수")) {
|
|
cause = "설비 공구 마모 또는 셋업 오차(치수 불량)"; action = "공구 교체 점검 + 초도품 재검사";
|
|
} else if (code.contains("SCR") || code.contains("스크래치") || code.contains("외관")) {
|
|
cause = "이송/포장 공정 표면 손상(외관 불량)"; action = "이송 지그 보호재 점검";
|
|
} else if (code.contains("MAT") || code.contains("자재")) {
|
|
cause = "수입자재 품질 편차(IQC 연계)"; action = "해당 LOT 입고검사 이력 추적 + 공급사 통보";
|
|
} else {
|
|
cause = "공정 변동(상관 데이터 부족 — 추가 수집 필요)"; action = "SPC 관리도 확인 + 4M 변경점 점검";
|
|
}
|
|
result.put("cause", cause);
|
|
result.put("action", action);
|
|
result.put("source", "fallback");
|
|
return result;
|
|
}
|
|
|
|
// 2. 수요·생산량 예측 (이동평균/선형추세 폴백)
|
|
public Map<String, Object> forecast(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;
|
|
}
|
|
|
|
// 3. 설비 예지보전 (가동/비가동 이상 → 정비 권고)
|
|
public Map<String, Object> predictiveMaintenance(String equipmentCode, double availability,
|
|
int downtimeCount, double mtbfHours) {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
double risk;
|
|
// 규칙: 가동률 낮고 비가동 빈번하며 MTBF 짧을수록 위험
|
|
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 -> "정상 — 정기 점검 유지.";
|
|
};
|
|
result.put("equipmentCode", equipmentCode);
|
|
result.put("risk", round2(risk));
|
|
result.put("level", level);
|
|
result.put("recommendation", recommend);
|
|
result.put("source", "fallback");
|
|
return result;
|
|
}
|
|
|
|
// 4. SPC 이상 감지 (관리한계 이탈·연속런·추세 — Western Electric 일부 규칙)
|
|
public Map<String, Object> spcAnomaly(List<Double> values, double ucl, double lcl, double cl) {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
List<String> violations = new ArrayList<>();
|
|
if (values != null && !values.isEmpty()) {
|
|
// 규칙1: 관리한계 이탈
|
|
for (int i = 0; i < values.size(); i++) {
|
|
double v = values.get(i);
|
|
if (v > ucl || v < lcl) violations.add("RULE1: idx" + i + " 관리한계 이탈(" + round2(v) + ")");
|
|
}
|
|
// 규칙2: 연속 9점이 중심선 한쪽
|
|
int run = 0; boolean above = false;
|
|
for (int i = 0; i < values.size(); i++) {
|
|
boolean a = values.get(i) > cl;
|
|
if (i == 0 || a == above) { run++; } else { run = 1; }
|
|
above = a;
|
|
if (run >= 9) { violations.add("RULE2: 연속 9점 중심선 " + (above ? "위" : "아래")); run = 0; }
|
|
}
|
|
// 규칙3: 연속 6점 증가/감소 추세
|
|
int inc = 1, dec = 1;
|
|
for (int i = 1; i < values.size(); i++) {
|
|
if (values.get(i) > values.get(i - 1)) { inc++; dec = 1; } else if (values.get(i) < values.get(i - 1)) { dec++; inc = 1; } else { inc = 1; dec = 1; }
|
|
if (inc >= 6) { violations.add("RULE3: 연속 6점 증가 추세"); inc = 1; }
|
|
if (dec >= 6) { violations.add("RULE3: 연속 6점 감소 추세"); dec = 1; }
|
|
}
|
|
}
|
|
result.put("anomaly", !violations.isEmpty());
|
|
result.put("violations", violations);
|
|
result.put("source", "fallback");
|
|
return result;
|
|
}
|
|
|
|
// 5. 작업지시/재고 자연어 조회 → 필터 추출
|
|
public Map<String, Object> parseQuery(String naturalQuery) {
|
|
Map<String, Object> filter = new LinkedHashMap<>();
|
|
if (naturalQuery == null || naturalQuery.isBlank()) return filter;
|
|
String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nITEM:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery;
|
|
String out = ollama.generate(prompt);
|
|
if (out != null && !out.isBlank()) {
|
|
putIf(filter, "status", extractLine(out, "STATUS:"));
|
|
putIf(filter, "item", extractLine(out, "ITEM:"));
|
|
putIf(filter, "date", extractLine(out, "DATE:"));
|
|
if (!filter.isEmpty()) { filter.put("source", "ollama"); return filter; }
|
|
}
|
|
// Java 폴백: 키워드 매칭
|
|
String q = naturalQuery.toLowerCase();
|
|
if (q.contains("진행") || q.contains("inprogress")) filter.put("status", "INPROGRESS");
|
|
else if (q.contains("완료") || q.contains("done")) filter.put("status", "DONE");
|
|
else if (q.contains("대기") || q.contains("created")) filter.put("status", "CREATED");
|
|
if (q.contains("부족") || q.contains("안전재고")) filter.put("belowSafety", true);
|
|
filter.put("source", "fallback");
|
|
return filter;
|
|
}
|
|
|
|
// 6. 검사 판정 보조 (측정값 vs 스펙)
|
|
public Map<String, Object> inspectionJudge(double measured, Double lsl, Double usl, Double target) {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
boolean pass = true;
|
|
StringBuilder reason = new StringBuilder();
|
|
if (lsl != null && measured < lsl) { pass = false; reason.append("하한(").append(lsl).append(") 미달; "); }
|
|
if (usl != null && measured > usl) { pass = false; reason.append("상한(").append(usl).append(") 초과; "); }
|
|
double deviation = target != null ? round2(measured - target) : 0.0;
|
|
result.put("judgement", pass ? "PASS" : "FAIL");
|
|
result.put("measured", measured);
|
|
result.put("deviation", deviation);
|
|
result.put("reason", reason.length() == 0 ? "규격 내" : reason.toString().trim());
|
|
result.put("source", "fallback");
|
|
return result;
|
|
}
|
|
|
|
// 7. 재고 최적화·안전재고 추천 (수요 변동 기반)
|
|
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;
|
|
}
|
|
|
|
// 8. 생산 일정 최적화 제안 (납기·셋업 기준 단순 우선순위)
|
|
public List<Map<String, Object>> scheduleOptimize(List<Map<String, Object>> orders) {
|
|
if (orders == null || orders.isEmpty()) return List.of();
|
|
// EDD(Earliest Due Date) + 동일품목 셋업 묶기 폴백
|
|
List<Map<String, Object>> sorted = new ArrayList<>(orders);
|
|
sorted.sort((a, b) -> {
|
|
String d1 = String.valueOf(a.getOrDefault("dueDate", "9999-12-31"));
|
|
String d2 = String.valueOf(b.getOrDefault("dueDate", "9999-12-31"));
|
|
int c = d1.compareTo(d2);
|
|
if (c != 0) return c;
|
|
return String.valueOf(a.getOrDefault("itemCode", "")).compareTo(String.valueOf(b.getOrDefault("itemCode", "")));
|
|
});
|
|
int seq = 1;
|
|
for (Map<String, Object> o : sorted) {
|
|
o.put("suggestedSeq", seq++);
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
@SuppressWarnings("unused")
|
|
private List<String> tokenize(String s) {
|
|
return Arrays.stream(s.split("[\\s,.]+")).filter(w -> w.length() > 1).toList();
|
|
}
|
|
}
|