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

This commit is contained in:
GUARDiA 2026-07-03 23:14:56 +09:00
parent 31906a4bd8
commit 81e3b6ebef
37 changed files with 4983 additions and 59 deletions

View File

@ -24,6 +24,8 @@
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.12.6</version><scope>runtime</scope></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId></dependency>
<!-- 로컬 임베디드 AI 학습 저장소(DuckDB) — 단일 의존성, /opt/guardia-fa/data/fa_learning.duckdb -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
</dependencies>
<build>
<plugins>

View File

@ -0,0 +1,54 @@
package com.zioinfo.fa.ai.controller;
import com.zioinfo.fa.ai.dto.AiConfigDto;
import com.zioinfo.fa.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.fa.ai.service.AiConfigService;
import com.zioinfo.fa.common.ApiResponse;
import com.zioinfo.fa.common.audit.AuditService;
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-FA]
* SecurityConfig {@code /api/admin/ai-config/** hasRole(ADMIN)} 게이트로 보호된다.
*
* <p>fa_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,61 @@
package com.zioinfo.fa.ai.controller;
import com.zioinfo.fa.ai.dto.AiFeedbackRequest;
import com.zioinfo.fa.ai.service.LearningStore;
import com.zioinfo.fa.ai.service.RagFeedbackClient;
import com.zioinfo.fa.common.ApiResponse;
import com.zioinfo.fa.common.audit.AuditService;
import lombok.RequiredArgsConstructor;
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 java.util.LinkedHashMap;
import java.util.Map;
/**
* AI 답변 피드백 수집(인증 사용자). [GUARDiA-FA]
*
* <p>{@code POST /api/ai/feedback} 로컬 DuckDB 학습 저장소({@link LearningStore}) 기록 +
* 중앙 guardia-rag {@code /feedback} 전달({@link RagFeedbackClient}) <b> </b> 수행한다.
* 저장 PII 마스킹. 저장소/중앙 미가용이어도 요청은 성공 처리(내결함성)하고 stored 플래그로 상태를 반환한다.
*
* <p>SecurityConfig {@code /api/ai/** authenticated} 게이트. verdict up|down.
*/
@RestController
@RequestMapping("/api/ai")
@RequiredArgsConstructor
public class AiFeedbackController {
private final LearningStore learningStore;
private final RagFeedbackClient ragFeedbackClient;
@PostMapping("/feedback")
public ApiResponse<Map<String, Object>> feedback(@RequestBody AiFeedbackRequest req) {
String verdict = normalizeVerdict(req.verdict());
String actor = AuditService.currentActor();
// 로컬 DuckDB 기록(PII 마스킹은 저장소 내부에서 수행)
learningStore.recordFeedback(
req.feature(), req.question(), req.answer(), verdict, req.correction(), actor);
// 중앙 guardia-rag 전달( ) 실패는 degraded 로만 표시, 요청은 성공
Map<String, Object> central = ragFeedbackClient.forward(
req.answerId(), req.question(), req.answer(), verdict, req.correction(), actor);
Map<String, Object> out = new LinkedHashMap<>();
out.put("verdict", verdict);
out.put("localStored", learningStore.isAvailable());
out.put("centralStored", Boolean.TRUE.equals(central.get("stored")));
return ApiResponse.ok(out);
}
private static String normalizeVerdict(String v) {
if (v == null) {
return "up";
}
String s = v.trim().toLowerCase();
return ("down".equals(s) || "bad".equals(s) || "👎".equals(s)) ? "down" : "up";
}
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.fa.ai.dto;
/**
* AI 설정 조회 DTO API 미반환(claudeKeySet 으로 설정 여부만). [GUARDiA-FA]
*
* @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)
* @param ramWarning 선택 Ollama 모델이 서버 RAM 여유를 초과할 있으면 true(glm4:9b 콜드로드 폴백 안내)
*/
public record AiConfigDto(
String provider,
String ollamaTextModel,
String claudeModel,
boolean claudeKeySet,
boolean aiEnabled,
boolean ramWarning) {
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.fa.ai.dto;
/**
* AI 설정 저장 요청 화이트리스트 검증. [GUARDiA-FA]
*
* @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,22 @@
package com.zioinfo.fa.ai.dto;
/**
* AI 답변 피드백 요청(👍/👎 + 교정). [GUARDiA-FA]
*
* <p>로컬 DuckDB 학습 저장소 기록 + 중앙 guardia-rag {@code /feedback} 전달( ). 저장 PII 마스킹.
*
* @param feature 기능 구분(quality_defect·equipment_predict·inventory_optimize )
* @param question 사용자 질의/컨텍스트(선택)
* @param answer AI 답변(선택)
* @param verdict 평가(up|down)
* @param correction 교정 텍스트(선택, 👎 권장)
* @param answerId 중앙 rag answer_id(있으면 연계)
*/
public record AiFeedbackRequest(
String feature,
String question,
String answer,
String verdict,
String correction,
String answerId) {
}

View File

@ -0,0 +1,266 @@
package com.zioinfo.fa.ai.service;
import com.zioinfo.fa.ai.dto.AiConfigDto;
import com.zioinfo.fa.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.fa.common.ai.ClaudeTextClient;
import com.zioinfo.fa.common.ai.OllamaTextClient;
import com.zioinfo.fa.common.ai.TextAiClient.GenResult;
import com.zioinfo.fa.common.audit.AuditService;
import com.zioinfo.fa.domain.FaSetting;
import com.zioinfo.fa.mapper.SettingMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
* AI provider 런타임 설정(fa_setting, key='ai.*') 단일 출처 서비스. [GUARDiA-FA]
*
* <p><b>해상도</b>: DB(fa_setting) 우선 미설정 기본값/env 폴백. 시드(db/104) 미적용/ 비움 상태에서도
* 기존 Ollama 동작이 바이트 동일하게 유지된다(provider 기본 ollama).
*
* <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 여유 초과 가능 화이트리스트엔 등록하되 {@code ramWarning} 배지로 안내.
*
* <p><b>레퍼런스</b>: guardia-ocr {@code ai.service.AiConfigService} 미러(provider glm 추가).
*/
@Service
@RequiredArgsConstructor
public class AiConfigService {
// --- fa_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 고정 소형 모델(서버 RAM 제약 준수)
public static final String MODEL_QWEN3 = "qwen3:1.7b";
public static final String MODEL_DEEPSEEK = "deepseek-r1:1.5b";
public static final String MODEL_GLM = "glm4:9b"; // RAM 여유 필요(콜드로드 실패 폴백)
public static final String MODEL_DEFAULT_OLLAMA = "llama3.2:1b";
/** 허용 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(MODEL_QWEN3, MODEL_DEEPSEEK, MODEL_GLM, MODEL_DEFAULT_OLLAMA);
/** RAM 여유 초과 가능 모델(선택 허용하되 배지 경고). */
public static final Set<String> RAM_HEAVY_MODELS = Set.of(MODEL_GLM);
private final SettingMapper repo;
private final ClaudeTextClient claudeClient;
private final OllamaTextClient ollamaTextClient; // 연결 테스트용 텍스트 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;
}
/** claude 폴백 시 사용할 안전 소형 모델(RAM 안전). glm 등 무거운 선택과 무관하게 llama3.2:1b 우선. */
public String fallbackOllamaModel() {
String v = dbVal(K_OLLAMA_TEXT_MODEL);
if (v != null && ALLOWED_OLLAMA_MODELS.contains(v.trim()) && !RAM_HEAVY_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();
}
/** 선택된 Ollama 모델이 서버 RAM 여유를 초과할 수 있는지(glm4:9b 등). */
public boolean ramWarning() {
return RAM_HEAVY_MODELS.contains(ollamaTextModel());
}
/**
* 실제 호출이 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(),
ramWarning());
}
/**
* 설정 갱신(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 = ollamaTextClient.generateText("ping", model);
long ms = System.currentTimeMillis() - start;
if (txt != null && !txt.isBlank()) {
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
}
String tail = RAM_HEAVY_MODELS.contains(model) ? " (RAM 여유 필요 모델 — 콜드로드 실패 시 소형 모델로 폴백)" : "";
return new TestResult(false, true, "Ollama 연결 실패 또는 모델 미가용 — 설정을 확인하세요." + tail);
}
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) {
FaSetting c = repo.findByKey(key);
if (c == null) {
return null;
}
String v = c.getSettingValue();
return (v != null && !v.isBlank()) ? v : null;
}
private void upsertAudited(String key, String val, String actor) {
FaSetting before = repo.findByKey(key);
String prev = before == null ? "(none)" : before.getSettingValue();
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,86 @@
package com.zioinfo.fa.ai.service;
import com.zioinfo.fa.common.ai.ClaudeTextClient;
import com.zioinfo.fa.common.ai.OllamaTextClient;
import com.zioinfo.fa.common.ai.TextAiClient;
import com.zioinfo.fa.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* AI provider 선택 라우팅(런타임). 평문 텍스트 생성 진입점(불량분석·설비예측·재고최적화 ). [GUARDiA-FA]
* {@link AiConfigService#provider()} 읽어 Claude Ollama(qwen3/deepseek/glm/기존소형) 선택한다.
*
* <p><b>선택/폴백 정책</b>(AI_PLATFORM_SPEC §3):
* <ul>
* <li>provider=claude · 설정됨 · AI 활성 {@link ClaudeTextClient}. 실패(degraded)
* <b>Ollama 자동 폴백</b>(선택모델 안전 소형모델 llama3.2:1b).</li>
* <li>provider=qwen3/deepseek/glm/ollama 해당 Ollama 텍스트 모델로 generate(실패 소형모델 폴백).</li>
* <li>provider=claude 인데 미설정 곧장 Ollama(폴백모델).</li>
* </ul>
* 모든 경로 실패 {@code GenResult.degraded=true·text=null} 호출자가 기존 규칙기반 폴백 유지(무회귀).
* 생성 시도는 {@link LearningStore#recordInfer} 로컬 DuckDB 기록한다(내결함성 실패 삼킴).
*
* <p><b>레퍼런스</b>: guardia-ocr {@code ai.service.AiTextRouter} 미러(FA Ollama 텍스트 경로·infer 로깅 추가).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiTextRouter implements TextAiClient {
private final AiConfigService aiConfig;
private final ClaudeTextClient claudeClient;
private final OllamaTextClient ollamaTextClient;
private final LearningStore learningStore;
/**
* 선택된 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;
boolean ok = !r.degraded() && r.text() != null && !r.text().isBlank();
learningStore.recordInfer("claude", model, ms, !ok);
if (ok) {
return r;
}
// claude 실패/빈응답 온프레미스 Ollama 자동 폴백(선택모델 안전 소형모델).
log.warn("Claude path degraded -> Ollama fallback");
GenResult fb = ollamaGenerate("claude-fallback", prompt, aiConfig.ollamaTextModel());
if (!fb.degraded()) {
return fb;
}
return ollamaGenerate("claude-fallback", prompt, aiConfig.fallbackOllamaModel());
}
// provider = ollama / qwen3 / deepseek / glm 해당 Ollama 모델
String provider = aiConfig.provider();
GenResult r = ollamaGenerate(provider, prompt, aiConfig.ollamaTextModel());
if (!r.degraded()) {
return r;
}
// 선택 모델 실패(glm4:9b RAM 초과 ) 안전 소형모델 최종 폴백.
String safe = aiConfig.fallbackOllamaModel();
if (!safe.equals(aiConfig.ollamaTextModel())) {
return ollamaGenerate(provider, prompt, safe);
}
return r;
}
/** Ollama 평문 generate 를 GenResult 로 래핑(실패 시 degraded) + infer 로깅. */
private GenResult ollamaGenerate(String provider, String prompt, String model) {
long start = System.currentTimeMillis();
String txt = ollamaTextClient.generateText(prompt, model);
long ms = System.currentTimeMillis() - start;
boolean degraded = (txt == null || txt.isBlank());
learningStore.recordInfer(provider, model, ms, degraded);
if (degraded) {
return new GenResult(null, true);
}
return new GenResult(txt, false);
}
}

View File

@ -0,0 +1,131 @@
package com.zioinfo.fa.ai.service;
import com.zioinfo.fa.common.ai.PiiMasker;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
/**
* 로컬 임베디드 DuckDB AI 학습·추론 저장소. [GUARDiA-FA]
*
* <p>AI 피드백(ai_feedback) 추론 로그(ai_infer_log) 솔루션별 격리 DuckDB 파일
* ({@code /opt/guardia-fa/data/fa_learning.duckdb}) 기록한다. 스키마는 멱등 생성한다.
* PII·자격증명은 {@link PiiMasker} 마스킹 저장한다.
*
* <p><b>내결함성 불변</b>: 저장소 초기화/기록 실패는 절대 요청을 깨지 않는다(모든 오류 삼킴·요약 로그만).
* DuckDB 파일 경로가 없거나 드라이버 미가용이면 저장을 조용히 비활성화하고 서비스는 정상 동작한다.
*
* <p><b>스펙</b>: AI_PLATFORM_SPEC.md §5 ( 솔루션 DuckDB 로컬 학습 저장소).
*/
@Slf4j
@Service
public class LearningStore {
private static final String SOLUTION = "fa";
private static final int MAX_FIELD = 4000;
@Value("${fa.learning.duckdb-path:/opt/guardia-fa/data/fa_learning.duckdb}")
private String duckdbPath;
/** 저장 가능 여부(초기화 성공 시 true). 실패 시 조용히 비활성. */
private volatile boolean available = false;
@PostConstruct
void init() {
try {
File f = new File(duckdbPath);
File parent = f.getParentFile();
if (parent != null && !parent.exists()) {
// best-effort: 부모 디렉터리 생성(권한 없으면 무시하고 비활성).
if (!parent.mkdirs() && !parent.exists()) {
log.warn("[LearningStore] DuckDB 디렉터리 생성 불가 — 학습 저장 비활성");
return;
}
}
try (Connection c = open(); Statement st = c.createStatement()) {
st.execute("CREATE SEQUENCE IF NOT EXISTS ai_feedback_seq");
st.execute("CREATE SEQUENCE IF NOT EXISTS ai_infer_seq");
st.execute("CREATE TABLE IF NOT EXISTS ai_feedback ("
+ "id BIGINT DEFAULT nextval('ai_feedback_seq') PRIMARY KEY,"
+ "ts TIMESTAMP, solution VARCHAR, feature VARCHAR,"
+ "question VARCHAR, answer VARCHAR, verdict VARCHAR,"
+ "correction VARCHAR, user_masked VARCHAR)");
st.execute("CREATE TABLE IF NOT EXISTS ai_infer_log ("
+ "id BIGINT DEFAULT nextval('ai_infer_seq') PRIMARY KEY,"
+ "ts TIMESTAMP, provider VARCHAR, model VARCHAR,"
+ "latency_ms BIGINT, degraded BOOLEAN)");
}
available = true;
log.info("[LearningStore] DuckDB 학습 저장소 준비 완료: {}", duckdbPath);
} catch (Throwable e) {
// 드라이버 미가용/권한/경로 문제 조용히 비활성(서비스 무영향).
available = false;
log.warn("[LearningStore] DuckDB 초기화 실패 — 학습 저장 비활성: {}", e.getClass().getSimpleName());
}
}
public boolean isAvailable() {
return available;
}
/** 피드백 1건 기록(PII 마스킹). 실패는 삼킨다. */
public synchronized void recordFeedback(String feature, String question, String answer,
String verdict, String correction, String userRef) {
if (!available) {
return;
}
String sql = "INSERT INTO ai_feedback"
+ "(ts, solution, feature, question, answer, verdict, correction, user_masked)"
+ " VALUES (?,?,?,?,?,?,?,?)";
try (Connection c = open(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setTimestamp(1, new Timestamp(System.currentTimeMillis()));
ps.setString(2, SOLUTION);
ps.setString(3, clip(feature));
ps.setString(4, PiiMasker.maskAndClip(question, MAX_FIELD));
ps.setString(5, PiiMasker.maskAndClip(answer, MAX_FIELD));
ps.setString(6, clip(verdict));
ps.setString(7, PiiMasker.maskAndClip(correction, MAX_FIELD));
ps.setString(8, PiiMasker.maskAndClip(userRef, 200));
ps.executeUpdate();
} catch (Throwable e) {
log.warn("[LearningStore] 피드백 기록 실패(무시): {}", e.getClass().getSimpleName());
}
}
/** 추론 1건 기록. 실패는 삼킨다. */
public synchronized void recordInfer(String provider, String model, long latencyMs, boolean degraded) {
if (!available) {
return;
}
String sql = "INSERT INTO ai_infer_log(ts, provider, model, latency_ms, degraded) VALUES (?,?,?,?,?)";
try (Connection c = open(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setTimestamp(1, new Timestamp(System.currentTimeMillis()));
ps.setString(2, clip(provider));
ps.setString(3, clip(model));
ps.setLong(4, latencyMs);
ps.setBoolean(5, degraded);
ps.executeUpdate();
} catch (Throwable e) {
log.warn("[LearningStore] 추론 로그 기록 실패(무시): {}", e.getClass().getSimpleName());
}
}
private Connection open() throws Exception {
return DriverManager.getConnection("jdbc:duckdb:" + duckdbPath);
}
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,78 @@
package com.zioinfo.fa.ai.service;
import com.zioinfo.fa.common.ai.PiiMasker;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 중앙 guardia-rag {@code /feedback} 얇은 전달 클라이언트 (solution=fa 격리). [GUARDiA-FA]
*
* <p>피드백을 로컬 DuckDB 기록과 <b>동시에</b> 중앙 학습·평가 게이트(guardia-rag, 기본 :8020) 전달한다.
* 미가용/타임아웃 예외를 전파하지 않고 {@code {stored:false, degraded:true}} 돌려준다(로컬 기록은 유지).
* 전송 PII 마스킹. 온프레미스 전용(외부 API 아님).
*/
@Slf4j
@Service
public class RagFeedbackClient {
@Value("${guardia.rag.base-url:http://localhost:8020}")
private String baseUrl;
@Value("${guardia.rag.enabled:true}")
private boolean enabled;
@Value("${guardia.rag.timeout-ms:2500}")
private long timeoutMs;
/** 중앙 rag 로 피드백 전달. 실패는 삼키고 degraded 반환. */
public Map<String, Object> forward(String answerId, String question, String answer,
String verdict, String correction, String userRef) {
if (!enabled) {
return Map.of("stored", false, "degraded", true);
}
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("solution", "fa");
if (answerId != null) body.put("answer_id", answerId);
if (question != null) body.put("query", PiiMasker.mask(question));
if (answer != null) body.put("answer", PiiMasker.mask(answer));
body.put("verdict", verdict);
if (correction != null) body.put("correction", PiiMasker.mask(correction));
if (userRef != null) body.put("user_ref", PiiMasker.maskAndClip(userRef, 200));
RestTemplate local = new RestTemplate();
local.setRequestFactory(factory(timeoutMs));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Solution-Key", "fa");
@SuppressWarnings("rawtypes")
Map res = local.postForObject(baseUrl + "/rag/feedback", new HttpEntity<>(body, headers), Map.class);
if (res == null) {
return Map.of("stored", false, "degraded", true);
}
@SuppressWarnings("unchecked")
Map<String, Object> out = res;
return out;
} catch (Exception e) {
log.warn("중앙 rag /feedback 전달 일시 불가(무시): {}", e.getClass().getSimpleName());
return Map.of("stored", false, "degraded", true);
}
}
private static org.springframework.http.client.SimpleClientHttpRequestFactory factory(long timeoutMs) {
org.springframework.http.client.SimpleClientHttpRequestFactory f =
new org.springframework.http.client.SimpleClientHttpRequestFactory();
f.setConnectTimeout(Duration.ofMillis(Math.min(timeoutMs, 2000)));
f.setReadTimeout(Duration.ofMillis(timeoutMs));
return f;
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.fa.common;
/**
* 표준 API 응답 봉투. [GUARDiA-FA]
*
* <p>{@code {success, message, data}} 형태. 프론트(adminAiConfig.ts) {@code r.data.data} 페이로드를 읽는다.
* 오류 메시지에는 스택트레이스·자격증명·키를 절대 포함하지 않는다(요약만).
*/
public record ApiResponse<T>(boolean success, String message, T data) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, "OK", data);
}
public static <T> ApiResponse<T> ok(String message, T data) {
return new ApiResponse<>(true, message, data);
}
}

View File

@ -0,0 +1,26 @@
package com.zioinfo.fa.common;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 전역 예외 표준 ApiResponse 매핑(요약만·스택트레이스 미노출). [GUARDiA-FA]
*
* <p><b>범위 최소화(무회귀)</b>: AI 설정 검증에서 던지는 {@link IllegalArgumentException} 400 으로
* 매핑한다(: ERR-AI-400 화이트리스트 위반). 예외는 기존 스프링 기본 처리에 위임(다른 엔드포인트
* 응답 형태를 바꾸지 않음). 메시지에 ·자격증명·스택트레이스는 포함하지 않는다.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleBadRequest(IllegalArgumentException e) {
String msg = e.getMessage() == null ? "잘못된 요청입니다." : e.getMessage();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ApiResponse<>(false, msg, null));
}
}

View File

@ -0,0 +1,168 @@
package com.zioinfo.fa.common.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.fa.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-FA]
*
* <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><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,96 @@
package com.zioinfo.fa.common.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.client.RestClientException;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 온프레미스 Ollama 평문 텍스트 생성 클라이언트. [GUARDiA-FA]
*
* <p>AiTextRouter Ollama 경로/폴백 진입점이며 AiConfigService 연결 테스트에도 쓰인다. FA 에는 기존
* OllamaClient 없어 신규 도입한다(기존 서비스의 RestTemplate 직접 호출 스타일과 동일 라이브러리).
*
* <p><b>보안 불변</b>: localhost Ollama 호출(외부 API 금지). 실패/빈응답/타임아웃 {@code null} 반환
* (예외 미전파·스택트레이스 미노출) 라우터가 degraded 래핑한다.
*
* <p><b>레퍼런스</b>: guardia-ocr {@code engine.OllamaOcrService#generateText} 미러(경량화).
*/
@Slf4j
@Service
public class OllamaTextClient {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Value("${ollama.base-url:http://localhost:11434}")
private String ollamaUrl;
/** 서버 기본 Ollama 텍스트 모델(미지정 시 폴백). */
@Value("${guardia.ollama-text-model:llama3.2:1b}")
private String defaultTextModel;
private final RestTemplate rt = new RestTemplate();
/**
* 온프레미스 Ollama 평문 텍스트 생성(이미지 없음).
*
* @param prompt 지시문
* @param model 화이트리스트 검증된 Ollama 텍스트 모델(qwen3:1.7b/deepseek-r1:1.5b/glm4:9b/llama3.2:1b )
* @return 생성 텍스트, 실패 null
*/
public String generateText(String prompt, String model) {
if (prompt == null || prompt.isBlank()) {
return null;
}
String useModel = (model == null || model.isBlank()) ? defaultTextModel : model.trim();
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", useModel);
body.put("prompt", prompt);
body.put("stream", false);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
@SuppressWarnings("rawtypes")
Map resp = rt.postForObject(ollamaUrl + "/api/generate", new HttpEntity<>(body, headers), Map.class);
if (resp == null) {
return null;
}
Object response = resp.get("response");
if (response == null) {
return null;
}
String txt = response.toString().trim();
return txt.isBlank() ? null : txt;
} catch (RestClientException e) {
// 스택트레이스 미노출 요약 로그만
log.warn("Ollama 텍스트 생성 실패: {}", e.getClass().getSimpleName());
return null;
} catch (Exception e) {
log.warn("Ollama 텍스트 생성 실패: {}", e.getClass().getSimpleName());
return null;
}
}
/** 진단용 파싱 헬퍼(향후 확장). 현재 미사용이지만 응답 파싱 규칙 일원화. */
static String parseResponse(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
JsonNode root = MAPPER.readTree(raw);
String response = root.path("response").asText("");
return response.isBlank() ? null : response.trim();
} catch (Exception e) {
return null;
}
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.fa.common.ai;
/**
* 학습 저장 PII·자격증명 마스킹 유틸. [GUARDiA-FA]
*
* <p>DuckDB 로컬 학습 저장소(ai_feedback) 중앙 rag 전달 전에 사용자 입력/답변에서 개인정보·비밀번호·
* 내부 IP 등을 마스킹한다. 규칙 기반(정규식) 외부 호출 없음. 완전 무해화는 아니며 저장 위험 최소화 목적.
*/
public final class PiiMasker {
private PiiMasker() {
}
/** 주민번호·카드·전화·이메일·IPv4·비밀번호 키워드 마스킹. null 안전. */
public static String mask(String s) {
if (s == null || s.isBlank()) {
return s;
}
String out = s;
// 주민등록번호 6-7
out = out.replaceAll("\\b\\d{6}[-\\s]?\\d{7}\\b", "######-#######");
// 카드번호 4-4-4-4
out = out.replaceAll("\\b\\d{4}[-\\s]?\\d{4}[-\\s]?\\d{4}[-\\s]?\\d{4}\\b", "****-****-****-****");
// 전화번호
out = out.replaceAll("\\b01[016789][-\\s]?\\d{3,4}[-\\s]?\\d{4}\\b", "***-****-****");
// 이메일
out = out.replaceAll("\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", "***@***");
// IPv4
out = out.replaceAll("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b", "***.***.***.***");
// 비밀번호/시크릿 =
out = out.replaceAll("(?i)(password|passwd|pw|secret|token|api[_-]?key)\\s*[:=]\\s*\\S+", "$1=***");
return out;
}
/** 저장 컬럼 길이 상한 적용(과대 입력 방지). */
public static String maskAndClip(String s, int max) {
String m = mask(s);
if (m == null) {
return null;
}
return m.length() > max ? m.substring(0, max) : m;
}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.fa.common.ai;
/**
* 텍스트 생성 공용 인터페이스 (AI provider 추상화). [GUARDiA-FA]
*
* <p>온프레미스 Ollama({@link OllamaTextClient}) 외부 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

@ -0,0 +1,40 @@
package com.zioinfo.fa.common.audit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
/**
* 경량 감사 로그 서비스. [GUARDiA-FA]
*
* <p>FA 별도 감사 테이블을 두지 않으므로, 관리자 설정 변경 등의 감사는 slf4j 로만 남긴다
* (·비밀번호·PII·스택트레이스 미기록 actor/action/target/요약 델타만). 향후 DB 감사 도입
* 계약을 유지한 저장소만 교체하면 된다.
*
* <p><b>레퍼런스</b>: guardia-ocr {@code admin.AuditService}(DB 저장 FA slf4j 경량화).
*/
@Slf4j
@Service
public class AuditService {
/** 현재 인증 주체(username)로 감사 로그를 기록한다. */
public void log(String action, String target, String detail) {
log(currentActor(), action, target, detail);
}
public void log(String actor, String action, String target, String detail) {
// /비밀번호/PII 호출자가 전달하지 않는다(설정 델타 요약만).
log.info("[AUDIT] actor={} action={} target={} detail={}",
actor == null ? "system" : actor, action, target, detail);
}
/** SecurityContext 의 JWT subject(username)를 추출. 없으면 system. */
public static String currentActor() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getName() != null && !auth.getName().isBlank()) {
return auth.getName();
}
return "system";
}
}

View File

@ -44,6 +44,12 @@ public class SecurityConfig {
"/index.html",
"/favicon.ico"
).permitAll()
// AI 플랫폼(LLM provider) 설정은 ADMIN 전용(조회/갱신/연결테스트)
.requestMatchers("/api/admin/ai-config", "/api/admin/ai-config/**").hasRole("ADMIN")
// 기타 관리자 API ADMIN 전용(향후 확장 방어)
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// AI 피드백 수집은 인증 사용자
.requestMatchers("/api/ai/**").authenticated()
.requestMatchers("/api/fa/**").authenticated()
.anyRequest().permitAll()
)

View File

@ -0,0 +1,16 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 시스템 설정 (fa_setting 테이블 매핑). [GUARDiA-FA]
* AI 플랫폼 런타임 설정(key='ai.*') 단일 저장소. FA 에는 기존 설정 테이블이 없어 신규 도입(멱등).
*/
@Data
public class FaSetting {
private String settingKey;
private String settingValue;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.FaSetting;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* fa_setting 설정 저장소 매퍼. [GUARDiA-FA]
* {@code @Mapper} 필수 FaApplication {@code @MapperScan(annotationClass = Mapper.class)}.
*/
@Mapper
public interface SettingMapper {
List<FaSetting> findAll();
FaSetting findByKey(@Param("key") String key);
/** upsert (ON CONFLICT). */
int upsert(@Param("key") String key, @Param("value") String value);
}

View File

@ -1,11 +1,10 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.ai.service.AiTextRouter;
import com.zioinfo.fa.common.ai.TextAiClient.GenResult;
import com.zioinfo.fa.domain.Equipment;
import com.zioinfo.fa.mapper.EquipmentMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.List;
@ -14,12 +13,11 @@ import java.util.Map;
@Service
public class EquipmentService {
private final EquipmentMapper mapper;
private final AiTextRouter aiTextRouter;
@Value("${ollama.base-url:http://localhost:11434}")
private String ollamaUrl;
public EquipmentService(EquipmentMapper mapper) {
public EquipmentService(EquipmentMapper mapper, AiTextRouter aiTextRouter) {
this.mapper = mapper;
this.aiTextRouter = aiTextRouter;
}
public List<Equipment> findAll(String status, String workstationCode) {
@ -66,19 +64,14 @@ public class EquipmentService {
return mapper.findById(id);
}
/** 설비 예지보전·고장위험 AI 예측. provider 선택은 AiTextRouter(Claude→Ollama 폴백)가 담당. */
public Map<String, Object> aiPredict(Map<String, Object> req) {
Map<String, Object> result = new HashMap<>();
try {
RestTemplate rt = new RestTemplate();
String prompt = "Equipment: " + req.get("equipmentCode") +
". OEE trend: " + req.get("oeeTrend") +
". Predict maintenance need and breakdown risk in Korean.";
Map<String, Object> body = Map.of("model", "llama3", "prompt", prompt, "stream", false);
ResponseEntity<Map> resp = rt.postForEntity(ollamaUrl + "/api/generate", body, Map.class);
result.put("prediction", resp.getBody() != null ? resp.getBody().get("response") : "예측 불가");
} catch (Exception e) {
result.put("prediction", "AI 예측 일시 중단. 설비 이력을 수동 확인하세요.");
}
GenResult r = aiTextRouter.generate(prompt);
result.put("prediction", (!r.degraded() && r.text() != null) ? r.text() : "AI 예측 일시 중단. 설비 이력을 수동 확인하세요.");
result.put("success", true);
return result;
}

View File

@ -1,11 +1,10 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.ai.service.AiTextRouter;
import com.zioinfo.fa.common.ai.TextAiClient.GenResult;
import com.zioinfo.fa.domain.FactoryInventory;
import com.zioinfo.fa.mapper.FactoryInventoryMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.List;
@ -14,12 +13,11 @@ import java.util.Map;
@Service
public class InventoryService {
private final FactoryInventoryMapper mapper;
private final AiTextRouter aiTextRouter;
@Value("${ollama.base-url:http://localhost:11434}")
private String ollamaUrl;
public InventoryService(FactoryInventoryMapper mapper) {
public InventoryService(FactoryInventoryMapper mapper, AiTextRouter aiTextRouter) {
this.mapper = mapper;
this.aiTextRouter = aiTextRouter;
}
public List<FactoryInventory> findAll(String locationCode) {
@ -41,18 +39,13 @@ public class InventoryService {
return mapper.findLowStock();
}
/** 안전재고·발주 최적화 AI 권고. provider 선택은 AiTextRouter(Claude→Ollama 폴백)가 담당. */
public Map<String, Object> aiOptimize(Map<String, Object> req) {
Map<String, Object> result = new HashMap<>();
try {
RestTemplate rt = new RestTemplate();
String prompt = "Optimize inventory for factory. Current low stock items: " +
req.get("lowStockItems") + ". Suggest reorder quantities in Korean.";
Map<String, Object> body = Map.of("model", "llama3", "prompt", prompt, "stream", false);
ResponseEntity<Map> resp = rt.postForEntity(ollamaUrl + "/api/generate", body, Map.class);
result.put("optimization", resp.getBody() != null ? resp.getBody().get("response") : "최적화 불가");
} catch (Exception e) {
result.put("optimization", "AI 최적화 일시 중단. 안전재고 기준으로 발주하세요.");
}
GenResult r = aiTextRouter.generate(prompt);
result.put("optimization", (!r.degraded() && r.text() != null) ? r.text() : "AI 최적화 일시 중단. 안전재고 기준으로 발주하세요.");
result.put("success", true);
return result;
}

View File

@ -1,23 +1,21 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.ai.service.AiTextRouter;
import com.zioinfo.fa.common.ai.TextAiClient.GenResult;
import com.zioinfo.fa.domain.QualityInspection;
import com.zioinfo.fa.mapper.QualityInspectionMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.*;
@Service
public class QualityService {
private final QualityInspectionMapper mapper;
private final AiTextRouter aiTextRouter;
@Value("${ollama.base-url:http://localhost:11434}")
private String ollamaUrl;
public QualityService(QualityInspectionMapper mapper) {
public QualityService(QualityInspectionMapper mapper, AiTextRouter aiTextRouter) {
this.mapper = mapper;
this.aiTextRouter = aiTextRouter;
}
public List<QualityInspection> findAll(String type, String result) {
@ -50,19 +48,15 @@ public class QualityService {
return mapper.getDashboardSummary();
}
/** 품질 불량 원인·개선안 AI 분석. provider 선택은 AiTextRouter(Claude→Ollama 폴백)가 담당. */
public Map<String, Object> aiAnalyze(Map<String, Object> req) {
Map<String, Object> result = new HashMap<>();
try {
RestTemplate rt = new RestTemplate();
String prompt = "Analyze quality defect: " + req.get("defectDescription") +
". Product: " + req.get("productCode") +
". Suggest root cause and corrective action in Korean.";
Map<String, Object> body = Map.of("model", "llama3", "prompt", prompt, "stream", false);
ResponseEntity<Map> resp = rt.postForEntity(ollamaUrl + "/api/generate", body, Map.class);
result.put("analysis", resp.getBody() != null ? resp.getBody().get("response") : "분석 불가");
} catch (Exception e) {
result.put("analysis", "AI 분석 일시 중단. 수동 검토가 필요합니다.");
}
GenResult r = aiTextRouter.generate(prompt);
// 실패(degraded) 기존 규칙기반 폴백 메시지 유지(무회귀).
result.put("analysis", (!r.degraded() && r.text() != null) ? r.text() : "AI 분석 일시 중단. 수동 검토가 필요합니다.");
result.put("success", true);
return result;
}

View File

@ -21,6 +21,17 @@ jwt:
expiration: 86400000
ollama:
base-url: http://localhost:11434
# AI 플랫폼 — Claude 전환 + 온프레미스 Ollama 폴백 + 로컬 학습 저장소(DuckDB) + 중앙 rag 연계
guardia:
ollama-text-model: llama3.2:1b # Ollama 계열/Claude 폴백 기본 소형 모델(RAM 안전)
rag:
base-url: http://localhost:8020
enabled: true
timeout-ms: 2500
fa:
learning:
duckdb-path: /opt/guardia-fa/data/fa_learning.duckdb # 로컬 AI 학습·추론 저장소(솔루션 격리)
# ANTHROPIC_API_KEY 는 서버 환경변수(systemd EnvironmentFile)로만 주입 — 여기에 절대 기재 금지.
logging:
level:
com.zioinfo.fa: DEBUG

View File

@ -0,0 +1,27 @@
-- =====================================================================
-- GUARDiA FA — 104. AI 플랫폼(LLM provider) 설정 시드 (멱등)
-- 대상 DB : fa_db (테이블 fa_setting — schema.sql 에서 생성)
-- 적용 : 운영 psql 로 schema.sql 적용 후 본 파일 실행(또는 setup 스크립트에 등재).
-- ON CONFLICT DO NOTHING 으로 재실행 안전.
-- 설명 : 외부 Claude(Anthropic) API 를 온프레미스 Ollama 와 병행·선택형으로 추가.
-- 관리자(ADMIN)가 런타임으로 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 동작과 바이트 동일.
-- 멱등 : INSERT ... ON CONFLICT (setting_key) DO NOTHING.
-- =====================================================================
SET client_encoding = 'UTF8';
INSERT INTO fa_setting (setting_key, setting_value) VALUES
('ai.provider', 'ollama'),
('ai.claude.model', 'claude-sonnet-4-6'),
('ai.ollama.textModel', ''),
('ai.enabled', 'true')
ON CONFLICT (setting_key) DO NOTHING;
-- end 104_seed_ai_config.sql

View File

@ -1,6 +1,14 @@
-- GUARDiA FA Database Schema
-- DB: fa_db / User: fa_user / Password: fa_pass2026
-- AI 플랫폼(LLM provider) 런타임 설정 저장소 (key='ai.*'). 멱등. [GUARDiA-FA]
-- 시드는 db/104_seed_ai_config.sql. 미적용/키 비움 시에도 provider 기본 ollama → 무회귀.
CREATE TABLE IF NOT EXISTS fa_setting (
setting_key VARCHAR(100) PRIMARY KEY,
setting_value TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_epaper_displays (
id BIGSERIAL PRIMARY KEY,
display_id VARCHAR(50) UNIQUE NOT NULL,

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- fa_setting 설정 저장소 (AI 플랫폼 런타임 설정 key='ai.*'). [GUARDiA-FA] -->
<mapper namespace="com.zioinfo.fa.mapper.SettingMapper">
<resultMap id="settingMap" type="com.zioinfo.fa.domain.FaSetting">
<result property="settingKey" column="setting_key"/>
<result property="settingValue" column="setting_value"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findAll" resultMap="settingMap">
SELECT setting_key, setting_value, updated_at
FROM fa_setting
ORDER BY setting_key
</select>
<select id="findByKey" resultMap="settingMap">
SELECT setting_key, setting_value, updated_at
FROM fa_setting
WHERE setting_key = #{key}
</select>
<update id="upsert">
INSERT INTO fa_setting (setting_key, setting_value, updated_at)
VALUES (#{key}, #{value}, NOW())
ON CONFLICT (setting_key)
DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW()
</update>
</mapper>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GUARDiA FA — Factory Automation Platform</title>
<script type="module" crossorigin src="/assets/index-aXX4xDhy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DqQ9Pbce.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

Binary file not shown.

2967
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, NavLink } from 'react-router-dom'
import {
LayoutDashboard, Map, Monitor, QrCode, Package,
AlertTriangle, ShieldCheck, Wrench, Boxes, Brain, LogOut
AlertTriangle, ShieldCheck, Wrench, Boxes, Brain, LogOut, Cpu
} from 'lucide-react'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
@ -14,6 +14,7 @@ import AndonBoard from './pages/AndonBoard'
import QualityControl from './pages/QualityControl'
import EquipmentStatus from './pages/EquipmentStatus'
import AiFactory from './pages/AiFactory'
import AiPlatformSettings from './pages/AiPlatformSettings'
const NAV_ITEMS = [
{ to: '/dashboard', icon: LayoutDashboard, label: '대시보드' },
@ -26,6 +27,7 @@ const NAV_ITEMS = [
{ to: '/equipment', icon: Wrench, label: '설비 현황' },
{ to: '/inventory', icon: Boxes, label: '재고 관리' },
{ to: '/ai', icon: Brain, label: 'AI 공장 분석' },
{ to: '/ai-settings', icon: Cpu, label: 'AI 플랫폼 설정' },
]
function Sidebar() {
@ -101,6 +103,7 @@ export default function App() {
<Route path="/equipment" element={<EquipmentStatus />} />
<Route path="/inventory" element={<AiFactory />} />
<Route path="/ai" element={<AiFactory />} />
<Route path="/ai-settings" element={<AiPlatformSettings />} />
</Routes>
</Layout>
</RequireAuth>

View File

@ -0,0 +1,61 @@
// AI 플랫폼(LLM Provider) 설정(ADMIN) API — /api/admin/ai-config (ADMIN 전용). [GUARDiA-FA]
// API 키 값은 응답에 절대 포함되지 않음(claudeKeySet 플래그만).
// PUT 은 provider/claudeModel(+선택 ollamaTextModel) 전송.
// test 는 저장된 설정 기준 선택 provider 짧은 ping.
// 레퍼런스: guardia-ocr frontend/src/api/adminAiConfig.ts (FA 는 루트 경로 + fa_token 헤더).
import axios from 'axios'
// FA 공용 client(baseURL '/api/fa')와 달리 admin 은 루트 경로 → 전용 인스턴스.
const adminApi = axios.create({ timeout: 130000 })
adminApi.interceptors.request.use((config) => {
const token = localStorage.getItem('fa_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
/** LLM 제공자: ollama/claude/qwen3/deepseek/glm (glm=glm4:9b·RAM 여유 필요). */
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
/** 선택 Ollama 모델이 서버 RAM 여유 초과 가능(glm4:9b 등) — 콜드로드 실패 시 폴백. */
ramWarning: 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 = () =>
adminApi.get('/api/admin/ai-config').then(r => r.data?.data as AiConfigDto)
/** PUT /api/admin/ai-config — provider/claudeModel 저장 → 반영분. */
export const updateAiConfig = (body: AiConfigUpdateRequest) =>
adminApi.put('/api/admin/ai-config', body).then(r => r.data?.data as AiConfigDto)
/** POST /api/admin/ai-config/test — 저장된 설정 기준 선택 provider 짧은 ping. */
export const testAiConfig = () =>
adminApi.post('/api/admin/ai-config/test', {}).then(r => r.data?.data as AiTestResult)

View File

@ -0,0 +1,29 @@
// AI 답변 피드백 API — /api/ai/feedback (인증 사용자). [GUARDiA-FA]
// 👍/👎 + 교정 → 로컬 DuckDB 학습 저장소 + 중앙 guardia-rag /feedback (둘 다). 저장 전 PII 마스킹은 서버 처리.
import axios from 'axios'
const aiApi = axios.create({ timeout: 15000 })
aiApi.interceptors.request.use((config) => {
const token = localStorage.getItem('fa_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export interface AiFeedbackRequest {
feature: string
question?: string
answer?: string
verdict: 'up' | 'down'
correction?: string
answerId?: string
}
export interface AiFeedbackResult {
verdict: string
localStored: boolean
centralStored: boolean
}
/** POST /api/ai/feedback — 피드백 전송(로컬+중앙). */
export const sendAiFeedback = (body: AiFeedbackRequest) =>
aiApi.post('/api/ai/feedback', body).then(r => r.data?.data as AiFeedbackResult)

View File

@ -1,15 +1,67 @@
import React, { useState } from 'react'
import { Brain, TrendingUp, Package, Wrench } from 'lucide-react'
import { Brain, TrendingUp, Package, Wrench, ThumbsUp, ThumbsDown } from 'lucide-react'
import client from '../api/client'
import { sendAiFeedback } from '../api/aiFeedback'
interface AiPanel {
title: string
feature: string
desc: string
icon: React.ReactNode
action: () => Promise<string>
color: string
}
// AI 결과 하단 피드백 위젯(👍/👎 + 교정) — 로컬 DuckDB + 중앙 rag 전달.
function FeedbackBar({ feature, answer }: { feature: string; answer: string }) {
const [sent, setSent] = useState<'up' | 'down' | null>(null)
const [showCorrection, setShowCorrection] = useState(false)
const [correction, setCorrection] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (verdict: 'up' | 'down', corr?: string) => {
setBusy(true)
try {
await sendAiFeedback({ feature, answer, verdict, correction: corr })
setSent(verdict)
setShowCorrection(false)
} catch {
/* 피드백 실패는 무시(내결함성) */
}
setBusy(false)
}
if (sent) {
return <div className="mt-2 text-[11px] text-emerald-400"> {sent === 'down' ? ' — 학습에 반영됩니다.' : '.'}</div>
}
return (
<div className="mt-2 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500"> ?</span>
<button disabled={busy} onClick={() => submit('up')}
className="p-1 rounded text-slate-400 hover:text-emerald-400 disabled:opacity-40" title="도움됨">
<ThumbsUp size={13} />
</button>
<button disabled={busy} onClick={() => setShowCorrection(v => !v)}
className="p-1 rounded text-slate-400 hover:text-rose-400 disabled:opacity-40" title="개선 필요">
<ThumbsDown size={13} />
</button>
</div>
{showCorrection && (
<div className="space-y-1.5">
<textarea value={correction} onChange={e => setCorrection(e.target.value)}
placeholder="어떻게 개선하면 좋을지 알려주세요(선택)"
className="w-full text-xs bg-slate-900 border border-slate-700 rounded p-2 text-slate-200 outline-none focus:border-blue-500" rows={2} />
<button disabled={busy} onClick={() => submit('down', correction)}
className="px-3 py-1 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs disabled:opacity-40">
</button>
</div>
)}
</div>
)
}
export default function AiFactory() {
const [results, setResults] = useState<Record<string, string>>({})
const [loading, setLoading] = useState<Record<string, boolean>>({})
@ -36,7 +88,8 @@ export default function AiFactory() {
const panels: AiPanel[] = [
{
title: '생산 수요 예측',
desc: '과거 생산 데이터 기반 Ollama AI 수요/생산 예측',
feature: 'demand_forecast',
desc: '과거 생산 데이터 기반 AI 수요/생산 예측 (Claude→Ollama 폴백)',
icon: <TrendingUp size={18} />,
color: 'text-blue-400 border-blue-700',
action: async () => {
@ -49,6 +102,7 @@ export default function AiFactory() {
},
{
title: '설비 이상 감지',
feature: 'equipment_predict',
desc: 'OEE 하락 패턴 분석 및 예지보전 AI 권고',
icon: <Wrench size={18} />,
color: 'text-yellow-400 border-yellow-700',
@ -62,6 +116,7 @@ export default function AiFactory() {
},
{
title: '재고 최적화',
feature: 'inventory_optimize',
desc: '안전재고 분석 및 발주 최적화 AI 권고',
icon: <Package size={18} />,
color: 'text-green-400 border-green-700',
@ -74,6 +129,7 @@ export default function AiFactory() {
},
{
title: '품질 불량 분석',
feature: 'quality_defect',
desc: '공정별 불량 패턴 AI 분석 및 개선 방안',
icon: <Brain size={18} />,
color: 'text-purple-400 border-purple-700',
@ -110,9 +166,12 @@ export default function AiFactory() {
<Brain size={12} /> {loading[p.title] ? '분석 중...' : 'AI 분석 실행'}
</button>
{results[p.title] && (
<>
<div className="bg-slate-700/50 rounded-lg p-3 text-xs text-slate-200 leading-relaxed max-h-48 overflow-y-auto">
{results[p.title]}
</div>
<FeedbackBar feature={p.feature} answer={results[p.title]} />
</>
)}
</div>
))}

View File

@ -0,0 +1,231 @@
// AI 플랫폼(LLM Provider) 설정(ADMIN) — 제공자·모델 선택 + 연결 테스트. [GUARDiA-FA]
// API 키 값은 화면에 절대 표시/입력하지 않는다(claudeKeySet 으로 설정 여부 배지만).
// 저장은 provider/claudeModel 만 전송(ollama 계열 모델·전역 활성은 읽기 전용 표시).
// 레퍼런스: guardia-ocr AiPlatformSettings.tsx (FA 슬레이트 다크 콘솔 스타일로 미러).
import { useEffect, useState } from 'react'
import { Cpu, KeyRound, PlugZap, Save } 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 설정은 ADMIN 전용).'
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 [ramWarning, setRamWarning] = useState(false)
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 apply = (dto: any) => {
setProvider(dto.provider)
setClaudeModel(normModel(dto.claudeModel ?? DEFAULT_CLAUDE_MODEL))
setClaudeKeySet(!!dto.claudeKeySet)
setOllamaTextModel(dto.ollamaTextModel ?? '')
setAiEnabled(!!dto.aiEnabled)
setRamWarning(!!dto.ramWarning)
}
const load = () => {
setLoading(true)
getAiConfig()
.then(dto => { apply(dto); 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 })
apply(dto)
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 showKeyFallback = isClaude && !claudeKeySet
const showDisabledFallback = !isClaude && !aiEnabled
const showRamWarning = !isClaude && (ramWarning || provider === 'glm')
const statusClass = testResult
? testResult.degraded ? 'text-amber-400' : testResult.ok ? 'text-emerald-400' : 'text-rose-400'
: ''
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Cpu size={20} className="text-blue-400" /> AI
</h1>
<p className="text-sm text-slate-400 mt-1">
AI (··) LLM .
</p>
</div>
<button onClick={load} disabled={loading}
className="px-3 py-1.5 rounded-lg bg-slate-800 border border-slate-700 text-sm text-slate-200 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">{err}</div>
)}
{(showKeyFallback || showDisabledFallback || showRamWarning) && (
<div className="bg-amber-500/10 border border-amber-500/40 text-amber-300 text-sm rounded-lg px-4 py-2.5 space-y-1">
{showKeyFallback && <div>Claude API Ollama() . ANTHROPIC_API_KEY .</div>}
{showDisabledFallback && <div>AI . AI (degraded) .</div>}
{showRamWarning && <div>GLM(glm4:9b) RAM . (llama3.2:1b) .</div>}
</div>
)}
<div className="grid gap-5 md:grid-cols-2">
{/* 제공자 & 모델 */}
<section className="bg-slate-800 border border-slate-700 rounded-xl p-5">
<h2 className="text-sm font-semibold mb-4 flex items-center gap-2 text-slate-200">
<Cpu size={16} className="text-blue-400" /> &
</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-blue-600/15 border-blue-500 text-blue-300'
: 'bg-slate-900 border-slate-700 text-slate-300 hover:border-blue-500/50'
}`}>
<div className="font-semibold flex items-center gap-1">
{p.label}
{p.ram && <span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">RAM</span>}
</div>
<div className="text-[11px] text-slate-500 mt-0.5">{p.hint}</div>
</button>
))}
</div>
{!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-slate-900 border border-slate-700 text-sm font-mono text-slate-200">
{ollamaTextModel || 'llama3.2:1b'}
</div>
<p className="text-[11px] text-slate-500 mt-1">
RAM . .
</p>
</div>
)}
{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-slate-900 border border-slate-700 text-sm text-slate-200 focus:border-blue-500 outline-none">
{CLAUDE_MODELS.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div className="mb-4">
<label className="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-blue-600 hover:bg-blue-500 text-white text-sm font-semibold disabled:opacity-40 transition-colors">
<Save size={15} /> {saving ? '저장 중…' : '저장'}
</button>
{saved && <span className="text-emerald-400 text-xs"></span>}
</div>
</section>
{/* 연결 테스트 */}
<section className="bg-slate-800 border border-slate-700 rounded-xl p-5">
<h2 className="text-sm font-semibold mb-2 flex items-center gap-2 text-slate-200">
<PlugZap size={16} className="text-blue-400" />
</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-slate-900 border border-slate-700 text-sm text-slate-200 disabled:opacity-40 hover:border-blue-500/50 transition-colors">
{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>
)
}