feat: 최신 AI 기법 적용 — 중앙 guardia-rag 경유 배선(근거검증·구조화출력·에이전틱) + 기법 토글

This commit is contained in:
GUARDiA 2026-06-21 11:38:36 +09:00
parent 0c5436291c
commit 23c1542c0a
14 changed files with 1223 additions and 0 deletions

View File

@ -57,6 +57,9 @@ public class SecurityConfig {
.requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER") .requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER")
.requestMatchers("/api/admin/**").hasRole("SUPERADMIN") .requestMatchers("/api/admin/**").hasRole("SUPERADMIN")
// RAG 기법 토글 변경 MANAGER 이상(운영자 전용). 분석 트리거(POST) 아래 WORKER+ 규칙 적용.
.requestMatchers(HttpMethod.PUT, "/api/mes/rag/toggles/**").hasAnyRole("SUPERADMIN", "MANAGER")
// 변경(실적·검사·입출고·재고이동) WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드) // 변경(실적·검사·입출고·재고이동) WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드)
.requestMatchers(HttpMethod.POST, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") .requestMatchers(HttpMethod.POST, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
.requestMatchers(HttpMethod.PUT, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") .requestMatchers(HttpMethod.PUT, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")

View File

@ -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 전용).
*
* <p><b>핵심 설계</b>: MES LangChain·검색·에이전틱 로직을 Java 재구현하지 않는다. 중앙 Python
* guardia-rag 검색·에이전틱·구조화·검증을 REST 호출하기만 한다. 미가용/타임아웃/RAM 부족
* 절대 예외를 전파하지 않고 {@code degraded:true} 표시된 /폴백 결과를 돌려준다(서비스 계층이
* MES 결정론 로컬 폴백 수행).
*
* <p><b>보안 불변</b>: 온프레미스 전용. 응답에서 자격증명/거래처·인사 PII/스택트레이스 미노출. 오류는 요약만 로깅.
*
* <p>호출 계약(중앙, 8020):
* <ul>
* <li>POST /rag/answer 검색(retrieval_mode)+근거생성+인용+guardrail</li>
* <li>POST /rag/agent 에이전틱 tool-use(멀티스텝 추론, step 상한)</li>
* <li>POST /rag/structured format:json 결정론 JSON(스키마 강제)</li>
* <li>POST /rag/feedback 👍/👎 피드백(solution=mes 격리)</li>
* <li>GET /rag/trust/settings 가용성 프로브</li>
* </ul>
*/
@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<String, Object> structured(String prompt, Map<String, Object> schema,
String model, double temperature) {
if (!props.isEnabled()) return degraded("rag_disabled");
try {
Map<String, Object> 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<String, Object> 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<String, Object> answer(String query, String retrievalMode, boolean rerank,
int topK, String model, boolean verify) {
if (!props.isEnabled()) return degraded("rag_disabled");
try {
Map<String, Object> options = new LinkedHashMap<>();
options.put("rerank", rerank);
options.put("stream", false);
options.put("verify", verify);
if (model != null) options.put("model", model);
Map<String, Object> 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<String, Object> 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<String, Object> agent(String task, Object tools, String retrievalMode,
int maxSteps, String model, Map<String, Object> structuredSchema) {
if (!props.isEnabled()) return degraded("rag_disabled");
try {
Map<String, Object> 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<String, Object> body = new LinkedHashMap<>();
body.put("solution", props.getSolution());
body.put("task", task);
if (tools != null) body.put("tools", tools);
body.put("options", options);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> post(String path, Map<String, Object> 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<String, Object>) m)
.block();
}
private Map<String, Object> degraded(String reason) {
Map<String, Object> 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;
}
}

View File

@ -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 별개 레이어.
*
* <p>기존 {@code /api/mes/ai/*}(AiController, 8개 제조 AI) <b>불변</b>. 컨트롤러는 중앙 경유
* 신규 경로만 추가한다. RBAC {@code SecurityConfig} 통제(POST=Worker+, 토글 PUT=Manager+).
*
* <ul>
* <li>POST /api/mes/rag/defect-analysis 불량 RCA + SPC 이상감지(/agent+/structured, SPC 수치는 결정론)</li>
* <li>POST /api/mes/rag/predict-analysis 설비 예지보전 + 수요/생산 예측(/agent, 수치는 결정론 베이스라인)</li>
* <li>POST /api/mes/rag/feedback 👍/👎 피드백(/rag/feedback, solution=mes 격리)</li>
* <li>GET /api/mes/rag/toggles 현재 기법 토글 스냅샷</li>
* <li>PUT /api/mes/rag/toggles/{key} 토글 변경(Manager+, mes_setting 격리 저장)</li>
* </ul>
*/
@RestController
@RequestMapping("/api/mes/rag")
@RequiredArgsConstructor
public class RagController {
private final RagMesService service;
private final SettingService settingService;
@PostMapping("/defect-analysis")
public ApiResponse<Map<String, Object>> defectAnalysis(@RequestBody Map<String, Object> req, Authentication auth) {
return ApiResponse.ok(service.defectAnalysis(req, name(auth)));
}
@PostMapping("/predict-analysis")
public ApiResponse<Map<String, Object>> predictAnalysis(@RequestBody Map<String, Object> req, Authentication auth) {
return ApiResponse.ok(service.predictAnalysis(req, name(auth)));
}
@PostMapping("/feedback")
public ApiResponse<Map<String, Object>> feedback(@RequestBody Map<String, Object> req, Authentication auth) {
return ApiResponse.ok(service.feedback(req, name(auth)));
}
@GetMapping("/toggles")
public ApiResponse<Map<String, Object>> toggles() {
return ApiResponse.ok(service.currentToggles());
}
/**
* 토글 변경 기존 mes_setting upsert(감사로그 자동 기록) 재사용. RBAC SecurityConfig 통제.
* 허용 키는 {@code rag.*} 네임스페이스로 한정(다른 설정 보호).
*/
@PutMapping("/toggles/{key}")
public ApiResponse<Map<String, Object>> updateToggle(@PathVariable String key,
@RequestBody Map<String, Object> 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();
}
}

View File

@ -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 배선 레이어(순증·비파괴).
*
* <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"));
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("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<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); }
}

View File

@ -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).
*
* <p><b>보안 불변</b>: base-url <b>온프레미스(중앙 guardia-rag) 전용</b>이다.
* 외부 엔드포인트를 가리키도록 설정해서는 된다. 기본값은 서버 내부 루프백이다.
*
* <p>application.yml:
* <pre>
* guardia:
* rag:
* base-url: http://127.0.0.1:8020
* timeout-ms: 120000
* enabled: true
* solution: mes
* </pre>
*/
@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";
}

View File

@ -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 기법 토글 서비스.
*
* <p>기존 {@code mes_setting}(key-value) 테이블을 재사용해 RAG 토글을 격리 저장한다(신규 설정 테이블
* 신설 없음, admin 설정 화면과 자연 통합). 네임스페이스 {@code rag.*} 다른 설정과 충돌 회피.
*
* <p><b>서버 RAM 제약</b>: 무거운 기법(graphrag·rerank·tool_use·stream) <b>기본 off</b>.
* 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<String, String> m = new LinkedHashMap<>();
List<MesSetting> 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<String, Object> toMap() {
Map<String, Object> 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;
}
}
}

View File

@ -55,6 +55,13 @@ guardia:
itsm-url: ${ITSM_URL:http://localhost:9001} itsm-url: ${ITSM_URL:http://localhost:9001}
ocr-url: ${OCR_URL:http://localhost:8005} ocr-url: ${OCR_URL:http://localhost:8005}
bi-url: ${BI_URL:http://localhost:8006} 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 온프레미스만 허용. 외부 AI API 절대 금지.
ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b} ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}

View File

@ -248,4 +248,24 @@ ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS otp_secret VARCHAR(255);
-- "업무 (UIWS)" 메뉴는 프론트 Sidebar/Route 에 추가(코드). 여기서는 주석으로만 명시. -- "업무 (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 -- end 91_uiws_port.sql

View File

@ -35,6 +35,7 @@ import Warehouses from './pages/Warehouses'
// 공통 // 공통
import Analytics from './pages/Analytics' import Analytics from './pages/Analytics'
import AiTools from './pages/AiTools' import AiTools from './pages/AiTools'
import RagSettings from './pages/RagSettings'
import UserManagement from './pages/UserManagement' import UserManagement from './pages/UserManagement'
import AuditLog from './pages/AuditLog' import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings' import SystemSettings from './pages/SystemSettings'
@ -88,6 +89,9 @@ export default function App() {
{/* 공통 */} {/* 공통 */}
<Route path="/analytics" element={<Analytics />} /> <Route path="/analytics" element={<Analytics />} />
<Route path="/ai-tools" element={<AiTools />} /> <Route path="/ai-tools" element={<AiTools />} />
<Route path="/ai-techniques" element={
<ProtectedRoute min="MANAGER"><RagSettings /></ProtectedRoute>
} />
{/* 업무 (UIWS 이식) — 기존 라우트 보존, 추가만 */} {/* 업무 (UIWS 이식) — 기존 라우트 보존, 추가만 */}
<Route path="/uiws/worklog" element={<UiwsWorklog />} /> <Route path="/uiws/worklog" element={<UiwsWorklog />} />
<Route path="/uiws/schedule" element={<UiwsSchedule />} /> <Route path="/uiws/schedule" element={<UiwsSchedule />} />

View File

@ -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 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)) 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+) ──────────────── // ── Admin (SUPERADMIN / 감사로그·설정 GET 은 MANAGER+) ────────────────
export const getUsers = () => api.get('/api/admin/users') export const getUsers = () => api.get('/api/admin/users')
export const createUser = (data: { username: string; password: string; displayName?: string; role?: string }) => export const createUser = (data: { username: string; password: string; displayName?: string; role?: string }) =>

View File

@ -50,6 +50,7 @@ const masterLinks: Link[] = [
const commonLinks: Link[] = [ const commonLinks: Link[] = [
{ to: '/analytics', label: '분석 / KPI', icon: BarChart3 }, { to: '/analytics', label: '분석 / KPI', icon: BarChart3 },
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles }, { to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
{ to: '/ai-techniques', label: 'AI 기법 설정', icon: Cpu, min: 'MANAGER' },
] ]
// 업무 (UIWS 이식) — 업무일지/일정/쪽지/업무통계 // 업무 (UIWS 이식) — 업무일지/일정/쪽지/업무통계
const uiwsLinks: Link[] = [ const uiwsLinks: Link[] = [

View File

@ -1,13 +1,54 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { import {
Sparkles, AlertTriangle, TrendingUp, Wrench, LineChart, Search, Boxes, CalendarClock, Sparkles, AlertTriangle, TrendingUp, Wrench, LineChart, Search, Boxes, CalendarClock,
Cpu, ThumbsUp, ThumbsDown,
} from 'lucide-react' } from 'lucide-react'
import { Card, Btn, Field } from '../components/ui' import { Card, Btn, Field } from '../components/ui'
import { import {
getAiStatus, aiDefectRootCause, aiForecast, aiPredictiveMaintenance, getAiStatus, aiDefectRootCause, aiForecast, aiPredictiveMaintenance,
aiSpcAnomaly, aiParseQuery, aiSafetyStock, aiScheduleOptimize, aiSpcAnomaly, aiParseQuery, aiSafetyStock, aiScheduleOptimize,
ragDefectAnalysis, ragPredictAnalysis, ragFeedback,
} from '../api/client' } 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 (
<div className="mt-2 flex flex-wrap gap-1">
{chips.map((c, i) => (
<span key={i} className={`text-[10px] px-1.5 py-0.5 rounded border ${c.startsWith('⚠') ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-brand/10 text-brand border-brand/30'}`}>{c}</span>
))}
</div>
)
}
/** 👍/👎 피드백 (중앙 /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 (
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
<span> ?</span>
<button onClick={() => send('up')} className="p-1 rounded hover:bg-brand/10 hover:text-brand"><ThumbsUp size={13} /></button>
<button onClick={() => send('down')} className="p-1 rounded hover:bg-rose-500/10 hover:text-rose-300"><ThumbsDown size={13} /></button>
{done && <span className="text-brand">{done}</span>}
</div>
)
}
function Output({ data }: { data: any }) { function Output({ data }: { data: any }) {
if (data == null) return null if (data == null) return null
if (typeof data === 'string') return <pre className="mt-3 text-xs text-slate-300 bg-ink border border-edge rounded p-3 whitespace-pre-wrap max-h-56 overflow-auto">{data}</pre> if (typeof data === 'string') return <pre className="mt-3 text-xs text-slate-300 bg-ink border border-edge rounded p-3 whitespace-pre-wrap max-h-56 overflow-auto">{data}</pre>
@ -31,6 +72,17 @@ export default function AiTools() {
</span> </span>
</div> </div>
{/* ── 최신 기법 (중앙 guardia-rag 경유) — 대표 2개 기능 ───────────── */}
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold flex items-center gap-2 text-brand"><Cpu size={15} /> AI (RAG · · )</h2>
<Link to="/ai-techniques" className="text-xs text-slate-400 hover:text-brand underline"> </Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 mb-8">
<RagDefectTool /><RagPredictTool />
</div>
{/* ── 기존 AI 도구 (불변, Ollama + Java 폴백) ───────────────────── */}
<h2 className="text-sm font-semibold text-slate-300 mb-3"> AI </h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<DefectTool /><ForecastTool /><MaintenanceTool /><SpcTool /> <DefectTool /><ForecastTool /><MaintenanceTool /><SpcTool />
<QueryTool /><SafetyStockTool /><ScheduleTool /> <QueryTool /><SafetyStockTool /><ScheduleTool />
@ -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<any>(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 (
<Card title="불량 원인분석 + SPC 이상감지 (RAG)" icon={<AlertTriangle size={15} />}>
<Field label="불량 코드"><input className="inp" value={code} onChange={e => setCode(e.target.value)} placeholder="예: D-DIM-01" /></Field>
<Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field>
<Field label="SPC 표본값(쉼표구분, 선택)"><input className="inp" value={values} onChange={e => setValues(e.target.value)} placeholder="예: 10.1, 10.3, 9.8, 10.5" /></Field>
<div className="grid grid-cols-3 gap-2">
<Field label="UCL"><input className="inp" value={ucl} onChange={e => setUcl(e.target.value)} /></Field>
<Field label="LCL"><input className="inp" value={lcl} onChange={e => setLcl(e.target.value)} /></Field>
<Field label="CL"><input className="inp" value={cl} onChange={e => setCl(e.target.value)} /></Field>
</div>
<Btn onClick={run} disabled={busy} size="sm"><Cpu size={13} /> {busy ? '분석 중…' : 'RCA + SPC 분석'}</Btn>
<AppliedBadge data={out} />
<Output data={out} />
<FeedbackBar answerId={out?.defect?.answerId} query={code} />
</Card>
)
}
/** 대표 2: 설비 예지보전 / 수요·생산 예측 (수치=결정론 베이스라인, 해석=/rag/agent). */
function RagPredictTool() {
const [kind, setKind] = useState<'pdm' | 'forecast'>('forecast')
const [eq, setEq] = useState(''); const [avail, setAvail] = useState(''); const [dt, setDt] = useState(''); const [mtbf, setMtbf] = useState('')
const [series, setSeries] = useState(''); const [horizon, setHorizon] = useState(7)
const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
const run = async () => {
setBusy(true)
try {
const req: any = { kind }
if (kind === 'pdm') { req.equipmentCode = eq; req.availability = Number(avail) || 0; req.downtimeCount = Number(dt) || 0; req.mtbfHours = Number(mtbf) || 0 }
else { req.series = series.split(',').map(s => s.trim()).filter(Boolean).map(Number); req.horizon = horizon }
setOut(await ragPredictAnalysis(req))
} finally { setBusy(false) }
}
return (
<Card title="예지보전 / 수요·생산 예측 (RAG)" icon={<TrendingUp size={15} />}>
<div className="flex gap-2 mb-2">
{(['forecast', 'pdm'] as const).map(k => (
<button key={k} onClick={() => setKind(k)} className={`px-3 py-1.5 text-xs rounded-lg border ${kind === k ? 'bg-brand text-ink border-brand' : 'bg-card border-edge text-slate-300'}`}>
{k === 'forecast' ? '수요/생산 예측' : '설비 예지보전'}
</button>
))}
</div>
{kind === 'pdm' ? (
<>
<Field label="설비 코드"><input className="inp" value={eq} onChange={e => setEq(e.target.value)} placeholder="예: EQ-CNC-01" /></Field>
<div className="grid grid-cols-3 gap-2">
<Field label="가동률(0~1)"><input className="inp" value={avail} onChange={e => setAvail(e.target.value)} /></Field>
<Field label="비가동 횟수"><input className="inp" value={dt} onChange={e => setDt(e.target.value)} /></Field>
<Field label="MTBF(시간)"><input className="inp" value={mtbf} onChange={e => setMtbf(e.target.value)} /></Field>
</div>
</>
) : (
<>
<Field label="시계열(쉼표구분)"><input className="inp" value={series} onChange={e => setSeries(e.target.value)} placeholder="예: 100, 120, 95, 130" /></Field>
<Field label="예측 기간(일)"><input type="number" className="inp" value={horizon} onChange={e => setHorizon(Number(e.target.value))} /></Field>
</>
)}
<Btn onClick={run} disabled={busy} size="sm"><Cpu size={13} /> {busy ? '분석 중…' : '예측 + 해석'}</Btn>
<AppliedBadge data={out} />
<Output data={out} />
<FeedbackBar answerId={out?.interpretation?.answerId} query={kind} />
</Card>
)
}
function DefectTool() { function DefectTool() {
const [code, setCode] = useState(''); const [ctx, setCtx] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false) const [code, setCode] = useState(''); const [ctx, setCtx] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
const run = async () => { setBusy(true); try { setOut(await aiDefectRootCause({ defectCode: code, context: ctx })) } finally { setBusy(false) } } const run = async () => { setBusy(true); try { setOut(await aiDefectRootCause({ defectCode: code, context: ctx })) } finally { setBusy(false) } }

View File

@ -0,0 +1,153 @@
import { useEffect, useState } from 'react'
import { Cpu, Save, Info, ShieldCheck } from 'lucide-react'
import { Card, Btn } from '../components/ui'
import { getRagToggles, updateRagToggle } from '../api/client'
import { canManage } from '../components/rbac'
/**
* MES AI guardia-rag ( , mes_setting ).
*
* - rerank·graphrag·hybrid·tool_use·structured·stream on/off + (top_k·max_steps·temperature·model).
* - (graphrag·rerank·tool_use·stream) RAM off .
* - MANAGER+ (SecurityConfig PUT /api/mes/rag/toggles/** = MANAGER+). .
*/
type Toggles = {
ragEnabled: boolean; ragAvailable: boolean
retrievalMode: string
rerank: boolean; graphrag: boolean; hybrid: boolean
toolUse: boolean; structured: boolean; stream: boolean
topK: number; maxSteps: number; temperature: number; generationModel: string
}
const HEAVY = new Set(['rag.graphrag', 'rag.rerank', 'rag.tool_use', 'rag.stream'])
export default function RagSettings() {
const [t, setT] = useState<Toggles | null>(null)
const [msg, setMsg] = useState(''); const [err, setErr] = useState('')
const editable = canManage()
const load = () =>
getRagToggles()
.then((d: any) => { setT(d); setErr('') })
.catch(() => setErr('RAG 토글을 불러오지 못했습니다.'))
useEffect(() => { load() }, [])
const save = async (key: string, value: any) => {
setMsg(''); setErr('')
try { const d = await updateRagToggle(key, value); setT(d); setMsg(`'${key}' 저장됨`) }
catch (e: any) { setErr(e?.response?.status === 403 ? '변경은 Manager+ 권한이 필요합니다.' : '저장 실패') }
}
if (!t) return <div className="text-slate-400 text-sm">{err || '불러오는 중…'}</div>
const Toggle = ({ k, label, desc, on }: { k: string; label: string; desc: string; on: boolean }) => (
<div className="flex items-start justify-between gap-3 py-2.5 border-b border-edge last:border-0">
<div>
<div className="text-sm font-medium flex items-center gap-2">
{label}
{HEAVY.has(k) && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30"></span>}
</div>
<div className="text-xs text-slate-400 mt-0.5">{desc}</div>
</div>
<button
disabled={!editable}
onClick={() => save(k, !on)}
className={`shrink-0 w-11 h-6 rounded-full transition-colors relative disabled:opacity-40 ${on ? 'bg-brand' : 'bg-slate-600'}`}
title={on ? 'ON' : 'OFF'}
>
<span className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all ${on ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
)
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2"><Cpu className="text-brand" size={20} /> AI </h1>
<p className="text-sm text-slate-400 mt-0.5"> guardia-rag ( ··GraphRAG···). .</p>
</div>
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${t.ragAvailable ? 'text-brand border-brand/30 bg-brand/10' : 'text-slate-400 border-edge bg-card'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${t.ragAvailable ? 'bg-brand' : 'bg-slate-500'}`} />
RAG {t.ragAvailable ? '연결됨' : '미가용 (로컬 폴백)'}
</span>
</div>
{msg && <div className="bg-brand/10 border border-brand/30 text-brand text-sm rounded-lg px-4 py-2.5 mb-4">{msg}</div>}
{err && <div className="bg-rose-500/10 border border-rose-500/40 text-rose-300 text-sm rounded-lg px-4 py-2.5 mb-4">{err}</div>}
{!editable && <div className="bg-amber-500/10 border border-amber-500/30 text-amber-300 text-sm rounded-lg px-4 py-2.5 mb-4"> . Manager+ .</div>}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<Card title="마스터 / 검색 모드" icon={<ShieldCheck size={15} />}>
<Toggle k="rag.enabled" label="중앙 RAG 경유" desc="off 시 MES 결정론 로컬 폴백으로 즉시 회귀(기존 동작)." on={t.ragEnabled} />
<div className="py-2.5 border-b border-edge">
<div className="text-sm font-medium mb-1.5"> (retrieval_mode)</div>
<div className="flex gap-2">
{['vector', 'hybrid', 'graph'].map(m => (
<button key={m} disabled={!editable} onClick={() => save('rag.retrieval_mode', m)}
className={`px-3 py-1.5 text-xs rounded-lg border disabled:opacity-40 ${t.retrievalMode === m ? 'bg-brand text-ink border-brand' : 'bg-card border-edge text-slate-300 hover:border-brand'}`}>
{m}
</button>
))}
</div>
<div className="text-xs text-slate-400 mt-1.5">graphrag/hybrid .</div>
</div>
<Toggle k="rag.hybrid" label="하이브리드 검색" desc="BM25 키워드 + 벡터 앙상블. 검색 정확도↑(중간 비용)." on={t.hybrid} />
<Toggle k="rag.rerank" label="리랭킹 (rerank)" desc="cross-encoder/LLM 재정렬. 상위 정밀도↑(추가 추론 비용)." on={t.rerank} />
<Toggle k="rag.graphrag" label="GraphRAG" desc="지식그래프 기반 검색. 다중 홉 질의에 유리(고비용)." on={t.graphrag} />
</Card>
<Card title="에이전틱 / 출력" icon={<Info size={15} />}>
<Toggle k="rag.tool_use" label="에이전틱 tool-use (/agent)" desc="조회 매퍼를 도구로 멀티스텝 추론. 불량 RCA·예측 해석에 활용(고비용)." on={t.toolUse} />
<Toggle k="rag.structured" label="구조화 출력 (/structured)" desc="JSON 스키마 강제 — 결정론적 결과 객체(경량, 기본 ON 권장)." on={t.structured} />
<Toggle k="rag.stream" label="토큰 스트리밍 (SSE)" desc="긴 답변 점진 표시. 동시성 위협 시 off 유지(고비용)." on={t.stream} />
<div className="grid grid-cols-2 gap-3 mt-3">
<NumField label="top_k (1~20)" value={t.topK} min={1} max={20} disabled={!editable} onSave={v => save('rag.top_k', v)} />
<NumField label="max_steps (1~8)" value={t.maxSteps} min={1} max={8} disabled={!editable} onSave={v => save('rag.max_steps', v)} />
<NumField label="temperature (0~1)" value={t.temperature} min={0} max={1} step={0.1} disabled={!editable} onSave={v => save('rag.temperature', v)} />
<TextField label="생성 모델" value={t.generationModel} disabled={!editable} onSave={v => save('rag.generation_model', v)} />
</div>
</Card>
</div>
<div className="mt-4 text-xs text-slate-500">
불변: 외부 API 0 (· Ollama ). RAM off (degraded:true).
</div>
</div>
)
}
function NumField({ label, value, min, max, step = 1, disabled, onSave }:
{ label: string; value: number; min: number; max: number; step?: number; disabled?: boolean; onSave: (v: number) => void }) {
const [v, setV] = useState(String(value))
useEffect(() => { setV(String(value)) }, [value])
return (
<div>
<label className="text-xs text-slate-400">{label}</label>
<div className="flex gap-1.5 mt-1">
<input type="number" min={min} max={max} step={step} value={v} disabled={disabled}
onChange={e => setV(e.target.value)}
className="w-full bg-ink border border-edge rounded px-2 py-1 text-sm disabled:opacity-50" />
<Btn size="sm" disabled={disabled} onClick={() => onSave(Number(v))}><Save size={12} /></Btn>
</div>
</div>
)
}
function TextField({ label, value, disabled, onSave }:
{ label: string; value: string; disabled?: boolean; onSave: (v: string) => void }) {
const [v, setV] = useState(value)
useEffect(() => { setV(value) }, [value])
return (
<div>
<label className="text-xs text-slate-400">{label}</label>
<div className="flex gap-1.5 mt-1">
<input value={v} disabled={disabled} onChange={e => setV(e.target.value)}
className="w-full bg-ink border border-edge rounded px-2 py-1 text-sm disabled:opacity-50" />
<Btn size="sm" disabled={disabled} onClick={() => onSave(v)}><Save size={12} /></Btn>
</div>
</div>
)
}

140
scripts/rag_ingest_mes.py Normal file
View File

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
GUARDiA MES 중앙 guardia-rag 색인 (대표 도메인 메타 mes 컬렉션).
불량 RCA·SPC 해석·예지보전/예측 해석의 근거 검색을 위해, MES 대표 도메인
**메타데이터(테이블/컬럼/판정규칙/방법 정의)** 중앙 RAG `mes` 컬렉션에 적재한다.
실제 생산/검사 데이터 ·거래처/인사 PII·자격증명은 색인하지 않는다(보안 불변).
사용:
python scripts/rag_ingest_mes.py # 기본(http://127.0.0.1:8020)
RAG_URL=http://127.0.0.1:8020 python scripts/rag_ingest_mes.py
설계 메모:
- 외부 API 0. 중앙 guardia-rag(온프레미스) 호출.
- 멱등: doc_id 안정 재실행 upsert(중앙이 처리). 실패해도 MES 동작에 영향 없음(보조 스크립트).
- PII/자격증명 미색인: 거래처 연락처·담당자명·SSH·비밀번호·IP 문서에 포함하지 않는다.
- 수치 결정론 불변: SPC 관리한계·Cp/Cpk·예측치는 MES Java 로직이 산출. 문서는 "방법·판정규칙 정의"
제공해 AI 수치를 재계산하지 않고 해석만 하도록 grounding 한다.
"""
import json
import os
import sys
import urllib.request
import urllib.error
RAG_URL = os.environ.get("RAG_URL", "http://127.0.0.1:8020").rstrip("/")
SOLUTION = "mes"
# 대표 도메인 메타 문서 — 불량 RCA·SPC·예측 해석 근거용.
# (PII/자격증명 컬럼 제외. 판정규칙·방법론·논리지표↔물리컬럼 중심.)
DOCS = [
{
"doc_id": "mes_qms_ncr",
"source": "MES-SCHEMA-QMS",
"text": (
"부적합(NCR) 도메인: 검사/공정에서 발견된 부적합 기록. 핵심 필드 - ncr_no(부적합번호), "
"defect_code(불량코드), lot_no(LOT), item_code(품목), process_code(공정), "
"equipment_code(설비), qty_defect(불량수량), root_cause(추정원인), status. "
"불량코드 접두 규칙: DIM=치수, SCR/외관=표면손상, MAT=자재편차. "
"원인분석은 4M(Man/Machine/Material/Method)·5Why·파레토 관점으로 수행한다."
),
},
{
"doc_id": "mes_qms_capa",
"source": "MES-SCHEMA-QMS",
"text": (
"시정조치(CAPA) 도메인: 부적합에 대한 시정·예방 조치. 필드 - capa_no, ncr_no(연결 부적합), "
"action_type(CORRECTIVE/PREVENTIVE), action_desc(조치내용), owner_role, due_date, status, "
"effectiveness(유효성 검증결과). 유사 불량의 과거 CAPA 이력은 RCA 근거로 활용한다."
),
},
{
"doc_id": "mes_qms_inspection",
"source": "MES-SCHEMA-QMS",
"text": (
"검사(inspection) 도메인: 수입/공정/출하 검사 결과. 필드 - inspection_no, item_code, lot_no, "
"characteristic(검사항목), measured(실측값), lsl(하한규격), usl(상한규격), target(목표), "
"judgement(PASS/FAIL/REINSPECT), aql. 판정규칙: measured<lsl 또는 measured>usl 이면 FAIL. "
"deviation = measured - target. 최종 판정 권한은 사람에게 있으며 AI 는 초안만 제시한다."
),
},
{
"doc_id": "mes_method_spc",
"source": "MES-METHOD-SPC",
"text": (
"SPC(통계적공정관리) 방법 정의 — 관리도 X̄-R, p, np, c, u. 런 규칙(Western Electric/Nelson): "
"RULE1 관리한계 이탈(>UCL 또는 <LCL), RULE2 연속 9점이 중심선 한쪽, RULE3 연속 6점 증가/감소 추세. "
"공정능력: Cp=(USL-LSL)/(6σ), Cpk=min((USL-μ),(μ-LSL))/(3σ). "
"★수치(평균·UCL·LCL·Cp/Cpk·이탈점)는 MES 결정론 로직이 계산한다. AI 는 이탈 의미·원인·조치만 해석한다(재계산 금지)."
),
},
{
"doc_id": "mes_equipment_oee",
"source": "MES-SCHEMA-MES",
"text": (
"설비/OEE 도메인: 설비 가동·비가동·종합효율. 필드 - equipment_code, availability(가동률 0~1), "
"performance, quality, oee(=가용성*성능*품질), downtime_count(비가동 횟수), mtbf_hours(평균고장간격), "
"mttr_hours(평균수리시간). 예지보전 위험 규칙: 가동률 낮고(<0.7) 비가동 잦으며 MTBF 짧을수록(<100h) 고위험. "
"★위험점수·정비시점은 MES 결정론 로직이 산출하고 AI 는 추세 해석·우선순위만 제시한다."
),
},
{
"doc_id": "mes_production_forecast",
"source": "MES-METHOD-FORECAST",
"text": (
"수요/생산 예측 방법 정의 — 생산실적(job)·작업지시·출하 시계열 기반. 베이스라인: 가중 이동평균 + 선형추세 "
"(예측 = (마지막값 + 기울기*i)*0.6 + 평균*0.4, 음수 클램프). horizon 기본 7일, 최대 90일. "
"★예측 수치는 MES 결정론 로직이 산출(재현 가능). AI 는 추세 해석·이상신호 강조·생산계획 조정 권고만 한다."
),
},
{
"doc_id": "mes_inventory_optimize",
"source": "MES-METHOD-INVENTORY",
"text": (
"재고 최적화 방법 정의 — 안전재고 = Z * 수요표준편차 * sqrt(리드타임), 발주점(ROP) = 평균일수요*리드타임 + 안전재고. "
"기본 서비스수준 95%(Z=1.65). LOT 만료 위험은 lotserial 의 expiry_date 로 감지. "
"★수치는 MES 결정론 로직이 산출. AI 는 과부족·만료 위험 해석과 발주 권고만 제시한다."
),
},
{
"doc_id": "mes_rag_contract",
"source": "MES-RAG",
"text": (
"MES 최신 AI 기법 적용(중앙 guardia-rag): 대표 2기능 — (1) 불량 RCA+SPC 이상감지: SPC 수치는 결정론, "
"원인서술은 /rag/agent(tool-use)+/rag/structured(JSON 강제). (2) 예지보전/수요·생산 예측: 수치는 결정론 베이스라인, "
"해석은 /rag/agent. 기법 토글(rerank·graphrag·hybrid·tool_use·structured·stream)은 mes_setting 의 rag.* 키로 격리 저장. "
"무거운 기법은 서버 RAM 제약상 기본 off, 미가용 시 결정론 폴백(degraded:true). 외부 API 0(Ollama 온프레미스)."
),
},
]
def ingest():
payload = {"solution": SOLUTION, "documents": DOCS}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{RAG_URL}/rag/ingest",
data=data,
headers={"Content-Type": "application/json", "X-Solution-Key": SOLUTION},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
body = resp.read().decode("utf-8")
print(f"[OK] ingested {len(DOCS)} docs into '{SOLUTION}' collection")
print(f" response: {body[:300]}")
return 0
except urllib.error.HTTPError as e:
print(f"[WARN] ingest HTTP {e.code} — 중앙 RAG 응답 이상(스크립트는 보조). 본 MES 동작 영향 없음.")
return 1
except urllib.error.URLError as e:
print(f"[WARN] 중앙 guardia-rag 미가용({RAG_URL}) — 색인 보류. 라이브 가동 후 재실행. (사유 요약: {str(e.reason)[:80]})")
return 1
except Exception:
print("[WARN] 색인 중 예기치 못한 오류 — 보류(MES 동작 영향 없음).")
return 1
if __name__ == "__main__":
sys.exit(ingest())