feat(ai): Claude 전환 + DuckDB 학습저장소
This commit is contained in:
parent
662ce5a401
commit
114bfc012e
@ -53,6 +53,13 @@
|
|||||||
<!-- Lombok -->
|
<!-- Lombok -->
|
||||||
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
|
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
|
||||||
|
|
||||||
|
<!-- DuckDB (로컬 임베디드 AI 학습·분석 저장소 — ai_feedback / ai_infer_log) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.duckdb</groupId>
|
||||||
|
<artifactId>duckdb_jdbc</artifactId>
|
||||||
|
<version>1.1.3</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Test -->
|
<!-- Test -->
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
package com.zioinfo.esn.ai.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.ai.dto.AiConfigDto;
|
||||||
|
import com.zioinfo.esn.ai.dto.AiConfigUpdateRequest;
|
||||||
|
import com.zioinfo.esn.ai.service.AiConfigService;
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 플랫폼(LLM provider) 설정 관리(ADMIN). [ZIOINFO-ESN]
|
||||||
|
* SecurityConfig {@code /api/admin/** hasRole(ADMIN)} 게이트로 보호된다.
|
||||||
|
*
|
||||||
|
* <p>esn_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, 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, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 감사 로그용 현재 사용자명(미인증 시 SYSTEM). */
|
||||||
|
private static String currentActor() {
|
||||||
|
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
return (a != null && a.getName() != null) ? a.getName() : "SYSTEM";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package com.zioinfo.esn.ai.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.ai.dto.FeedbackRequest;
|
||||||
|
import com.zioinfo.esn.ai.service.LearningStore;
|
||||||
|
import com.zioinfo.esn.ai.service.RagFeedbackClient;
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
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.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 답변 피드백 수집(인증 사용자). [ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* <p>👍/👎(+교정)를 ① 로컬 DuckDB({@link LearningStore}) 기록 ② 중앙 guardia-rag({@link RagFeedbackClient})
|
||||||
|
* 전달 — 둘 다 수행한다. PII 는 로컬 저장 시 마스킹되며, 중앙 전달분도 마스킹 후 전송한다.
|
||||||
|
*
|
||||||
|
* <ul><li>POST /api/ai/feedback 피드백 1건 저장(로컬+중앙)</li></ul>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/ai/feedback")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AiFeedbackController {
|
||||||
|
|
||||||
|
private final LearningStore learningStore;
|
||||||
|
private final RagFeedbackClient ragClient;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<Map<String, Object>> submit(@RequestBody FeedbackRequest req) {
|
||||||
|
String user = currentActor();
|
||||||
|
// 로컬 DuckDB (마스킹은 LearningStore 내부에서 수행).
|
||||||
|
learningStore.saveFeedback(req.feature(), req.question(), req.answer(),
|
||||||
|
req.verdict(), req.correction(), user);
|
||||||
|
// 중앙 rag 전달 — 마스킹 후 전송(로컬과 동일 정책).
|
||||||
|
ragClient.forward(req.feature(),
|
||||||
|
LearningStore.mask(req.question()),
|
||||||
|
LearningStore.mask(req.answer()),
|
||||||
|
req.verdict(),
|
||||||
|
LearningStore.mask(req.correction()));
|
||||||
|
return ApiResponse.ok(Map.of("stored", true, "local", learningStore.isEnabled()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String currentActor() {
|
||||||
|
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
return (a != null && a.getName() != null) ? a.getName() : "anonymous";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.zioinfo.esn.ai.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 설정 조회 DTO — API 키 값 미반환(claudeKeySet 으로 설정 여부만). [ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* @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.esn.ai.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 설정 저장 요청 — 화이트리스트 검증. [ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* @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,18 @@
|
|||||||
|
package com.zioinfo.esn.ai.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 답변 피드백 요청(👍/👎 + 선택 교정). [ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* @param feature 기능 구분(예: alarm-analysis / pos-classify / chat)
|
||||||
|
* @param question 사용자 질문/입력(마스킹은 저장 시점에 수행)
|
||||||
|
* @param answer AI 답변
|
||||||
|
* @param verdict UP | DOWN (그 외 값은 저장은 하되 무의미)
|
||||||
|
* @param correction 교정 텍스트(선택)
|
||||||
|
*/
|
||||||
|
public record FeedbackRequest(
|
||||||
|
String feature,
|
||||||
|
String question,
|
||||||
|
String answer,
|
||||||
|
String verdict,
|
||||||
|
String correction) {
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.zioinfo.esn.ai.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* esn_setting(key/value) 접근 매퍼 — AI provider 런타임 설정 저장소. [ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* <p>설정 전용 경량 key/value 테이블(esn_setting, db/104_seed_ai_config.sql 에서 멱등 생성).
|
||||||
|
* 어노테이션 SQL 로 정의(별도 XML 없음). {@code @MapperScan(annotationClass=Mapper.class)} 로 스캔된다.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SettingMapper {
|
||||||
|
|
||||||
|
/** key 에 해당하는 value(없으면 null). */
|
||||||
|
@Select("SELECT value FROM esn_setting WHERE key = #{key}")
|
||||||
|
String findValue(@Param("key") String key);
|
||||||
|
|
||||||
|
/** upsert(멱등) — 존재 시 value 갱신, 없으면 삽입. */
|
||||||
|
@Update("INSERT INTO esn_setting(key, value) VALUES(#{key}, #{value}) "
|
||||||
|
+ "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value")
|
||||||
|
void upsert(@Param("key") String key, @Param("value") String value);
|
||||||
|
}
|
||||||
@ -0,0 +1,249 @@
|
|||||||
|
package com.zioinfo.esn.ai.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.ai.dto.AiConfigDto;
|
||||||
|
import com.zioinfo.esn.ai.dto.AiConfigUpdateRequest;
|
||||||
|
import com.zioinfo.esn.ai.mapper.SettingMapper;
|
||||||
|
import com.zioinfo.esn.common.ai.ClaudeTextClient;
|
||||||
|
import com.zioinfo.esn.common.ai.TextAiClient.GenResult;
|
||||||
|
import com.zioinfo.esn.config.OllamaClient;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI provider 런타임 설정(esn_setting, key='ai.*') 단일 출처 서비스. [ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* <p><b>해상도</b>: DB(esn_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} 미러(provider 에 glm 추가·설정 저장소 치환).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AiConfigService {
|
||||||
|
|
||||||
|
// --- esn_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 제약 준수). glm 은 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";
|
||||||
|
|
||||||
|
/** 허용 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 텍스트 모델(임의 문자열 거부). glm4:9b 는 RAM 여유 필요. */
|
||||||
|
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
|
||||||
|
|
||||||
|
/** 서버 기본 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 중 하나여야 합니다.");
|
||||||
|
}
|
||||||
|
upsertLogged(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 입니다.");
|
||||||
|
}
|
||||||
|
upsertLogged(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 텍스트 모델입니다.");
|
||||||
|
}
|
||||||
|
upsertLogged(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));
|
||||||
|
}
|
||||||
|
return new TestResult(false, true, "Ollama 연결 실패 또는 모델 미가용 — 설정을 확인하세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
String v = repo.findValue(key);
|
||||||
|
return (v != null && !v.isBlank()) ? v : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// esn_setting 미적용(시드 전) 등 — 기본값 폴백(무회귀).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** upsert + slf4j 변경 로그(값은 provider/모델명 등 비민감 — 키/시크릿 아님). */
|
||||||
|
private void upsertLogged(String key, String val, String actor) {
|
||||||
|
String prev = dbVal(key);
|
||||||
|
if (val.equals(prev)) {
|
||||||
|
return; // 변경 없음
|
||||||
|
}
|
||||||
|
repo.upsert(key, val);
|
||||||
|
log.info("AI_CONFIG_CHANGE by={} {} : {} -> {}", actor == null ? "SYSTEM" : actor, key,
|
||||||
|
prev == null ? "(none)" : prev, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
package com.zioinfo.esn.ai.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.common.ai.ClaudeTextClient;
|
||||||
|
import com.zioinfo.esn.common.ai.TextAiClient;
|
||||||
|
import com.zioinfo.esn.common.ai.TextAiClient.GenResult;
|
||||||
|
import com.zioinfo.esn.config.OllamaClient;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI provider 선택 라우팅(런타임). 평문 텍스트 생성 진입점(알람분석·POS분류·채팅 등). [ZIOINFO-ESN]
|
||||||
|
* {@link AiConfigService#provider()} 를 읽어 Claude ↔ Ollama(qwen3/deepseek/glm/기존소형) 를 선택한다.
|
||||||
|
*
|
||||||
|
* <p><b>추론 폴백 체인</b>(AI_PLATFORM_SPEC §3):
|
||||||
|
* <ul>
|
||||||
|
* <li>claude(키·활성) → 실패 시 Ollama {@code qwen3:1.7b} → 실패 시 {@code llama3.2:1b} → degraded.</li>
|
||||||
|
* <li>qwen3/deepseek/glm → 해당 Ollama 모델 → 실패 시 {@code llama3.2:1b} → degraded.</li>
|
||||||
|
* <li>ollama → 설정 모델(기본 llama3.2:1b) → degraded.</li>
|
||||||
|
* </ul>
|
||||||
|
* Claude 실패는 예외가 아니라 degraded → 다음 단계 폴백(서비스 중단 없음). 모든 시도는 DuckDB
|
||||||
|
* {@link LearningStore#logInference} 로 provider/model/latency/degraded 를 기록한다(fail-safe).
|
||||||
|
*
|
||||||
|
* <p><b>레퍼런스</b>: guardia-ocr {@code ai.service.AiTextRouter} 미러(Ollama 텍스트 경로·2차 폴백·학습로그 치환).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AiTextRouter implements TextAiClient {
|
||||||
|
|
||||||
|
private static final String FALLBACK_QWEN = "qwen3:1.7b";
|
||||||
|
private static final String FALLBACK_SMALL = "llama3.2:1b";
|
||||||
|
|
||||||
|
private final AiConfigService aiConfig;
|
||||||
|
private final ClaudeTextClient claudeClient;
|
||||||
|
private final OllamaClient ollamaClient;
|
||||||
|
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);
|
||||||
|
boolean ok = !r.degraded() && r.text() != null && !r.text().isBlank();
|
||||||
|
learningStore.logInference("claude", model, System.currentTimeMillis() - start, !ok);
|
||||||
|
if (ok) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
// claude 실패 → 온프레미스 Ollama 자동 폴백(qwen3 → 소형).
|
||||||
|
log.warn("Claude path degraded -> Ollama fallback");
|
||||||
|
GenResult q = ollamaTry(prompt, FALLBACK_QWEN);
|
||||||
|
if (!q.degraded()) {
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
return ollamaTry(prompt, FALLBACK_SMALL);
|
||||||
|
}
|
||||||
|
// provider = ollama / qwen3 / deepseek / glm → 해당 Ollama 모델, 실패 시 소형 2차 폴백.
|
||||||
|
String model = aiConfig.ollamaTextModel();
|
||||||
|
GenResult r = ollamaTry(prompt, model);
|
||||||
|
if (!r.degraded()) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
if (!FALLBACK_SMALL.equals(model)) {
|
||||||
|
return ollamaTry(prompt, FALLBACK_SMALL);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ollama 평문 generate + 학습로그(실패 시 degraded). */
|
||||||
|
private GenResult ollamaTry(String prompt, String model) {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
String txt = ollamaClient.generate(prompt, model);
|
||||||
|
boolean degraded = (txt == null || txt.isBlank());
|
||||||
|
learningStore.logInference("ollama", model, System.currentTimeMillis() - start, degraded);
|
||||||
|
return degraded ? new GenResult(null, true) : new GenResult(txt, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
package com.zioinfo.esn.ai.service;
|
||||||
|
|
||||||
|
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.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 임베디드 DuckDB AI 학습·분석 저장소. [ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* <p><b>목적</b>: AI 추론 로그(ai_infer_log)와 사용자 피드백(ai_feedback)을 솔루션 로컬 DuckDB 파일에
|
||||||
|
* 축적해 오프라인 AI 분석/평가에 사용한다(중앙 guardia-rag 는 통합 학습 게이트, 로컬 DuckDB 는 솔루션별).
|
||||||
|
*
|
||||||
|
* <p><b>경로</b>: {@code /opt/zioinfo-esn/data/esn_learning.duckdb}(prop {@code guardia.duckdb-path}).
|
||||||
|
* 스키마는 멱등(IF NOT EXISTS). 파일 격리 — 솔루션별 단일 파일.
|
||||||
|
*
|
||||||
|
* <p><b>Fail-Safe</b>: DuckDB 드라이버/파일/디렉터리 미가용 시 조용히 비활성(no-op)한다 —
|
||||||
|
* 학습 저장 실패가 AI 응답/서비스 기동을 절대 막지 않는다. 단일 커넥션 동기화.
|
||||||
|
*
|
||||||
|
* <p><b>PII 마스킹</b>: question/answer/correction/user 저장 전 주민번호·카드·전화·이메일을 마스킹한다.
|
||||||
|
* 자격증명/키/스택트레이스는 애초에 수집하지 않는다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class LearningStore {
|
||||||
|
|
||||||
|
public static final String SOLUTION = "zioinfo-esn";
|
||||||
|
|
||||||
|
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[016-9][- ]?\\d{3,4}[- ]?\\d{4}");
|
||||||
|
private static final Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w.-]+\\.[A-Za-z]{2,}");
|
||||||
|
|
||||||
|
@Value("${guardia.duckdb-path:/opt/zioinfo-esn/data/esn_learning.duckdb}")
|
||||||
|
private String duckdbPath;
|
||||||
|
|
||||||
|
/** null 이면 비활성(no-op). */
|
||||||
|
private volatile Connection conn;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() {
|
||||||
|
try {
|
||||||
|
Class.forName("org.duckdb.DuckDBDriver");
|
||||||
|
File f = new File(duckdbPath);
|
||||||
|
File dir = f.getParentFile();
|
||||||
|
if (dir != null && !dir.exists() && !dir.mkdirs()) {
|
||||||
|
log.warn("DuckDB 디렉터리 생성 불가 — 학습 저장 비활성: {}", dir.getPath());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.conn = DriverManager.getConnection("jdbc:duckdb:" + duckdbPath);
|
||||||
|
try (Statement st = conn.createStatement()) {
|
||||||
|
st.execute("CREATE TABLE IF NOT EXISTS ai_feedback ("
|
||||||
|
+ "id VARCHAR 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 VARCHAR PRIMARY KEY, ts TIMESTAMP, provider VARCHAR, model VARCHAR, "
|
||||||
|
+ "latency_ms BIGINT, degraded BOOLEAN)");
|
||||||
|
}
|
||||||
|
log.info("DuckDB 학습 저장소 초기화 완료: {}", duckdbPath);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// 드라이버 미탑재/파일락/권한 등 — 조용히 비활성(무회귀). 값/경로만, 시크릿 없음.
|
||||||
|
this.conn = null;
|
||||||
|
log.warn("DuckDB 학습 저장소 비활성(no-op): {}", t.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활성 여부(테스트/진단용). */
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return conn != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 추론 로그 1건 기록(실패 시 무시). AiTextRouter 가 provider/model/latency/degraded 로 호출. */
|
||||||
|
public synchronized void logInference(String provider, String model, long latencyMs, boolean degraded) {
|
||||||
|
if (conn == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = conn.prepareStatement(
|
||||||
|
"INSERT INTO ai_infer_log(id, ts, provider, model, latency_ms, degraded) VALUES (?,?,?,?,?,?)")) {
|
||||||
|
ps.setString(1, UUID.randomUUID().toString());
|
||||||
|
ps.setObject(2, LocalDateTime.now());
|
||||||
|
ps.setString(3, provider);
|
||||||
|
ps.setString(4, model);
|
||||||
|
ps.setLong(5, latencyMs);
|
||||||
|
ps.setBoolean(6, degraded);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("ai_infer_log 기록 실패(무시): {}", e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 피드백 1건 기록(PII 마스킹). verdict 예: UP/DOWN. 실패 시 무시. */
|
||||||
|
public synchronized void saveFeedback(String feature, String question, String answer,
|
||||||
|
String verdict, String correction, String user) {
|
||||||
|
if (conn == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = conn.prepareStatement(
|
||||||
|
"INSERT INTO ai_feedback(id, ts, solution, feature, question, answer, verdict, correction, user_masked) "
|
||||||
|
+ "VALUES (?,?,?,?,?,?,?,?,?)")) {
|
||||||
|
ps.setString(1, UUID.randomUUID().toString());
|
||||||
|
ps.setObject(2, LocalDateTime.now());
|
||||||
|
ps.setString(3, SOLUTION);
|
||||||
|
ps.setString(4, feature);
|
||||||
|
ps.setString(5, mask(question));
|
||||||
|
ps.setString(6, mask(answer));
|
||||||
|
ps.setString(7, verdict);
|
||||||
|
ps.setString(8, mask(correction));
|
||||||
|
ps.setString(9, mask(user));
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("ai_feedback 기록 실패(무시): {}", e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PII 마스킹(주민번호·카드·전화·이메일). null 안전. 중앙 전달분 마스킹에도 재사용. */
|
||||||
|
public static String mask(String s) {
|
||||||
|
if (s == null || s.isBlank()) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
String out = s;
|
||||||
|
out = RRN.matcher(out).replaceAll("######-#######");
|
||||||
|
out = CARD.matcher(out).replaceAll("****-****-****-****");
|
||||||
|
out = PHONE.matcher(out).replaceAll("***-****-****");
|
||||||
|
out = EMAIL.matcher(out).replaceAll("***@***");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.zioinfo.esn.ai.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
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;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중앙 guardia-rag 학습 게이트로 피드백을 전달하는 얇은 클라이언트(온프레미스 localhost). [ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* <p>로컬 DuckDB 저장과 <b>병행</b>해 중앙 rag {@code /feedback} 에 best-effort 전달한다(통합 학습·평가).
|
||||||
|
* rag 미기동/실패는 무시한다(예외 미전파 — 사용자 피드백 저장 자체는 로컬로 성립). 외부 API 아님(내부 서비스).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class RagFeedbackClient {
|
||||||
|
|
||||||
|
@Value("${guardia.rag-url:http://localhost:8020}")
|
||||||
|
private String ragUrl;
|
||||||
|
|
||||||
|
private final HttpClient http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(3))
|
||||||
|
.build();
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
/** 피드백을 중앙 rag /feedback 로 전달(실패 무시). PII 마스킹은 호출 전에 수행되어야 한다. */
|
||||||
|
public void forward(String feature, String question, String answer, String verdict, String correction) {
|
||||||
|
try {
|
||||||
|
String json = mapper.writeValueAsString(Map.of(
|
||||||
|
"solution", LearningStore.SOLUTION,
|
||||||
|
"feature", feature == null ? "" : feature,
|
||||||
|
"question", question == null ? "" : question,
|
||||||
|
"answer", answer == null ? "" : answer,
|
||||||
|
"verdict", verdict == null ? "" : verdict,
|
||||||
|
"correction", correction == null ? "" : correction));
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(ragUrl + "/feedback"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.timeout(Duration.ofSeconds(5))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (resp.statusCode() / 100 != 2) {
|
||||||
|
log.debug("중앙 rag 피드백 전달 비2xx({}) — 무시", resp.statusCode());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("중앙 rag 피드백 전달 실패(무시): {}", e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
package com.zioinfo.esn.common.ai;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.zioinfo.esn.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=ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* <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 com.zioinfo.ocr.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 문자열 이스케이프(OllamaClient 프롬프트 직렬화와 동일 규칙). */
|
||||||
|
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.esn.common.ai;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 생성 공용 인터페이스 (AI provider 추상화). [ISSUER=ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* <p>온프레미스 Ollama({@code config.OllamaClient})와 외부 Claude({@link ClaudeTextClient})를
|
||||||
|
* 동일 계약으로 다루기 위한 얇은 추상화. 구현체는 어떤 사유로든(비활성/실패/타임아웃/키미설정) 실패 시
|
||||||
|
* {@link GenResult#degraded()}=true · {@link GenResult#text()}=null 을 반환하고, 호출자가 폴백을 책임진다.
|
||||||
|
*
|
||||||
|
* <p>provider 선택/폴백 라우팅은 {@code ai.service.AiTextRouter} 가 담당한다(이 인터페이스를 구현).
|
||||||
|
*
|
||||||
|
* <p><b>레퍼런스</b>: guardia-ocr {@code com.zioinfo.ocr.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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -65,6 +65,49 @@ public class OllamaClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지정 모델로 평문 생성(라우터/설정 테스트용). [ZIOINFO-ESN AI 트랙]
|
||||||
|
*
|
||||||
|
* <p>기존 {@link #chat(String, String)} 은 실패 시 규칙기반 fallback 텍스트를 반환하지만,
|
||||||
|
* 본 메서드는 폴백 판정을 호출자(AiTextRouter)가 하도록 <b>실패 시 null</b> 을 반환한다.
|
||||||
|
* 모델을 인자로 받아 provider 별 소형 모델(qwen3:1.7b·deepseek-r1:1.5b·glm4:9b·llama3.2:1b)을 선택한다.
|
||||||
|
*
|
||||||
|
* @return 생성 텍스트, 실패(비200/타임아웃/예외/공백)면 {@code null}
|
||||||
|
*/
|
||||||
|
public String generate(String prompt, String model) {
|
||||||
|
if (prompt == null || prompt.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String useModel = (model == null || model.isBlank()) ? this.model : model.trim();
|
||||||
|
try {
|
||||||
|
var body = Map.of(
|
||||||
|
"model", useModel,
|
||||||
|
"messages", new Object[]{
|
||||||
|
Map.of("role", "user", "content", prompt)
|
||||||
|
},
|
||||||
|
"stream", false
|
||||||
|
);
|
||||||
|
String json = mapper.writeValueAsString(body);
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(baseUrl + "/api/chat"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||||
|
.timeout(Duration.ofSeconds(120))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (resp.statusCode() == 200) {
|
||||||
|
var res = mapper.readTree(resp.body());
|
||||||
|
String txt = res.path("message").path("content").asText("");
|
||||||
|
return (txt == null || txt.isBlank()) ? null : txt;
|
||||||
|
}
|
||||||
|
log.warn("Ollama generate status {} -> degraded", resp.statusCode());
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Ollama generate 실패 -> degraded: {}", e.getClass().getSimpleName());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String fallback(String prompt) {
|
private String fallback(String prompt) {
|
||||||
if (prompt.toLowerCase().contains("alarm") || prompt.contains("알람")) {
|
if (prompt.toLowerCase().contains("alarm") || prompt.contains("알람")) {
|
||||||
return "알람 분석: 장치 연결 상태를 확인하고 네트워크 환경을 점검하세요. 지속 발생 시 현장 엔지니어 파견이 필요합니다.";
|
return "알람 분석: 장치 연결 상태를 확인하고 네트워크 환경을 점검하세요. 지속 발생 시 현장 엔지니어 파견이 필요합니다.";
|
||||||
|
|||||||
@ -43,6 +43,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.GET, "/", "/index.html", "/assets/**", "/*.js", "/*.css", "/*.ico").permitAll()
|
.requestMatchers(HttpMethod.GET, "/", "/index.html", "/assets/**", "/*.js", "/*.css", "/*.ico").permitAll()
|
||||||
|
|
||||||
// 관리자 전용
|
// 관리자 전용
|
||||||
|
// AI 플랫폼 설정(provider/모델/연결테스트) — ADMIN 전용(모든 메서드). 반드시 generic /api/** 앞.
|
||||||
|
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/tenants/**").hasRole("ADMIN")
|
.requestMatchers("/api/tenants/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/users/**").hasAnyRole("ADMIN", "MANAGER")
|
.requestMatchers("/api/users/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
.requestMatchers("/api/firmware/**").hasAnyRole("ADMIN", "MANAGER")
|
.requestMatchers("/api/firmware/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
|
|||||||
@ -1,56 +1,60 @@
|
|||||||
package com.zioinfo.esn.controller;
|
package com.zioinfo.esn.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.ai.service.AiTextRouter;
|
||||||
import com.zioinfo.esn.common.ApiResponse;
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
import com.zioinfo.esn.config.OllamaClient;
|
import com.zioinfo.esn.common.ai.TextAiClient.GenResult;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ESN AI 진입점 — 알람분석·POS분류·채팅. [ZIOINFO-ESN]
|
||||||
|
*
|
||||||
|
* <p>기존 {@code OllamaClient} 직접 호출을 {@link AiTextRouter} 경유로 전환한다(provider=claude 선택 시
|
||||||
|
* Claude, 실패/미설정 시 Ollama 자동 폴백). 라우터가 degraded 를 반환하면 기존과 동일한 규칙기반 폴백
|
||||||
|
* 문구를 유지한다(무회귀). systemPrompt 는 프롬프트 앞에 문맥으로 병합한다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/ai")
|
@RequestMapping("/api/ai")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AiController {
|
public class AiController {
|
||||||
|
|
||||||
private final OllamaClient ollama;
|
private final AiTextRouter aiRouter;
|
||||||
|
|
||||||
/**
|
/** 알람 AI 이상 분석 — provider 라우팅(Claude↔Ollama). */
|
||||||
* 알람 AI 이상 분석 — Ollama 온프레미스만 허용.
|
|
||||||
*/
|
|
||||||
@PostMapping("/analyze-alarm")
|
@PostMapping("/analyze-alarm")
|
||||||
public ApiResponse<Map<String, String>> analyzeAlarm(@RequestBody Map<String, Object> body) {
|
public ApiResponse<Map<String, String>> analyzeAlarm(@RequestBody Map<String, Object> body) {
|
||||||
String alarmType = (String) body.getOrDefault("alarmType", "UNKNOWN");
|
String alarmType = (String) body.getOrDefault("alarmType", "UNKNOWN");
|
||||||
String message = (String) body.getOrDefault("message", "");
|
String message = (String) body.getOrDefault("message", "");
|
||||||
String severity = (String) body.getOrDefault("severity", "LOW");
|
String severity = (String) body.getOrDefault("severity", "LOW");
|
||||||
|
|
||||||
String prompt = String.format(
|
String prompt = "당신은 ESL(전자 가격표) 시스템 전문 AI 엔지니어입니다. 알람을 분석하고 실용적인 조치를 제안하세요.\n\n"
|
||||||
"ESL 장치 알람 분석:\n유형: %s\n심각도: %s\n메시지: %s\n\n" +
|
+ String.format(
|
||||||
"원인 분석 및 조치 방안을 3줄 이내로 간결하게 제시하세요.",
|
"ESL 장치 알람 분석:\n유형: %s\n심각도: %s\n메시지: %s\n\n"
|
||||||
alarmType, severity, message
|
+ "원인 분석 및 조치 방안을 3줄 이내로 간결하게 제시하세요.",
|
||||||
);
|
alarmType, severity, message);
|
||||||
|
|
||||||
String result = ollama.chat(prompt,
|
String result = generateOr(prompt,
|
||||||
"당신은 ESL(전자 가격표) 시스템 전문 AI 엔지니어입니다. 알람을 분석하고 실용적인 조치를 제안하세요.");
|
"알람 분석: 장치 연결 상태를 확인하고 네트워크 환경을 점검하세요. 지속 발생 시 현장 엔지니어 파견이 필요합니다.");
|
||||||
|
|
||||||
return ApiResponse.ok(Map.of("analysis", result, "alarmType", alarmType, "severity", severity));
|
return ApiResponse.ok(Map.of("analysis", result, "alarmType", alarmType, "severity", severity));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** POS 데이터 AI 분류 — 이상 데이터 자동 감지. */
|
||||||
* POS 데이터 AI 분류 — 이상 데이터 자동 감지.
|
|
||||||
*/
|
|
||||||
@PostMapping("/classify-pos")
|
@PostMapping("/classify-pos")
|
||||||
public ApiResponse<Map<String, String>> classifyPos(@RequestBody Map<String, Object> body) {
|
public ApiResponse<Map<String, String>> classifyPos(@RequestBody Map<String, Object> body) {
|
||||||
String productCode = (String) body.getOrDefault("productCode", "");
|
String productCode = (String) body.getOrDefault("productCode", "");
|
||||||
String price = String.valueOf(body.getOrDefault("price", "0"));
|
String price = String.valueOf(body.getOrDefault("price", "0"));
|
||||||
String salePrice = String.valueOf(body.getOrDefault("salePrice", "0"));
|
String salePrice = String.valueOf(body.getOrDefault("salePrice", "0"));
|
||||||
|
|
||||||
String prompt = String.format(
|
String prompt = "당신은 POS 데이터 품질 관리 AI입니다. 가격 데이터의 이상을 감지하세요.\n\n"
|
||||||
"POS 가격 데이터 검증:\n상품코드: %s\n정가: %s\n판매가: %s\n\n" +
|
+ String.format(
|
||||||
"이 데이터가 정상인지 이상(오류/이상값)인지 판단하고, 분류(NORMAL/ABNORMAL)와 이유를 2줄 이내로 제시하세요.",
|
"POS 가격 데이터 검증:\n상품코드: %s\n정가: %s\n판매가: %s\n\n"
|
||||||
productCode, price, salePrice
|
+ "이 데이터가 정상인지 이상(오류/이상값)인지 판단하고, 분류(NORMAL/ABNORMAL)와 이유를 2줄 이내로 제시하세요.",
|
||||||
);
|
productCode, price, salePrice);
|
||||||
|
|
||||||
String result = ollama.chat(prompt,
|
String result = generateOr(prompt,
|
||||||
"당신은 POS 데이터 품질 관리 AI입니다. 가격 데이터의 이상을 감지하세요.");
|
"POS 데이터 분류: 정상 가격 변환 데이터입니다. 이상 감지된 항목은 수동 검토가 필요합니다.");
|
||||||
|
|
||||||
String classification = result.toUpperCase().contains("ABNORMAL") ? "ABNORMAL" : "NORMAL";
|
String classification = result.toUpperCase().contains("ABNORMAL") ? "ABNORMAL" : "NORMAL";
|
||||||
|
|
||||||
@ -61,13 +65,22 @@ public class AiController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 일반 AI 채팅 — ESN 운영 관련 질문. */
|
||||||
* 일반 AI 채팅 — ESN 운영 관련 질문.
|
|
||||||
*/
|
|
||||||
@PostMapping("/chat")
|
@PostMapping("/chat")
|
||||||
public ApiResponse<Map<String, String>> chat(@RequestBody Map<String, String> body) {
|
public ApiResponse<Map<String, String>> chat(@RequestBody Map<String, String> body) {
|
||||||
String message = body.getOrDefault("message", "");
|
String message = body.getOrDefault("message", "");
|
||||||
String result = ollama.chat(message, "ESL 전자 가격표 통합 관리 플랫폼 운영 전문가입니다.");
|
String prompt = "ESL 전자 가격표 통합 관리 플랫폼 운영 전문가입니다.\n\n" + message;
|
||||||
|
String result = generateOr(prompt,
|
||||||
|
"AI 분석 결과: 현재 시스템 상태를 검토하고 운영 매뉴얼을 참조하세요.");
|
||||||
return ApiResponse.ok(Map.of("response", result));
|
return ApiResponse.ok(Map.of("response", result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 라우터 생성 → degraded/빈응답 시 기존 규칙기반 폴백 문구(무회귀). */
|
||||||
|
private String generateOr(String prompt, String ruleFallback) {
|
||||||
|
GenResult r = aiRouter.generate(prompt);
|
||||||
|
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
|
||||||
|
return r.text();
|
||||||
|
}
|
||||||
|
return ruleFallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ spring:
|
|||||||
- classpath:db/schema.sql
|
- classpath:db/schema.sql
|
||||||
- classpath:db/90_uiws_system.sql
|
- classpath:db/90_uiws_system.sql
|
||||||
- classpath:db/91_uiws_port.sql
|
- classpath:db/91_uiws_port.sql
|
||||||
|
- classpath:db/104_seed_ai_config.sql
|
||||||
web:
|
web:
|
||||||
resources:
|
resources:
|
||||||
static-locations: classpath:/static/
|
static-locations: classpath:/static/
|
||||||
@ -41,6 +42,9 @@ guardia:
|
|||||||
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
||||||
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
|
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
|
||||||
itsm-url: ${ITSM_URL:http://localhost:9001}
|
itsm-url: ${ITSM_URL:http://localhost:9001}
|
||||||
|
# AI 학습 트랙: 로컬 DuckDB 저장소 + 중앙 guardia-rag 학습 게이트(온프레미스)
|
||||||
|
duckdb-path: ${DUCKDB_PATH:/opt/zioinfo-esn/data/esn_learning.duckdb}
|
||||||
|
rag-url: ${RAG_URL:http://localhost:8020}
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits}
|
secret: ${JWT_SECRET:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits}
|
||||||
expiration: 86400000
|
expiration: 86400000
|
||||||
|
|||||||
31
backend/src/main/resources/db/104_seed_ai_config.sql
Normal file
31
backend/src/main/resources/db/104_seed_ai_config.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- zioinfo-esn — 104. AI 플랫폼(LLM provider) 설정 테이블 + 시드 (멱등)
|
||||||
|
-- 대상 DB : esn_db
|
||||||
|
-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일 등재.
|
||||||
|
-- mode:always 재실행 안전(CREATE TABLE IF NOT EXISTS + 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 동작과 바이트 동일.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS esn_setting (
|
||||||
|
key VARCHAR(128) PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO esn_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
|
||||||
432
backend/src/main/resources/static/assets/index-9EZCC7x1.js
Normal file
432
backend/src/main/resources/static/assets/index-9EZCC7x1.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
|
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<script type="module" crossorigin src="/assets/index-C74GP7J-.js"></script>
|
<script type="module" crossorigin src="/assets/index-9EZCC7x1.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CJ_-BW0p.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DVQJ4a3u.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
doc/zioinfo-esn_아키텍처설계서_v1.0.pptx
Normal file
BIN
doc/zioinfo-esn_아키텍처설계서_v1.0.pptx
Normal file
Binary file not shown.
@ -32,6 +32,8 @@ import CodeManagement from './pages/uiws/system/CodeManagement'
|
|||||||
import MenuManagement from './pages/uiws/system/MenuManagement'
|
import MenuManagement from './pages/uiws/system/MenuManagement'
|
||||||
import DeptManagement from './pages/uiws/system/DeptManagement'
|
import DeptManagement from './pages/uiws/system/DeptManagement'
|
||||||
import CompanyManagement from './pages/uiws/system/CompanyManagement'
|
import CompanyManagement from './pages/uiws/system/CompanyManagement'
|
||||||
|
// Claude AI 전환: AI 플랫폼 설정(ADMIN)
|
||||||
|
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||||
|
|
||||||
const qc = new QueryClient()
|
const qc = new QueryClient()
|
||||||
|
|
||||||
@ -74,6 +76,8 @@ export default function App() {
|
|||||||
<Route path="/system/menus" element={<MenuManagement />} />
|
<Route path="/system/menus" element={<MenuManagement />} />
|
||||||
<Route path="/system/depts" element={<DeptManagement />} />
|
<Route path="/system/depts" element={<DeptManagement />} />
|
||||||
<Route path="/system/companies" element={<CompanyManagement />} />
|
<Route path="/system/companies" element={<CompanyManagement />} />
|
||||||
|
{/* AI 플랫폼 설정 — 관리자 전용(백엔드 /api/admin/** ADMIN 게이트) */}
|
||||||
|
<Route path="/system/ai-config" element={<AiPlatformSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
60
frontend/src/api/adminAiConfig.ts
Normal file
60
frontend/src/api/adminAiConfig.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// AI 플랫폼(LLM Provider) 설정(ADMIN) API — /api/admin/ai-config (ADMIN 전용).
|
||||||
|
// API 키 값은 응답에 절대 포함되지 않음(claudeKeySet 플래그만).
|
||||||
|
// PUT 은 provider/claudeModel(+선택 ollamaTextModel) 전송.
|
||||||
|
// test 는 저장된 설정 기준 선택 provider 짧은 ping.
|
||||||
|
// 레퍼런스: guardia-ocr frontend/src/api/adminAiConfig.ts (esn axios 패턴으로 미러).
|
||||||
|
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)
|
||||||
|
|
||||||
|
/** POST /api/ai/feedback — AI 답변 👍/👎(+교정) 피드백(로컬 DuckDB + 중앙 rag). */
|
||||||
|
export const sendAiFeedback = (body: {
|
||||||
|
feature: string
|
||||||
|
question?: string
|
||||||
|
answer?: string
|
||||||
|
verdict: 'UP' | 'DOWN'
|
||||||
|
correction?: string
|
||||||
|
}) => api.post('/api/ai/feedback', body).then(r => r.data?.data)
|
||||||
@ -3,7 +3,7 @@ import {
|
|||||||
LayoutDashboard, Store, FileText, RefreshCw,
|
LayoutDashboard, Store, FileText, RefreshCw,
|
||||||
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
|
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
|
||||||
Link2, ListOrdered, BookOpen, CalendarDays, Mail, BarChart3, ShieldCheck, Smartphone,
|
Link2, ListOrdered, BookOpen, CalendarDays, Mail, BarChart3, ShieldCheck, Smartphone,
|
||||||
KeyRound, Tags, Menu as MenuIcon, Network, Briefcase
|
KeyRound, Tags, Menu as MenuIcon, Network, Briefcase, Bot
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
@ -37,6 +37,7 @@ const sysNav = [
|
|||||||
{ to: '/system/codes', icon: Tags, label: '공통코드' },
|
{ to: '/system/codes', icon: Tags, label: '공통코드' },
|
||||||
{ to: '/system/depts', icon: Network, label: '부서 관리' },
|
{ to: '/system/depts', icon: Network, label: '부서 관리' },
|
||||||
{ to: '/system/companies', icon: Briefcase, label: '거래처 관리' },
|
{ to: '/system/companies', icon: Briefcase, label: '거래처 관리' },
|
||||||
|
{ to: '/system/ai-config', icon: Bot, label: 'AI 플랫폼 설정' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
|||||||
269
frontend/src/pages/AiPlatformSettings.tsx
Normal file
269
frontend/src/pages/AiPlatformSettings.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
// AI 플랫폼(LLM Provider) 설정(ADMIN) — 제공자·모델 선택 + 연결 테스트.
|
||||||
|
// API 키 값은 화면에 절대 표시/입력하지 않는다(claudeKeySet 으로 설정 여부 배지만).
|
||||||
|
// 저장은 provider/claudeModel 만 전송(ollama 계열 모델·전역 활성은 읽기 전용 표시).
|
||||||
|
// 레퍼런스: guardia-ocr AiPlatformSettings.tsx (esn 다크 콘솔 스타일로 미러, glm 추가).
|
||||||
|
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 [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-gray-400 mt-1">
|
||||||
|
알람 분석·POS 분류·채팅 등 텍스트 생성에 사용할 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">
|
||||||
|
GLM(glm4:9b)은 약 5.5GB 로드가 필요해 현재 서버 RAM 여유(~2.1GB)로는 콜드로드가 실패할 수 있습니다.
|
||||||
|
선택은 가능하나 실패 시 Ollama 소형 모델로 자동 폴백합니다. RAM 증설 후 사용을 권장합니다.
|
||||||
|
</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-gray-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/20 text-amber-300 border border-amber-500/40">
|
||||||
|
RAM
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500 mt-0.5">{p.hint}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ollama 계열 — 텍스트 모델 읽기 전용 표시 */}
|
||||||
|
{!isClaude && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-xs text-gray-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-gray-500 mt-1">
|
||||||
|
온프레미스 모델 — provider 별로 고정. 외부 호출 없음.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Claude — 모델 셀렉트 + 키 배지(값 비노출) */}
|
||||||
|
{isClaude && (
|
||||||
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-xs text-gray-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-gray-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-gray-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-gray-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-gray-500 mt-3">
|
||||||
|
먼저 변경 사항을 저장한 뒤 테스트하세요. Claude 실패 시 Ollama 로 자동 폴백합니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user