feat(ai): Claude 전환 + DuckDB 학습저장소 이식
This commit is contained in:
parent
3c3b676416
commit
7b0b696cd6
@ -27,6 +27,8 @@
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
|
||||
<dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>${mybatis.version}</version></dependency>
|
||||
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
|
||||
<!-- 로컬 임베디드 학습 저장소(DuckDB) — AI 피드백/추론 로그 격리 파일(/opt/guardia-mall/data). 외부 호출 없음. -->
|
||||
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
|
||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
|
||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.zioinfo.mall.ai;
|
||||
|
||||
import com.zioinfo.mall.ai.service.AiTextRouter;
|
||||
import com.zioinfo.mall.common.ai.TextAiClient.GenResult;
|
||||
import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper;
|
||||
import com.zioinfo.mall.product.MallProduct;
|
||||
import com.zioinfo.mall.product.mapper.ProductMapper;
|
||||
@ -22,11 +24,20 @@ import java.util.*;
|
||||
@RequiredArgsConstructor
|
||||
public class MallAiService {
|
||||
|
||||
private final OllamaClient ollama;
|
||||
private final AiTextRouter aiRouter; // provider 라우팅(Claude↔Ollama) + infer 로그. 실패 시 아래 Java 폴백.
|
||||
private final ProductMapper productMapper;
|
||||
private final ReviewMapper reviewMapper;
|
||||
private final StoreInventoryMapper storeInventoryMapper;
|
||||
|
||||
/**
|
||||
* 선택된 provider(Claude/Ollama)로 텍스트 생성. degraded/빈응답이면 빈 문자열 반환 →
|
||||
* 각 기능의 기존 결정론적 Java 폴백이 그대로 동작(무회귀).
|
||||
*/
|
||||
private String aiGenerate(String prompt) {
|
||||
GenResult r = aiRouter.generate(prompt);
|
||||
return (r != null && !r.degraded() && r.text() != null) ? r.text() : "";
|
||||
}
|
||||
|
||||
/** 1. 상품 추천 — 행사/키워드 기반. AI 실패 시 인기/평점 폴백. */
|
||||
public List<MallProduct> recommend(String occasion, String keyword, int limit) {
|
||||
List<MallProduct> pool = productMapper.search(null, keyword, "ON_SALE", occasion, null, null, "sales", 30, 0);
|
||||
@ -37,7 +48,7 @@ public class MallAiService {
|
||||
String prompt = "You are a florist recommender. From this catalog: [" + names + "]. "
|
||||
+ "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion)
|
||||
+ "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated.";
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) {
|
||||
List<MallProduct> ordered = reorderByAi(pool, ai);
|
||||
if (!ordered.isEmpty()) return ordered.subList(0, Math.min(limit, ordered.size()));
|
||||
@ -58,7 +69,7 @@ public class MallAiService {
|
||||
}
|
||||
String prompt = "Summarize these flower bouquet reviews in 2 concise sentences (pros/cons):\n"
|
||||
+ String.join("\n", contents);
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) {
|
||||
out.put("summary", ai);
|
||||
out.put("source", "ollama");
|
||||
@ -89,7 +100,7 @@ public class MallAiService {
|
||||
public String csAutoReply(String subject, String content) {
|
||||
String prompt = "You are a polite flower-shop customer support agent. Write a short helpful reply (<=4 sentences) to:\n"
|
||||
+ "Subject: " + subject + "\nMessage: " + content;
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) return ai;
|
||||
return "Thank you for reaching out about \"" + subject + "\". We're sorry for any inconvenience. "
|
||||
+ "Our team is reviewing your request and will follow up shortly. "
|
||||
@ -121,7 +132,7 @@ public class MallAiService {
|
||||
}
|
||||
String prompt = "Compose a creative 'Daily Standard' bouquet name and 1-line description using surplus flowers: ["
|
||||
+ String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION.";
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) {
|
||||
out.put("bouquet", ai);
|
||||
out.put("source", "ollama");
|
||||
@ -165,7 +176,7 @@ public class MallAiService {
|
||||
String prompt = "Write 3 short flower-card messages for occasion='" + occasion
|
||||
+ "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient)
|
||||
+ "'. One per line, no numbering.";
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (String l : ai.split("\n")) {
|
||||
|
||||
@ -30,10 +30,23 @@ public class OllamaClient {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
/** 기본 모델(guardia.ollama-text-model)로 생성. 실패 시 빈 문자열. */
|
||||
public String generate(String prompt) {
|
||||
return generateText(prompt, model);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 모델로 평문 생성(AiTextRouter 의 Ollama 경로·Claude 폴백 공용 진입점).
|
||||
*
|
||||
* <p>모델 미지정 시 서버 기본(guardia.ollama-text-model). localhost Ollama 만 호출하며,
|
||||
* 장애/오프라인/타임아웃 시 예외 없이 빈 문자열 반환(호출자가 폴백 수행). [GUARDiA-MALL]
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public String generateText(String prompt, String reqModel) {
|
||||
if (prompt == null || prompt.isBlank()) return "";
|
||||
String useModel = (reqModel == null || reqModel.isBlank()) ? model : reqModel.trim();
|
||||
try {
|
||||
Map<String, Object> body = Map.of("model", model, "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)
|
||||
.retrieve().bodyToMono(Map.class)
|
||||
@ -43,7 +56,7 @@ public class OllamaClient {
|
||||
Object r = res.get("response");
|
||||
return r == null ? "" : String.valueOf(r).trim();
|
||||
} catch (Exception e) {
|
||||
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage());
|
||||
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getClass().getSimpleName());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
package com.zioinfo.mall.ai.controller;
|
||||
|
||||
import com.zioinfo.mall.admin.AuditService;
|
||||
import com.zioinfo.mall.ai.dto.AiConfigDto;
|
||||
import com.zioinfo.mall.ai.dto.AiConfigUpdateRequest;
|
||||
import com.zioinfo.mall.ai.service.AiConfigService;
|
||||
import com.zioinfo.mall.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-MALL]
|
||||
* SecurityConfig {@code /api/admin/ai-config/** hasRole(ADMIN)} 게이트로 보호된다.
|
||||
*
|
||||
* <p>mall_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,106 @@
|
||||
package com.zioinfo.mall.ai.controller;
|
||||
|
||||
import com.zioinfo.mall.ai.learning.MallLearningStore;
|
||||
import com.zioinfo.mall.common.ApiResponse;
|
||||
import com.zioinfo.mall.rag.RagProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 답변 피드백 수집 API — {@code POST /api/ai/feedback} (인증 사용자). [GUARDiA-MALL]
|
||||
*
|
||||
* <p>👍/👎(+교정)을 <b>둘 다</b> 기록한다:
|
||||
* <ol>
|
||||
* <li>로컬 임베디드 DuckDB({@link MallLearningStore#recordFeedback}) — 솔루션별 오프라인 학습셋(PII 마스킹).</li>
|
||||
* <li>중앙 guardia-rag 학습 서비스({@code {rag.base-url}/feedback}, 온프레미스 루프백) — 통합 학습·평가 게이트.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>불변</b>: 중앙 전달 실패는 예외 미전파(로컬 기록은 유지). 응답에 키·PII·스택트레이스 미노출.
|
||||
* 외부 호출 금지 — 중앙 rag 는 서버 내부 루프백(127.0.0.1:8020)이다.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/ai/feedback")
|
||||
@RequiredArgsConstructor
|
||||
public class AiFeedbackController {
|
||||
|
||||
private final MallLearningStore learningStore;
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
private final RagProperties ragProps;
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, Object>> feedback(@RequestBody Map<String, Object> req, Authentication auth) {
|
||||
String feature = str(req.get("feature"));
|
||||
String question = str(req.get("question"));
|
||||
String answer = str(req.get("answer"));
|
||||
String verdict = normVerdict(str(req.get("verdict")));
|
||||
String correction = str(req.get("correction"));
|
||||
String userRef = auth != null ? auth.getName() : null;
|
||||
|
||||
// 1) 로컬 DuckDB 기록(PII 마스킹, 내결함성)
|
||||
learningStore.recordFeedback(feature, question, answer, verdict, correction, userRef);
|
||||
|
||||
// 2) 중앙 guardia-rag 전달(온프레미스 루프백, 실패해도 무시)
|
||||
boolean forwarded = forwardCentral(feature, question, answer, verdict, correction, userRef);
|
||||
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("storedLocal", learningStore.isEnabled());
|
||||
out.put("forwardedCentral", forwarded);
|
||||
out.put("verdict", verdict);
|
||||
return ApiResponse.ok(out);
|
||||
}
|
||||
|
||||
/** POST {rag.base-url}/feedback — solution=mall 격리. 실패 시 false(예외 미전파). */
|
||||
@SuppressWarnings("unchecked")
|
||||
private boolean forwardCentral(String feature, String question, String answer,
|
||||
String verdict, String correction, String userRef) {
|
||||
if (!ragProps.isEnabled()) return false;
|
||||
try {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("solution", ragProps.getSolution());
|
||||
if (feature != null) body.put("feature", feature);
|
||||
if (question != null) body.put("question", question);
|
||||
if (answer != null) body.put("answer", answer);
|
||||
body.put("verdict", verdict);
|
||||
if (correction != null) body.put("correction", correction);
|
||||
if (userRef != null) body.put("user_ref", userRef);
|
||||
Map<String, Object> res = webClientBuilder.baseUrl(ragProps.getBaseUrl()).build()
|
||||
.post().uri("/feedback")
|
||||
.header("X-Solution-Key", ragProps.getSolution())
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.timeout(Duration.ofMillis(Math.min(ragProps.getTimeoutMs(), 10_000L)))
|
||||
.map(m -> (Map<String, Object>) m)
|
||||
.block();
|
||||
return res != null;
|
||||
} catch (Exception e) {
|
||||
log.warn("중앙 rag /feedback 전달 실패(무시): {}", e.getClass().getSimpleName());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
if (o == null) return null;
|
||||
String s = String.valueOf(o).trim();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
/** verdict 정규화: up/down 만 허용(그 외는 'down' 로 보수적 처리). */
|
||||
private static String normVerdict(String v) {
|
||||
if (v == null) return "down";
|
||||
String l = v.toLowerCase();
|
||||
if (l.startsWith("up") || l.equals("good") || l.equals("👍") || l.equals("positive")) return "up";
|
||||
return "down";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.zioinfo.mall.ai.dto;
|
||||
|
||||
/**
|
||||
* AI 설정 조회 DTO — API 키 값 미반환(claudeKeySet 으로 설정 여부만). [GUARDiA-MALL]
|
||||
*
|
||||
* @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.mall.ai.dto;
|
||||
|
||||
/**
|
||||
* AI 설정 저장 요청 — 화이트리스트 검증. [GUARDiA-MALL]
|
||||
*
|
||||
* @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,148 @@
|
||||
package com.zioinfo.mall.ai.learning;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* GUARDiA Mall 로컬 임베디드 DuckDB 학습 저장소. [GUARDiA-MALL]
|
||||
*
|
||||
* <p>AI 피드백·추론 로그를 솔루션 로컬 파일({@code /opt/guardia-mall/data/mall_learning.duckdb})에 격리
|
||||
* 저장한다(오프라인 분석·학습 데이터셋). 중앙 guardia-rag(8020) 로의 전달은 {@code AiFeedbackController} 가
|
||||
* 별도로 수행한다(둘 다 기록).
|
||||
*
|
||||
* <p><b>내결함성(불변)</b>: DuckDB 초기화/기록 실패는 절대 예외를 전파하지 않는다(AI 본기능·서비스 무영향).
|
||||
* 초기화 실패 시 {@code enabled=false} 로 no-op 동작한다. 로컬 컴파일 환경에 경로가 없어도 안전.
|
||||
*
|
||||
* <p><b>PII 마스킹(불변)</b>: question·answer·correction·user_ref 는 저장 전 이메일/전화/카드/주민번호를
|
||||
* 마스킹한다. 자격증명·카드·회원 PII 원문을 파일에 남기지 않는다.
|
||||
*
|
||||
* <p>스키마(멱등): {@code ai_feedback(id,ts,solution,feature,question,answer,verdict,correction,user_masked)},
|
||||
* {@code ai_infer_log(id,ts,provider,model,latency_ms,degraded)}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MallLearningStore {
|
||||
|
||||
private static final String SOLUTION = "mall";
|
||||
|
||||
private static final Pattern EMAIL = Pattern.compile("[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}");
|
||||
private static final Pattern CARD = Pattern.compile("\\b(?:\\d[ \\-]?){13,16}\\b");
|
||||
private static final Pattern PHONE = Pattern.compile("\\b(?:\\+?\\d{1,3}[\\-. ]?)?(?:\\(?\\d{2,4}\\)?[\\-. ]?)\\d{3,4}[\\-. ]?\\d{4}\\b");
|
||||
private static final Pattern SSN = Pattern.compile("\\b\\d{6}[\\-]?\\d{7}\\b");
|
||||
|
||||
private final String dbPath;
|
||||
private volatile boolean enabled = false;
|
||||
private Connection conn;
|
||||
|
||||
public MallLearningStore(
|
||||
@Value("${guardia.mall.learning.duckdb-path:/opt/guardia-mall/data/mall_learning.duckdb}") String dbPath) {
|
||||
this.dbPath = dbPath;
|
||||
}
|
||||
|
||||
/** 부팅 시 DuckDB 연결·스키마 멱등 생성. 실패해도 서비스는 정상(enabled=false). */
|
||||
@PostConstruct
|
||||
public synchronized void init() {
|
||||
try {
|
||||
File f = new File(dbPath);
|
||||
File parent = f.getParentFile();
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||
log.warn("Mall learning store: data dir 생성 불가 -> 로컬 학습저장소 비활성");
|
||||
return;
|
||||
}
|
||||
Class.forName("org.duckdb.DuckDBDriver");
|
||||
this.conn = DriverManager.getConnection("jdbc:duckdb:" + dbPath);
|
||||
try (Statement st = conn.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)");
|
||||
}
|
||||
this.enabled = true;
|
||||
log.info("Mall learning store(DuckDB) 활성: {}", dbPath);
|
||||
} catch (Throwable t) { // 드라이버 부재·경로 불가 등 모든 오류를 흡수(서비스 무영향)
|
||||
this.enabled = false;
|
||||
log.warn("Mall learning store(DuckDB) 초기화 실패 -> 비활성(no-op): {}", t.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** 피드백 로컬 기록(PII 마스킹). 실패해도 예외 미전파. */
|
||||
public synchronized void recordFeedback(String feature, String question, String answer,
|
||||
String verdict, String correction, String userRef) {
|
||||
if (!enabled || conn == null) return;
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
"INSERT INTO ai_feedback (id,ts,solution,feature,question,answer,verdict,correction,user_masked) "
|
||||
+ "VALUES (nextval('seq_ai_feedback'),?,?,?,?,?,?,?,?)")) {
|
||||
ps.setObject(1, LocalDateTime.now());
|
||||
ps.setString(2, SOLUTION);
|
||||
ps.setString(3, trunc(feature, 120));
|
||||
ps.setString(4, mask(trunc(question, 4000)));
|
||||
ps.setString(5, mask(trunc(answer, 8000)));
|
||||
ps.setString(6, trunc(verdict, 16));
|
||||
ps.setString(7, mask(trunc(correction, 8000)));
|
||||
ps.setString(8, mask(trunc(userRef, 120)));
|
||||
ps.executeUpdate();
|
||||
} catch (Throwable t) {
|
||||
log.warn("Mall learning store: feedback 기록 실패(무시): {}", t.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/** 추론 로그 기록(provider/model/지연/degraded). 실패해도 예외 미전파. */
|
||||
public synchronized void recordInfer(String provider, String model, long latencyMs, boolean degraded) {
|
||||
if (!enabled || conn == null) return;
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
"INSERT INTO ai_infer_log (id,ts,provider,model,latency_ms,degraded) "
|
||||
+ "VALUES (nextval('seq_ai_infer_log'),?,?,?,?,?)")) {
|
||||
ps.setObject(1, LocalDateTime.now());
|
||||
ps.setString(2, trunc(provider, 40));
|
||||
ps.setString(3, trunc(model, 60));
|
||||
ps.setLong(4, Math.max(0, latencyMs));
|
||||
ps.setBoolean(5, degraded);
|
||||
ps.executeUpdate();
|
||||
} catch (Throwable t) {
|
||||
log.warn("Mall learning store: infer 로그 실패(무시): {}", t.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public synchronized void close() {
|
||||
if (conn != null) {
|
||||
try { conn.close(); } catch (Exception ignore) { /* no-op */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────
|
||||
/** 이메일/카드/전화/주민번호 마스킹(원문 미저장). */
|
||||
static String mask(String s) {
|
||||
if (s == null || s.isBlank()) return s;
|
||||
String out = s;
|
||||
out = SSN.matcher(out).replaceAll("######-#######");
|
||||
out = CARD.matcher(out).replaceAll("****-****-****-****");
|
||||
out = EMAIL.matcher(out).replaceAll("***@***");
|
||||
out = PHONE.matcher(out).replaceAll("***-****-****");
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String trunc(String s, int max) {
|
||||
if (s == null) return null;
|
||||
return s.length() > max ? s.substring(0, max) : s;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,248 @@
|
||||
package com.zioinfo.mall.ai.service;
|
||||
|
||||
import com.zioinfo.mall.admin.AuditService;
|
||||
import com.zioinfo.mall.admin.dto.MallSetting;
|
||||
import com.zioinfo.mall.admin.mapper.SettingMapper;
|
||||
import com.zioinfo.mall.ai.OllamaClient;
|
||||
import com.zioinfo.mall.ai.dto.AiConfigDto;
|
||||
import com.zioinfo.mall.ai.dto.AiConfigUpdateRequest;
|
||||
import com.zioinfo.mall.common.ai.ClaudeTextClient;
|
||||
import com.zioinfo.mall.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 런타임 설정(mall_setting, key='ai.*') 단일 출처 서비스. [GUARDiA-MALL]
|
||||
*
|
||||
* <p><b>해상도</b>: DB(mall_setting) 우선 → 미설정 시 기본값/env 폴백. 시드(db/104) 미적용/키 비움
|
||||
* 상태에서도 기존 Ollama 동작이 바이트 동일하게 유지된다(provider 기본 ollama, ollamaTextModel 미설정 시
|
||||
* {@code guardia.ollama-text-model} env 그대로).
|
||||
*
|
||||
* <p><b>보안</b>: Claude API 키는 본 서비스가 다루지 않는다. {@code claudeKeySet} 은
|
||||
* {@link ClaudeTextClient#isConfigured()}(환경변수 존재 여부)만 반환 — 값/길이/마스킹 일절 미포함.
|
||||
*
|
||||
* <p><b>프로바이더</b>: claude / qwen3 / deepseek / glm / ollama. Ollama 계열(qwen3·deepseek·glm·ollama)은 각
|
||||
* 해당 소형 텍스트 모델로 generate. claude 는 Claude→실패 시 Ollama(선택모델) 폴백(라우터).
|
||||
* glm(glm4:9b)은 서버 RAM 초과 가능(§RAM 배지) — 화이트리스트 등록/선택 허용, 콜드로드 실패 시 폴백.
|
||||
*
|
||||
* <p><b>레퍼런스</b>: guardia-ocr {@code ai.service.AiConfigService} 미러(provider 에 glm 추가).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiConfigService {
|
||||
|
||||
// --- mall_setting 키 (db/104 시드와 일치)
|
||||
public static final String K_PROVIDER = "ai.provider"; // ENUM: ollama|claude|qwen3|deepseek|glm
|
||||
public static final String K_CLAUDE_MODEL = "ai.claude.model"; // ENUM: 화이트리스트
|
||||
public static final String K_OLLAMA_TEXT_MODEL = "ai.ollama.textModel"; // STRING (미설정 시 env 폴백)
|
||||
public static final String K_ENABLED = "ai.enabled"; // BOOL (off=규칙기반 degraded)
|
||||
|
||||
public static final String PROVIDER_OLLAMA = "ollama";
|
||||
public static final String PROVIDER_CLAUDE = "claude";
|
||||
public static final String PROVIDER_QWEN3 = "qwen3";
|
||||
public static final String PROVIDER_DEEPSEEK = "deepseek";
|
||||
public static final String PROVIDER_GLM = "glm";
|
||||
public static final String DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
|
||||
|
||||
// Ollama 계열 provider → 고정 소형/중형 모델(서버 RAM 제약 준수, glm 은 RAM 초과 가능)
|
||||
public static final String MODEL_QWEN3 = "qwen3:1.7b";
|
||||
public static final String MODEL_DEEPSEEK = "deepseek-r1:1.5b";
|
||||
public static final String MODEL_GLM = "glm4:9b";
|
||||
|
||||
/** 허용 provider(임의 문자열 거부). */
|
||||
public static final Set<String> ALLOWED_PROVIDERS =
|
||||
Set.of(PROVIDER_OLLAMA, PROVIDER_CLAUDE, PROVIDER_QWEN3, PROVIDER_DEEPSEEK, PROVIDER_GLM);
|
||||
/** 허용 Claude 모델 ID(임의 문자열 거부). */
|
||||
public static final Set<String> ALLOWED_CLAUDE_MODELS =
|
||||
Set.of("claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8");
|
||||
/** 허용 Ollama 텍스트 모델(임의 문자열 거부). */
|
||||
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.generateText("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) {
|
||||
MallSetting 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) {
|
||||
MallSetting 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,69 @@
|
||||
package com.zioinfo.mall.ai.service;
|
||||
|
||||
import com.zioinfo.mall.ai.OllamaClient;
|
||||
import com.zioinfo.mall.ai.learning.MallLearningStore;
|
||||
import com.zioinfo.mall.common.ai.ClaudeTextClient;
|
||||
import com.zioinfo.mall.common.ai.TextAiClient;
|
||||
import com.zioinfo.mall.common.ai.TextAiClient.GenResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* AI provider 선택 라우팅(런타임). 평문 텍스트 생성 진입점(추천·리뷰요약·자연어검색·CS응답·카드메시지 등). [GUARDiA-MALL]
|
||||
* {@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} → 호출자가 기존 Java 규칙기반 폴백 유지(무회귀).
|
||||
* 각 생성은 로컬 DuckDB {@link MallLearningStore}(ai_infer_log)에 provider/model/지연/degraded 를 기록한다.
|
||||
*
|
||||
* <p><b>레퍼런스</b>: guardia-ocr {@code ai.service.AiTextRouter} 미러(Ollama 텍스트 경로·infer 로그 치환).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiTextRouter implements TextAiClient {
|
||||
|
||||
private final AiConfigService aiConfig;
|
||||
private final ClaudeTextClient claudeClient;
|
||||
private final OllamaClient ollamaClient; // 온프레미스 평문 generate
|
||||
private final MallLearningStore learningStore;
|
||||
|
||||
/**
|
||||
* 선택된 provider 로 텍스트를 생성한다. Claude 선택·실패 시 Ollama 폴백. 예외를 던지지 않는다.
|
||||
*/
|
||||
@Override
|
||||
public GenResult generate(String prompt) {
|
||||
if (aiConfig.isClaudeActive()) {
|
||||
String model = aiConfig.claudeModel();
|
||||
long start = System.currentTimeMillis();
|
||||
GenResult r = claudeClient.generate(prompt, model);
|
||||
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
|
||||
learningStore.recordInfer("claude", model, System.currentTimeMillis() - start, false);
|
||||
return r;
|
||||
}
|
||||
// claude 실패/빈응답 → 온프레미스 Ollama 자동 폴백(선택모델).
|
||||
log.warn("Claude path degraded -> Ollama fallback");
|
||||
return ollamaGenerate(prompt, aiConfig.ollamaTextModel(), "claude->ollama");
|
||||
}
|
||||
// provider = ollama / qwen3 / deepseek / glm → 해당 Ollama 모델
|
||||
return ollamaGenerate(prompt, aiConfig.ollamaTextModel(), aiConfig.provider());
|
||||
}
|
||||
|
||||
/** Ollama 평문 generate 를 GenResult 로 래핑(실패 시 degraded) + infer 로그. */
|
||||
private GenResult ollamaGenerate(String prompt, String model, String providerLabel) {
|
||||
long start = System.currentTimeMillis();
|
||||
String txt = ollamaClient.generateText(prompt, model);
|
||||
boolean degraded = (txt == null || txt.isBlank());
|
||||
learningStore.recordInfer(providerLabel, model, System.currentTimeMillis() - start, degraded);
|
||||
if (degraded) {
|
||||
return new GenResult(null, true);
|
||||
}
|
||||
return new GenResult(txt, false);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
package com.zioinfo.mall.common.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.mall.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-MALL]
|
||||
*
|
||||
* <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 문자열 이스케이프(Ollama 프롬프트 직렬화와 동일 규칙). */
|
||||
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.mall.common.ai;
|
||||
|
||||
/**
|
||||
* 텍스트 생성 공용 인터페이스 (AI provider 추상화). [GUARDiA-MALL]
|
||||
*
|
||||
* <p>온프레미스 Ollama({@code ai.OllamaClient#generateText})와 외부 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) {
|
||||
}
|
||||
}
|
||||
@ -71,6 +71,10 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/admin/audit").hasAnyRole("ADMIN", "MANAGER")
|
||||
.requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("ADMIN", "MANAGER")
|
||||
.requestMatchers("/api/admin/settings/**").hasRole("ADMIN")
|
||||
// AI 플랫폼(LLM provider) 설정 — ADMIN 전용(조회/갱신/연결테스트)
|
||||
.requestMatchers("/api/admin/ai-config/**", "/api/admin/ai-config").hasRole("ADMIN")
|
||||
// AI 답변 피드백 수집(로컬 DuckDB + 중앙 rag 전달) — 인증 사용자
|
||||
.requestMatchers(HttpMethod.POST, "/api/ai/feedback").authenticated()
|
||||
// 운영 분석 — MANAGER 이상
|
||||
.requestMatchers("/api/mall/analytics/**").hasAnyRole("ADMIN", "MANAGER")
|
||||
.requestMatchers("/api/mall/settlement/**").hasAnyRole("ADMIN", "MANAGER")
|
||||
|
||||
@ -14,7 +14,7 @@ spring:
|
||||
sql:
|
||||
init:
|
||||
mode: ${SQL_INIT_MODE:always}
|
||||
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
|
||||
servlet:
|
||||
multipart:
|
||||
@ -60,6 +60,10 @@ guardia:
|
||||
ocr-url: ${OCR_URL:http://localhost:8005}
|
||||
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
||||
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
|
||||
# 로컬 임베디드 DuckDB 학습 저장소 파일(솔루션 격리). 경로 미가용/드라이버 부재 시 자동 비활성(no-op).
|
||||
mall:
|
||||
learning:
|
||||
duckdb-path: ${MALL_LEARNING_DUCKDB:/opt/guardia-mall/data/mall_learning.duckdb}
|
||||
# 중앙 guardia-rag(온프레미스 전용) — 최신 AI 기법 경유. 미가용 시 Mall 로컬 폴백(degraded)
|
||||
rag:
|
||||
base-url: ${RAG_URL:http://127.0.0.1:8020}
|
||||
|
||||
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 Mall — 104. AI 플랫폼(LLM provider) 설정 시드 (멱등)
|
||||
-- 대상 DB : mall_db (테이블 mall_setting — schema.sql 에서 생성)
|
||||
-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일 등재.
|
||||
-- mode:always 재실행 안전(ON CONFLICT DO NOTHING).
|
||||
-- 설명 : 외부 Claude(Anthropic) API 를 온프레미스 Ollama 와 병행·선택형으로 추가.
|
||||
-- 관리자(ADMIN)가 런타임으로 provider/모델을 선택(재기동 불요, AiConfigService).
|
||||
-- * ai.provider : ollama(기본) / claude / qwen3 / deepseek / glm
|
||||
-- * ai.claude.model : claude-sonnet-4-6(기본) / claude-haiku-4-5 / claude-opus-4-8
|
||||
-- * ai.ollama.textModel: 빈값(=서버 프로퍼티 guardia.ollama-text-model 폴백, llama3.2:1b)
|
||||
-- * ai.enabled : true(기본, off=규칙기반 degraded)
|
||||
-- 보안 : Claude API 키는 DB 에 저장하지 않는다 — 서버 환경변수 ANTHROPIC_API_KEY 로만 주입.
|
||||
-- 본 시드에 키/시크릿/IP/비밀번호 일절 미포함.
|
||||
-- 무회귀 : 기본 provider=ollama → 설정 미변경 시 기존 Ollama 동작과 바이트 동일.
|
||||
-- 멱등 : INSERT ... ON CONFLICT (key) DO NOTHING.
|
||||
-- =====================================================================
|
||||
|
||||
SET client_encoding = 'UTF8';
|
||||
|
||||
INSERT INTO mall_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
@ -15,8 +15,8 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script type="module" crossorigin src="/assets/index-_1lL5Le4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B51ISNUZ.css">
|
||||
<script type="module" crossorigin src="/assets/index-CbRMKyZ9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CS-pep58.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
doc/guardia-mall_아키텍처설계서_v1.0.pptx
Normal file
BIN
doc/guardia-mall_아키텍처설계서_v1.0.pptx
Normal file
Binary file not shown.
@ -42,6 +42,7 @@ import AuditLog from './admin/AuditLog'
|
||||
import Settings from './admin/Settings'
|
||||
import AdminApp from './admin/AdminApp'
|
||||
import AiTechniques from './admin/AiTechniques'
|
||||
import AiPlatformSettings from './admin/AiPlatformSettings'
|
||||
|
||||
// UIWS 이식 — 업무 모듈(관리자 영역 병합, 고객 쇼핑 화면 무영향)
|
||||
import UiwsLayout from './pages/uiws/UiwsLayout'
|
||||
@ -110,6 +111,7 @@ export default function App() {
|
||||
<Route path="audit" element={<AuditLog />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="ai-techniques" element={<AiTechniques />} />
|
||||
<Route path="ai-platform" element={<AiPlatformSettings />} />
|
||||
<Route path="app" element={<AdminApp />} />
|
||||
{/* UIWS 이식 업무 모듈 (관리자 영역 병합) */}
|
||||
<Route path="uiws/worklog" element={<UiwsLayout><UiwsWorklog /></UiwsLayout>} />
|
||||
|
||||
@ -3,7 +3,7 @@ import { Outlet, Navigate, NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
|
||||
Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone,
|
||||
ClipboardList, CalendarDays, Mail, PieChart, Sparkles,
|
||||
ClipboardList, CalendarDays, Mail, PieChart, Sparkles, Cpu,
|
||||
ShieldCheck, KeySquare, ListTree, Menu as MenuIcon, Building2, Briefcase,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -33,6 +33,7 @@ const adminLinks = [
|
||||
// 최신 AI 기법(중앙 guardia-rag) 토글 — 라벨 i18n 미의존(고정 표기), 변경은 MANAGER+(USER 차단)
|
||||
const aiLinks = [
|
||||
{ to: '/admin/ai-techniques', label: 'AI Techniques', icon: Sparkles, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/ai-platform', label: 'AI 플랫폼 설정', icon: Cpu, roles: ['ADMIN'] },
|
||||
]
|
||||
// UIWS 이식 — 업무 모듈(관리자 영역 병합). 라벨은 i18n 미의존(고정 한/영 안전 표기).
|
||||
const uiwsLinks = [
|
||||
|
||||
270
frontend/src/admin/AiPlatformSettings.tsx
Normal file
270
frontend/src/admin/AiPlatformSettings.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
// AI 플랫폼(LLM Provider) 설정(ADMIN) — 제공자·모델 선택 + 연결 테스트.
|
||||
// API 키 값은 화면에 절대 표시/입력하지 않는다(claudeKeySet 으로 설정 여부 배지만).
|
||||
// 저장은 provider/claudeModel 만 전송(ollama 계열 모델·전역 활성은 읽기 전용 표시).
|
||||
// 레퍼런스: guardia-ocr AiPlatformSettings.tsx (Mall 관리자 다크 콘솔 스타일, glm 프로바이더 + RAM 배지 추가).
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Cpu, KeyRound, PlugZap, Save, AlertTriangle } from 'lucide-react'
|
||||
import { getAiConfig, updateAiConfig, testAiConfig } from '../api/adminAiConfig'
|
||||
import type { AiProvider, ClaudeModel, AiTestResult } from '../api/adminAiConfig'
|
||||
import AiFeedback from '../components/AiFeedback'
|
||||
|
||||
const PROVIDERS: { id: AiProvider; label: string; hint: string }[] = [
|
||||
{ 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 증설 필요' },
|
||||
{ id: 'claude', label: 'Claude (외부)', hint: 'api.anthropic.com · 키 필요' },
|
||||
]
|
||||
|
||||
const CLAUDE_MODELS: ClaudeModel[] = ['claude-sonnet-4-6', 'claude-haiku-4-5', 'claude-opus-4-8']
|
||||
const DEFAULT_CLAUDE_MODEL: ClaudeModel = 'claude-sonnet-4-6'
|
||||
const normModel = (v: string): ClaudeModel =>
|
||||
(CLAUDE_MODELS as string[]).includes(v) ? (v as ClaudeModel) : DEFAULT_CLAUDE_MODEL
|
||||
|
||||
function friendlyError(e: any): string {
|
||||
const msg: string = e?.response?.data?.message || ''
|
||||
if (e?.response?.status === 403) return '권한이 없습니다 (AI 설정은 ADMIN 전용).'
|
||||
if (msg.includes('ERR-AI-400')) return msg.replace(/^ERR-AI-400:\s*/, '')
|
||||
return 'AI 설정을 처리하지 못했습니다.'
|
||||
}
|
||||
|
||||
export default function AiPlatformSettings() {
|
||||
const [provider, setProvider] = useState<AiProvider>('ollama')
|
||||
const [claudeModel, setClaudeModel] = useState<ClaudeModel>(DEFAULT_CLAUDE_MODEL)
|
||||
const [claudeKeySet, setClaudeKeySet] = useState(false)
|
||||
const [ollamaTextModel, setOllamaTextModel] = useState('')
|
||||
const [aiEnabled, setAiEnabled] = useState(true)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<AiTestResult | null>(null)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
getAiConfig()
|
||||
.then(dto => {
|
||||
setProvider(dto.provider)
|
||||
setClaudeModel(normModel(dto.claudeModel ?? DEFAULT_CLAUDE_MODEL))
|
||||
setClaudeKeySet(!!dto.claudeKeySet)
|
||||
setOllamaTextModel(dto.ollamaTextModel ?? '')
|
||||
setAiEnabled(!!dto.aiEnabled)
|
||||
setErr('')
|
||||
})
|
||||
.catch(e => setErr(friendlyError(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const markDirty = () => { setSaved(false); setTestResult(null) }
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
try {
|
||||
const dto = await updateAiConfig({ provider, claudeModel })
|
||||
setProvider(dto.provider)
|
||||
setClaudeModel(normModel(dto.claudeModel ?? DEFAULT_CLAUDE_MODEL))
|
||||
setClaudeKeySet(!!dto.claudeKeySet)
|
||||
setOllamaTextModel(dto.ollamaTextModel ?? '')
|
||||
setAiEnabled(!!dto.aiEnabled)
|
||||
setSaved(true)
|
||||
setErr('')
|
||||
} catch (e) {
|
||||
setErr(friendlyError(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
setTestResult(await testAiConfig())
|
||||
} catch (e) {
|
||||
setTestResult({ ok: false, degraded: false, message: friendlyError(e) })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isClaude = provider === 'claude'
|
||||
const isGlm = provider === 'glm'
|
||||
const showKeyFallback = isClaude && !claudeKeySet
|
||||
const showDisabledFallback = !isClaude && !aiEnabled
|
||||
|
||||
const statusClass = testResult
|
||||
? testResult.degraded
|
||||
? 'text-amber-300'
|
||||
: testResult.ok
|
||||
? 'text-emerald-300'
|
||||
: 'text-rose-300'
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<Cpu size={20} className="text-brand" /> AI 플랫폼 설정
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
상품추천·리뷰요약·CS응답 등 텍스트 생성에 사용할 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>
|
||||
)}
|
||||
|
||||
{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 증설 전까지 콜드로드 실패 시 Ollama 소형모델로 폴백합니다.
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* ── 제공자 & 모델 ── */}
|
||||
<section className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<Cpu size={16} className="text-brand" /> 제공자 & 모델
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
{PROVIDERS.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => { setProvider(p.id); markDirty() }}
|
||||
className={`text-left px-3 py-2.5 rounded-lg border text-sm transition-colors ${
|
||||
provider === p.id
|
||||
? 'bg-brand/15 border-brand text-brand'
|
||||
: 'bg-ink border-edge text-slate-300 hover:border-brand/50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold flex items-center gap-1">
|
||||
{p.label}
|
||||
{p.id === 'glm' && <AlertTriangle size={12} className="text-amber-400" />}
|
||||
</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">
|
||||
온프레미스 모델 — 외부 호출 없음. glm 은 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() }}
|
||||
className="w-full px-3 py-2 rounded-lg bg-ink border border-edge text-sm focus:border-brand outline-none"
|
||||
>
|
||||
{CLAUDE_MODELS.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-slate-500 mb-1 flex items-center gap-1">
|
||||
<KeyRound size={13} /> API 키
|
||||
</label>
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-lg text-xs font-semibold ${
|
||||
claudeKeySet
|
||||
? 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/40'
|
||||
: 'bg-rose-500/15 text-rose-300 border border-rose-500/40'
|
||||
}`}
|
||||
>
|
||||
{claudeKeySet ? '설정됨' : '미설정'}
|
||||
</span>
|
||||
<p className="text-[11px] text-slate-500 mt-1.5">
|
||||
키는 서버 환경변수(ANTHROPIC_API_KEY)로만 주입되며 화면·응답·로그에 노출되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={saving || loading}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand text-ink text-sm font-semibold disabled:opacity-40"
|
||||
>
|
||||
<Save size={15} /> {saving ? '저장 중…' : '저장'}
|
||||
</button>
|
||||
{saved && <span className="text-emerald-300 text-xs">저장됨</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 연결 테스트 ── */}
|
||||
<section className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||
<PlugZap size={16} className="text-brand" /> 연결 테스트
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
저장된 설정 기준으로 선택한 제공자에 짧은 ping 을 보내 연결을 확인합니다(요약 결과만).
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={runTest}
|
||||
disabled={testing}
|
||||
className="px-4 py-2 rounded-lg bg-card border border-edge text-sm disabled:opacity-40 hover:border-brand/50"
|
||||
>
|
||||
{testing ? '테스트 중…' : '테스트 실행'}
|
||||
</button>
|
||||
{testResult && (
|
||||
<span className={`text-sm ${statusClass}`} role="status" aria-live="polite">
|
||||
{testResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 mt-3">
|
||||
먼저 변경 사항을 저장한 뒤 테스트하세요. Claude 실패 시 Ollama 로 자동 폴백합니다.
|
||||
</p>
|
||||
|
||||
{/* 피드백(로컬 DuckDB + 중앙 rag) — 설정/응답 품질 개선 루프 */}
|
||||
<div className="mt-4 pt-4 border-t border-edge">
|
||||
<AiFeedback feature="ai-config" question="AI 플랫폼 설정 화면" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
frontend/src/api/adminAiConfig.ts
Normal file
51
frontend/src/api/adminAiConfig.ts
Normal file
@ -0,0 +1,51 @@
|
||||
// AI 플랫폼(LLM Provider) 설정(ADMIN) API — /api/admin/ai-config (ADMIN 전용).
|
||||
// API 키 값은 응답에 절대 포함되지 않음(claudeKeySet 플래그만).
|
||||
// PUT 은 provider/claudeModel(+선택 ollamaTextModel) 전송.
|
||||
// test 는 저장된 설정 기준 선택 provider 짧은 ping.
|
||||
// 레퍼런스: guardia-ocr frontend/src/api/adminAiConfig.ts (Mall same-origin axios 패턴, glm 추가).
|
||||
import api from './client'
|
||||
|
||||
/** LLM 제공자: ollama(온프레미스)/claude(외부)/qwen3/deepseek/glm(온프레미스). */
|
||||
export type AiProvider = 'ollama' | 'claude' | 'qwen3' | 'deepseek' | 'glm'
|
||||
|
||||
/** 화이트리스트 Claude 모델 ID. */
|
||||
export type ClaudeModel = 'claude-sonnet-4-6' | 'claude-haiku-4-5' | 'claude-opus-4-8'
|
||||
|
||||
/** AI 설정 조회 DTO — API 키 값 미반환(claudeKeySet 으로 설정 여부만). */
|
||||
export interface AiConfigDto {
|
||||
provider: AiProvider
|
||||
/** Ollama 텍스트 모델(provider 별 해석 결과, 읽기 전용 표시). */
|
||||
ollamaTextModel: string
|
||||
/** 선택된 Claude 모델 ID(미설정 시 서버 기본 claude-sonnet-4-6). */
|
||||
claudeModel: string
|
||||
/** 서버 환경변수 ANTHROPIC_API_KEY 존재 여부(값·길이·마스킹 일절 미포함). */
|
||||
claudeKeySet: boolean
|
||||
/** 전역 AI 사용 여부(off 시 규칙 기반 degraded). */
|
||||
aiEnabled: boolean
|
||||
}
|
||||
|
||||
/** 저장 요청 — provider 필수, claudeModel/ollamaTextModel 선택. */
|
||||
export interface AiConfigUpdateRequest {
|
||||
provider: AiProvider
|
||||
claudeModel?: ClaudeModel
|
||||
ollamaTextModel?: string
|
||||
}
|
||||
|
||||
/** 연결 테스트 결과. */
|
||||
export interface AiTestResult {
|
||||
ok: boolean
|
||||
degraded: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
/** GET /api/admin/ai-config — 현재 AI 설정(API 키 값 제외). */
|
||||
export const getAiConfig = () =>
|
||||
api.get('/api/admin/ai-config').then(r => r.data?.data as AiConfigDto)
|
||||
|
||||
/** PUT /api/admin/ai-config — provider/claudeModel 저장 → 반영분. */
|
||||
export const updateAiConfig = (body: AiConfigUpdateRequest) =>
|
||||
api.put('/api/admin/ai-config', body).then(r => r.data?.data as AiConfigDto)
|
||||
|
||||
/** POST /api/admin/ai-config/test — 저장된 설정 기준 선택 provider 짧은 ping. */
|
||||
export const testAiConfig = () =>
|
||||
api.post('/api/admin/ai-config/test', {}).then(r => r.data?.data as AiTestResult)
|
||||
26
frontend/src/api/aiFeedback.ts
Normal file
26
frontend/src/api/aiFeedback.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// AI 답변 피드백 수집 API — /api/ai/feedback (인증 사용자).
|
||||
// 👍/👎(+교정)을 로컬 DuckDB + 중앙 guardia-rag 로 전달(둘 다). PII 는 서버에서 마스킹.
|
||||
import api from './client'
|
||||
|
||||
export interface AiFeedbackRequest {
|
||||
/** 기능 식별자(예: recommend / review-summary / cs-reply / card-message / ai-config). */
|
||||
feature: string
|
||||
/** 사용자 질의/프롬프트(선택). */
|
||||
question?: string
|
||||
/** AI 답변(선택). */
|
||||
answer?: string
|
||||
/** 평가: up(👍) / down(👎). */
|
||||
verdict: 'up' | 'down'
|
||||
/** 교정 내용(선택). */
|
||||
correction?: string
|
||||
}
|
||||
|
||||
export interface AiFeedbackResult {
|
||||
storedLocal: boolean
|
||||
forwardedCentral: boolean
|
||||
verdict: string
|
||||
}
|
||||
|
||||
/** POST /api/ai/feedback — 피드백 기록(로컬 DuckDB + 중앙 rag). */
|
||||
export const sendAiFeedback = (body: AiFeedbackRequest) =>
|
||||
api.post('/api/ai/feedback', body).then(r => r.data?.data as AiFeedbackResult)
|
||||
95
frontend/src/components/AiFeedback.tsx
Normal file
95
frontend/src/components/AiFeedback.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
// AI 답변 피드백 UI(👍/👎 + 교정) — 재사용 컴포넌트.
|
||||
// AI 결과 화면 하단에 배치. 로컬 DuckDB + 중앙 guardia-rag 로 전달(둘 다). PII 는 서버 마스킹.
|
||||
import { useState } from 'react'
|
||||
import { ThumbsUp, ThumbsDown } from 'lucide-react'
|
||||
import { sendAiFeedback } from '../api/aiFeedback'
|
||||
|
||||
interface Props {
|
||||
/** 기능 식별자(예: recommend / review-summary / cs-reply / ai-config). */
|
||||
feature: string
|
||||
/** 사용자 질의/프롬프트(선택). */
|
||||
question?: string
|
||||
/** AI 답변(선택). */
|
||||
answer?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 👍/👎 피드백 위젯. 👎 선택 시 교정 입력을 노출한다.
|
||||
* 서버(/api/ai/feedback)가 로컬 DuckDB 기록 + 중앙 rag 전달을 동시에 수행한다.
|
||||
*/
|
||||
export default function AiFeedback({ feature, question, answer, className }: Props) {
|
||||
const [verdict, setVerdict] = useState<'up' | 'down' | null>(null)
|
||||
const [correction, setCorrection] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (v: 'up' | 'down', corr?: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await sendAiFeedback({ feature, question, answer, verdict: v, correction: corr })
|
||||
setSent(true)
|
||||
} catch {
|
||||
// 피드백 실패는 조용히 무시(본기능 무영향)
|
||||
setSent(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pick = (v: 'up' | 'down') => {
|
||||
setVerdict(v)
|
||||
if (v === 'up') submit('up')
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return <div className={`text-xs text-emerald-300 ${className ?? ''}`}>피드백 감사합니다.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className ?? ''}`}>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span>이 답변이 도움이 되었나요?</span>
|
||||
<button
|
||||
onClick={() => pick('up')}
|
||||
disabled={busy}
|
||||
aria-label="도움됨"
|
||||
className={`p-1.5 rounded-lg border transition-colors ${
|
||||
verdict === 'up' ? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-300'
|
||||
: 'bg-ink border-edge text-slate-300 hover:border-emerald-500/50'
|
||||
}`}
|
||||
>
|
||||
<ThumbsUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => pick('down')}
|
||||
disabled={busy}
|
||||
aria-label="도움 안됨"
|
||||
className={`p-1.5 rounded-lg border transition-colors ${
|
||||
verdict === 'down' ? 'bg-rose-500/15 border-rose-500/40 text-rose-300'
|
||||
: 'bg-ink border-edge text-slate-300 hover:border-rose-500/50'
|
||||
}`}
|
||||
>
|
||||
<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-xs focus:border-brand outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => submit('down', correction)}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 rounded-lg bg-brand text-ink text-xs font-semibold disabled:opacity-40"
|
||||
>
|
||||
보내기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user