feat(ai): Claude 전환 + DuckDB 학습저장소 이식

This commit is contained in:
GUARDiA 2026-07-03 22:43:25 +09:00
parent b2dd933e12
commit 08d9543116
25 changed files with 1451 additions and 155 deletions

View File

@ -22,6 +22,7 @@
<springdoc.version>2.6.0</springdoc.version> <springdoc.version>2.6.0</springdoc.version>
<mybatis.version>3.0.3</mybatis.version> <mybatis.version>3.0.3</mybatis.version>
<postgresql.version>42.7.7</postgresql.version> <postgresql.version>42.7.7</postgresql.version>
<duckdb.version>1.1.3</duckdb.version>
</properties> </properties>
<dependencies> <dependencies>
@ -42,6 +43,10 @@
<!-- DB Driver --> <!-- DB Driver -->
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency> <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- DuckDB (로컬 임베디드 AI 학습/추론 저장소 — /opt/guardia-mes/data/mes_learning.duckdb) -->
<!-- 런타임 전용: 코드는 java.sql + 리플렉션 드라이버 로드로 컴파일 의존 0. 미가용 시 학습 저장만 비활성. -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>${duckdb.version}</version><scope>runtime</scope></dependency>
<!-- JWT --> <!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>

View File

@ -1,5 +1,7 @@
package com.zioinfo.mes.ai; package com.zioinfo.mes.ai;
import com.zioinfo.mes.ai.service.AiTextRouter;
import com.zioinfo.mes.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -31,11 +33,19 @@ import java.util.Map;
public class AiService { public class AiService {
private final OllamaClient ollama; private final OllamaClient ollama;
/** provider 라우팅(Claude→Ollama 폴백) + 로컬 학습 로그 경유. 기존 OllamaClient 직접 호출을 대체. */
private final AiTextRouter aiTextRouter;
public boolean ollamaAvailable() { public boolean ollamaAvailable() {
return ollama.available(); 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. 불량 원인 분석 (공정/설비/자재 상관) // 1. 불량 원인 분석 (공정/설비/자재 상관)
public Map<String, Object> defectRootCause(String defectCode, List<Map<String, Object>> context) { public Map<String, Object> defectRootCause(String defectCode, List<Map<String, Object>> context) {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
@ -44,11 +54,11 @@ public class AiService {
"당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. " "당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. "
+ "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.", + "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
defectCode, ctx); defectCode, ctx);
String out = ollama.generate(prompt); String out = routeGenerate(prompt);
if (out != null && !out.isBlank()) { if (out != null && !out.isBlank()) {
result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out)); result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out));
result.put("action", extractLine(out, "ACTION:")); result.put("action", extractLine(out, "ACTION:"));
result.put("source", "ollama"); result.put("source", "ai");
return result; return result;
} }
// Java 폴백: 불량코드 분류 규칙 // Java 폴백: 불량코드 분류 규칙
@ -156,12 +166,12 @@ public class AiService {
Map<String, Object> filter = new LinkedHashMap<>(); Map<String, Object> filter = new LinkedHashMap<>();
if (naturalQuery == null || naturalQuery.isBlank()) return filter; if (naturalQuery == null || naturalQuery.isBlank()) return filter;
String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nITEM:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery; String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nITEM:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery;
String out = ollama.generate(prompt); String out = routeGenerate(prompt);
if (out != null && !out.isBlank()) { if (out != null && !out.isBlank()) {
putIf(filter, "status", extractLine(out, "STATUS:")); putIf(filter, "status", extractLine(out, "STATUS:"));
putIf(filter, "item", extractLine(out, "ITEM:")); putIf(filter, "item", extractLine(out, "ITEM:"));
putIf(filter, "date", extractLine(out, "DATE:")); putIf(filter, "date", extractLine(out, "DATE:"));
if (!filter.isEmpty()) { filter.put("source", "ollama"); return filter; } if (!filter.isEmpty()) { filter.put("source", "ai"); return filter; }
} }
// Java 폴백: 키워드 매칭 // Java 폴백: 키워드 매칭
String q = naturalQuery.toLowerCase(); String q = naturalQuery.toLowerCase();

View File

@ -34,11 +34,20 @@ public class OllamaClient {
this.visionModel = visionModel; this.visionModel = visionModel;
} }
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */ /** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
@SuppressWarnings("unchecked")
public String generate(String prompt) { public String generate(String prompt) {
return generate(prompt, textModel);
}
/**
* 지정 모델로 텍스트 생성(AiTextRouter provider 소형 모델 선택 경유). 실패 문자열.
* 기존 {@link #generate(String)} 계약 보존 오버로드만 추가.
*/
@SuppressWarnings("unchecked")
public String generate(String prompt, String model) {
String useModel = (model == null || model.isBlank()) ? textModel : model.trim();
try { try {
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false); Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build() Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate") .post().uri("/api/generate")
.bodyValue(body) .bodyValue(body)

View File

@ -0,0 +1,54 @@
package com.zioinfo.mes.ai.controller;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.ai.dto.AiConfigDto;
import com.zioinfo.mes.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.mes.ai.service.AiConfigService;
import com.zioinfo.mes.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* AI 플랫폼(LLM provider) 설정 관리(ADMIN). [GUARDiA-MES]
* SecurityConfig {@code /api/admin/** hasRole(SUPERADMIN)} 게이트로 보호된다.
*
* <p>mes_setting(key='ai.*') 저장된 런타임 설정을 조회/갱신/테스트한다. <b>API 키는 환경변수
* (ANTHROPIC_API_KEY)로만 주입</b>되어 응답·로그에 노출되지 않는다(GET {@code claudeKeySet} 불리언만).
* 설정 변경은 재기동 없이 다음 AI 호출부터 반영된다.
*
* <ul>
* <li>GET /api/admin/ai-config 현재 효과 설정( 제외, keySet 불리언만)</li>
* <li>PUT /api/admin/ai-config provider/모델 갱신(화이트리스트 검증)</li>
* <li>POST /api/admin/ai-config/test 저장 설정 기준 선택 provider 연결 테스트(요약 결과만)</li>
* </ul>
*/
@RestController
@RequestMapping("/api/admin/ai-config")
@RequiredArgsConstructor
public class AiConfigController {
private final AiConfigService service;
@GetMapping
public ApiResponse<AiConfigDto> get() {
return ApiResponse.ok(service.getConfig());
}
@PutMapping
public ApiResponse<AiConfigDto> update(@RequestBody AiConfigUpdateRequest req) {
return ApiResponse.ok(service.update(req, AuditService.currentActor()));
}
@PostMapping("/test")
public ApiResponse<AiConfigService.TestResult> test() {
AiConfigService.TestResult result = service.test();
return result.ok()
? ApiResponse.ok(result)
: new ApiResponse<>(false, result.message(), result);
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.mes.ai.dto;
/**
* AI 설정 조회 DTO API 미반환(claudeKeySet 으로 설정 여부만). [GUARDiA-MES]
*
* @param provider 효과 provider(ollama/claude/qwen3/deepseek/glm)
* @param ollamaTextModel 효과 Ollama 텍스트 모델(provider 해석 결과)
* @param claudeModel 효과 Claude 모델 ID
* @param claudeKeySet 서버 환경변수 ANTHROPIC_API_KEY 존재 여부(/길이/마스킹 일절 미포함)
* @param aiEnabled 전역 AI 사용 여부(off 규칙 기반 degraded)
*/
public record AiConfigDto(
String provider,
String ollamaTextModel,
String claudeModel,
boolean claudeKeySet,
boolean aiEnabled) {
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.mes.ai.dto;
/**
* AI 설정 저장 요청 화이트리스트 검증. [GUARDiA-MES]
*
* @param provider ollama/claude/qwen3/deepseek/glm (필수)
* @param claudeModel Claude 모델 ID(선택, 미제공 기존 유지)
* @param ollamaTextModel Ollama 텍스트 모델(선택, 미제공 기존 유지)
*/
public record AiConfigUpdateRequest(
String provider,
String claudeModel,
String ollamaTextModel) {
}

View File

@ -0,0 +1,97 @@
package com.zioinfo.mes.ai.learning;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* AI 답변 피드백 수집(👍/👎 + 교정). [GUARDiA-MES]
*
* <p>{@code POST /api/ai/feedback} 로컬 DuckDB({@link AiLearningStore}) 적재(오프라인 AI 분석/학습셋)
* + 중앙 guardia-rag {@code /feedback} 전달(통합 학습·평가 게이트). 하나가 실패해도 200 유지한다.
*
* <p>인증: {@code /api/ai/**} SecurityConfig {@code anyRequest().authenticated()} 로그인 사용자면
* 누구나 피드백 가능(관리자 전용 아님). PII/자격증명은 로컬 적재 마스킹된다.
* 보안 불변: 중앙 rag 온프레미스 루프백 전용. 외부 전달 없음. 응답에 /스택트레이스 미포함.
*/
@Slf4j
@RestController
@RequestMapping("/api/ai/feedback")
@RequiredArgsConstructor
public class AiFeedbackController {
private final AiLearningStore store;
private final WebClient.Builder webClientBuilder;
@Value("${guardia.rag.base-url:http://127.0.0.1:8020}")
private String ragBaseUrl;
@Value("${guardia.rag.enabled:true}")
private boolean ragEnabled;
@PostMapping
public ApiResponse<Map<String, Object>> feedback(@RequestBody FeedbackRequest req) {
String actor = AuditService.currentActor();
// 로컬 DuckDB 적재(PII 마스킹은 store 내부에서 수행)
store.saveFeedback(req.feature(), req.question(), req.answer(),
req.verdict(), req.correction(), actor);
// 중앙 guardia-rag 전달(fire-and-forget, 실패 무시 응답은 항상 200)
boolean forwarded = forwardToCentralRag(req);
Map<String, Object> out = new LinkedHashMap<>();
out.put("stored", store.isEnabled()); // 로컬 DuckDB 적재 가능 여부
out.put("forwarded", forwarded); // 중앙 rag 전달 시도 여부
return ApiResponse.ok(out);
}
/** 중앙 guardia-rag /feedback 전달. 미가용/타임아웃/예외는 조용히 무시. */
private boolean forwardToCentralRag(FeedbackRequest req) {
if (!ragEnabled) {
return false;
}
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("solution", "mes");
body.put("feature", req.feature());
body.put("question", req.question());
body.put("answer", req.answer());
body.put("verdict", req.verdict());
body.put("correction", req.correction());
webClientBuilder.baseUrl(ragBaseUrl).build()
.post().uri("/feedback")
.bodyValue(body)
.retrieve()
.toBodilessEntity()
.timeout(Duration.ofSeconds(5))
.subscribe(
ok -> {},
err -> log.debug("중앙 rag /feedback 전달 실패(무시): {}", err.getClass().getSimpleName()));
return true;
} catch (Exception e) {
log.debug("중앙 rag /feedback 전달 예외(무시): {}", e.getClass().getSimpleName());
return false;
}
}
/** 피드백 요청 — verdict: up/down, correction: 교정문(선택). */
public record FeedbackRequest(
String feature,
String question,
String answer,
String verdict,
String correction) {
}
}

View File

@ -0,0 +1,170 @@
package com.zioinfo.mes.ai.learning;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.regex.Pattern;
/**
* MES AI 로컬 학습 저장소 임베디드 <b>DuckDB</b>(단일 파일). [GUARDiA-MES]
*
* <p>AI 피드백(👍/👎+교정) 추론 로그를 솔루션 로컬 DuckDB 파일에 적재해 오프라인 AI 분석/학습 데이터셋으로
* 사용한다(중앙 guardia-rag 로의 전달은 {@code AiFeedbackController} 별도로 수행 클래스는 로컬 적재만).
*
* <p>스키마(멱등):
* <ul>
* <li>{@code ai_feedback(id, ts, solution, feature, question, answer, verdict, correction, user_masked)}</li>
* <li>{@code ai_infer_log(id, ts, provider, model, latency_ms, degraded)}</li>
* </ul>
*
* <p><b>안전성</b>: 드라이버는 리플렉션 로드(컴파일 타임 의존 0). 파일/디렉터리 미가용(로컬 개발 )이면
* {@code enabled=false} 조용히 비활성 절대 예외를 던지지 않아 AI 기능은 그대로 동작한다.
* <b>보안</b>: 저장 PII(주민번호·카드·전화·이메일)·자격증명 마스킹. 솔루션별 파일 격리.
*/
@Slf4j
@Component
public class AiLearningStore {
private static final String SOLUTION = "mes";
// PII 마스킹 패턴(수집 적용)
private static final Pattern RRN = Pattern.compile("\\d{6}[- ]?\\d{7}");
private static final Pattern CARD = Pattern.compile("\\b(?:\\d[ -]?){13,16}\\b");
private static final Pattern PHONE = Pattern.compile("01[016789][- ]?\\d{3,4}[- ]?\\d{4}");
private static final Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w.-]+\\.[A-Za-z]{2,}");
private static final int MAX_TEXT = 4000;
@Value("${mes.ai.duckdb-path:/opt/guardia-mes/data/mes_learning.duckdb}")
private String dbPath;
private volatile boolean enabled = false;
private Connection conn;
private final Object lock = new Object();
@PostConstruct
void init() {
try {
Class.forName("org.duckdb.DuckDBDriver");
File f = new File(dbPath);
File dir = f.getParentFile();
if (dir != null && !dir.exists() && !dir.mkdirs()) {
log.warn("AiLearningStore(DuckDB) 비활성 — 디렉터리 생성 불가(로컬 AI 분석 저장 생략, AI 기능은 정상)");
return;
}
conn = DriverManager.getConnection("jdbc:duckdb:" + dbPath);
try (Statement st = conn.createStatement()) {
st.execute("CREATE SEQUENCE IF NOT EXISTS ai_feedback_seq START 1");
st.execute("CREATE TABLE IF NOT EXISTS ai_feedback (" +
"id BIGINT, ts TIMESTAMP, solution VARCHAR, feature VARCHAR, question VARCHAR, " +
"answer VARCHAR, verdict VARCHAR, correction VARCHAR, user_masked VARCHAR)");
st.execute("CREATE SEQUENCE IF NOT EXISTS ai_infer_log_seq START 1");
st.execute("CREATE TABLE IF NOT EXISTS ai_infer_log (" +
"id BIGINT, ts TIMESTAMP, provider VARCHAR, model VARCHAR, latency_ms BIGINT, degraded BOOLEAN)");
}
enabled = true;
log.info("AiLearningStore(DuckDB) 준비 완료 (solution=mes)");
} catch (Throwable t) {
enabled = false; // duckdb 미로딩/파일 미가용 조용히 비활성(AI 기능 무영향)
log.warn("AiLearningStore(DuckDB) 비활성 ({}) — 로컬 AI 학습 저장 생략, AI 기능은 정상 동작",
t.getClass().getSimpleName());
}
}
public boolean isEnabled() {
return enabled;
}
/** 추론 1건 로그(provider/model/지연/폴백여부). 실패해도 예외 없음. */
public void logInfer(String provider, String model, long latencyMs, boolean degraded) {
if (!enabled) {
return;
}
synchronized (lock) {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO ai_infer_log VALUES (nextval('ai_infer_log_seq'), ?, ?, ?, ?, ?)")) {
ps.setTimestamp(1, Timestamp.from(Instant.now()));
ps.setString(2, provider);
ps.setString(3, model);
ps.setLong(4, Math.max(0, latencyMs));
ps.setBoolean(5, degraded);
ps.executeUpdate();
} catch (Throwable t) {
log.debug("ai_infer_log insert skip: {}", t.getClass().getSimpleName());
}
}
}
/** 피드백 1건 저장(로컬). PII 마스킹 후 적재. 실패해도 예외 없음. */
public void saveFeedback(String feature, String question, String answer,
String verdict, String correction, String actor) {
if (!enabled) {
return;
}
synchronized (lock) {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO ai_feedback VALUES (nextval('ai_feedback_seq'), ?, ?, ?, ?, ?, ?, ?, ?)")) {
ps.setTimestamp(1, Timestamp.from(Instant.now()));
ps.setString(2, SOLUTION);
ps.setString(3, clip(feature));
ps.setString(4, mask(question));
ps.setString(5, mask(answer));
ps.setString(6, clip(verdict));
ps.setString(7, mask(correction));
ps.setString(8, maskActor(actor));
ps.executeUpdate();
} catch (Throwable t) {
log.debug("ai_feedback insert skip: {}", t.getClass().getSimpleName());
}
}
}
@PreDestroy
void close() {
if (conn != null) {
try {
conn.close();
} catch (Throwable ignored) {
// best-effort
}
}
}
// PII/자격증명 마스킹
static String mask(String s) {
if (s == null || s.isBlank()) {
return null;
}
String v = s.length() > MAX_TEXT ? s.substring(0, MAX_TEXT) : s;
v = RRN.matcher(v).replaceAll("######-#######");
v = CARD.matcher(v).replaceAll("****-****-****-****");
v = PHONE.matcher(v).replaceAll("***-****-****");
v = EMAIL.matcher(v).replaceAll("***@***");
return v;
}
/** 작성자 식별자는 앞 2자만 남기고 마스킹(감사 최소화). */
static String maskActor(String actor) {
if (actor == null || actor.isBlank()) {
return "anon";
}
String a = actor.trim();
return a.length() <= 2 ? a.charAt(0) + "*" : a.substring(0, 2) + "***";
}
private static String clip(String s) {
if (s == null) {
return null;
}
return s.length() > 200 ? s.substring(0, 200) : s;
}
}

View File

@ -0,0 +1,251 @@
package com.zioinfo.mes.ai.service;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.admin.dto.MesSetting;
import com.zioinfo.mes.admin.mapper.SettingMapper;
import com.zioinfo.mes.ai.OllamaClient;
import com.zioinfo.mes.ai.dto.AiConfigDto;
import com.zioinfo.mes.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.mes.common.ai.ClaudeTextClient;
import com.zioinfo.mes.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
* AI provider 런타임 설정(mes_setting, key='ai.*') 단일 출처 서비스. [GUARDiA-MES]
*
* <p><b>해상도</b>: DB(mes_setting) 우선 미설정 기본값/env 폴백. 시드(db/104) 미적용/ 비움
* 상태에서도 기존 Ollama 동작이 바이트 동일하게 유지된다(provider 기본 ollama, ollamaTextModel 미설정
* {@code guardia.ollama-text-model} env 그대로).
*
* <p><b>보안</b>: Claude API 키는 서비스가 다루지 않는다. {@code claudeKeySet}
* {@link ClaudeTextClient#isConfigured()}(환경변수 존재 여부) 반환 /길이/마스킹 일절 미포함.
*
* <p><b>프로바이더</b>: claude / qwen3 / deepseek / glm / ollama. Ollama 계열(qwen3·deepseek·glm·ollama)
* 해당 텍스트 모델로 generate. claude Claude실패 Ollama(선택모델) 폴백(라우터).
* glm(glm4:9b) 서버 RAM 초과 가능 선택은 허용하되 콜드로드 실패 폴백(설정 화면 RAM 배지 표시).
*
* <p><b>레퍼런스</b>: guardia-ocr {@code ai.service.AiConfigService} 미러(glm provider 추가).
*/
@Service
@RequiredArgsConstructor
public class AiConfigService {
// --- mes_setting (db/104 시드와 일치)
public static final String K_PROVIDER = "ai.provider"; // ENUM: ollama|claude|qwen3|deepseek|glm
public static final String K_CLAUDE_MODEL = "ai.claude.model"; // ENUM: 화이트리스트
public static final String K_OLLAMA_TEXT_MODEL = "ai.ollama.textModel"; // STRING (미설정 env 폴백)
public static final String K_ENABLED = "ai.enabled"; // BOOL (off=규칙기반 degraded)
public static final String PROVIDER_OLLAMA = "ollama";
public static final String PROVIDER_CLAUDE = "claude";
public static final String PROVIDER_QWEN3 = "qwen3";
public static final String PROVIDER_DEEPSEEK = "deepseek";
public static final String PROVIDER_GLM = "glm";
public static final String DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
// Ollama 계열 provider 고정 텍스트 모델
public static final String MODEL_QWEN3 = "qwen3:1.7b"; // 서버 pull·검증 완료(중국 top1 오픈소스)
public static final String MODEL_DEEPSEEK = "deepseek-r1:1.5b";
public static final String MODEL_GLM = "glm4:9b"; // RAM 초과 가능 콜드로드 실패 폴백
/** 허용 provider(임의 문자열 거부). */
public static final Set<String> ALLOWED_PROVIDERS =
Set.of(PROVIDER_OLLAMA, PROVIDER_CLAUDE, PROVIDER_QWEN3, PROVIDER_DEEPSEEK, PROVIDER_GLM);
/** 허용 Claude 모델 ID(임의 문자열 거부). */
public static final Set<String> ALLOWED_CLAUDE_MODELS =
Set.of("claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8");
/** 허용 Ollama 텍스트 모델(임의 문자열 거부). */
public static final Set<String> ALLOWED_OLLAMA_MODELS =
Set.of("qwen3:1.7b", "deepseek-r1:1.5b", "glm4:9b", "llama3.2:1b");
private final SettingMapper repo;
private final ClaudeTextClient claudeClient;
private final OllamaClient ollamaClient; // 연결 테스트용 텍스트 generate
private final AuditService auditService;
/** 서버 기본 Ollama 텍스트 모델(application.yml guardia.ollama-text-model). */
@Value("${guardia.ollama-text-model:llama3.2:1b}")
private String defaultOllamaTextModel;
// ---------------------------------------------------------------- 효과값(DB 우선 기본)
/** 효과 provider. DB 우선, 미설정/비허용 시 ollama. */
public String provider() {
String v = dbVal(K_PROVIDER);
if (v != null) {
String p = v.trim().toLowerCase();
if (ALLOWED_PROVIDERS.contains(p)) {
return p;
}
}
return PROVIDER_OLLAMA;
}
/** 효과 Claude 모델 ID(DB 우선, 미설정/비허용 시 기본 sonnet-4-6). */
public String claudeModel() {
String v = dbVal(K_CLAUDE_MODEL);
if (v != null) {
String m = v.trim();
if (ALLOWED_CLAUDE_MODELS.contains(m)) {
return m;
}
}
return DEFAULT_CLAUDE_MODEL;
}
/**
* 효과 Ollama 텍스트 모델. provider=qwen3/deepseek/glm 고정 모델, (ollama·claude 폴백)
* DB 설정(화이트리스트) 우선 미설정 env 기본(llama3.2:1b). 라우터의 Ollama 경로·Claude 폴백 공용.
*/
public String ollamaTextModel() {
String p = provider();
if (PROVIDER_QWEN3.equals(p)) {
return MODEL_QWEN3;
}
if (PROVIDER_DEEPSEEK.equals(p)) {
return MODEL_DEEPSEEK;
}
if (PROVIDER_GLM.equals(p)) {
return MODEL_GLM;
}
String v = dbVal(K_OLLAMA_TEXT_MODEL);
if (v != null && ALLOWED_OLLAMA_MODELS.contains(v.trim())) {
return v.trim();
}
return defaultOllamaTextModel;
}
/** 전역 AI 토글(ai.enabled). false 면 모든 AI 결과는 규칙기반(degraded). 기본 true. */
public boolean aiEnabled() {
String v = dbVal(K_ENABLED);
return v == null || !"false".equalsIgnoreCase(v.trim());
}
/** Claude API 키가 환경변수로 설정되어 있는지(여부만). */
public boolean claudeKeySet() {
return claudeClient.isConfigured();
}
/**
* 실제 호출이 Claude 경로로 가야 하는지: provider=claude · 설정됨 · AI 전역 활성.
* false 호출자는 Ollama 경로/폴백을 사용한다( 미설정/실패 자동 폴백 정책 반영).
*/
public boolean isClaudeActive() {
return PROVIDER_CLAUDE.equals(provider()) && claudeKeySet() && aiEnabled();
}
// ---------------------------------------------------------------- ADMIN: 조회 / 갱신
/** 현재 효과 설정 조회. 키 값·길이·마스킹 일절 미포함(claudeKeySet 불리언만). */
public AiConfigDto getConfig() {
return new AiConfigDto(
provider(),
ollamaTextModel(),
claudeModel(),
claudeKeySet(),
aiEnabled());
}
/**
* 설정 갱신(upsert). 화이트리스트 검증: provider{ollama,claude,qwen3,deepseek,glm}, claudeModel허용셋,
* ollamaTextModel(선택)허용셋. 재기동 없이 다음 호출부터 반영.
*/
public AiConfigDto update(AiConfigUpdateRequest req, String actor) {
String provider = req.provider() == null ? "" : req.provider().trim().toLowerCase();
if (!ALLOWED_PROVIDERS.contains(provider)) {
throw new IllegalArgumentException("ERR-AI-400: provider 는 ollama/claude/qwen3/deepseek/glm 중 하나여야 합니다.");
}
upsertAudited(K_PROVIDER, provider, actor);
if (req.claudeModel() != null && !req.claudeModel().isBlank()) {
String model = req.claudeModel().trim();
if (!ALLOWED_CLAUDE_MODELS.contains(model)) {
throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Claude 모델 ID 입니다.");
}
upsertAudited(K_CLAUDE_MODEL, model, actor);
}
// ollamaTextModel 선택. 제공 시에만 갱신(미제공/공백 = 기존 유지).
if (req.ollamaTextModel() != null && !req.ollamaTextModel().isBlank()) {
String om = req.ollamaTextModel().trim();
if (!ALLOWED_OLLAMA_MODELS.contains(om)) {
throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Ollama 텍스트 모델입니다.");
}
upsertAudited(K_OLLAMA_TEXT_MODEL, om, actor);
}
return getConfig();
}
// ---------------------------------------------------------------- ADMIN: 연결 테스트
/**
* 저장된 설정 기준으로 선택 provider 짧은 ping 생성을 요청해 연결을 확인한다.
* 결과 메시지에는 ·스택트레이스·내부 IP 절대 포함하지 않는다(요약만).
*/
public TestResult test() {
if (isClaudeActive()) {
String model = claudeModel();
long start = System.currentTimeMillis();
GenResult r = claudeClient.generate("ping", model, 8);
long ms = System.currentTimeMillis() - start;
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
}
return new TestResult(false, true, "Claude 연결 실패 · 모델/네트워크/키 설정을 확인하세요.");
}
// provider=claude 인데 미설정 실제로는 Ollama 폴백 동작 안내(혼선 방지).
if (PROVIDER_CLAUDE.equals(provider()) && !claudeKeySet()) {
return new TestResult(false, true,
"Claude API 키가 설정되지 않아 Ollama(온프레미스)로 폴백 동작합니다. 서버 환경변수 설정 후 다시 시도하세요.");
}
// Ollama 계열 경로(ollama/qwen3/deepseek/glm)
if (!aiEnabled()) {
return new TestResult(false, true, "AI 기능이 비활성 상태입니다. 모든 AI 결과는 규칙 기반(degraded)으로 동작합니다.");
}
String model = ollamaTextModel();
long start = System.currentTimeMillis();
String txt = ollamaClient.generate("ping", model);
long ms = System.currentTimeMillis() - start;
if (txt != null && !txt.isBlank()) {
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
}
String hint = MODEL_GLM.equals(model)
? "Ollama 연결 실패 또는 모델 미가용 — glm4:9b 는 RAM 여유가 필요합니다(설정 확인)."
: "Ollama 연결 실패 또는 모델 미가용 — 설정을 확인하세요.";
return new TestResult(false, true, hint);
}
private static String fmtMs(long ms) {
return String.format("%.1fs", ms / 1000.0);
}
/** 연결 테스트 결과(요약만 — 키/스택/IP 미노출). */
public record TestResult(boolean ok, boolean degraded, String message) {
}
// ---------------------------------------------------------------- helpers
/** DB 값(공백/null 은 '미설정'으로 보고 null 반환 → 기본/env 폴백 유도). */
private String dbVal(String key) {
MesSetting c = repo.findByKey(key);
if (c == null) {
return null;
}
String v = c.getValue();
return (v != null && !v.isBlank()) ? v : null;
}
private void upsertAudited(String key, String val, String actor) {
MesSetting before = repo.findByKey(key);
String prev = before == null ? "(none)" : before.getValue();
if (val.equals(prev)) {
return; // 변경 없음 감사 로그 생략
}
repo.upsert(key, val);
auditService.log(actor == null ? "SYSTEM" : actor, "AI_CONFIG_CHANGE", key, prev + " -> " + val);
}
}

View File

@ -0,0 +1,74 @@
package com.zioinfo.mes.ai.service;
import com.zioinfo.mes.ai.OllamaClient;
import com.zioinfo.mes.ai.learning.AiLearningStore;
import com.zioinfo.mes.common.ai.ClaudeTextClient;
import com.zioinfo.mes.common.ai.TextAiClient;
import com.zioinfo.mes.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* AI provider 선택 라우팅(런타임). 평문 텍스트 생성 진입점(불량원인분석·자연어조회 ). [GUARDiA-MES]
* {@link AiConfigService#provider()} 읽어 Claude Ollama(qwen3/deepseek/glm/기존소형) 선택한다.
*
* <p><b>선택/폴백 정책</b>:
* <ul>
* <li>provider=claude · 설정됨 · AI 활성 {@link ClaudeTextClient}. 실패(degraded) <b>Ollama 자동 폴백</b>(선택모델).</li>
* <li>provider=qwen3/deepseek/glm/ollama 해당 Ollama 텍스트 모델로 generate.</li>
* <li>provider=claude 인데 미설정 곧장 Ollama(폴백모델).</li>
* </ul>
* 경로 모두 실패 {@code GenResult.degraded=true·text=null} 호출자가 기존 규칙기반 폴백 유지(무회귀).
* 호출의 provider/model/지연/degraded 로컬 DuckDB({@link AiLearningStore}) 기록(학습·오프라인 분석용).
*
* <p>비전 OCR 이미지 경로는 {@link OllamaClient#vision} 담당( 텍스트 라우터를 거치지 않음).
*
* <p><b>레퍼런스</b>: guardia-ocr {@code ai.service.AiTextRouter} 미러(glm provider·infer log 추가).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiTextRouter implements TextAiClient {
private final AiConfigService aiConfig;
private final ClaudeTextClient claudeClient;
private final OllamaClient ollamaClient; // 온프레미스 평문 generate
private final AiLearningStore learningStore; // 추론 로그(로컬 DuckDB)
/**
* 선택된 provider 텍스트를 생성한다. Claude 선택·실패 Ollama 폴백. 예외를 던지지 않는다.
*/
@Override
public GenResult generate(String prompt) {
if (aiConfig.isClaudeActive()) {
String model = aiConfig.claudeModel();
long start = System.currentTimeMillis();
GenResult r = claudeClient.generate(prompt, model);
long ms = System.currentTimeMillis() - start;
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
learningStore.logInfer(AiConfigService.PROVIDER_CLAUDE, model, ms, false);
return r;
}
// claude 실패/빈응답 온프레미스 Ollama 자동 폴백(선택모델).
learningStore.logInfer(AiConfigService.PROVIDER_CLAUDE, model, ms, true);
log.warn("Claude path degraded -> Ollama fallback");
return ollamaGenerate(prompt, aiConfig.ollamaTextModel());
}
// provider = ollama / qwen3 / deepseek / glm 해당 Ollama 모델
return ollamaGenerate(prompt, aiConfig.ollamaTextModel());
}
/** Ollama 평문 generate 를 GenResult 로 래핑(실패 시 degraded). provider/model/지연 로컬 로그. */
private GenResult ollamaGenerate(String prompt, String model) {
long start = System.currentTimeMillis();
String txt = ollamaClient.generate(prompt, model);
long ms = System.currentTimeMillis() - start;
boolean degraded = (txt == null || txt.isBlank());
learningStore.logInfer(aiConfig.provider(), model, ms, degraded);
if (degraded) {
return new GenResult(null, true);
}
return new GenResult(txt, false);
}
}

View File

@ -0,0 +1,171 @@
package com.zioinfo.mes.common.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.mes.common.ai.TextAiClient.GenResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* 외부 Claude(Anthropic) Messages API 텍스트 생성 클라이언트. [ISSUER=GUARDiA-MES]
*
* <p><b>외부 호출 예외 허용</b>: 클라이언트는 소유자 승인(폐쇄망 아님) 따라 {@code api.anthropic.com}
* 호출이 허용된 유일한 외부 경로다. 외부 API 호출은 여전히 금지(Ollama localhost 전용).
*
* <p><b>API 보안(최우선)</b>: 키는 환경변수 {@code ANTHROPIC_API_KEY} 에서만 로드하며
* ({@code @Value("${ANTHROPIC_API_KEY:}")}), DB·코드·커밋·로그·응답·예외 메시지 어디에도 기록하지 않는다.
* 키는 오직 HTTP 헤더 {@code x-api-key} 로만 전달된다. 로그는 상태코드/예외 클래스명만 남긴다(본문· 미기록).
*
* <p><b>실패 처리</b>: 미설정/타임아웃/비200/예외 {@code GenResult.degraded=true·text=null} 반환
* (예외를 던지지 않음 호출자는 Ollama 폴백). 신규 라이브러리 0 JDK {@link HttpClient} 사용.
*
* <p>모델은 인자로 받는다(호출자가 화이트리스트 검증된 모델 ID 전달). 인터페이스 {@link TextAiClient}
* {@code generate(String)} 기본 모델로 위임한다(주로 라우터가 모델을 명시 호출).
*
* <p><b>레퍼런스</b>: guardia-ocr {@code common.ai.ClaudeTextClient} 미러(패키지·ISSUER 치환).
*/
@Slf4j
@Service
public class ClaudeTextClient implements TextAiClient {
/** Anthropic Messages API 엔드포인트(외부 호출 예외 허용 단일 경로). */
private static final String API_URL = "https://api.anthropic.com/v1/messages";
/** Anthropic 버전 헤더(고정). */
private static final String ANTHROPIC_VERSION = "2023-06-01";
/** 기본 모델(키 검증된 화이트리스트의 기본값). */
static final String DEFAULT_MODEL = "claude-sonnet-4-6";
/** 일반 생성 max_tokens. */
private static final int DEFAULT_MAX_TOKENS = 1024;
/** 콜드 응답 대비 요청 타임아웃(초). Ollama timeout 과 동급. */
private static final int TIMEOUT_SEC = 120;
private static final int CONNECT_TIMEOUT_SEC = 15;
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 환경변수 전용 주입. 미설정 시 빈 문자열(=미구성). 값은 절대 외부 노출/로그 금지. */
@Value("${ANTHROPIC_API_KEY:}")
private String apiKey;
/** API 키가 환경변수로 설정되어 있는지(여부만 — 값/길이/마스킹 일절 미노출). */
public boolean isConfigured() {
return apiKey != null && !apiKey.isBlank();
}
/** {@link TextAiClient} 계약: 기본 모델로 생성. */
@Override
public GenResult generate(String prompt) {
return generate(prompt, DEFAULT_MODEL);
}
/** 지정 모델로 생성(라우터가 화이트리스트 검증된 모델 ID 전달). */
public GenResult generate(String prompt, String model) {
return generate(prompt, model, DEFAULT_MAX_TOKENS);
}
/**
* Anthropic Messages API 호출. 실패 {@code degraded=true·text=null}.
* ·요청본문·응답본문은 로그에 남기지 않는다(상태코드/예외 클래스명만).
*/
public GenResult generate(String prompt, String model, int maxTokens) {
if (!isConfigured()) {
return new GenResult(null, true);
}
if (prompt == null || prompt.isBlank()) {
return new GenResult(null, true);
}
String useModel = (model == null || model.isBlank()) ? DEFAULT_MODEL : model.trim();
int tokens = maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;
try {
String payload = "{"
+ "\"model\":" + str(useModel) + ","
+ "\"max_tokens\":" + tokens + ","
+ "\"messages\":[{\"role\":\"user\",\"content\":" + str(prompt) + "}]"
+ "}";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SEC))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.timeout(Duration.ofSeconds(TIMEOUT_SEC))
.header("content-type", "application/json")
.header("x-api-key", apiKey) // 키는 헤더로만 로그/응답 미노출
.header("anthropic-version", ANTHROPIC_VERSION)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
log.warn("Claude API status {} -> degraded fallback", resp.statusCode()); // 본문 미기록
return new GenResult(null, true);
}
String text = extractText(resp.body());
if (text == null || text.isBlank()) {
return new GenResult(null, true);
}
return new GenResult(text.trim(), false);
} catch (Exception e) {
log.warn("Claude API call failed -> degraded fallback: {}", e.getClass().getSimpleName()); // 메시지· 미기록
return new GenResult(null, true);
}
}
/** Messages API 응답에서 content[].text(type=text) 추출·연결. */
private static String extractText(String body) {
if (body == null || body.isBlank()) {
return null;
}
try {
JsonNode root = MAPPER.readTree(body);
JsonNode content = root.get("content");
if (content == null || !content.isArray()) {
return null;
}
StringBuilder sb = new StringBuilder();
for (JsonNode block : content) {
JsonNode type = block.get("type");
if (type != null && "text".equals(type.asText())) {
JsonNode t = block.get("text");
if (t != null && !t.isNull()) {
sb.append(t.asText());
}
}
}
String out = sb.toString();
return out.isBlank() ? null : out;
} catch (Exception e) {
return null;
}
}
/** JSON 문자열 이스케이프(프롬프트 직렬화 규칙). */
private static String str(String s) {
if (s == null) {
return "\"\"";
}
StringBuilder sb = new StringBuilder("\"");
for (char c : s.toCharArray()) {
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
return sb.append("\"").toString();
}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.mes.common.ai;
/**
* 텍스트 생성 공용 인터페이스 (AI provider 추상화). [GUARDiA-MES]
*
* <p>온프레미스 Ollama({@code ai.OllamaClient#generate}) 외부 Claude({@link ClaudeTextClient})
* 동일 계약으로 다루기 위한 얇은 추상화. 구현체는 어떤 사유로든(비활성/실패/타임아웃/키미설정) 실패
* {@link GenResult#degraded()}=true · {@link GenResult#text()}=null 반환하고, 호출자가 폴백을 책임진다.
*
* <p>provider 선택/폴백 라우팅은 {@code ai.service.AiTextRouter} 담당한다( 인터페이스를 구현).
*
* <p><b>레퍼런스</b>: guardia-ocr {@code common.ai.TextAiClient} 미러(패키지·ISSUER 치환).
*/
public interface TextAiClient {
/**
* 프롬프트로 텍스트를 생성한다. 실패 {@code degraded=true·text=null}(예외를 던지지 않음).
*
* @param prompt 한국어 지시문(컨텍스트 포함)
* @return 생성 결과(텍스트 또는 degraded 폴백 신호)
*/
GenResult generate(String prompt);
/** 생성 결과: 텍스트(폴백 시 null) + degraded(폴백 여부). */
record GenResult(String text, boolean degraded) {
}
}

View File

@ -18,8 +18,8 @@ spring:
# UIWS 이식: 91_uiws_port.sql(tb_uiws_* + mes_user 2FA ALTER, 전부 멱등)만 부팅 시 적용. # UIWS 이식: 91_uiws_port.sql(tb_uiws_* + mes_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피). # 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피).
mode: ${SQL_INIT_MODE:always} mode: ${SQL_INIT_MODE:always}
# 91=업무/2FA(tb_uiws_* + mes_user ALTER), 92=권한관리 system(tb_uiws_sys_user/role/menu...). 전부 멱등. # 91=업무/2FA(tb_uiws_* + mes_user ALTER), 92=권한관리 system, 104=AI 플랫폼 설정 시드(멱등). 전부 멱등.
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/104_seed_ai_config.sql
continue-on-error: true continue-on-error: true
servlet: servlet:
multipart: multipart:
@ -50,6 +50,9 @@ mes:
mode: ${UIWS_MAIL_MODE:log} # LogMailSender 폴백(외부 API 0). 운영 smtp 시 별도 빈 mode: ${UIWS_MAIL_MODE:log} # LogMailSender 폴백(외부 API 0). 운영 smtp 시 별도 빈
upload: upload:
upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws} upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws}
# ── AI 로컬 학습 저장소(임베디드 DuckDB) — 피드백/추론 로그. 미가용 시 조용히 비활성(AI 기능 무영향) ──
ai:
duckdb-path: ${MES_DUCKDB_PATH:/opt/guardia-mes/data/mes_learning.duckdb}
guardia: guardia:
erp-url: ${ERP_URL:http://localhost:8003} erp-url: ${ERP_URL:http://localhost:8003}
@ -64,6 +67,7 @@ guardia:
enabled: ${RAG_ENABLED:true} enabled: ${RAG_ENABLED:true}
solution: mes solution: mes
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지. # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
# (예외: Claude 는 소유자 승인으로 api.anthropic.com 단일 경로 허용 — 키는 ANTHROPIC_API_KEY env 로만 주입)
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}
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream} ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream}

View File

@ -0,0 +1,34 @@
-- =====================================================================
-- GUARDiA MES — 104. AI 플랫폼(LLM provider) 설정 시드 (멱등)
-- 대상 DB : mes_db (테이블 mes_setting)
-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일 등재.
-- mode:always + continue-on-error 재실행 안전(ON CONFLICT DO NOTHING).
-- 설명 : 외부 Claude(Anthropic) API 를 온프레미스 Ollama 와 병행·선택형으로 추가.
-- 관리자(SUPERADMIN)가 런타임으로 provider/모델을 선택(재기동 불요, AiConfigService).
-- * ai.provider : ollama(기본) / claude / qwen3 / deepseek / glm
-- * ai.claude.model : claude-sonnet-4-6(기본) / claude-haiku-4-5 / claude-opus-4-8
-- * ai.ollama.textModel: 빈값(=서버 프로퍼티 guardia.ollama-text-model 폴백, llama3.2:1b)
-- * ai.enabled : true(기본, off=규칙기반 degraded)
-- 보안 : Claude API 키는 DB 에 저장하지 않는다 — 서버 환경변수 ANTHROPIC_API_KEY 로만 주입.
-- 본 시드에 키/시크릿/IP/비밀번호 일절 미포함.
-- 무회귀 : 기본 provider=ollama → 설정 미변경 시 기존 Ollama 동작과 바이트 동일.
-- 멱등 : 테이블 IF NOT EXISTS + INSERT ... ON CONFLICT (key) DO NOTHING.
-- =====================================================================
SET client_encoding = 'UTF8';
-- mes_setting 이 (schema.sql 미적용 환경에서도) 존재하도록 멱등 생성.
CREATE TABLE IF NOT EXISTS mes_setting (
key VARCHAR(200) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO mes_setting (key, value) VALUES
('ai.provider', 'ollama'),
('ai.claude.model', 'claude-sonnet-4-6'),
('ai.ollama.textModel', ''),
('ai.enabled', 'true')
ON CONFLICT (key) DO NOTHING;
-- end 104_seed_ai_config.sql

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GUARDiA MES — AI 제조실행시스템</title> <title>GUARDiA MES — AI 제조실행시스템</title>
<script type="module" crossorigin src="/assets/index-Bjvlf-Mv.js"></script> <script type="module" crossorigin src="/assets/index-CwsiO9y6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D6J_SNUk.css"> <link rel="stylesheet" crossorigin href="/assets/index-BMixRD2-.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

View File

@ -36,6 +36,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 RagSettings from './pages/RagSettings'
import AiPlatformSettings from './pages/AiPlatformSettings'
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'
@ -99,6 +100,9 @@ export default function App() {
<Route path="/ai-techniques" element={ <Route path="/ai-techniques" element={
<ProtectedRoute min="MANAGER"><RagSettings /></ProtectedRoute> <ProtectedRoute min="MANAGER"><RagSettings /></ProtectedRoute>
} /> } />
<Route path="/admin/ai-platform" element={
<ProtectedRoute min="SUPERADMIN"><AiPlatformSettings /></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

@ -0,0 +1,51 @@
// AI 플랫폼(LLM Provider) 설정(ADMIN) API — /api/admin/ai-config (SUPERADMIN 전용).
// API 키 값은 응답에 절대 포함되지 않음(claudeKeySet 플래그만).
// PUT 은 provider/claudeModel(+선택 ollamaTextModel) 전송.
// test 는 저장된 설정 기준 선택 provider 짧은 ping.
// 레퍼런스: guardia-ocr frontend/src/api/adminAiConfig.ts (glm provider 추가).
import api from './client'
/** LLM 제공자: ollama(온프레미스)/claude(외부)/qwen3/deepseek/glm(온프레미스). */
export type AiProvider = 'ollama' | 'claude' | 'qwen3' | 'deepseek' | 'glm'
/** 화이트리스트 Claude 모델 ID. */
export type ClaudeModel = 'claude-sonnet-4-6' | 'claude-haiku-4-5' | 'claude-opus-4-8'
/** AI 설정 조회 DTO — API 키 값 미반환(claudeKeySet 으로 설정 여부만). */
export interface AiConfigDto {
provider: AiProvider
/** Ollama 텍스트 모델(provider 별 해석 결과, 읽기 전용 표시). */
ollamaTextModel: string
/** 선택된 Claude 모델 ID(미설정 시 서버 기본 claude-sonnet-4-6). */
claudeModel: string
/** 서버 환경변수 ANTHROPIC_API_KEY 존재 여부(값·길이·마스킹 일절 미포함). */
claudeKeySet: boolean
/** 전역 AI 사용 여부(off 시 규칙 기반 degraded). */
aiEnabled: boolean
}
/** 저장 요청 — provider 필수, claudeModel/ollamaTextModel 선택. */
export interface AiConfigUpdateRequest {
provider: AiProvider
claudeModel?: ClaudeModel
ollamaTextModel?: string
}
/** 연결 테스트 결과. */
export interface AiTestResult {
ok: boolean
degraded: boolean
message: string
}
/** GET /api/admin/ai-config — 현재 AI 설정(API 키 값 제외). */
export const getAiConfig = () =>
api.get('/api/admin/ai-config').then(r => r.data?.data as AiConfigDto)
/** PUT /api/admin/ai-config — provider/claudeModel 저장 → 반영분. */
export const updateAiConfig = (body: AiConfigUpdateRequest) =>
api.put('/api/admin/ai-config', body).then(r => r.data?.data as AiConfigDto)
/** POST /api/admin/ai-config/test — 저장된 설정 기준 선택 provider 짧은 ping. */
export const testAiConfig = () =>
api.post('/api/admin/ai-config/test', {}).then(r => r.data?.data as AiTestResult)

View File

@ -312,6 +312,10 @@ export const getRagToggles = () => unwrap(api.get('/api/mes/rag/toggles'))
export const updateRagToggle = (key: string, value: any) => export const updateRagToggle = (key: string, value: any) =>
unwrap(api.put(`/api/mes/rag/toggles/${key}`, { value })) unwrap(api.put(`/api/mes/rag/toggles/${key}`, { value }))
// ── AI 피드백 (로컬 DuckDB 학습저장소 + 중앙 rag(8020) 전달, 인증 사용자 전체) ──
export const postAiFeedback = (d: { feature: string; question?: string; answer?: string; verdict: 'up' | 'down'; correction?: string }) =>
unwrap(api.post('/api/ai/feedback', d))
// ── 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

@ -71,6 +71,7 @@ const sysLinks: Link[] = [
] ]
const adminLinks: Link[] = [ const adminLinks: Link[] = [
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' }, { to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
{ to: '/admin/ai-platform', label: 'AI 플랫폼 설정', icon: Cpu, min: 'SUPERADMIN' },
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'MANAGER' }, { to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'MANAGER' },
{ to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'MANAGER' }, { to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'MANAGER' },
] ]

View File

@ -0,0 +1,272 @@
// AI 플랫폼(LLM Provider) 설정(ADMIN) — 제공자·모델 선택 + 연결 테스트.
// API 키 값은 화면에 절대 표시/입력하지 않는다(claudeKeySet 으로 설정 여부 배지만).
// 저장은 provider/claudeModel 만 전송(ollama 계열 모델·전역 활성은 읽기 전용 표시).
// 레퍼런스: guardia-ocr AiPlatformSettings.tsx (qwen3/deepseek/glm provider 추가, glm=RAM 배지).
import { useEffect, useState } from 'react'
import { Cpu, KeyRound, PlugZap, Save, AlertTriangle } from 'lucide-react'
import { getAiConfig, updateAiConfig, testAiConfig } from '../api/adminAiConfig'
import type { AiProvider, ClaudeModel, AiTestResult } from '../api/adminAiConfig'
const PROVIDERS: { id: AiProvider; label: string; hint: string; ram?: boolean }[] = [
{ id: 'ollama', label: 'Ollama (기본)', hint: '온프레미스 · llama3.2:1b' },
{ id: 'qwen3', label: 'Qwen3', hint: '온프레미스 · qwen3:1.7b' },
{ id: 'deepseek', label: 'DeepSeek', hint: '온프레미스 · deepseek-r1:1.5b' },
{ id: 'glm', label: 'GLM', hint: '온프레미스 · glm4:9b', ram: true },
{ id: 'claude', label: 'Claude (외부)', hint: 'api.anthropic.com · 키 필요' },
]
const CLAUDE_MODELS: ClaudeModel[] = ['claude-sonnet-4-6', 'claude-haiku-4-5', 'claude-opus-4-8']
const DEFAULT_CLAUDE_MODEL: ClaudeModel = 'claude-sonnet-4-6'
const normModel = (v: string): ClaudeModel =>
(CLAUDE_MODELS as string[]).includes(v) ? (v as ClaudeModel) : DEFAULT_CLAUDE_MODEL
function friendlyError(e: any): string {
const msg: string = e?.response?.data?.message || ''
if (e?.response?.status === 403) return '권한이 없습니다 (AI 설정은 관리자 전용).'
if (msg.includes('ERR-AI-400')) return msg.replace(/^ERR-AI-400:\s*/, '')
return 'AI 설정을 처리하지 못했습니다.'
}
export default function AiPlatformSettings() {
const [provider, setProvider] = useState<AiProvider>('ollama')
const [claudeModel, setClaudeModel] = useState<ClaudeModel>(DEFAULT_CLAUDE_MODEL)
const [claudeKeySet, setClaudeKeySet] = useState(false)
const [ollamaTextModel, setOllamaTextModel] = useState('')
const [aiEnabled, setAiEnabled] = useState(true)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [err, setErr] = useState('')
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<AiTestResult | null>(null)
const load = () => {
setLoading(true)
getAiConfig()
.then(dto => {
setProvider(dto.provider)
setClaudeModel(normModel(dto.claudeModel ?? DEFAULT_CLAUDE_MODEL))
setClaudeKeySet(!!dto.claudeKeySet)
setOllamaTextModel(dto.ollamaTextModel ?? '')
setAiEnabled(!!dto.aiEnabled)
setErr('')
})
.catch(e => setErr(friendlyError(e)))
.finally(() => setLoading(false))
}
useEffect(() => { load() }, [])
const markDirty = () => { setSaved(false); setTestResult(null) }
const submit = async () => {
setSaving(true)
setSaved(false)
try {
const dto = await updateAiConfig({ provider, claudeModel })
setProvider(dto.provider)
setClaudeModel(normModel(dto.claudeModel ?? DEFAULT_CLAUDE_MODEL))
setClaudeKeySet(!!dto.claudeKeySet)
setOllamaTextModel(dto.ollamaTextModel ?? '')
setAiEnabled(!!dto.aiEnabled)
setSaved(true)
setErr('')
} catch (e) {
setErr(friendlyError(e))
} finally {
setSaving(false)
}
}
const runTest = async () => {
setTesting(true)
setTestResult(null)
try {
setTestResult(await testAiConfig())
} catch (e) {
setTestResult({ ok: false, degraded: false, message: friendlyError(e) })
} finally {
setTesting(false)
}
}
const isClaude = provider === 'claude'
const isGlm = provider === 'glm'
const showKeyFallback = isClaude && !claudeKeySet
const showDisabledFallback = !isClaude && !aiEnabled
const statusClass = testResult
? testResult.degraded
? 'text-amber-300'
: testResult.ok
? 'text-emerald-300'
: 'text-rose-300'
: ''
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<Cpu size={20} className="text-brand" /> AI
</h1>
<p className="text-sm text-slate-400 mt-1">
· LLM .
</p>
</div>
<button
onClick={load}
disabled={loading}
className="px-3 py-1.5 rounded-lg bg-card border border-edge text-sm disabled:opacity-40"
>
</button>
</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>
)}
{(showKeyFallback || showDisabledFallback) && (
<div className="bg-amber-500/10 border border-amber-500/40 text-amber-300 text-sm rounded-lg px-4 py-2.5 mb-4">
{showKeyFallback
? 'Claude API 키가 설정되지 않아 Ollama(온프레미스)로 폴백 동작합니다. 서버 환경변수 ANTHROPIC_API_KEY 설정 후 사용하세요.'
: 'AI 기능이 비활성 상태입니다. 모든 AI 결과는 규칙 기반(degraded)으로 동작합니다.'}
</div>
)}
{isGlm && (
<div className="bg-amber-500/10 border border-amber-500/40 text-amber-300 text-sm rounded-lg px-4 py-2.5 mb-4 flex items-start gap-2">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span>
GLM(glm4:9b) 5.5GB RAM(2.1GB) .
(degraded). RAM .
</span>
</div>
)}
<div className="grid gap-5 md:grid-cols-2">
{/* ── 제공자 & 모델 ── */}
<section className="bg-card border border-edge rounded-xl p-5">
<h2 className="text-sm font-semibold mb-4 flex items-center gap-2">
<Cpu size={16} className="text-brand" /> &
</h2>
<div className="grid grid-cols-2 gap-2 mb-4">
{PROVIDERS.map(p => (
<button
key={p.id}
onClick={() => { setProvider(p.id); markDirty() }}
className={`text-left px-3 py-2.5 rounded-lg border text-sm transition-colors ${
provider === p.id
? 'bg-brand/15 border-brand text-brand'
: 'bg-ink border-edge text-slate-300 hover:border-brand/50'
}`}
>
<div className="font-semibold flex items-center gap-1.5">
{p.label}
{p.ram && (
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30">
RAM
</span>
)}
</div>
<div className="text-[11px] text-slate-500 mt-0.5">{p.hint}</div>
</button>
))}
</div>
{/* Ollama 계열 — 텍스트 모델 읽기 전용 표시 */}
{!isClaude && (
<div className="mb-4">
<label className="block text-xs text-slate-500 mb-1">Ollama </label>
<div className="px-3 py-2 rounded-lg bg-ink border border-edge text-sm font-mono">
{ollamaTextModel || 'llama3.2:1b'}
</div>
<p className="text-[11px] text-slate-500 mt-1">
provider . .
</p>
</div>
)}
{/* Claude — 모델 셀렉트 + 키 배지(값 비노출) */}
{isClaude && (
<>
<div className="mb-4">
<label className="block text-xs text-slate-500 mb-1">Claude </label>
<select
value={claudeModel}
onChange={e => { setClaudeModel(normModel(e.target.value)); markDirty() }}
className="w-full px-3 py-2 rounded-lg bg-ink border border-edge text-sm focus:border-brand outline-none"
>
{CLAUDE_MODELS.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-xs text-slate-500 mb-1 flex items-center gap-1">
<KeyRound size={13} /> API
</label>
<span
className={`inline-block px-3 py-1 rounded-lg text-xs font-semibold ${
claudeKeySet
? 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/40'
: 'bg-rose-500/15 text-rose-300 border border-rose-500/40'
}`}
>
{claudeKeySet ? '설정됨' : '미설정'}
</span>
<p className="text-[11px] text-slate-500 mt-1.5">
(ANTHROPIC_API_KEY) ·· .
</p>
</div>
</>
)}
<div className="flex items-center gap-3">
<button
onClick={submit}
disabled={saving || loading}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand text-ink text-sm font-semibold disabled:opacity-40"
>
<Save size={15} /> {saving ? '저장 중…' : '저장'}
</button>
{saved && <span className="text-emerald-300 text-xs"></span>}
</div>
</section>
{/* ── 연결 테스트 ── */}
<section className="bg-card border border-edge rounded-xl p-5">
<h2 className="text-sm font-semibold mb-2 flex items-center gap-2">
<PlugZap size={16} className="text-brand" />
</h2>
<p className="text-sm text-slate-400 mb-4">
ping ( ).
</p>
<div className="flex items-center gap-3">
<button
onClick={runTest}
disabled={testing}
className="px-4 py-2 rounded-lg bg-card border border-edge text-sm disabled:opacity-40 hover:border-brand/50"
>
{testing ? '테스트 중…' : '테스트 실행'}
</button>
{testResult && (
<span className={`text-sm ${statusClass}`} role="status" aria-live="polite">
{testResult.message}
</span>
)}
</div>
<p className="text-[11px] text-slate-500 mt-3">
. Claude Ollama .
</p>
</section>
</div>
</div>
)
}

View File

@ -8,7 +8,7 @@ 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, ragDefectAnalysis, ragPredictAnalysis, ragFeedback, postAiFeedback,
} from '../api/client' } from '../api/client'
/** 적용된 기법 배지 — 응답의 applied metadata 를 시각화(토글 effect 관측). */ /** 적용된 기법 배지 — 응답의 applied metadata 를 시각화(토글 effect 관측). */
@ -49,6 +49,25 @@ function FeedbackBar({ answerId, query }: { answerId?: string; query?: string })
) )
} }
/** 👍/👎 로컬 학습 피드백 (POST /api/ai/feedback → 로컬 DuckDB + 중앙 rag). 결과가 있을 때만 노출. */
function LocalFeedbackBar({ feature, question, answer }: { feature: string; question?: string; answer?: any }) {
const [done, setDone] = useState('')
if (answer == null) return null
const ans = typeof answer === 'string' ? answer : JSON.stringify(answer)
const send = async (verdict: 'up' | 'down') => {
try { await postAiFeedback({ feature, question, answer: ans, 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>
@ -179,6 +198,7 @@ function DefectTool() {
<Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field> <Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field>
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '분석 중…' : '원인 분석'}</Btn> <Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '분석 중…' : '원인 분석'}</Btn>
<Output data={out} /> <Output data={out} />
<LocalFeedbackBar feature="defect-root-cause" question={code} answer={out} />
</Card> </Card>
) )
} }
@ -230,6 +250,7 @@ function QueryTool() {
<Field label="자연어 질의"><input className="inp" value={q} onChange={e => setQ(e.target.value)} placeholder="예: 지난주 지연된 작업지시" onKeyDown={e => e.key === 'Enter' && run()} /></Field> <Field label="자연어 질의"><input className="inp" value={q} onChange={e => setQ(e.target.value)} placeholder="예: 지난주 지연된 작업지시" onKeyDown={e => e.key === 'Enter' && run()} /></Field>
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '해석 중…' : '질의 해석'}</Btn> <Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '해석 중…' : '질의 해석'}</Btn>
<Output data={out} /> <Output data={out} />
<LocalFeedbackBar feature="parse-query" question={q} answer={out} />
</Card> </Card>
) )
} }