feat(ai): Claude 전환 + DuckDB 학습저장소
This commit is contained in:
parent
e7545f64c0
commit
097ddbb2a5
@ -60,6 +60,9 @@
|
||||
<!-- HTTP Client (Ollama / 연계 GUARDiA 호출) -->
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
|
||||
|
||||
<!-- DuckDB (로컬 임베디드 AI 학습/피드백 저장소 — /opt/guardia-cms/data/cms_learning.duckdb) -->
|
||||
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.zioinfo.cms.ai;
|
||||
|
||||
import com.zioinfo.cms.ai.service.AiTextRouter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -23,7 +24,8 @@ import java.util.regex.Pattern;
|
||||
@RequiredArgsConstructor
|
||||
public class AiService {
|
||||
|
||||
private final OllamaClient ollama;
|
||||
private final OllamaClient ollama; // 비전(vision) 경로 전용 + 가용성 핑 유지
|
||||
private final AiTextRouter aiRouter; // 텍스트 생성은 provider 라우팅(Claude↔Ollama) 경유
|
||||
|
||||
// 욕설/스팸 폴백 사전 (규칙기반)
|
||||
private static final Set<String> PROFANITY = Set.of(
|
||||
@ -41,7 +43,7 @@ public class AiService {
|
||||
"당신은 e-커머스 콘텐츠 작가입니다. 유형: %s, 주제: %s, 톤: %s. "
|
||||
+ "한국어로 자연스러운 마케팅 초안을 200자 내외로 작성하세요. 설명 없이 본문만 출력.",
|
||||
kind, topic, tone == null ? "친근함" : tone);
|
||||
String out = ollama.generate(prompt);
|
||||
String out = aiRouter.generateText(prompt);
|
||||
if (out != null && !out.isBlank()) {
|
||||
return out;
|
||||
}
|
||||
@ -85,7 +87,7 @@ public class AiService {
|
||||
"다음 콘텐츠의 SEO를 개선하세요. 제목: %s\\n본문: %s\\n"
|
||||
+ "'META: ...\\nKEYWORDS: a,b,c\\nSCORE: 0~100' 형식으로 한국어 출력.",
|
||||
title, truncate(body, 800));
|
||||
String out = ollama.generate(prompt);
|
||||
String out = aiRouter.generateText(prompt);
|
||||
if (out != null && !out.isBlank()) {
|
||||
result.put("metaDescription", firstNonBlank(extractLine(out, "META:"), truncate(body, 150)));
|
||||
String kw = extractLine(out, "KEYWORDS:");
|
||||
@ -108,7 +110,7 @@ public class AiService {
|
||||
String prompt = String.format(
|
||||
"다음 텍스트를 %s 로케일 언어로 자연스럽게 번역하세요. 번역문만 출력:\\n%s",
|
||||
targetLocale, truncate(text, 1500));
|
||||
String out = ollama.generate(prompt);
|
||||
String out = aiRouter.generateText(prompt);
|
||||
if (out != null && !out.isBlank()) {
|
||||
return out;
|
||||
}
|
||||
@ -136,7 +138,7 @@ public class AiService {
|
||||
String joined = truncate(String.join("\\n- ", reviews), 1500);
|
||||
String prompt = "다음 리뷰들을 한 문장으로 요약하고 감성(POSITIVE/NEGATIVE/NEUTRAL)을 판정하세요. "
|
||||
+ "'SUMMARY: ...\\nSENTIMENT: ...' 형식:\\n- " + joined;
|
||||
String out = ollama.generate(prompt);
|
||||
String out = aiRouter.generateText(prompt);
|
||||
if (out != null && !out.isBlank()) {
|
||||
result.put("summary", firstNonBlank(extractLine(out, "SUMMARY:"), out));
|
||||
String s = extractLine(out, "SENTIMENT:").toUpperCase();
|
||||
@ -162,7 +164,7 @@ public class AiService {
|
||||
public List<String> searchKeywords(String naturalQuery) {
|
||||
if (naturalQuery == null || naturalQuery.isBlank()) return List.of();
|
||||
String prompt = "다음 검색 의도에서 핵심 키워드 3~5개를 쉼표로만 출력:\\n" + naturalQuery;
|
||||
String out = ollama.generate(prompt);
|
||||
String out = aiRouter.generateText(prompt);
|
||||
if (out != null && !out.isBlank()) {
|
||||
String line = out.replaceAll("[\\n\\r]", " ").trim();
|
||||
List<String> kws = new ArrayList<>();
|
||||
@ -190,7 +192,7 @@ public class AiService {
|
||||
}
|
||||
String prompt = "다음 UGC가 욕설/스팸/부적절한지 판정하세요. "
|
||||
+ "'DECISION: APPROVE|REJECT|REVIEW\\nRISK: 0~1\\nREASON: ...' 형식 한국어:\\n" + truncate(text, 800);
|
||||
String out = ollama.generate(prompt);
|
||||
String out = aiRouter.generateText(prompt);
|
||||
if (out != null && !out.isBlank()) {
|
||||
String d = extractLine(out, "DECISION:").toUpperCase();
|
||||
String decision = d.contains("REJECT") ? "REJECT" : d.contains("REVIEW") ? "REVIEW" : "APPROVE";
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.zioinfo.cms.ai;
|
||||
|
||||
import com.zioinfo.cms.ai.service.AiTextRouter;
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@ -19,7 +20,8 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
@RequiredArgsConstructor
|
||||
public class ContentAiController {
|
||||
|
||||
private final OllamaClient ollama;
|
||||
private final OllamaClient ollama; // 비전(image-alt-text) 경로 전용
|
||||
private final AiTextRouter aiRouter; // 텍스트 생성은 provider 라우팅(Claude↔Ollama) 경유
|
||||
|
||||
/** 통계 카운터 (메모리, 재시작 시 초기화) */
|
||||
private static final Map<String, AtomicLong> USAGE = new LinkedHashMap<>();
|
||||
@ -35,7 +37,7 @@ public class ContentAiController {
|
||||
}
|
||||
|
||||
private String ask(String prompt, String fallback) {
|
||||
String r = ollama.generate(prompt);
|
||||
String r = aiRouter.generateText(prompt); // Claude(활성 시)→Ollama 폴백, 실패 시 빈 문자열
|
||||
return (r == null || r.isBlank()) ? fallback : r;
|
||||
}
|
||||
|
||||
|
||||
@ -34,11 +34,22 @@ public class OllamaClient {
|
||||
this.visionModel = visionModel;
|
||||
}
|
||||
|
||||
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */
|
||||
@SuppressWarnings("unchecked")
|
||||
/** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
|
||||
public String generate(String prompt) {
|
||||
return generate(prompt, textModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 모델로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음).
|
||||
*
|
||||
* <p>{@code AiTextRouter}·{@code AiConfigService} 가 provider(qwen3/deepseek/glm/ollama)별 소형 모델을
|
||||
* 지정 호출하기 위한 진입점. model 이 비었으면 서버 기본 텍스트 모델로 폴백한다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public String generate(String prompt, String model) {
|
||||
String useModel = (model == null || model.isBlank()) ? textModel : model.trim();
|
||||
try {
|
||||
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
|
||||
Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
|
||||
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
|
||||
.post().uri("/api/generate")
|
||||
.bodyValue(body)
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
package com.zioinfo.cms.ai.controller;
|
||||
|
||||
import com.zioinfo.cms.admin.AuditService;
|
||||
import com.zioinfo.cms.ai.dto.AiConfigDto;
|
||||
import com.zioinfo.cms.ai.dto.AiConfigUpdateRequest;
|
||||
import com.zioinfo.cms.ai.service.AiConfigService;
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* AI 플랫폼(LLM provider) 설정 관리(ADMIN). [GUARDiA-CMS]
|
||||
* SecurityConfig {@code /api/admin/** hasRole(SUPERADMIN)} 게이트로 보호된다.
|
||||
*
|
||||
* <p>cms_setting(key='ai.*')에 저장된 런타임 설정을 조회/갱신/테스트한다. <b>API 키는 환경변수
|
||||
* (ANTHROPIC_API_KEY)로만 주입</b>되어 응답·로그에 노출되지 않는다(GET 은 {@code claudeKeySet} 불리언만).
|
||||
* 설정 변경은 재기동 없이 다음 AI 호출부터 반영된다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>GET /api/admin/ai-config 현재 효과 설정(키 값 제외, keySet 불리언만)</li>
|
||||
* <li>PUT /api/admin/ai-config provider/모델 갱신(화이트리스트 검증)</li>
|
||||
* <li>POST /api/admin/ai-config/test 저장 설정 기준 선택 provider 연결 테스트(요약 결과만)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/ai-config")
|
||||
@RequiredArgsConstructor
|
||||
public class AiConfigController {
|
||||
|
||||
private final AiConfigService service;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<AiConfigDto> get() {
|
||||
return ApiResponse.ok(service.getConfig());
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public ApiResponse<AiConfigDto> update(@RequestBody AiConfigUpdateRequest req) {
|
||||
return ApiResponse.ok(service.update(req, AuditService.currentActor()));
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public ApiResponse<AiConfigService.TestResult> test() {
|
||||
AiConfigService.TestResult result = service.test();
|
||||
return result.ok()
|
||||
? ApiResponse.ok(result)
|
||||
: new ApiResponse<>(false, result.message(), result);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.zioinfo.cms.ai.dto;
|
||||
|
||||
/**
|
||||
* AI 설정 조회 DTO — API 키 값 미반환(claudeKeySet 으로 설정 여부만). [GUARDiA-CMS]
|
||||
*
|
||||
* @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.cms.ai.dto;
|
||||
|
||||
/**
|
||||
* AI 설정 저장 요청 — 화이트리스트 검증. [GUARDiA-CMS]
|
||||
*
|
||||
* @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,40 @@
|
||||
package com.zioinfo.cms.ai.learning;
|
||||
|
||||
import com.zioinfo.cms.admin.AuditService;
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
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.Map;
|
||||
|
||||
/**
|
||||
* AI 피드백 수집 API. [GUARDiA-CMS]
|
||||
*
|
||||
* <p>{@code POST /api/ai/feedback} — 인증 사용자(SecurityConfig anyRequest().authenticated())가
|
||||
* AI 결과에 대해 👍/👎 + 교정 의견을 제출한다. 로컬 DuckDB 기록 + 중앙 guardia-rag 전달(둘 다 best-effort).
|
||||
*
|
||||
* <p>요청 본문(JSON): {@code {feature, question, answer, verdict(up|down), correction?}}.
|
||||
* 사용자 식별자는 서버 인증 주체(JWT subject)에서 취득하며, 저장 시 마스킹된다(응답에 미노출).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/ai/feedback")
|
||||
@RequiredArgsConstructor
|
||||
public class FeedbackController {
|
||||
|
||||
private final LearningFeedbackService service;
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, Object>> submit(@RequestBody Map<String, String> req) {
|
||||
Map<String, Object> out = service.submit(
|
||||
req.get("feature"),
|
||||
req.get("question"),
|
||||
req.get("answer"),
|
||||
req.getOrDefault("verdict", "unknown"),
|
||||
req.get("correction"),
|
||||
AuditService.currentActor());
|
||||
return ApiResponse.ok(out);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package com.zioinfo.cms.ai.learning;
|
||||
|
||||
import com.zioinfo.cms.rag.RagProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 피드백 수집 서비스. [GUARDiA-CMS]
|
||||
*
|
||||
* <p>스펙(AI_PLATFORM_SPEC §5): 피드백(👍/👎 + 교정)은 <b>로컬 DuckDB 기록 + 중앙 guardia-rag
|
||||
* {@code /feedback} 전달</b>(둘 다). 로컬은 솔루션별 AI 분석/오프라인, 중앙은 통합 학습·평가 게이트.
|
||||
*
|
||||
* <p><b>안전</b>: 두 경로 모두 best-effort — 실패해도 예외를 전파하지 않는다. 중앙 전달은
|
||||
* 온프레미스 루프백(RagProperties base-url)만. 자격증명/스택트레이스 미노출(요약 로깅).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LearningFeedbackService {
|
||||
|
||||
private final LearningStore store;
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
private final RagProperties ragProps;
|
||||
|
||||
/**
|
||||
* 피드백 1건 처리: 로컬 DuckDB 기록 + 중앙 rag 전달.
|
||||
*
|
||||
* @return {@code {localStored, centralForwarded}} — 실패 시 각 false(요청은 성공 응답).
|
||||
*/
|
||||
public Map<String, Object> submit(String feature, String question, String answer,
|
||||
String verdict, String correction, String userRef) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
boolean localStored = false;
|
||||
try {
|
||||
store.insertFeedback(feature, question, answer, verdict, correction, userRef);
|
||||
localStored = store.available();
|
||||
} catch (Exception e) {
|
||||
log.warn("로컬 피드백 기록 실패(무시): {}", e.getClass().getSimpleName());
|
||||
}
|
||||
result.put("localStored", localStored);
|
||||
result.put("centralForwarded", forwardCentral(feature, question, answer, verdict, correction));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 중앙 guardia-rag /feedback 전달(best-effort, 온프레미스 루프백 전용). */
|
||||
private boolean forwardCentral(String feature, String question, String answer,
|
||||
String verdict, String correction) {
|
||||
if (!ragProps.isEnabled()) return false;
|
||||
try {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("solution", ragProps.getSolution());
|
||||
body.put("feature", feature);
|
||||
body.put("question", LearningStore.maskPii(question));
|
||||
body.put("answer", LearningStore.maskPii(answer));
|
||||
body.put("verdict", verdict);
|
||||
if (correction != null && !correction.isBlank()) {
|
||||
body.put("correction", LearningStore.maskPii(correction));
|
||||
}
|
||||
webClientBuilder.baseUrl(ragProps.getBaseUrl()).build()
|
||||
.post().uri("/feedback")
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.block();
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("중앙 rag /feedback 전달 실패(무시): {}", e.getClass().getSimpleName());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
package com.zioinfo.cms.ai.learning;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 로컬 임베디드 DuckDB AI 학습/피드백 저장소. [GUARDiA-CMS]
|
||||
*
|
||||
* <p>스펙(AI_PLATFORM_SPEC §5): 각 솔루션은 로컬 DuckDB 파일을 AI 피드백·추론로그·학습 데이터셋
|
||||
* 저장소로 사용한다. 파일 = {@code /opt/guardia-cms/data/cms_learning.duckdb}(솔루션별 격리).
|
||||
* 테이블(멱등): {@code ai_feedback}, {@code ai_infer_log}.
|
||||
*
|
||||
* <p><b>안전(최우선)</b>: 본 저장소는 절대 요청 흐름을 끊지 않는다. 연결/DDL/INSERT 실패 시 예외를
|
||||
* 전파하지 않고 경고만 남기며 disabled 로 degrade 한다(피드백 UI·라우터는 정상 동작 유지). 연결은
|
||||
* 최초 사용 시 지연 오픈(부팅 실패 방지). 파일 부모 디렉터리가 없으면 생성 시도.
|
||||
*
|
||||
* <p><b>PII 보호</b>: 저장 전 {@link #maskPii(String)} 로 주민번호/카드번호/전화번호/이메일 마스킹.
|
||||
* 자격증명/비밀번호/내부 IP 는 애초에 수집하지 않는다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class LearningStore {
|
||||
|
||||
private static final String SOLUTION = "cms";
|
||||
|
||||
private final String dbPath;
|
||||
private volatile Connection conn; // 지연 오픈 단일 연결(쓰기 직렬화)
|
||||
private volatile boolean disabled; // 오픈/DDL 실패 시 true — 이후 조용히 no-op
|
||||
private final Object lock = new Object();
|
||||
|
||||
public LearningStore(@Value("${guardia.duckdb.path:/opt/guardia-cms/data/cms_learning.duckdb}") String dbPath) {
|
||||
this.dbPath = dbPath;
|
||||
}
|
||||
|
||||
/** 저장소 가용 여부(연결 확보 시도 포함). */
|
||||
public boolean available() {
|
||||
return connection() != null;
|
||||
}
|
||||
|
||||
/** 피드백 저장(👍/👎 + 교정). 실패는 무시(best-effort). */
|
||||
public void insertFeedback(String feature, String question, String answer,
|
||||
String verdict, String correction, String userRef) {
|
||||
Connection c = connection();
|
||||
if (c == null) return;
|
||||
String sql = "INSERT INTO ai_feedback "
|
||||
+ "(id, ts, solution, feature, question, answer, verdict, correction, user_masked) "
|
||||
+ "VALUES (nextval('seq_ai_feedback'), CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?, ?)";
|
||||
synchronized (lock) {
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, SOLUTION);
|
||||
ps.setString(2, trunc(feature, 120));
|
||||
ps.setString(3, maskPii(trunc(question, 4000)));
|
||||
ps.setString(4, maskPii(trunc(answer, 4000)));
|
||||
ps.setString(5, normVerdict(verdict));
|
||||
ps.setString(6, maskPii(trunc(correction, 4000)));
|
||||
ps.setString(7, maskPii(trunc(userRef, 200)));
|
||||
ps.executeUpdate();
|
||||
} catch (Exception e) {
|
||||
log.warn("DuckDB ai_feedback INSERT 실패(무시): {}", e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 추론 로그(provider/model/지연/degraded). 실패는 무시(best-effort). */
|
||||
public void logInfer(String provider, String model, long latencyMs, boolean degraded) {
|
||||
Connection c = connection();
|
||||
if (c == null) return;
|
||||
String sql = "INSERT INTO ai_infer_log (id, ts, provider, model, latency_ms, degraded) "
|
||||
+ "VALUES (nextval('seq_ai_infer_log'), CURRENT_TIMESTAMP, ?, ?, ?, ?)";
|
||||
synchronized (lock) {
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, trunc(provider, 40));
|
||||
ps.setString(2, trunc(model, 80));
|
||||
ps.setLong(3, latencyMs);
|
||||
ps.setBoolean(4, degraded);
|
||||
ps.executeUpdate();
|
||||
} catch (Exception e) {
|
||||
log.warn("DuckDB ai_infer_log INSERT 실패(무시): {}", e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 지연 오픈 + 멱등 DDL
|
||||
|
||||
private Connection connection() {
|
||||
if (disabled) return null;
|
||||
Connection existing = conn;
|
||||
if (existing != null) return existing;
|
||||
synchronized (lock) {
|
||||
if (disabled) return null;
|
||||
if (conn != null) return conn;
|
||||
try {
|
||||
File f = new File(dbPath);
|
||||
File dir = f.getParentFile();
|
||||
if (dir != null && !dir.exists() && !dir.mkdirs()) {
|
||||
log.warn("DuckDB 데이터 디렉터리 생성 불가 — 로컬 학습저장소 비활성: {}", dir.getPath());
|
||||
disabled = true;
|
||||
return null;
|
||||
}
|
||||
// DuckDB JDBC 드라이버 로드(명시) → 연결 오픈 → 멱등 DDL.
|
||||
Class.forName("org.duckdb.DuckDBDriver");
|
||||
Connection c = DriverManager.getConnection("jdbc:duckdb:" + dbPath);
|
||||
initSchema(c);
|
||||
conn = c;
|
||||
log.info("DuckDB 로컬 학습저장소 준비 완료: {}", dbPath);
|
||||
return conn;
|
||||
} catch (Throwable t) {
|
||||
log.warn("DuckDB 로컬 학습저장소 비활성(무시): {}", t.getClass().getSimpleName());
|
||||
disabled = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void initSchema(Connection c) throws Exception {
|
||||
try (Statement st = c.createStatement()) {
|
||||
st.execute("CREATE SEQUENCE IF NOT EXISTS seq_ai_feedback START 1");
|
||||
st.execute("CREATE SEQUENCE IF NOT EXISTS seq_ai_infer_log START 1");
|
||||
st.execute("CREATE TABLE IF NOT EXISTS ai_feedback ("
|
||||
+ "id BIGINT 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 PRIMARY KEY, ts TIMESTAMP, provider VARCHAR, model VARCHAR, "
|
||||
+ "latency_ms BIGINT, degraded BOOLEAN)");
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void close() {
|
||||
synchronized (lock) {
|
||||
if (conn != null) {
|
||||
try { conn.close(); } catch (Exception ignore) { }
|
||||
conn = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
private static final Pattern RRN = Pattern.compile("\\b\\d{6}[- ]?\\d{7}\\b"); // 주민번호
|
||||
private static final Pattern CARD = Pattern.compile("\\b(?:\\d[ -]?){13,16}\\b"); // 카드/계좌
|
||||
private static final Pattern PHONE = Pattern.compile("\\b0\\d{1,2}[- ]?\\d{3,4}[- ]?\\d{4}\\b"); // 전화
|
||||
private static final Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w-]+\\.[\\w.-]+"); // 이메일
|
||||
|
||||
/** 저장 전 PII 마스킹(주민/카드/전화/이메일). null 안전. */
|
||||
static String maskPii(String s) {
|
||||
if (s == null || s.isBlank()) return s;
|
||||
String out = RRN.matcher(s).replaceAll("######-#######");
|
||||
out = CARD.matcher(out).replaceAll("****-****-****-****");
|
||||
out = PHONE.matcher(out).replaceAll("***-****-****");
|
||||
out = EMAIL.matcher(out).replaceAll("***@***");
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String normVerdict(String v) {
|
||||
if (v == null) return "unknown";
|
||||
String t = v.trim().toLowerCase();
|
||||
return switch (t) {
|
||||
case "up", "good", "positive", "👍" -> "up";
|
||||
case "down", "bad", "negative", "👎" -> "down";
|
||||
default -> t.isEmpty() ? "unknown" : trunc(t, 20);
|
||||
};
|
||||
}
|
||||
|
||||
private static String trunc(String s, int max) {
|
||||
if (s == null) return null;
|
||||
return s.length() <= max ? s : s.substring(0, max);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,248 @@
|
||||
package com.zioinfo.cms.ai.service;
|
||||
|
||||
import com.zioinfo.cms.admin.AuditService;
|
||||
import com.zioinfo.cms.admin.dto.CmsSetting;
|
||||
import com.zioinfo.cms.admin.mapper.SettingMapper;
|
||||
import com.zioinfo.cms.ai.OllamaClient;
|
||||
import com.zioinfo.cms.ai.dto.AiConfigDto;
|
||||
import com.zioinfo.cms.ai.dto.AiConfigUpdateRequest;
|
||||
import com.zioinfo.cms.common.ai.ClaudeTextClient;
|
||||
import com.zioinfo.cms.common.ai.TextAiClient.GenResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* AI provider 런타임 설정(cms_setting, key='ai.*') 단일 출처 서비스. [GUARDiA-CMS]
|
||||
*
|
||||
* <p><b>해상도</b>: DB(cms_setting) 우선 → 미설정 시 기본값/env 폴백. 시드(db/104) 미적용/키 비움
|
||||
* 상태에서도 기존 Ollama 동작이 바이트 동일하게 유지된다(provider 기본 ollama, ollamaTextModel 미설정 시
|
||||
* {@code guardia.ollama-text-model} env 그대로).
|
||||
*
|
||||
* <p><b>보안</b>: Claude API 키는 본 서비스가 다루지 않는다. {@code claudeKeySet} 은
|
||||
* {@link ClaudeTextClient#isConfigured()}(환경변수 존재 여부)만 반환 — 값/길이/마스킹 일절 미포함.
|
||||
*
|
||||
* <p><b>프로바이더</b>: claude / qwen3 / deepseek / glm / ollama. Ollama 계열(qwen3·deepseek·glm·ollama)은
|
||||
* 각 해당 소형 텍스트 모델로 generate. claude 는 Claude→실패 시 Ollama(선택모델) 폴백(라우터).
|
||||
* glm(glm4:9b)은 화이트리스트에는 있으나 서버 RAM 초과 가능 → 콜드로드 실패 시 폴백(설정 화면 RAM 배지).
|
||||
*
|
||||
* <p><b>레퍼런스</b>: guardia-ocr {@code ai.service.AiConfigService} 미러(glm provider 확장).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiConfigService {
|
||||
|
||||
// --- cms_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 초과 가능(설정 화면 배지) — 콜드로드 실패 시 폴백
|
||||
|
||||
/** 허용 provider(임의 문자열 거부). */
|
||||
public static final Set<String> ALLOWED_PROVIDERS =
|
||||
Set.of(PROVIDER_OLLAMA, PROVIDER_CLAUDE, PROVIDER_QWEN3, PROVIDER_DEEPSEEK, PROVIDER_GLM);
|
||||
/** 허용 Claude 모델 ID(임의 문자열 거부). */
|
||||
public static final Set<String> ALLOWED_CLAUDE_MODELS =
|
||||
Set.of("claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8");
|
||||
/** 허용 Ollama 텍스트 모델(임의 문자열 거부). */
|
||||
public static final Set<String> ALLOWED_OLLAMA_MODELS =
|
||||
Set.of("qwen3:1.7b", "deepseek-r1:1.5b", "glm4:9b", "llama3.2:1b");
|
||||
|
||||
private final SettingMapper repo;
|
||||
private final ClaudeTextClient claudeClient;
|
||||
private final OllamaClient ollamaClient; // 연결 테스트용 텍스트 generate
|
||||
private final AuditService auditService;
|
||||
|
||||
/** 서버 기본 Ollama 텍스트 모델(application.yml guardia.ollama-text-model). */
|
||||
@Value("${guardia.ollama-text-model:llama3.2:1b}")
|
||||
private String defaultOllamaTextModel;
|
||||
|
||||
// ---------------------------------------------------------------- 효과값(DB 우선 → 기본)
|
||||
|
||||
/** 효과 provider. DB 우선, 미설정/비허용 시 ollama. */
|
||||
public String provider() {
|
||||
String v = dbVal(K_PROVIDER);
|
||||
if (v != null) {
|
||||
String p = v.trim().toLowerCase();
|
||||
if (ALLOWED_PROVIDERS.contains(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return PROVIDER_OLLAMA;
|
||||
}
|
||||
|
||||
/** 효과 Claude 모델 ID(DB 우선, 미설정/비허용 시 기본 sonnet-4-6). */
|
||||
public String claudeModel() {
|
||||
String v = dbVal(K_CLAUDE_MODEL);
|
||||
if (v != null) {
|
||||
String m = v.trim();
|
||||
if (ALLOWED_CLAUDE_MODELS.contains(m)) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return DEFAULT_CLAUDE_MODEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 효과 Ollama 텍스트 모델. provider=qwen3/deepseek/glm 이면 고정 모델, 그 외(ollama·claude 폴백)는
|
||||
* DB 설정(화이트리스트) 우선 → 미설정 시 env 기본(llama3.2:1b). 라우터의 Ollama 경로·Claude 폴백 공용.
|
||||
*/
|
||||
public String ollamaTextModel() {
|
||||
String p = provider();
|
||||
if (PROVIDER_QWEN3.equals(p)) {
|
||||
return MODEL_QWEN3;
|
||||
}
|
||||
if (PROVIDER_DEEPSEEK.equals(p)) {
|
||||
return MODEL_DEEPSEEK;
|
||||
}
|
||||
if (PROVIDER_GLM.equals(p)) {
|
||||
return MODEL_GLM;
|
||||
}
|
||||
String v = dbVal(K_OLLAMA_TEXT_MODEL);
|
||||
if (v != null && ALLOWED_OLLAMA_MODELS.contains(v.trim())) {
|
||||
return v.trim();
|
||||
}
|
||||
return defaultOllamaTextModel;
|
||||
}
|
||||
|
||||
/** 전역 AI 토글(ai.enabled). false 면 모든 AI 결과는 규칙기반(degraded). 기본 true. */
|
||||
public boolean aiEnabled() {
|
||||
String v = dbVal(K_ENABLED);
|
||||
return v == null || !"false".equalsIgnoreCase(v.trim());
|
||||
}
|
||||
|
||||
/** Claude API 키가 환경변수로 설정되어 있는지(여부만). */
|
||||
public boolean claudeKeySet() {
|
||||
return claudeClient.isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 호출이 Claude 경로로 가야 하는지: provider=claude · 키 설정됨 · AI 전역 활성.
|
||||
* false 면 호출자는 Ollama 경로/폴백을 사용한다(키 미설정/실패 시 자동 폴백 정책 반영).
|
||||
*/
|
||||
public boolean isClaudeActive() {
|
||||
return PROVIDER_CLAUDE.equals(provider()) && claudeKeySet() && aiEnabled();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ADMIN: 조회 / 갱신
|
||||
|
||||
/** 현재 효과 설정 조회. 키 값·길이·마스킹 일절 미포함(claudeKeySet 불리언만). */
|
||||
public AiConfigDto getConfig() {
|
||||
return new AiConfigDto(
|
||||
provider(),
|
||||
ollamaTextModel(),
|
||||
claudeModel(),
|
||||
claudeKeySet(),
|
||||
aiEnabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 갱신(upsert). 화이트리스트 검증: provider∈{ollama,claude,qwen3,deepseek,glm}, claudeModel∈허용셋,
|
||||
* ollamaTextModel(선택)∈허용셋. 재기동 없이 다음 호출부터 반영.
|
||||
*/
|
||||
public AiConfigDto update(AiConfigUpdateRequest req, String actor) {
|
||||
String provider = req.provider() == null ? "" : req.provider().trim().toLowerCase();
|
||||
if (!ALLOWED_PROVIDERS.contains(provider)) {
|
||||
throw new IllegalArgumentException("ERR-AI-400: provider 는 ollama/claude/qwen3/deepseek/glm 중 하나여야 합니다.");
|
||||
}
|
||||
upsertAudited(K_PROVIDER, provider, actor);
|
||||
|
||||
if (req.claudeModel() != null && !req.claudeModel().isBlank()) {
|
||||
String model = req.claudeModel().trim();
|
||||
if (!ALLOWED_CLAUDE_MODELS.contains(model)) {
|
||||
throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Claude 모델 ID 입니다.");
|
||||
}
|
||||
upsertAudited(K_CLAUDE_MODEL, model, actor);
|
||||
}
|
||||
|
||||
// ollamaTextModel 은 선택. 제공 시에만 갱신(미제공/공백 = 기존 유지).
|
||||
if (req.ollamaTextModel() != null && !req.ollamaTextModel().isBlank()) {
|
||||
String om = req.ollamaTextModel().trim();
|
||||
if (!ALLOWED_OLLAMA_MODELS.contains(om)) {
|
||||
throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Ollama 텍스트 모델입니다.");
|
||||
}
|
||||
upsertAudited(K_OLLAMA_TEXT_MODEL, om, actor);
|
||||
}
|
||||
return getConfig();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ADMIN: 연결 테스트
|
||||
|
||||
/**
|
||||
* 저장된 설정 기준으로 선택 provider 에 짧은 ping 생성을 요청해 연결을 확인한다.
|
||||
* 결과 메시지에는 키·스택트레이스·내부 IP 를 절대 포함하지 않는다(요약만).
|
||||
*/
|
||||
public TestResult test() {
|
||||
if (isClaudeActive()) {
|
||||
String model = claudeModel();
|
||||
long start = System.currentTimeMillis();
|
||||
GenResult r = claudeClient.generate("ping", model, 8);
|
||||
long ms = System.currentTimeMillis() - start;
|
||||
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
|
||||
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
|
||||
}
|
||||
return new TestResult(false, true, "Claude 연결 실패 · 모델/네트워크/키 설정을 확인하세요.");
|
||||
}
|
||||
// provider=claude 인데 키 미설정 → 실제로는 Ollama 폴백 동작 안내(혼선 방지).
|
||||
if (PROVIDER_CLAUDE.equals(provider()) && !claudeKeySet()) {
|
||||
return new TestResult(false, true,
|
||||
"Claude API 키가 설정되지 않아 Ollama(온프레미스)로 폴백 동작합니다. 서버 환경변수 설정 후 다시 시도하세요.");
|
||||
}
|
||||
// Ollama 계열 경로(ollama/qwen3/deepseek/glm)
|
||||
if (!aiEnabled()) {
|
||||
return new TestResult(false, true, "AI 기능이 비활성 상태입니다. 모든 AI 결과는 규칙 기반(degraded)으로 동작합니다.");
|
||||
}
|
||||
String model = ollamaTextModel();
|
||||
long start = System.currentTimeMillis();
|
||||
String txt = ollamaClient.generate("ping", model);
|
||||
long ms = System.currentTimeMillis() - start;
|
||||
if (txt != null && !txt.isBlank()) {
|
||||
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
|
||||
}
|
||||
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) {
|
||||
CmsSetting c = repo.findByKey(key);
|
||||
if (c == null) {
|
||||
return null;
|
||||
}
|
||||
String v = c.getValue();
|
||||
return (v != null && !v.isBlank()) ? v : null;
|
||||
}
|
||||
|
||||
private void upsertAudited(String key, String val, String actor) {
|
||||
CmsSetting before = repo.findByKey(key);
|
||||
String prev = before == null ? "(none)" : before.getValue();
|
||||
if (val.equals(prev)) {
|
||||
return; // 변경 없음 → 감사 로그 생략
|
||||
}
|
||||
repo.upsert(key, val);
|
||||
auditService.log(actor == null ? "SYSTEM" : actor, "AI_CONFIG_CHANGE", key, prev + " -> " + val);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
package com.zioinfo.cms.ai.service;
|
||||
|
||||
import com.zioinfo.cms.ai.OllamaClient;
|
||||
import com.zioinfo.cms.ai.learning.LearningStore;
|
||||
import com.zioinfo.cms.common.ai.ClaudeTextClient;
|
||||
import com.zioinfo.cms.common.ai.TextAiClient;
|
||||
import com.zioinfo.cms.common.ai.TextAiClient.GenResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* AI provider 선택 라우팅(런타임). 평문 텍스트 생성 진입점(콘텐츠초안·SEO·번역·리뷰요약·검색 등). [GUARDiA-CMS]
|
||||
* {@link AiConfigService#provider()} 를 읽어 Claude ↔ Ollama(qwen3/deepseek/glm/기존소형) 를 선택한다.
|
||||
*
|
||||
* <p><b>선택/폴백 정책</b>:
|
||||
* <ul>
|
||||
* <li>provider=claude · 키 설정됨 · AI 활성 → {@link ClaudeTextClient}. 실패(degraded)면 <b>Ollama 자동 폴백</b>(선택모델).</li>
|
||||
* <li>provider=qwen3/deepseek/glm/ollama → 해당 Ollama 소형 텍스트 모델로 generate.</li>
|
||||
* <li>provider=claude 인데 키 미설정 → 곧장 Ollama(폴백모델).</li>
|
||||
* </ul>
|
||||
* 두 경로 모두 실패 시 {@code GenResult.degraded=true·text=null} → 호출자가 기존 규칙기반 폴백 유지(무회귀).
|
||||
*
|
||||
* <p>비전(llava/moondream) 경로는 {@link OllamaClient#vision} 이 담당한다(이 텍스트 라우터를 거치지
|
||||
* 않음 — Claude 비전 전환은 본 트랙 범위 밖).
|
||||
*
|
||||
* <p><b>레퍼런스</b>: guardia-ocr {@code ai.service.AiTextRouter} 미러(OllamaClient 텍스트 경로 치환).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiTextRouter implements TextAiClient {
|
||||
|
||||
private final AiConfigService aiConfig;
|
||||
private final ClaudeTextClient claudeClient;
|
||||
private final OllamaClient ollamaClient; // 온프레미스 평문 generate
|
||||
private final LearningStore learningStore; // 로컬 DuckDB 추론 로그(best-effort)
|
||||
|
||||
/**
|
||||
* 선택된 provider 로 텍스트를 생성한다. Claude 선택·실패 시 Ollama 폴백. 예외를 던지지 않는다.
|
||||
* 추론 지연/degraded 를 로컬 DuckDB(ai_infer_log)에 best-effort 기록한다.
|
||||
*/
|
||||
@Override
|
||||
public GenResult generate(String prompt) {
|
||||
long start = System.currentTimeMillis();
|
||||
if (aiConfig.isClaudeActive()) {
|
||||
String claudeModel = aiConfig.claudeModel();
|
||||
GenResult r = claudeClient.generate(prompt, claudeModel);
|
||||
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
|
||||
learningStore.logInfer("claude", claudeModel, System.currentTimeMillis() - start, false);
|
||||
return r;
|
||||
}
|
||||
// claude 실패/빈응답 → 온프레미스 Ollama 자동 폴백(선택모델).
|
||||
log.warn("Claude path degraded -> Ollama fallback");
|
||||
String om = aiConfig.ollamaTextModel();
|
||||
GenResult fb = ollamaGenerate(prompt, om);
|
||||
learningStore.logInfer("ollama(claude-fallback)", om, System.currentTimeMillis() - start, fb.degraded());
|
||||
return fb;
|
||||
}
|
||||
// provider = ollama / qwen3 / deepseek / glm → 해당 Ollama 소형 모델
|
||||
String provider = aiConfig.provider();
|
||||
String om = aiConfig.ollamaTextModel();
|
||||
GenResult r = ollamaGenerate(prompt, om);
|
||||
learningStore.logInfer(provider, om, System.currentTimeMillis() - start, r.degraded());
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우팅된 텍스트를 String 으로 반환(기존 {@code OllamaClient.generate(prompt)} 호출부 호환용).
|
||||
* 실패 시 빈 문자열 — 기존 AiService 폴백이 그대로 동작(무회귀).
|
||||
*/
|
||||
public String generateText(String prompt) {
|
||||
GenResult r = generate(prompt);
|
||||
return (r == null || r.degraded() || r.text() == null) ? "" : r.text();
|
||||
}
|
||||
|
||||
/** Ollama 평문 generate 를 GenResult 로 래핑(실패 시 degraded). */
|
||||
private GenResult ollamaGenerate(String prompt, String model) {
|
||||
String txt = ollamaClient.generate(prompt, model);
|
||||
if (txt == null || txt.isBlank()) {
|
||||
return new GenResult(null, true);
|
||||
}
|
||||
return new GenResult(txt, false);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
package com.zioinfo.cms.common.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.cms.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-CMS]
|
||||
*
|
||||
* <p><b>외부 호출 예외 허용</b>: 본 클라이언트는 소유자 승인(2026-07-03)에 따라 {@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 문자열 이스케이프(프롬프트 직렬화 규칙). */
|
||||
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.cms.common.ai;
|
||||
|
||||
/**
|
||||
* 텍스트 생성 공용 인터페이스 (AI provider 추상화). [GUARDiA-CMS]
|
||||
*
|
||||
* <p>온프레미스 Ollama({@code com.zioinfo.cms.ai.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) {
|
||||
}
|
||||
}
|
||||
@ -9,14 +9,17 @@ spring:
|
||||
username: ${DB_USER:cms_user}
|
||||
password: ${DB_PASS:cms_pass2026}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
# 공유 PostgreSQL max_connections 포화 방지(형제 솔루션 다수 동거) — 풀 캡 3.
|
||||
maximum-pool-size: ${DB_POOL_MAX:3}
|
||||
sql:
|
||||
init:
|
||||
# UIWS 이식: 91_uiws_port.sql(신규 tb_uiws_* + cms_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
|
||||
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 cms_setting/cms_i18n 시드 충돌 회피).
|
||||
# 91_uiws_port.sql 단독은 완전 멱등 → mode:always 안전. continue-on-error 로 부분 실패 격리.
|
||||
mode: ${SQL_INIT_MODE:always}
|
||||
# 91=업무모듈+2FA ALTER, 92=system(시스템관리·권한관리) 이식. 둘 다 완전 멱등.
|
||||
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql
|
||||
# 91=업무모듈+2FA ALTER, 92=system(시스템관리·권한관리) 이식, 104=AI 플랫폼 설정 시드. 전부 멱등.
|
||||
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/104_seed_ai_config.sql
|
||||
continue-on-error: true
|
||||
servlet:
|
||||
multipart:
|
||||
@ -45,6 +48,9 @@ guardia:
|
||||
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
||||
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
|
||||
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream}
|
||||
# 로컬 임베디드 DuckDB — AI 피드백/추론 학습 저장소(솔루션별 격리). 서버 기본 경로.
|
||||
duckdb:
|
||||
path: ${CMS_DUCKDB_PATH:/opt/guardia-cms/data/cms_learning.duckdb}
|
||||
# 중앙 guardia-rag 연동 (최신 AI 기법 배선 — 별개 레이어, 미가용 시 CMS 로컬 폴백).
|
||||
# 보안 불변: 온프레미스(서버 내부 루프백) 전용. 외부 엔드포인트 지정 금지.
|
||||
rag:
|
||||
|
||||
27
backend/src/main/resources/db/104_seed_ai_config.sql
Normal file
27
backend/src/main/resources/db/104_seed_ai_config.sql
Normal file
@ -0,0 +1,27 @@
|
||||
-- =====================================================================
|
||||
-- GUARDiA CMS — 104. AI 플랫폼(LLM provider) 설정 시드 (멱등)
|
||||
-- 대상 DB : cms_db (테이블 cms_setting — schema.sql 에서 생성)
|
||||
-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일 등재.
|
||||
-- mode:always 재실행 안전(ON CONFLICT DO NOTHING). continue-on-error 로 부분 실패 격리.
|
||||
-- 설명 : 외부 Claude(Anthropic) API 를 온프레미스 Ollama 와 병행·선택형으로 추가.
|
||||
-- 관리자(SUPERADMIN)가 런타임으로 provider/모델을 선택(재기동 불요, AiConfigService).
|
||||
-- * ai.provider : ollama(기본) / claude / qwen3 / deepseek / glm
|
||||
-- * ai.claude.model : claude-sonnet-4-6(기본) / claude-haiku-4-5 / claude-opus-4-8
|
||||
-- * ai.ollama.textModel: 빈값(=서버 프로퍼티 guardia.ollama-text-model 폴백, llama3.2:1b)
|
||||
-- * ai.enabled : true(기본, off=규칙기반 degraded)
|
||||
-- 보안 : Claude API 키는 DB 에 저장하지 않는다 — 서버 환경변수 ANTHROPIC_API_KEY 로만 주입.
|
||||
-- 본 시드에 키/시크릿/IP/비밀번호 일절 미포함.
|
||||
-- 무회귀 : 기본 provider=ollama → 설정 미변경 시 기존 Ollama 동작과 바이트 동일.
|
||||
-- 멱등 : INSERT ... ON CONFLICT (key) DO NOTHING.
|
||||
-- =====================================================================
|
||||
|
||||
SET client_encoding = 'UTF8';
|
||||
|
||||
INSERT INTO cms_setting (key, value) VALUES
|
||||
('ai.provider', 'ollama'),
|
||||
('ai.claude.model', 'claude-sonnet-4-6'),
|
||||
('ai.ollama.textModel', ''),
|
||||
('ai.enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- end 104_seed_ai_config.sql
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
582
backend/src/main/resources/static/assets/index-CVTAEWqv.js
Normal file
582
backend/src/main/resources/static/assets/index-CVTAEWqv.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
@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
|
||||
<script type="module" crossorigin src="/assets/index-B9c1ITWA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DQCKibZ6.css">
|
||||
<script type="module" crossorigin src="/assets/index-CVTAEWqv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CAmwXOHj.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
doc/guardia-cms_아키텍처설계서_v1.0.pptx
Normal file
BIN
doc/guardia-cms_아키텍처설계서_v1.0.pptx
Normal file
Binary file not shown.
@ -23,6 +23,7 @@ import AiTechniques from './pages/AiTechniques'
|
||||
import UserManagement from './pages/UserManagement'
|
||||
import AuditLog from './pages/AuditLog'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||
import MobileApp from './pages/MobileApp'
|
||||
// UIWS 이식 업무 모듈(업무일지·일정·쪽지·통계) — 기존 라우트 보존, 신규 메뉴만 추가
|
||||
import UiwsLayout from './components/uiws/UiwsLayout'
|
||||
@ -74,6 +75,9 @@ export default function App() {
|
||||
<Route path="/admin/settings" element={
|
||||
<ProtectedRoute min="EDITOR"><SystemSettings /></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/ai-platform" element={
|
||||
<ProtectedRoute min="SUPERADMIN"><AiPlatformSettings /></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/mobile-app" element={<MobileApp />} />
|
||||
|
||||
{/* 업무(UIWS 이식) — 인증 사용자 전체(Viewer+). UiwsLayout = .uiws-scope 다크/라이트 토큰 */}
|
||||
|
||||
64
frontend/src/api/adminAiConfig.ts
Normal file
64
frontend/src/api/adminAiConfig.ts
Normal file
@ -0,0 +1,64 @@
|
||||
// AI 플랫폼(LLM Provider) 설정(ADMIN) API — /api/admin/ai-config (SUPERADMIN 전용).
|
||||
// API 키 값은 응답에 절대 포함되지 않음(claudeKeySet 플래그만).
|
||||
// PUT 은 provider/claudeModel(+선택 ollamaTextModel) 전송.
|
||||
// test 는 저장된 설정 기준 선택 provider 짧은 ping.
|
||||
// 레퍼런스: guardia-ocr frontend/src/api/adminAiConfig.ts (glm provider 확장).
|
||||
import api from './client'
|
||||
|
||||
/** LLM 제공자: ollama(온프레미스)/claude(외부)/qwen3/deepseek/glm(온프레미스 소형·GLM는 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
|
||||
}
|
||||
|
||||
/** 저장 요청 — 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)
|
||||
|
||||
// ── AI 피드백(👍/👎 + 교정) — 로컬 DuckDB + 중앙 rag 전달 ─────────────────────
|
||||
export interface AiFeedbackRequest {
|
||||
feature: string
|
||||
question?: string
|
||||
answer?: string
|
||||
verdict: 'up' | 'down'
|
||||
correction?: string
|
||||
}
|
||||
|
||||
/** POST /api/ai/feedback — 인증 사용자 피드백 제출(로컬+중앙 best-effort). */
|
||||
export const submitAiFeedback = (body: AiFeedbackRequest) =>
|
||||
api.post('/api/ai/feedback', body).then(r => r.data?.data as { localStored: boolean; centralForwarded: boolean })
|
||||
84
frontend/src/components/AiFeedback.tsx
Normal file
84
frontend/src/components/AiFeedback.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
// AI 결과 피드백 위젯(👍/👎 + 교정) — 로컬 DuckDB + 중앙 rag 전달.
|
||||
// AI 도구·설정 등 AI 결과가 표시되는 곳에서 재사용. 사용자 식별자는 서버가 JWT 에서 취득(전송 불필요).
|
||||
// PII 는 서버 저장 시 마스킹. 실패해도 사용자 흐름을 막지 않는다(best-effort 표시).
|
||||
import { useState } from 'react'
|
||||
import { ThumbsUp, ThumbsDown, Send } from 'lucide-react'
|
||||
import { submitAiFeedback } from '../api/adminAiConfig'
|
||||
|
||||
interface Props {
|
||||
/** 피드백 대상 기능 식별자(예: content-draft, seo, moderate). */
|
||||
feature: string
|
||||
/** 선택: 질문/입력 컨텍스트(마스킹되어 저장). */
|
||||
question?: string
|
||||
/** 선택: AI 응답(마스킹되어 저장). */
|
||||
answer?: string
|
||||
}
|
||||
|
||||
export default function AiFeedback({ feature, question, answer }: Props) {
|
||||
const [verdict, setVerdict] = useState<'up' | 'down' | null>(null)
|
||||
const [correction, setCorrection] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const send = async (v: 'up' | 'down') => {
|
||||
setVerdict(v)
|
||||
if (v === 'up') { await post(v) }
|
||||
}
|
||||
|
||||
const post = async (v: 'up' | 'down') => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await submitAiFeedback({ feature, question, answer, verdict: v, correction: correction || undefined })
|
||||
setSent(true)
|
||||
} catch {
|
||||
setSent(true) // best-effort — 실패해도 UI 를 막지 않음
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return <div className="text-xs text-accent">피드백 감사합니다. 학습에 반영됩니다.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">이 결과가 도움이 되었나요?</span>
|
||||
<button
|
||||
onClick={() => send('up')}
|
||||
disabled={busy}
|
||||
className={`p-1.5 rounded-lg border text-xs ${verdict === 'up' ? 'bg-accent/15 border-accent text-accent' : 'bg-ink border-edge text-slate-300 hover:border-accent/50'}`}
|
||||
aria-label="좋아요"
|
||||
>
|
||||
<ThumbsUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => send('down')}
|
||||
disabled={busy}
|
||||
className={`p-1.5 rounded-lg border text-xs ${verdict === 'down' ? 'bg-rose-500/15 border-rose-500 text-rose-300' : 'bg-ink border-edge text-slate-300 hover:border-rose-500/50'}`}
|
||||
aria-label="아쉬워요"
|
||||
>
|
||||
<ThumbsDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{verdict === 'down' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={correction}
|
||||
onChange={e => setCorrection(e.target.value)}
|
||||
placeholder="무엇이 잘못됐는지 알려주세요 (선택)"
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-ink border border-edge text-sm outline-none focus:border-brand"
|
||||
/>
|
||||
<button
|
||||
onClick={() => post('down')}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand text-ink text-xs font-semibold disabled:opacity-40"
|
||||
>
|
||||
<Send size={13} /> 제출
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -38,6 +38,7 @@ const adminLinks: Link[] = [
|
||||
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
|
||||
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'EDITOR' },
|
||||
{ to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'EDITOR' },
|
||||
{ to: '/admin/ai-platform', label: 'AI 플랫폼', icon: Cpu, min: 'SUPERADMIN' },
|
||||
]
|
||||
// 업무(UIWS 이식) — 업무일지·일정·쪽지·통계 (인증 사용자 전체)
|
||||
const uiwsLinks: Link[] = [
|
||||
|
||||
281
frontend/src/pages/AiPlatformSettings.tsx
Normal file
281
frontend/src/pages/AiPlatformSettings.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
// AI 플랫폼(LLM Provider) 설정(SUPERADMIN) — 제공자·모델 선택 + 연결 테스트 + 피드백 위젯.
|
||||
// API 키 값은 화면에 절대 표시/입력하지 않는다(claudeKeySet 으로 설정 여부 배지만).
|
||||
// 저장은 provider/claudeModel 만 전송(ollama 계열 모델·전역 활성은 읽기 전용 표시).
|
||||
// 레퍼런스: guardia-ocr AiPlatformSettings.tsx (glm provider + RAM 배지 추가).
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Cpu, KeyRound, PlugZap, Save, AlertTriangle } from 'lucide-react'
|
||||
import { getAiConfig, updateAiConfig, testAiConfig } from '../api/adminAiConfig'
|
||||
import type { AiProvider, ClaudeModel, AiTestResult } from '../api/adminAiConfig'
|
||||
import AiFeedback from '../components/AiFeedback'
|
||||
import { isSuperAdmin } from '../components/rbac'
|
||||
|
||||
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 (Zhipu)', 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 설정은 SuperAdmin 전용).'
|
||||
if (msg.includes('ERR-AI-400')) return msg.replace(/^ERR-AI-400:\s*/, '')
|
||||
return 'AI 설정을 처리하지 못했습니다.'
|
||||
}
|
||||
|
||||
export default function AiPlatformSettings() {
|
||||
const editable = isSuperAdmin()
|
||||
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-accent'
|
||||
: 'text-rose-300'
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<Cpu size={20} className="text-brand" /> AI 플랫폼 설정
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
콘텐츠 초안·SEO·번역·리뷰 요약·검색·모더레이션 등 텍스트 생성에 사용할 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>
|
||||
)}
|
||||
|
||||
{!editable && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 text-amber-300 text-sm rounded-lg px-4 py-2.5 mb-4">
|
||||
조회 전용입니다. AI 플랫폼 설정 변경은 SuperAdmin 권한이 필요합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showKeyFallback || showDisabledFallback) && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/40 text-amber-300 text-sm rounded-lg px-4 py-2.5 mb-4">
|
||||
{showKeyFallback
|
||||
? 'Claude API 키가 설정되지 않아 Ollama(온프레미스)로 폴백 동작합니다. 서버 환경변수 ANTHROPIC_API_KEY 설정 후 사용하세요.'
|
||||
: 'AI 기능이 비활성 상태입니다. 모든 AI 결과는 규칙 기반(degraded)으로 동작합니다.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGlm && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/40 text-amber-300 text-sm rounded-lg px-4 py-2.5 mb-4 flex items-center gap-2">
|
||||
<AlertTriangle size={15} />
|
||||
GLM(glm4:9b, 약 5.5GB)은 현재 서버 가용 RAM(~2GB)을 초과합니다. 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={() => { if (editable) { setProvider(p.id); markDirty() } }}
|
||||
disabled={!editable}
|
||||
className={`text-left px-3 py-2.5 rounded-lg border text-sm transition-colors disabled:opacity-60 ${
|
||||
provider === p.id
|
||||
? 'bg-brand/15 border-brand text-brand'
|
||||
: 'bg-ink border-edge text-slate-300 hover:border-brand/50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold flex items-center gap-1.5">
|
||||
{p.label}
|
||||
{p.ram && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/15 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>
|
||||
|
||||
{/* Ollama 계열 — 텍스트 모델 읽기 전용 표시 */}
|
||||
{!isClaude && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-slate-500 mb-1">Ollama 텍스트 모델</label>
|
||||
<div className="px-3 py-2 rounded-lg bg-ink border border-edge text-sm font-mono">
|
||||
{ollamaTextModel || 'llama3.2:1b'}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 mt-1">
|
||||
온프레미스 소형 모델 — 서버 RAM 제약으로 고정. 외부 호출 없음.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude — 모델 셀렉트 + 키 배지(값 비노출) */}
|
||||
{isClaude && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-slate-500 mb-1">Claude 모델</label>
|
||||
<select
|
||||
value={claudeModel}
|
||||
onChange={e => { setClaudeModel(normModel(e.target.value)); markDirty() }}
|
||||
disabled={!editable}
|
||||
className="w-full px-3 py-2 rounded-lg bg-ink border border-edge text-sm focus:border-brand outline-none disabled:opacity-60"
|
||||
>
|
||||
{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-accent/15 text-accent border border-accent/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 || !editable}
|
||||
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-accent text-xs">저장됨</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 연결 테스트 ── */}
|
||||
<section className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||
<PlugZap size={16} className="text-brand" /> 연결 테스트
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
저장된 설정 기준으로 선택한 제공자에 짧은 ping 을 보내 연결을 확인합니다(요약 결과만).
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={runTest}
|
||||
disabled={testing}
|
||||
className="px-4 py-2 rounded-lg bg-card border border-edge text-sm disabled:opacity-40 hover:border-brand/50"
|
||||
>
|
||||
{testing ? '테스트 중…' : '테스트 실행'}
|
||||
</button>
|
||||
{testResult && (
|
||||
<span className={`text-sm ${statusClass}`} role="status" aria-live="polite">
|
||||
{testResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 mt-3">
|
||||
먼저 변경 사항을 저장한 뒤 테스트하세요. Claude 실패 시 Ollama 로 자동 폴백합니다.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 pt-4 border-t border-edge/60">
|
||||
<div className="text-xs text-slate-500 mb-2">AI 품질 피드백</div>
|
||||
<AiFeedback feature="ai-platform-settings" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user