feat(ai): Claude 전환 + DuckDB 학습저장소 이식
This commit is contained in:
parent
b2dd933e12
commit
08d9543116
@ -22,6 +22,7 @@
|
||||
<springdoc.version>2.6.0</springdoc.version>
|
||||
<mybatis.version>3.0.3</mybatis.version>
|
||||
<postgresql.version>42.7.7</postgresql.version>
|
||||
<duckdb.version>1.1.3</duckdb.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@ -42,6 +43,10 @@
|
||||
<!-- DB Driver -->
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
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.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -31,11 +33,19 @@ import java.util.Map;
|
||||
public class AiService {
|
||||
|
||||
private final OllamaClient ollama;
|
||||
/** provider 라우팅(Claude→Ollama 폴백) + 로컬 학습 로그 경유. 기존 OllamaClient 직접 호출을 대체. */
|
||||
private final AiTextRouter aiTextRouter;
|
||||
|
||||
public boolean ollamaAvailable() {
|
||||
return ollama.available();
|
||||
}
|
||||
|
||||
/** 라우터 경유 텍스트 생성 — degraded 면 null(호출부가 Java 폴백 수행). */
|
||||
private String routeGenerate(String prompt) {
|
||||
GenResult r = aiTextRouter.generate(prompt);
|
||||
return (r != null && !r.degraded()) ? r.text() : null;
|
||||
}
|
||||
|
||||
// 1. 불량 원인 분석 (공정/설비/자재 상관)
|
||||
public Map<String, Object> defectRootCause(String defectCode, List<Map<String, Object>> context) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
@ -44,11 +54,11 @@ public class AiService {
|
||||
"당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. "
|
||||
+ "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
|
||||
defectCode, ctx);
|
||||
String out = ollama.generate(prompt);
|
||||
String out = routeGenerate(prompt);
|
||||
if (out != null && !out.isBlank()) {
|
||||
result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out));
|
||||
result.put("action", extractLine(out, "ACTION:"));
|
||||
result.put("source", "ollama");
|
||||
result.put("source", "ai");
|
||||
return result;
|
||||
}
|
||||
// Java 폴백: 불량코드 분류 규칙
|
||||
@ -156,12 +166,12 @@ public class AiService {
|
||||
Map<String, Object> filter = new LinkedHashMap<>();
|
||||
if (naturalQuery == null || naturalQuery.isBlank()) return filter;
|
||||
String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nITEM:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery;
|
||||
String out = ollama.generate(prompt);
|
||||
String out = routeGenerate(prompt);
|
||||
if (out != null && !out.isBlank()) {
|
||||
putIf(filter, "status", extractLine(out, "STATUS:"));
|
||||
putIf(filter, "item", extractLine(out, "ITEM:"));
|
||||
putIf(filter, "date", extractLine(out, "DATE:"));
|
||||
if (!filter.isEmpty()) { filter.put("source", "ollama"); return filter; }
|
||||
if (!filter.isEmpty()) { filter.put("source", "ai"); return filter; }
|
||||
}
|
||||
// Java 폴백: 키워드 매칭
|
||||
String q = naturalQuery.toLowerCase();
|
||||
|
||||
@ -34,11 +34,20 @@ public class OllamaClient {
|
||||
this.visionModel = visionModel;
|
||||
}
|
||||
|
||||
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */
|
||||
@SuppressWarnings("unchecked")
|
||||
/** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
|
||||
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 {
|
||||
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()
|
||||
.post().uri("/api/generate")
|
||||
.bodyValue(body)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -18,8 +18,8 @@ spring:
|
||||
# UIWS 이식: 91_uiws_port.sql(tb_uiws_* + mes_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
|
||||
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피).
|
||||
mode: ${SQL_INIT_MODE:always}
|
||||
# 91=업무/2FA(tb_uiws_* + mes_user ALTER), 92=권한관리 system(tb_uiws_sys_user/role/menu...). 전부 멱등.
|
||||
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql
|
||||
# 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,classpath:db/104_seed_ai_config.sql
|
||||
continue-on-error: true
|
||||
servlet:
|
||||
multipart:
|
||||
@ -50,6 +50,9 @@ mes:
|
||||
mode: ${UIWS_MAIL_MODE:log} # LogMailSender 폴백(외부 API 0). 운영 smtp 시 별도 빈
|
||||
upload:
|
||||
upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws}
|
||||
# ── AI 로컬 학습 저장소(임베디드 DuckDB) — 피드백/추론 로그. 미가용 시 조용히 비활성(AI 기능 무영향) ──
|
||||
ai:
|
||||
duckdb-path: ${MES_DUCKDB_PATH:/opt/guardia-mes/data/mes_learning.duckdb}
|
||||
|
||||
guardia:
|
||||
erp-url: ${ERP_URL:http://localhost:8003}
|
||||
@ -64,6 +67,7 @@ guardia:
|
||||
enabled: ${RAG_ENABLED:true}
|
||||
solution: mes
|
||||
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
|
||||
# (예외: Claude 는 소유자 승인으로 api.anthropic.com 단일 경로 허용 — 키는 ANTHROPIC_API_KEY env 로만 주입)
|
||||
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
||||
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
|
||||
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream}
|
||||
|
||||
34
backend/src/main/resources/db/104_seed_ai_config.sql
Normal file
34
backend/src/main/resources/db/104_seed_ai_config.sql
Normal 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
File diff suppressed because one or more lines are too long
@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GUARDiA MES — AI 제조실행시스템</title>
|
||||
<script type="module" crossorigin src="/assets/index-Bjvlf-Mv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D6J_SNUk.css">
|
||||
<script type="module" crossorigin src="/assets/index-CwsiO9y6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BMixRD2-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
doc/guardia-mes_아키텍처설계서_v1.0.pptx
Normal file
BIN
doc/guardia-mes_아키텍처설계서_v1.0.pptx
Normal file
Binary file not shown.
@ -36,6 +36,7 @@ import Warehouses from './pages/Warehouses'
|
||||
import Analytics from './pages/Analytics'
|
||||
import AiTools from './pages/AiTools'
|
||||
import RagSettings from './pages/RagSettings'
|
||||
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||
import UserManagement from './pages/UserManagement'
|
||||
import AuditLog from './pages/AuditLog'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
@ -99,6 +100,9 @@ export default function App() {
|
||||
<Route path="/ai-techniques" element={
|
||||
<ProtectedRoute min="MANAGER"><RagSettings /></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/ai-platform" element={
|
||||
<ProtectedRoute min="SUPERADMIN"><AiPlatformSettings /></ProtectedRoute>
|
||||
} />
|
||||
{/* 업무 (UIWS 이식) — 기존 라우트 보존, 추가만 */}
|
||||
<Route path="/uiws/worklog" element={<UiwsWorklog />} />
|
||||
<Route path="/uiws/schedule" element={<UiwsSchedule />} />
|
||||
|
||||
51
frontend/src/api/adminAiConfig.ts
Normal file
51
frontend/src/api/adminAiConfig.ts
Normal 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)
|
||||
@ -312,6 +312,10 @@ 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 }))
|
||||
|
||||
// ── 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+) ────────────────
|
||||
export const getUsers = () => api.get('/api/admin/users')
|
||||
export const createUser = (data: { username: string; password: string; displayName?: string; role?: string }) =>
|
||||
|
||||
@ -71,6 +71,7 @@ const sysLinks: Link[] = [
|
||||
]
|
||||
const adminLinks: Link[] = [
|
||||
{ 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/settings', label: '시스템 설정', icon: Settings, min: 'MANAGER' },
|
||||
]
|
||||
|
||||
272
frontend/src/pages/AiPlatformSettings.tsx
Normal file
272
frontend/src/pages/AiPlatformSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -8,7 +8,7 @@ import { Card, Btn, Field } from '../components/ui'
|
||||
import {
|
||||
getAiStatus, aiDefectRootCause, aiForecast, aiPredictiveMaintenance,
|
||||
aiSpcAnomaly, aiParseQuery, aiSafetyStock, aiScheduleOptimize,
|
||||
ragDefectAnalysis, ragPredictAnalysis, ragFeedback,
|
||||
ragDefectAnalysis, ragPredictAnalysis, ragFeedback, postAiFeedback,
|
||||
} from '../api/client'
|
||||
|
||||
/** 적용된 기법 배지 — 응답의 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 }) {
|
||||
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>
|
||||
@ -179,6 +198,7 @@ function DefectTool() {
|
||||
<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>
|
||||
<Output data={out} />
|
||||
<LocalFeedbackBar feature="defect-root-cause" question={code} answer={out} />
|
||||
</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>
|
||||
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '해석 중…' : '질의 해석'}</Btn>
|
||||
<Output data={out} />
|
||||
<LocalFeedbackBar feature="parse-query" question={q} answer={out} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user