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

This commit is contained in:
GUARDiA 2026-07-03 23:18:15 +09:00
parent e7545f64c0
commit 097ddbb2a5
27 changed files with 1997 additions and 579 deletions

View File

@ -60,6 +60,9 @@
<!-- HTTP Client (Ollama / 연계 GUARDiA 호출) --> <!-- HTTP Client (Ollama / 연계 GUARDiA 호출) -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency> <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 --> <!-- 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>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency> <dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>

View File

@ -1,5 +1,6 @@
package com.zioinfo.cms.ai; package com.zioinfo.cms.ai;
import com.zioinfo.cms.ai.service.AiTextRouter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -23,7 +24,8 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AiService { public class AiService {
private final OllamaClient ollama; private final OllamaClient ollama; // 비전(vision) 경로 전용 + 가용성 유지
private final AiTextRouter aiRouter; // 텍스트 생성은 provider 라우팅(ClaudeOllama) 경유
// 욕설/스팸 폴백 사전 (규칙기반) // 욕설/스팸 폴백 사전 (규칙기반)
private static final Set<String> PROFANITY = Set.of( private static final Set<String> PROFANITY = Set.of(
@ -41,7 +43,7 @@ public class AiService {
"당신은 e-커머스 콘텐츠 작가입니다. 유형: %s, 주제: %s, 톤: %s. " "당신은 e-커머스 콘텐츠 작가입니다. 유형: %s, 주제: %s, 톤: %s. "
+ "한국어로 자연스러운 마케팅 초안을 200자 내외로 작성하세요. 설명 없이 본문만 출력.", + "한국어로 자연스러운 마케팅 초안을 200자 내외로 작성하세요. 설명 없이 본문만 출력.",
kind, topic, tone == null ? "친근함" : tone); kind, topic, tone == null ? "친근함" : tone);
String out = ollama.generate(prompt); String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) { if (out != null && !out.isBlank()) {
return out; return out;
} }
@ -85,7 +87,7 @@ public class AiService {
"다음 콘텐츠의 SEO를 개선하세요. 제목: %s\\n본문: %s\\n" "다음 콘텐츠의 SEO를 개선하세요. 제목: %s\\n본문: %s\\n"
+ "'META: ...\\nKEYWORDS: a,b,c\\nSCORE: 0~100' 형식으로 한국어 출력.", + "'META: ...\\nKEYWORDS: a,b,c\\nSCORE: 0~100' 형식으로 한국어 출력.",
title, truncate(body, 800)); title, truncate(body, 800));
String out = ollama.generate(prompt); String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) { if (out != null && !out.isBlank()) {
result.put("metaDescription", firstNonBlank(extractLine(out, "META:"), truncate(body, 150))); result.put("metaDescription", firstNonBlank(extractLine(out, "META:"), truncate(body, 150)));
String kw = extractLine(out, "KEYWORDS:"); String kw = extractLine(out, "KEYWORDS:");
@ -108,7 +110,7 @@ public class AiService {
String prompt = String.format( String prompt = String.format(
"다음 텍스트를 %s 로케일 언어로 자연스럽게 번역하세요. 번역문만 출력:\\n%s", "다음 텍스트를 %s 로케일 언어로 자연스럽게 번역하세요. 번역문만 출력:\\n%s",
targetLocale, truncate(text, 1500)); targetLocale, truncate(text, 1500));
String out = ollama.generate(prompt); String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) { if (out != null && !out.isBlank()) {
return out; return out;
} }
@ -136,7 +138,7 @@ public class AiService {
String joined = truncate(String.join("\\n- ", reviews), 1500); String joined = truncate(String.join("\\n- ", reviews), 1500);
String prompt = "다음 리뷰들을 한 문장으로 요약하고 감성(POSITIVE/NEGATIVE/NEUTRAL)을 판정하세요. " String prompt = "다음 리뷰들을 한 문장으로 요약하고 감성(POSITIVE/NEGATIVE/NEUTRAL)을 판정하세요. "
+ "'SUMMARY: ...\\nSENTIMENT: ...' 형식:\\n- " + joined; + "'SUMMARY: ...\\nSENTIMENT: ...' 형식:\\n- " + joined;
String out = ollama.generate(prompt); String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) { if (out != null && !out.isBlank()) {
result.put("summary", firstNonBlank(extractLine(out, "SUMMARY:"), out)); result.put("summary", firstNonBlank(extractLine(out, "SUMMARY:"), out));
String s = extractLine(out, "SENTIMENT:").toUpperCase(); String s = extractLine(out, "SENTIMENT:").toUpperCase();
@ -162,7 +164,7 @@ public class AiService {
public List<String> searchKeywords(String naturalQuery) { public List<String> searchKeywords(String naturalQuery) {
if (naturalQuery == null || naturalQuery.isBlank()) return List.of(); if (naturalQuery == null || naturalQuery.isBlank()) return List.of();
String prompt = "다음 검색 의도에서 핵심 키워드 3~5개를 쉼표로만 출력:\\n" + naturalQuery; String prompt = "다음 검색 의도에서 핵심 키워드 3~5개를 쉼표로만 출력:\\n" + naturalQuery;
String out = ollama.generate(prompt); String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) { if (out != null && !out.isBlank()) {
String line = out.replaceAll("[\\n\\r]", " ").trim(); String line = out.replaceAll("[\\n\\r]", " ").trim();
List<String> kws = new ArrayList<>(); List<String> kws = new ArrayList<>();
@ -190,7 +192,7 @@ public class AiService {
} }
String prompt = "다음 UGC가 욕설/스팸/부적절한지 판정하세요. " String prompt = "다음 UGC가 욕설/스팸/부적절한지 판정하세요. "
+ "'DECISION: APPROVE|REJECT|REVIEW\\nRISK: 0~1\\nREASON: ...' 형식 한국어:\\n" + truncate(text, 800); + "'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()) { if (out != null && !out.isBlank()) {
String d = extractLine(out, "DECISION:").toUpperCase(); String d = extractLine(out, "DECISION:").toUpperCase();
String decision = d.contains("REJECT") ? "REJECT" : d.contains("REVIEW") ? "REVIEW" : "APPROVE"; String decision = d.contains("REJECT") ? "REJECT" : d.contains("REVIEW") ? "REVIEW" : "APPROVE";

View File

@ -1,5 +1,6 @@
package com.zioinfo.cms.ai; package com.zioinfo.cms.ai;
import com.zioinfo.cms.ai.service.AiTextRouter;
import com.zioinfo.cms.common.ApiResponse; import com.zioinfo.cms.common.ApiResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -19,7 +20,8 @@ import java.util.concurrent.atomic.AtomicLong;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ContentAiController { public class ContentAiController {
private final OllamaClient ollama; private final OllamaClient ollama; // 비전(image-alt-text) 경로 전용
private final AiTextRouter aiRouter; // 텍스트 생성은 provider 라우팅(ClaudeOllama) 경유
/** 통계 카운터 (메모리, 재시작 시 초기화) */ /** 통계 카운터 (메모리, 재시작 시 초기화) */
private static final Map<String, AtomicLong> USAGE = new LinkedHashMap<>(); private static final Map<String, AtomicLong> USAGE = new LinkedHashMap<>();
@ -35,7 +37,7 @@ public class ContentAiController {
} }
private String ask(String prompt, String fallback) { 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; return (r == null || r.isBlank()) ? fallback : r;
} }

View File

@ -34,11 +34,22 @@ public class OllamaClient {
this.visionModel = visionModel; this.visionModel = visionModel;
} }
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */ /** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
@SuppressWarnings("unchecked")
public String generate(String prompt) { public String generate(String prompt) {
return generate(prompt, textModel);
}
/**
* 지정 모델로 텍스트 생성. 실패 문자열 반환(예외 없음).
*
* <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 { try {
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false); Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build() Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate") .post().uri("/api/generate")
.bodyValue(body) .bodyValue(body)

View File

@ -0,0 +1,54 @@
package com.zioinfo.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);
}
}

View File

@ -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) {
}

View File

@ -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) {
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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) {
}
}

View File

@ -9,14 +9,17 @@ spring:
username: ${DB_USER:cms_user} username: ${DB_USER:cms_user}
password: ${DB_PASS:cms_pass2026} password: ${DB_PASS:cms_pass2026}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
hikari:
# 공유 PostgreSQL max_connections 포화 방지(형제 솔루션 다수 동거) — 풀 캡 3.
maximum-pool-size: ${DB_POOL_MAX:3}
sql: sql:
init: init:
# UIWS 이식: 91_uiws_port.sql(신규 tb_uiws_* + cms_user 2FA ALTER, 전부 멱등)만 부팅 시 적용. # UIWS 이식: 91_uiws_port.sql(신규 tb_uiws_* + cms_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 cms_setting/cms_i18n 시드 충돌 회피). # 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 cms_setting/cms_i18n 시드 충돌 회피).
# 91_uiws_port.sql 단독은 완전 멱등 → mode:always 안전. continue-on-error 로 부분 실패 격리. # 91_uiws_port.sql 단독은 완전 멱등 → mode:always 안전. continue-on-error 로 부분 실패 격리.
mode: ${SQL_INIT_MODE:always} mode: ${SQL_INIT_MODE:always}
# 91=업무모듈+2FA ALTER, 92=system(시스템관리·권한관리) 이식. 둘 다 완전 멱등. # 91=업무모듈+2FA ALTER, 92=system(시스템관리·권한관리) 이식, 104=AI 플랫폼 설정 시드. 전부 멱등.
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/104_seed_ai_config.sql
continue-on-error: true continue-on-error: true
servlet: servlet:
multipart: multipart:
@ -45,6 +48,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}
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream} 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 로컬 폴백). # 중앙 guardia-rag 연동 (최신 AI 기법 배선 — 별개 레이어, 미가용 시 CMS 로컬 폴백).
# 보안 불변: 온프레미스(서버 내부 루프백) 전용. 외부 엔드포인트 지정 금지. # 보안 불변: 온프레미스(서버 내부 루프백) 전용. 외부 엔드포인트 지정 금지.
rag: rag:

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GUARDiA CMS — AI 콘텐츠 관리</title> <title>GUARDiA CMS — AI 콘텐츠 관리</title>
<script type="module" crossorigin src="/assets/index-B9c1ITWA.js"></script> <script type="module" crossorigin src="/assets/index-CVTAEWqv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DQCKibZ6.css"> <link rel="stylesheet" crossorigin href="/assets/index-CAmwXOHj.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

View File

@ -23,6 +23,7 @@ import AiTechniques from './pages/AiTechniques'
import UserManagement from './pages/UserManagement' import UserManagement from './pages/UserManagement'
import AuditLog from './pages/AuditLog' import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings' import SystemSettings from './pages/SystemSettings'
import AiPlatformSettings from './pages/AiPlatformSettings'
import MobileApp from './pages/MobileApp' import MobileApp from './pages/MobileApp'
// UIWS 이식 업무 모듈(업무일지·일정·쪽지·통계) — 기존 라우트 보존, 신규 메뉴만 추가 // UIWS 이식 업무 모듈(업무일지·일정·쪽지·통계) — 기존 라우트 보존, 신규 메뉴만 추가
import UiwsLayout from './components/uiws/UiwsLayout' import UiwsLayout from './components/uiws/UiwsLayout'
@ -74,6 +75,9 @@ export default function App() {
<Route path="/admin/settings" element={ <Route path="/admin/settings" element={
<ProtectedRoute min="EDITOR"><SystemSettings /></ProtectedRoute> <ProtectedRoute min="EDITOR"><SystemSettings /></ProtectedRoute>
} /> } />
<Route path="/admin/ai-platform" element={
<ProtectedRoute min="SUPERADMIN"><AiPlatformSettings /></ProtectedRoute>
} />
<Route path="/admin/mobile-app" element={<MobileApp />} /> <Route path="/admin/mobile-app" element={<MobileApp />} />
{/* 업무(UIWS 이식) — 인증 사용자 전체(Viewer+). UiwsLayout = .uiws-scope 다크/라이트 토큰 */} {/* 업무(UIWS 이식) — 인증 사용자 전체(Viewer+). UiwsLayout = .uiws-scope 다크/라이트 토큰 */}

View 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 })

View 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>
)
}

View File

@ -38,6 +38,7 @@ const adminLinks: Link[] = [
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' }, { to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'EDITOR' }, { to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'EDITOR' },
{ to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'EDITOR' }, { to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'EDITOR' },
{ to: '/admin/ai-platform', label: 'AI 플랫폼', icon: Cpu, min: 'SUPERADMIN' },
] ]
// 업무(UIWS 이식) — 업무일지·일정·쪽지·통계 (인증 사용자 전체) // 업무(UIWS 이식) — 업무일지·일정·쪽지·통계 (인증 사용자 전체)
const uiwsLinks: Link[] = [ const uiwsLinks: Link[] = [

View 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>
)
}