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

This commit is contained in:
GUARDiA 2026-07-03 23:27:28 +09:00
parent e4ccc688c3
commit d404678d4c
26 changed files with 1627 additions and 223 deletions

View File

@ -42,6 +42,9 @@
<!-- DB Driver --> <!-- DB Driver -->
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency> <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- DuckDB — 로컬 임베디드 AI 학습 저장소(ai_feedback·ai_infer_log). 파일: /opt/guardia-hrm/data/hrm_learning.duckdb -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
<!-- JWT --> <!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</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-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>

View File

@ -1,37 +1,27 @@
package com.zioinfo.hrm.ai; package com.zioinfo.hrm.ai;
import com.zioinfo.hrm.ai.service.AiTextRouter;
import com.zioinfo.hrm.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Ollama 온프레미스 AI 인사 분석 서비스. * AI 인사 분석 서비스. 텍스트 생성은 {@link AiTextRouter} 경유(provider 선택: Claude Ollama).
* 외부 AI API 절대 금지 localhost:11434 . * 외부 AI API 소유자 승인 예외인 Claude(api.anthropic.com) 허용, 온프레미스 Ollama .
* Ollama 응답 실패 Java 폴백 로직으로 대체. * Claude/Ollama 응답 실패 Java 폴백 로직으로 대체(무회귀).
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AiService { public class AiService {
@Value("${guardia.ollama-url:http://localhost:11434}") /** provider 라우터(Claude→Ollama 폴백 + 추론 로그). 기존 Ollama 직접호출을 대체. */
private String ollamaUrl; private final AiTextRouter aiTextRouter;
@Value("${guardia.ollama-text-model:llama3}")
private String textModel;
private WebClient ollamaClient() {
return WebClient.builder().baseUrl(ollamaUrl)
.codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))
.build();
}
public Map<String, Object> predictTurnover(Long empId) { public Map<String, Object> predictTurnover(Long empId) {
try { try {
@ -117,16 +107,12 @@ public class AiService {
} }
private Map<String, Object> callOllama(String prompt) { private Map<String, Object> callOllama(String prompt) {
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false); // provider 라우터 경유: provider=claude·키설정·활성 Claude, 실패/그외 Ollama(선택모델) 폴백.
@SuppressWarnings("unchecked") GenResult gr = aiTextRouter.generate(prompt);
Map<String, Object> resp = ollamaClient().post().uri("/api/generate") if (gr.degraded() || gr.text() == null || gr.text().isBlank()) {
.bodyValue(body) return fallbackTurnover();
.retrieve() }
.bodyToMono(Map.class) String response = gr.text();
.timeout(Duration.ofSeconds(30))
.block();
if (resp == null) return fallbackTurnover();
String response = (String) resp.getOrDefault("response", "{}");
// JSON 추출 // JSON 추출
int start = response.indexOf('{'); int start = response.indexOf('{');
int end = response.lastIndexOf('}'); int end = response.lastIndexOf('}');
@ -134,10 +120,10 @@ public class AiService {
// 간단 파싱: 실제로는 Jackson 사용 // 간단 파싱: 실제로는 Jackson 사용
Map<String, Object> r = new HashMap<>(); Map<String, Object> r = new HashMap<>();
r.put("raw", response.substring(start, end + 1)); r.put("raw", response.substring(start, end + 1));
r.put("source", "ollama"); r.put("source", "ai");
return r; return r;
} }
return Map.of("response", response, "source", "ollama"); return Map.of("response", response, "source", "ai");
} }
private Map<String, Object> fallbackTurnover() { private Map<String, Object> fallbackTurnover() {

View File

@ -0,0 +1,55 @@
package com.zioinfo.hrm.ai.controller;
import com.zioinfo.hrm.ai.dto.AiConfigDto;
import com.zioinfo.hrm.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.hrm.ai.service.AiConfigService;
import com.zioinfo.hrm.common.ApiResponse;
import com.zioinfo.hrm.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
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-HRM]
* SecurityConfig {@code /api/hrm/admin/**} = hasRole(SUPERADMIN) 게이트로 보호된다.
*
* <p>hrm_settings(key='ai.*') 저장된 런타임 설정을 조회/갱신/테스트한다. <b>API 키는 환경변수
* (ANTHROPIC_API_KEY)로만 주입</b>되어 응답·로그에 노출되지 않는다(GET {@code claudeKeySet} 불리언만).
* 설정 변경은 재기동 없이 다음 AI 호출부터 반영된다.
*
* <ul>
* <li>GET /api/hrm/admin/ai-config 현재 효과 설정( 제외, keySet 불리언만)</li>
* <li>PUT /api/hrm/admin/ai-config provider/모델 갱신(화이트리스트 검증)</li>
* <li>POST /api/hrm/admin/ai-config/test 저장 설정 기준 선택 provider 연결 테스트(요약 결과만)</li>
* </ul>
*/
@RestController
@RequestMapping("/api/hrm/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, Authentication auth) {
return ApiResponse.ok(service.update(req, AuthSupport.actor(auth)));
}
@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,64 @@
package com.zioinfo.hrm.ai.controller;
import com.zioinfo.hrm.ai.dto.FeedbackRequest;
import com.zioinfo.hrm.ai.learning.CentralFeedbackForwarder;
import com.zioinfo.hrm.ai.learning.LearningStore;
import com.zioinfo.hrm.ai.learning.PiiMasker;
import com.zioinfo.hrm.common.ApiResponse;
import lombok.RequiredArgsConstructor;
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 java.util.LinkedHashMap;
import java.util.Map;
/**
* AI 답변 피드백 수집(인증 사용자). [GUARDiA-HRM]
*
* <p>{@code POST /api/hrm/ai/feedback} 👍/👎 + 교정문을 로컬 DuckDB({@link LearningStore}) 적재 +
* 중앙 guardia-rag {@code /feedback} 전달( ). 텍스트는 적재/전달 {@link PiiMasker} 마스킹된다
* (인사 데이터 특성상 필수). DuckDB 미가용 시에도 중앙 전달은 시도하며, 어느 실패도 사용자 흐름을
* 막지 않는다(무회귀).
*/
@RestController
@RequestMapping("/api/hrm/ai")
@RequiredArgsConstructor
public class FeedbackController {
private final LearningStore learningStore;
private final CentralFeedbackForwarder forwarder;
@PostMapping("/feedback")
public ApiResponse<Map<String, Object>> feedback(@RequestBody FeedbackRequest req, Authentication auth) {
String verdict = normalizeVerdict(req.verdict());
String user = auth != null ? auth.getName() : "anon";
// 로컬 DuckDB 적재(내부에서 PII 마스킹).
boolean stored = learningStore.recordFeedback(
req.feature(), req.question(), req.answer(), verdict, req.correction(), user);
// 중앙 rag 전달(마스킹된 값만).
forwarder.forward(
req.feature(),
PiiMasker.mask(req.question()),
PiiMasker.mask(req.answer()),
verdict,
PiiMasker.mask(req.correction()));
Map<String, Object> out = new LinkedHashMap<>();
out.put("storedLocal", stored);
out.put("verdict", verdict);
return ApiResponse.ok(out);
}
private static String normalizeVerdict(String v) {
if (v == null) {
return "up";
}
String s = v.trim().toLowerCase();
return "down".equals(s) ? "down" : "up";
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.hrm.ai.dto;
/**
* AI 설정 조회 DTO API 미반환(claudeKeySet 으로 설정 여부만). [GUARDiA-HRM]
*
* @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.hrm.ai.dto;
/**
* AI 설정 저장 요청 화이트리스트 검증. [GUARDiA-HRM]
*
* @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,18 @@
package com.zioinfo.hrm.ai.dto;
/**
* AI 답변 피드백 요청. [GUARDiA-HRM]
*
* @param feature 피드백 대상 기능(turnover/performance/insights , 표시용)
* @param question 질문/프롬프트(적재 PII 마스킹)
* @param answer AI 답변(적재 PII 마스킹)
* @param verdict up | down
* @param correction 교정문(선택, 적재 PII 마스킹)
*/
public record FeedbackRequest(
String feature,
String question,
String answer,
String verdict,
String correction) {
}

View File

@ -0,0 +1,95 @@
package com.zioinfo.hrm.ai.learning;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* 피드백을 중앙 guardia-rag {@code /feedback} best-effort 전달(통합 학습·평가 게이트). [GUARDiA-HRM]
*
* <p><b>온프레미스 전용</b>: base-url 서버 내부 루프백(127.0.0.1:8020) 외부 URL 금지.
* 실패(미가용/타임아웃/비활성) 조용히 무시한다 로컬 DuckDB 적재가 1차 저장소이므로 무회귀.
* 페이로드의 텍스트는 이미 {@link PiiMasker} 마스킹된 값만 전달한다.
*
* <p><b>레퍼런스</b>: guardia-bi {@code ai.learning.CentralFeedbackForwarder} 미러(RagProperties@Value).
*/
@Slf4j
@Component
public class CentralFeedbackForwarder {
/** 중앙 guardia-rag 베이스 URL(서버 내부 루프백 기본). 외부 URL 설정 금지. */
@Value("${guardia.rag.base-url:http://127.0.0.1:8020}")
private String ragBaseUrl;
/** RAG 경유 마스터 스위치. false 면 전달 스킵(로컬 DuckDB 적재만). */
@Value("${guardia.rag.enabled:true}")
private boolean ragEnabled;
/** 중앙 rag 로 피드백 전달(마스킹된 값). 실패는 무시. */
public void forward(String feature, String maskedQuestion, String maskedAnswer,
String verdict, String maskedCorrection) {
if (!ragEnabled) {
return;
}
try {
String url = trimTrailingSlash(ragBaseUrl) + "/feedback";
String payload = "{"
+ "\"solution\":\"" + LearningStore.SOLUTION + "\","
+ "\"feature\":" + json(feature) + ","
+ "\"query\":" + json(maskedQuestion) + ","
+ "\"answer\":" + json(maskedAnswer) + ","
+ "\"verdict\":" + json(verdict) + ","
+ "\"correction\":" + json(maskedCorrection)
+ "}";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5))
.header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
client.send(req, HttpResponse.BodyHandlers.discarding());
} catch (Exception e) {
log.warn("중앙 rag 피드백 전달 실패(무시): {}", e.getClass().getSimpleName());
}
}
private static String trimTrailingSlash(String s) {
if (s == null || s.isBlank()) {
return "http://127.0.0.1:8020";
}
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
private static String json(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,133 @@
package com.zioinfo.hrm.ai.learning;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Statement;
/**
* 로컬 임베디드 DuckDB 학습 저장소. [GUARDiA-HRM]
*
* <p><b>목적</b>: AI 피드백(👍/👎+교정)·추론 로그를 솔루션 로컬 파일에 축적해 오프라인 AI 분석/학습 데이터셋
* 근거로 삼는다(중앙 guardia-rag 전달과 병행). 파일 격리: {@code /opt/guardia-hrm/data/hrm_learning.duckdb}
* (env {@code guardia.learning.duckdb-path} override).
*
* <p><b>스키마(멱등)</b>:
* <ul>
* <li>{@code ai_feedback(id, ts, solution, feature, question, answer, verdict, correction, user_masked)}</li>
* <li>{@code ai_infer_log(id, ts, provider, model, latency_ms, degraded)}</li>
* </ul>
*
* <p><b>안전성(무회귀)</b>: 어떤 사유로든(드라이버 미탑재·경로 생성 실패·잠금) 초기화 실패 저장소를
* <b>비활성</b>으로 두고 예외를 던지지 않는다 기록은 no-op 되며 AI 기능은 그대로 동작한다.
* PII {@link PiiMasker} 적재 마스킹한다(인사 데이터 특성상 필수).
*
* <p>DuckDB 파일당 단일 read-write 커넥션만 허용하므로 커넥션을 보유하고 {@code synchronized}
* 접근을 직렬화한다(피드백/추론 로그는 저빈도).
*
* <p><b>레퍼런스</b>: guardia-bi {@code ai.learning.LearningStore} 미러(SOLUTION·경로 치환).
*/
@Slf4j
@Component
public class LearningStore {
public static final String SOLUTION = "hrm";
@Value("${guardia.learning.duckdb-path:/opt/guardia-hrm/data/hrm_learning.duckdb}")
private String duckdbPath;
private volatile boolean enabled = false;
private Connection conn;
@PostConstruct
synchronized void init() {
try {
Path p = Paths.get(duckdbPath);
Path parent = p.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
// 드라이버 로드(신규 의존성 org.duckdb:duckdb_jdbc). 미탑재/환경 문제 비활성 폴백.
Class.forName("org.duckdb.DuckDBDriver");
conn = DriverManager.getConnection("jdbc:duckdb:" + p.toAbsolutePath());
try (Statement st = conn.createStatement()) {
st.execute("CREATE SEQUENCE IF NOT EXISTS ai_feedback_seq START 1");
st.execute("CREATE SEQUENCE IF NOT EXISTS ai_infer_seq START 1");
st.execute("CREATE TABLE IF NOT EXISTS ai_feedback ("
+ "id BIGINT DEFAULT nextval('ai_feedback_seq') PRIMARY KEY,"
+ "ts TIMESTAMP DEFAULT now(),"
+ "solution VARCHAR, feature VARCHAR, question VARCHAR, answer VARCHAR,"
+ "verdict VARCHAR, correction VARCHAR, user_masked VARCHAR)");
st.execute("CREATE TABLE IF NOT EXISTS ai_infer_log ("
+ "id BIGINT DEFAULT nextval('ai_infer_seq') PRIMARY KEY,"
+ "ts TIMESTAMP DEFAULT now(),"
+ "provider VARCHAR, model VARCHAR, latency_ms BIGINT, degraded BOOLEAN)");
}
enabled = true;
log.info("DuckDB 학습 저장소 활성: {}", p.toAbsolutePath());
} catch (Throwable t) { // ClassNotFound·IO·SQL·잠금 조용히 비활성(무회귀)
enabled = false;
log.warn("DuckDB 학습 저장소 비활성(무회귀 폴백): {}", t.getClass().getSimpleName());
}
}
public boolean isEnabled() {
return enabled;
}
/** 피드백 1건 적재(PII 마스킹). 실패 시 조용히 무시(false 반환). */
public synchronized boolean recordFeedback(String feature, String question, String answer,
String verdict, String correction, String user) {
if (!enabled || conn == null) {
return false;
}
String sql = "INSERT INTO ai_feedback (solution, feature, question, answer, verdict, correction, user_masked)"
+ " VALUES (?,?,?,?,?,?,?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, SOLUTION);
ps.setString(2, trunc(feature, 100));
ps.setString(3, PiiMasker.mask(trunc(question, 4000)));
ps.setString(4, PiiMasker.mask(trunc(answer, 8000)));
ps.setString(5, trunc(verdict, 10));
ps.setString(6, PiiMasker.mask(trunc(correction, 8000)));
ps.setString(7, PiiMasker.maskUser(user));
ps.executeUpdate();
return true;
} catch (Exception e) {
log.warn("DuckDB feedback 적재 실패(무시): {}", e.getClass().getSimpleName());
return false;
}
}
/** 추론 로그 1건 적재. 실패 시 조용히 무시. */
public synchronized void recordInfer(String provider, String model, long latencyMs, boolean degraded) {
if (!enabled || conn == null) {
return;
}
String sql = "INSERT INTO ai_infer_log (provider, model, latency_ms, degraded) VALUES (?,?,?,?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, trunc(provider, 40));
ps.setString(2, trunc(model, 60));
ps.setLong(3, latencyMs);
ps.setBoolean(4, degraded);
ps.executeUpdate();
} catch (Exception e) {
log.warn("DuckDB infer 적재 실패(무시): {}", e.getClass().getSimpleName());
}
}
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,51 @@
package com.zioinfo.hrm.ai.learning;
/**
* 학습 저장소(DuckDB) 적재 PII·자격증명 마스킹. [GUARDiA-HRM]
*
* <p>인사(HR) 데이터 특성상 민감정보가 많다 피드백/추론 로그에 사용자가 입력한 질문·답변·교정문이 그대로
* 남지 않도록 이메일·전화·주민번호·카드번호·IP 마스킹한다. 원문 복원 불가(단방향). 로컬 DuckDB·중앙 rag
* 전달 양쪽에 동일 적용.
*/
public final class PiiMasker {
private PiiMasker() {
}
private static final java.util.regex.Pattern EMAIL =
java.util.regex.Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}");
private static final java.util.regex.Pattern RRN =
java.util.regex.Pattern.compile("\\b\\d{6}[-\\s]?\\d{7}\\b");
private static final java.util.regex.Pattern CARD =
java.util.regex.Pattern.compile("\\b(?:\\d[ -]?){13,16}\\b");
private static final java.util.regex.Pattern PHONE =
java.util.regex.Pattern.compile("\\b0\\d{1,2}[-\\s]?\\d{3,4}[-\\s]?\\d{4}\\b");
private static final java.util.regex.Pattern IPV4 =
java.util.regex.Pattern.compile("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b");
/** 텍스트의 PII 를 마스킹한다. null 은 null 로 통과. */
public static String mask(String s) {
if (s == null || s.isBlank()) {
return s;
}
String out = s;
out = RRN.matcher(out).replaceAll("######-#######");
out = CARD.matcher(out).replaceAll("****-****-****-****");
out = EMAIL.matcher(out).replaceAll("***@***");
out = PHONE.matcher(out).replaceAll("***-****-****");
out = IPV4.matcher(out).replaceAll("***.***.***.***");
return out;
}
/** 사용자 식별자 마스킹(앞 2글자만 노출). */
public static String maskUser(String user) {
if (user == null || user.isBlank()) {
return "anon";
}
String u = user.trim();
if (u.length() <= 2) {
return u.charAt(0) + "*";
}
return u.substring(0, 2) + "*".repeat(Math.min(6, u.length() - 2));
}
}

View File

@ -0,0 +1,271 @@
package com.zioinfo.hrm.ai.service;
import com.zioinfo.hrm.admin.AdminMapper;
import com.zioinfo.hrm.ai.dto.AiConfigDto;
import com.zioinfo.hrm.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.hrm.common.ai.ClaudeTextClient;
import com.zioinfo.hrm.common.ai.OllamaTextClient;
import com.zioinfo.hrm.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/**
* AI provider 런타임 설정(hrm_settings, key='ai.*') 단일 출처 서비스. [GUARDiA-HRM]
*
* <p><b>해상도</b>: DB(hrm_settings) 우선 미설정 기본값/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>설정 저장/감사는 HRM 기존 {@link AdminMapper}(hrm_settings + hrm_audit_log) 재사용한다.
*
* <p><b>레퍼런스</b>: guardia-bi {@code ai.service.AiConfigService} 미러(SettingMapperAdminMapper, glm 포함).
*/
@Service
@RequiredArgsConstructor
public class AiConfigService {
// --- hrm_settings (db/104 시드와 일치)
public static final String K_PROVIDER = "ai.provider"; // ENUM: ollama|claude|qwen3|deepseek|glm
public static final String K_CLAUDE_MODEL = "ai.claude.model"; // ENUM: 화이트리스트
public static final String K_OLLAMA_TEXT_MODEL = "ai.ollama.textModel"; // STRING (미설정 env 폴백)
public static final String K_ENABLED = "ai.enabled"; // BOOL (off=규칙기반 degraded)
public static final String PROVIDER_OLLAMA = "ollama";
public static final String PROVIDER_CLAUDE = "claude";
public static final String PROVIDER_QWEN3 = "qwen3";
public static final String PROVIDER_DEEPSEEK = "deepseek";
public static final String PROVIDER_GLM = "glm";
public static final String DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
// Ollama 계열 provider 고정 소형/중형 모델(서버 RAM 제약 준수, glm RAM 초과 가능)
public static final String MODEL_QWEN3 = "qwen3:1.7b";
public static final String MODEL_DEEPSEEK = "deepseek-r1:1.5b";
public static final String MODEL_GLM = "glm4:9b";
/** 허용 provider(임의 문자열 거부). */
public static final Set<String> ALLOWED_PROVIDERS =
Set.of(PROVIDER_OLLAMA, PROVIDER_CLAUDE, PROVIDER_QWEN3, PROVIDER_DEEPSEEK, PROVIDER_GLM);
/** 허용 Claude 모델 ID(임의 문자열 거부). */
public static final Set<String> ALLOWED_CLAUDE_MODELS =
Set.of("claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8");
/** 허용 Ollama 텍스트 모델(임의 문자열 거부). glm4:9b 은 RAM 초과 가능(선택 허용·콜드로드 실패 시 폴백). */
public static final Set<String> ALLOWED_OLLAMA_MODELS =
Set.of("qwen3:1.7b", "deepseek-r1:1.5b", "llama3.2:1b", "glm4:9b");
private final AdminMapper adminMapper;
private final ClaudeTextClient claudeClient;
private final OllamaTextClient ollamaTextClient; // 연결 테스트용 텍스트 generate
/** 서버 기본 Ollama 텍스트 모델(application.yml guardia.ollama-text-model). */
@Value("${guardia.ollama-text-model:llama3.2:1b}")
private String defaultOllamaTextModel;
// ---------------------------------------------------------------- 효과값(DB 우선 기본)
/** 효과 provider. DB 우선, 미설정/비허용 시 ollama. */
public String provider() {
String v = dbVal(K_PROVIDER);
if (v != null) {
String p = v.trim().toLowerCase();
if (ALLOWED_PROVIDERS.contains(p)) {
return p;
}
}
return PROVIDER_OLLAMA;
}
/** 효과 Claude 모델 ID(DB 우선, 미설정/비허용 시 기본 sonnet-4-6). */
public String claudeModel() {
String v = dbVal(K_CLAUDE_MODEL);
if (v != null) {
String m = v.trim();
if (ALLOWED_CLAUDE_MODELS.contains(m)) {
return m;
}
}
return DEFAULT_CLAUDE_MODEL;
}
/**
* 효과 Ollama 텍스트 모델. provider=qwen3/deepseek/glm 고정 모델, (ollama·claude 폴백)
* DB 설정(화이트리스트) 우선 미설정 env 기본. 라우터의 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 = ollamaTextClient.generate("ping", model);
long ms = System.currentTimeMillis() - start;
if (txt != null && !txt.isBlank()) {
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
}
String hint = MODEL_GLM.equals(model)
? "glm4:9b 미가용(RAM 증설 필요 가능) — 설정/서버 자원을 확인하세요."
: "Ollama 연결 실패 또는 모델 미가용 — 설정을 확인하세요.";
return new TestResult(false, true, hint);
}
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) {
Map<String, Object> row = adminMapper.findSetting(key);
if (row == null) {
return null;
}
Object v = row.get("value");
if (v == null) {
return null;
}
String s = v.toString();
return s.isBlank() ? null : s;
}
/** hrm_settings upsert + 변경분만 hrm_audit_log 감사(값이 동일하면 생략). */
private void upsertAudited(String key, String val, String actor) {
String prev = dbVal(key);
String prevShown = prev == null ? "(none)" : prev;
if (val.equals(prev)) {
return; // 변경 없음 감사 로그 생략
}
String who = (actor == null || actor.isBlank()) ? "SYSTEM" : actor;
Map<String, Object> setting = new HashMap<>();
setting.put("key", key);
setting.put("value", val);
setting.put("updatedBy", who);
adminMapper.upsertSetting(setting);
Map<String, Object> log = new HashMap<>();
log.put("actor", who);
log.put("action", "AI_CONFIG_CHANGE");
log.put("targetType", "ai.config");
log.put("targetId", key);
log.put("detail", prevShown + " -> " + val);
log.put("ipAddr", null);
adminMapper.insertAuditLog(log);
}
}

View File

@ -0,0 +1,72 @@
package com.zioinfo.hrm.ai.service;
import com.zioinfo.hrm.ai.learning.LearningStore;
import com.zioinfo.hrm.common.ai.ClaudeTextClient;
import com.zioinfo.hrm.common.ai.OllamaTextClient;
import com.zioinfo.hrm.common.ai.TextAiClient;
import com.zioinfo.hrm.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* AI provider 선택 라우팅(런타임). 평문 텍스트 생성 진입점(이직/성과 예측·인사이트 ). [GUARDiA-HRM]
* {@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> 호출은 {@link LearningStore#recordInfer} provider/model/latency/degraded 로컬 DuckDB 기록한다
* (실패 no-op 무회귀).
*
* <p><b>레퍼런스</b>: guardia-bi {@code ai.service.AiTextRouter} 미러(OllamaClientOllamaTextClient).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiTextRouter implements TextAiClient {
private final AiConfigService aiConfig;
private final ClaudeTextClient claudeClient;
private final OllamaTextClient ollamaTextClient; // 온프레미스 평문 generate
private final LearningStore learningStore; // 추론 로그(로컬 DuckDB)
/**
* 선택된 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 자동 폴백(선택모델).
learningStore.recordInfer("claude", model, System.currentTimeMillis() - start, true);
log.warn("Claude path degraded -> Ollama fallback");
return ollamaGenerate(prompt, aiConfig.provider(), aiConfig.ollamaTextModel());
}
// provider = ollama / qwen3 / deepseek / glm 해당 Ollama 모델
return ollamaGenerate(prompt, aiConfig.provider(), aiConfig.ollamaTextModel());
}
/** Ollama 평문 generate 를 GenResult 로 래핑(실패 시 degraded) + 추론 로그. */
private GenResult ollamaGenerate(String prompt, String provider, String model) {
long start = System.currentTimeMillis();
String txt = ollamaTextClient.generate(prompt, model);
boolean degraded = (txt == null || txt.isBlank());
learningStore.recordInfer(provider, model, System.currentTimeMillis() - start, degraded);
if (degraded) {
return new GenResult(null, true);
}
return new GenResult(txt, false);
}
}

View File

@ -0,0 +1,171 @@
package com.zioinfo.hrm.common.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.hrm.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-HRM]
*
* <p><b>외부 호출 예외 허용</b>: 클라이언트는 소유자 승인(폐쇄망 아님) 따라 {@code api.anthropic.com}
* 호출이 허용된 유일한 외부 경로다. 외부 API 호출은 여전히 금지(Ollama localhost 전용).
*
* <p><b>API 보안(최우선)</b>: 키는 환경변수 {@code ANTHROPIC_API_KEY} 에서만 로드하며
* ({@code @Value("${ANTHROPIC_API_KEY:}")}), DB·코드·커밋·로그·응답·예외 메시지 어디에도 기록하지 않는다.
* 키는 오직 HTTP 헤더 {@code x-api-key} 로만 전달된다. 로그는 상태코드/예외 클래스명만 남긴다(본문· 미기록).
*
* <p><b>실패 처리</b>: 미설정/타임아웃/비200/예외 {@code GenResult.degraded=true·text=null} 반환
* (예외를 던지지 않음 호출자는 Ollama 폴백). 신규 라이브러리 0 JDK {@link HttpClient} 사용.
*
* <p>모델은 인자로 받는다(호출자가 화이트리스트 검증된 모델 ID 전달). 인터페이스 {@link TextAiClient}
* {@code generate(String)} 기본 모델로 위임한다(주로 라우터가 모델을 명시 호출).
*
* <p><b>레퍼런스</b>: guardia-ocr {@code com.zioinfo.ocr.common.ai.ClaudeTextClient} 미러(패키지·ISSUER 치환).
*/
@Slf4j
@Service
public class ClaudeTextClient implements TextAiClient {
/** Anthropic Messages API 엔드포인트(외부 호출 예외 허용 단일 경로). */
private static final String API_URL = "https://api.anthropic.com/v1/messages";
/** Anthropic 버전 헤더(고정). */
private static final String ANTHROPIC_VERSION = "2023-06-01";
/** 기본 모델(키 검증된 화이트리스트의 기본값). */
static final String DEFAULT_MODEL = "claude-sonnet-4-6";
/** 일반 생성 max_tokens. */
private static final int DEFAULT_MAX_TOKENS = 1024;
/** 콜드 응답 대비 요청 타임아웃(초). Ollama timeout 과 동급. */
private static final int TIMEOUT_SEC = 120;
private static final int CONNECT_TIMEOUT_SEC = 15;
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 환경변수 전용 주입. 미설정 시 빈 문자열(=미구성). 값은 절대 외부 노출/로그 금지. */
@Value("${ANTHROPIC_API_KEY:}")
private String apiKey;
/** API 키가 환경변수로 설정되어 있는지(여부만 — 값/길이/마스킹 일절 미노출). */
public boolean isConfigured() {
return apiKey != null && !apiKey.isBlank();
}
/** {@link TextAiClient} 계약: 기본 모델로 생성. */
@Override
public GenResult generate(String prompt) {
return generate(prompt, DEFAULT_MODEL);
}
/** 지정 모델로 생성(라우터가 화이트리스트 검증된 모델 ID 전달). */
public GenResult generate(String prompt, String model) {
return generate(prompt, model, DEFAULT_MAX_TOKENS);
}
/**
* Anthropic Messages API 호출. 실패 {@code degraded=true·text=null}.
* ·요청본문·응답본문은 로그에 남기지 않는다(상태코드/예외 클래스명만).
*/
public GenResult generate(String prompt, String model, int maxTokens) {
if (!isConfigured()) {
return new GenResult(null, true);
}
if (prompt == null || prompt.isBlank()) {
return new GenResult(null, true);
}
String useModel = (model == null || model.isBlank()) ? DEFAULT_MODEL : model.trim();
int tokens = maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;
try {
String payload = "{"
+ "\"model\":" + str(useModel) + ","
+ "\"max_tokens\":" + tokens + ","
+ "\"messages\":[{\"role\":\"user\",\"content\":" + str(prompt) + "}]"
+ "}";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SEC))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.timeout(Duration.ofSeconds(TIMEOUT_SEC))
.header("content-type", "application/json")
.header("x-api-key", apiKey) // 키는 헤더로만 로그/응답 미노출
.header("anthropic-version", ANTHROPIC_VERSION)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
log.warn("Claude API status {} -> degraded fallback", resp.statusCode()); // 본문 미기록
return new GenResult(null, true);
}
String text = extractText(resp.body());
if (text == null || text.isBlank()) {
return new GenResult(null, true);
}
return new GenResult(text.trim(), false);
} catch (Exception e) {
log.warn("Claude API call failed -> degraded fallback: {}", e.getClass().getSimpleName()); // 메시지· 미기록
return new GenResult(null, true);
}
}
/** Messages API 응답에서 content[].text(type=text) 추출·연결. */
private static String extractText(String body) {
if (body == null || body.isBlank()) {
return null;
}
try {
JsonNode root = MAPPER.readTree(body);
JsonNode content = root.get("content");
if (content == null || !content.isArray()) {
return null;
}
StringBuilder sb = new StringBuilder();
for (JsonNode block : content) {
JsonNode type = block.get("type");
if (type != null && "text".equals(type.asText())) {
JsonNode t = block.get("text");
if (t != null && !t.isNull()) {
sb.append(t.asText());
}
}
}
String out = sb.toString();
return out.isBlank() ? null : out;
} catch (Exception e) {
return null;
}
}
/** JSON 문자열 이스케이프. */
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,71 @@
package com.zioinfo.hrm.common.ai;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.Map;
/**
* 온프레미스 Ollama 평문 텍스트 생성 클라이언트. [GUARDiA-HRM]
*
* <p>기존 {@code AiService} Ollama 호출 로직을 라우터/설정 테스트와 공용으로 쓰기 위해 분리한 얇은
* 컴포넌트. {@code /api/generate}(stream=false) 호출하고 응답의 {@code response} 문자열만 반환한다.
* 실패(미가용/타임아웃/예외) {@code null} 반환한다(예외 미전파 호출자 폴백).
*
* <p><b>보안 불변</b>: base-url 온프레미스 Ollama(localhost:11434) 전용 외부 AI API 금지.
* 콜드로드(소형/중형 모델) 대비 타임아웃 120s.
*/
@Slf4j
@Component
public class OllamaTextClient {
@Value("${guardia.ollama-url:http://localhost:11434}")
private String ollamaUrl;
@Value("${guardia.ollama-text-model:llama3}")
private String defaultModel;
private static final int TIMEOUT_SEC = 120;
private WebClient client() {
return WebClient.builder().baseUrl(ollamaUrl)
.codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))
.build();
}
/**
* 평문 텍스트 생성. 실패 {@code null}(예외 미전파).
*
* @param prompt 프롬프트
* @param model 모델 태그(null/blank 이면 서버 기본 모델)
* @return 생성 텍스트 또는 null
*/
@SuppressWarnings("unchecked")
public String generate(String prompt, String model) {
if (prompt == null || prompt.isBlank()) {
return null;
}
String useModel = (model == null || model.isBlank()) ? defaultModel : model.trim();
try {
Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
Map<String, Object> resp = client().post().uri("/api/generate")
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(TIMEOUT_SEC))
.block();
if (resp == null) {
return null;
}
Object r = resp.get("response");
String out = r == null ? null : r.toString();
return (out == null || out.isBlank()) ? null : out;
} catch (Exception e) {
log.warn("Ollama generate 실패(폴백): {}", e.getClass().getSimpleName());
return null;
}
}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.hrm.common.ai;
/**
* 텍스트 생성 공용 인터페이스 (AI provider 추상화). [GUARDiA-HRM]
*
* <p>온프레미스 Ollama({@link OllamaTextClient}) 외부 Claude({@link ClaudeTextClient}) 동일 계약으로
* 다루기 위한 얇은 추상화. 구현체는 어떤 사유로든(비활성/실패/타임아웃/키미설정) 실패
* {@link GenResult#degraded()}=true · {@link GenResult#text()}=null 반환하고, 호출자가 폴백을 책임진다.
*
* <p>provider 선택/폴백 라우팅은 {@code ai.service.AiTextRouter} 담당한다( 인터페이스를 구현).
*
* <p><b>레퍼런스</b>: guardia-ocr {@code 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

@ -19,7 +19,8 @@ spring:
# 91_uiws_port.sql: 업무 모듈 tb_uiws_* + hrm_users 2FA/로그인보조 컬럼 ALTER(멱등). # 91_uiws_port.sql: 업무 모듈 tb_uiws_* + hrm_users 2FA/로그인보조 컬럼 ALTER(멱등).
# 92_uiws_system.sql: UIWS system(권한관리) tb_uiws_* 10테이블(부서/회사/코드/사용자/역할/메뉴/프로그램, 멱등). # 92_uiws_system.sql: UIWS system(권한관리) tb_uiws_* 10테이블(부서/회사/코드/사용자/역할/메뉴/프로그램, 멱등).
mode: ${SQL_INIT_MODE:always} mode: ${SQL_INIT_MODE:always}
schema-locations: classpath:db/schema.sql,classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql # 104_seed_ai_config.sql: AI 플랫폼(provider/모델) 설정 시드 hrm_settings ai.* (멱등 ON CONFLICT DO NOTHING).
schema-locations: classpath:db/schema.sql,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:
@ -42,9 +43,16 @@ guardia:
erp-url: ${ERP_URL:http://localhost:8003} erp-url: ${ERP_URL:http://localhost:8003}
itsm-url: ${ITSM_URL:http://localhost:9001} itsm-url: ${ITSM_URL:http://localhost:9001}
groupware-url: ${GROUPWARE_URL:http://localhost:8009} groupware-url: ${GROUPWARE_URL:http://localhost:8009}
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지. # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지(Claude 는 소유자 승인 예외).
ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3} ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
# 중앙 guardia-rag 연동(피드백 전달). base-url 은 서버 내부 루프백 전용 — 외부 URL 금지.
rag:
base-url: ${RAG_URL:http://127.0.0.1:8020}
enabled: ${RAG_ENABLED:true}
# 로컬 임베디드 DuckDB 학습 저장소 파일 경로(솔루션별 격리).
learning:
duckdb-path: ${HRM_LEARNING_DUCKDB:/opt/guardia-hrm/data/hrm_learning.duckdb}
crypto: crypto:
secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo} secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo}
jwt: jwt:

View File

@ -0,0 +1,27 @@
-- =====================================================================
-- GUARDiA HRM — 104. AI 플랫폼(LLM provider) 설정 시드 (멱등)
-- 대상 DB : hrm_db (테이블 hrm_settings — schema.sql 에서 생성)
-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일 등재.
-- mode:always 재실행 안전(ON CONFLICT DO NOTHING).
-- 설명 : 외부 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 폴백)
-- * 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 hrm_settings (key, value, description) VALUES
('ai.provider', 'ollama', 'AI provider(ollama/claude/qwen3/deepseek/glm)'),
('ai.claude.model', 'claude-sonnet-4-6', 'Claude 모델 ID(화이트리스트)'),
('ai.ollama.textModel', '', 'Ollama 텍스트 모델(빈값=서버 기본 폴백)'),
('ai.enabled', 'true', '전역 AI 사용 여부(off=규칙기반 degraded)')
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

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GUARDiA HRM — AI 인사관리 플랫폼</title> <title>GUARDiA HRM — AI 인사관리 플랫폼</title>
<script type="module" crossorigin src="/assets/index-DMna-z4N.js"></script> <script type="module" crossorigin src="/assets/index-fwjGAGOv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1fHWJij.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bcwr7v00.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

View File

@ -10,6 +10,7 @@ import PerformancePage from './pages/PerformancePage'
import RecruitmentPage from './pages/RecruitmentPage' import RecruitmentPage from './pages/RecruitmentPage'
import TrainingPage from './pages/TrainingPage' import TrainingPage from './pages/TrainingPage'
import AiPage from './pages/AiPage' import AiPage from './pages/AiPage'
import AiPlatformSettings from './pages/AiPlatformSettings'
import AdminPage from './pages/AdminPage' import AdminPage from './pages/AdminPage'
import MobileApp from './pages/MobileApp' import MobileApp from './pages/MobileApp'
// UIWS(UIMS) 이식 — 공통 업무협업 화면(업무일지·일정·쪽지·통계) // UIWS(UIMS) 이식 — 공통 업무협업 화면(업무일지·일정·쪽지·통계)
@ -38,6 +39,7 @@ const MENU = [
{ path: '/messages', label: '쪽지함', icon: '✉️' }, { path: '/messages', label: '쪽지함', icon: '✉️' },
{ path: '/work-stats', label: '업무통계', icon: '📈' }, { path: '/work-stats', label: '업무통계', icon: '📈' },
{ path: '/ai', label: 'AI 인사분석', icon: '🤖' }, { path: '/ai', label: 'AI 인사분석', icon: '🤖' },
{ path: '/ai-platform', label: 'AI 플랫폼 설정', icon: '🧠', adminOnly: true },
{ path: '/admin', label: '시스템관리', icon: '⚙️' }, { path: '/admin', label: '시스템관리', icon: '⚙️' },
// UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용) // UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용)
{ path: '/system', label: '시스템관리(권한)', icon: '🔐', adminOnly: true }, { path: '/system', label: '시스템관리(권한)', icon: '🔐', adminOnly: true },
@ -137,6 +139,8 @@ export default function App() {
<Route path="/messages" element={<PrivateRoute><MessageBox /></PrivateRoute>} /> <Route path="/messages" element={<PrivateRoute><MessageBox /></PrivateRoute>} />
<Route path="/work-stats" element={<PrivateRoute><StatsPivot /></PrivateRoute>} /> <Route path="/work-stats" element={<PrivateRoute><StatsPivot /></PrivateRoute>} />
<Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} /> <Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} />
{/* AI 플랫폼 설정(provider/모델·연결테스트·피드백 학습) — 관리자 전용. 백엔드 /api/hrm/admin/** RBAC 강제 */}
<Route path="/ai-platform" element={<AdminRoute><AiPlatformSettings /></AdminRoute>} />
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} /> <Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />
{/* UIWS system 이식 — 시스템관리(권한). 관리자 전용(SUPERADMIN/MANAGER/ADMIN), 그 외 대시보드로 */} {/* UIWS system 이식 — 시스템관리(권한). 관리자 전용(SUPERADMIN/MANAGER/ADMIN), 그 외 대시보드로 */}
<Route path="/system" element={<AdminRoute><SystemPage /></AdminRoute>} /> <Route path="/system" element={<AdminRoute><SystemPage /></AdminRoute>} />

View File

@ -0,0 +1,67 @@
// AI 플랫폼(LLM Provider) 설정(ADMIN) API — /api/hrm/admin/ai-config (SUPERADMIN 전용).
// API 키 값은 응답에 절대 포함되지 않음(claudeKeySet 플래그만).
// PUT 은 provider/claudeModel(+선택 ollamaTextModel) 전송.
// test 는 저장된 설정 기준 선택 provider 짧은 ping.
// feedback 은 인증 사용자 전체 — 로컬 DuckDB + 중앙 rag 전달(PII 마스킹).
// 레퍼런스: guardia-ocr/guardia-bi frontend/src/api/adminAiConfig.ts (HRM axios 패턴으로 미러).
// client baseURL 이 /api/hrm 이므로 상대경로 /admin/ai-config · /ai/feedback 로 호출.
import api from './client'
const unwrap = (p: Promise<any>) => p.then(r => r.data?.data)
/** LLM 제공자: ollama/qwen3/deepseek/glm(온프레미스)·claude(외부). */
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
}
/** 피드백 요청 — feature/question/answer/correction 은 서버에서 PII 마스킹 후 적재. */
export interface AiFeedbackRequest {
feature?: string
question?: string
answer?: string
verdict: 'up' | 'down'
correction?: string
}
/** GET /api/hrm/admin/ai-config — 현재 AI 설정(API 키 값 제외). */
export const getAiConfig = () => unwrap(api.get('/admin/ai-config')) as Promise<AiConfigDto>
/** PUT /api/hrm/admin/ai-config — provider/claudeModel 저장 → 반영분. */
export const updateAiConfig = (body: AiConfigUpdateRequest) =>
unwrap(api.put('/admin/ai-config', body)) as Promise<AiConfigDto>
/** POST /api/hrm/admin/ai-config/test — 저장된 설정 기준 선택 provider 짧은 ping. */
export const testAiConfig = () =>
unwrap(api.post('/admin/ai-config/test', {})) as Promise<AiTestResult>
/** POST /api/hrm/ai/feedback — AI 답변 피드백(로컬 DuckDB + 중앙 rag). */
export const sendAiFeedback = (body: AiFeedbackRequest) =>
unwrap(api.post('/ai/feedback', body)) as Promise<{ storedLocal: boolean; verdict: string }>

View File

@ -0,0 +1,209 @@
import { useEffect, useState } from 'react'
import { Cpu, Cloud, ShieldCheck, Zap, AlertTriangle, ThumbsUp, ThumbsDown, Send } from 'lucide-react'
import {
getAiConfig, updateAiConfig, testAiConfig, sendAiFeedback,
type AiConfigDto, type AiProvider, type ClaudeModel, type AiTestResult,
} from '../api/adminAiConfig'
// 프로바이더 카탈로그 — glm 은 RAM 경고 배지(선택 허용·콜드로드 실패 시 폴백).
const PROVIDERS: { id: AiProvider; label: string; sub: string; icon: any; ram?: boolean; ext?: boolean }[] = [
{ id: 'claude', label: 'Claude (외부)', sub: 'Anthropic Messages API · 소유자 승인 예외', icon: Cloud, ext: true },
{ id: 'qwen3', label: 'Qwen3 (온프레미스)', sub: 'qwen3:1.7b · 중국 top1 오픈소스(기본)', icon: Cpu },
{ id: 'deepseek', label: 'DeepSeek (온프레미스)', sub: 'deepseek-r1:1.5b · 추론 특화', icon: Cpu },
{ id: 'glm', label: 'GLM (온프레미스)', sub: 'glm4:9b · RAM 여유 필요', icon: Cpu, ram: true },
{ id: 'ollama', label: 'Ollama 로컬', sub: 'llama3 등 기존 소형 폴백', icon: Cpu },
]
const CLAUDE_MODELS: { id: ClaudeModel; label: string }[] = [
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (기본)' },
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' },
{ id: 'claude-opus-4-8', label: 'Claude Opus 4.8' },
]
const Badge = ({ text, color = 'bg-blue-100 text-blue-700' }: any) => (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${color}`}>{text}</span>
)
export default function AiPlatformSettings() {
const [cfg, setCfg] = useState<AiConfigDto | null>(null)
const [provider, setProvider] = useState<AiProvider>('ollama')
const [claudeModel, setClaudeModel] = useState<ClaudeModel>('claude-sonnet-4-6')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [test, setTest] = useState<AiTestResult | null>(null)
// 피드백 위젯(학습 루프)
const [fbFeature, setFbFeature] = useState('insights')
const [fbAnswer, setFbAnswer] = useState('')
const [fbCorrection, setFbCorrection] = useState('')
const [fbMsg, setFbMsg] = useState('')
const load = () => getAiConfig().then(c => {
setCfg(c)
setProvider(c.provider)
setClaudeModel((CLAUDE_MODELS.find(m => m.id === c.claudeModel)?.id) ?? 'claude-sonnet-4-6')
}).catch(() => setMsg('설정을 불러오지 못했습니다. 관리자(SUPERADMIN) 권한이 필요합니다.'))
useEffect(() => { load() }, [])
const save = async () => {
setBusy(true); setMsg(''); setTest(null)
try {
const updated = await updateAiConfig({ provider, claudeModel: provider === 'claude' ? claudeModel : undefined })
setCfg(updated)
setMsg('저장되었습니다. 다음 AI 호출부터 반영됩니다.')
} catch (e: any) {
setMsg(e?.response?.data?.message ?? '저장 실패')
} finally { setBusy(false) }
}
const runTest = async () => {
setBusy(true); setTest(null); setMsg('')
try { setTest(await testAiConfig()) }
catch (e: any) { setTest({ ok: false, degraded: true, message: e?.response?.data?.message ?? '연결 테스트 실패' }) }
finally { setBusy(false) }
}
const submitFeedback = async (verdict: 'up' | 'down') => {
setFbMsg('')
try {
const r = await sendAiFeedback({ feature: fbFeature, answer: fbAnswer, verdict, correction: fbCorrection })
setFbMsg(`피드백 반영됨 · 로컬 저장 ${r.storedLocal ? '성공' : '(비활성)'} · 중앙 학습 전달`)
setFbCorrection('')
} catch {
setFbMsg('피드백 전송 실패')
}
}
return (
<div className="space-y-5 max-w-4xl">
<div>
<h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<Cloud className="text-blue-600" size={22} /> AI
</h1>
<p className="text-slate-500 text-sm mt-1">
LLM · . <b>API (ANTHROPIC_API_KEY) </b> · .
Claude Ollama() .
</p>
</div>
{msg && <div className="card text-sm text-slate-700 py-3">{msg}</div>}
{/* 현재 상태 */}
{cfg && (
<div className="card flex flex-wrap gap-4 items-center">
<div className="text-sm text-slate-600"> provider <Badge text={cfg.provider} /></div>
<div className="text-sm text-slate-600">Ollama <Badge text={cfg.ollamaTextModel} color="bg-slate-100 text-slate-600" /></div>
<div className="text-sm text-slate-600 flex items-center gap-1">
<ShieldCheck size={14} className={cfg.claudeKeySet ? 'text-emerald-500' : 'text-slate-400'} />
Claude {cfg.claudeKeySet
? <Badge text="설정됨" color="bg-emerald-100 text-emerald-700" />
: <Badge text="미설정(폴백)" color="bg-amber-100 text-amber-700" />}
</div>
<div className="text-sm text-slate-600">AI {cfg.aiEnabled
? <Badge text="활성" color="bg-emerald-100 text-emerald-700" />
: <Badge text="비활성(규칙기반)" color="bg-amber-100 text-amber-700" />}</div>
</div>
)}
{/* provider 선택 */}
<div className="card">
<div className="text-sm font-semibold text-slate-700 mb-3"> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{PROVIDERS.map(p => {
const Icon = p.icon
const active = provider === p.id
return (
<button key={p.id} onClick={() => setProvider(p.id)}
className={`text-left border rounded-lg p-3 transition-colors ${active ? 'border-blue-500 bg-blue-50' : 'border-slate-200 hover:bg-slate-50'}`}>
<div className="flex items-center gap-2">
<Icon size={16} className={active ? 'text-blue-600' : 'text-slate-400'} />
<span className="font-medium text-sm text-slate-700">{p.label}</span>
{p.ext && <Badge text="외부" color="bg-blue-100 text-blue-700" />}
{p.ram && <Badge text="RAM 증설 필요" color="bg-amber-100 text-amber-700" />}
</div>
<div className="text-xs text-slate-500 mt-1">{p.sub}</div>
</button>
)
})}
</div>
{provider === 'glm' && (
<div className="mt-3 text-xs text-amber-600 flex items-center gap-1">
<AlertTriangle size={14} /> glm4:9b 5.5GB RAM .
</div>
)}
{provider === 'claude' && (
<div className="mt-4">
<div className="text-sm font-semibold text-slate-700 mb-2">Claude </div>
<select value={claudeModel} onChange={e => setClaudeModel(e.target.value as ClaudeModel)}
className="border border-slate-200 rounded-lg px-3 py-2 text-sm w-full max-w-sm">
{CLAUDE_MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
{cfg && !cfg.claudeKeySet && (
<div className="mt-2 text-xs text-amber-600 flex items-center gap-1">
<AlertTriangle size={14} /> ANTHROPIC_API_KEY Ollama .
</div>
)}
</div>
)}
<div className="mt-4 flex gap-2">
<button onClick={save} disabled={busy} className="btn-primary disabled:opacity-50"></button>
<button onClick={runTest} disabled={busy}
className="btn-secondary flex items-center gap-1 disabled:opacity-50">
<Zap size={14} />
</button>
</div>
{test && (
<div className={`mt-3 text-sm rounded-lg px-4 py-2 border ${test.ok ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-amber-200 bg-amber-50 text-amber-700'}`}>
{test.ok ? '정상' : '주의'} · {test.message}
</div>
)}
</div>
{/* AI 답변 피드백(학습 루프) */}
<div className="card">
<div className="text-sm font-semibold text-slate-700 mb-1 flex items-center gap-2"><ThumbsUp size={16} className="text-blue-600" /> AI ( )</div>
<p className="text-xs text-slate-500 mb-3">
DuckDB(ai_feedback) guardia-rag . PII ( ).
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500"></label>
<select value={fbFeature} onChange={e => setFbFeature(e.target.value)}
className="border border-slate-200 rounded-lg px-3 py-2 text-sm w-full mt-1">
<option value="insights">HR </option>
<option value="turnover"> </option>
<option value="performance"> </option>
<option value="recruitment"> </option>
<option value="orghealth"> </option>
<option value="resume"> </option>
</select>
</div>
</div>
<textarea value={fbAnswer} onChange={e => setFbAnswer(e.target.value)} rows={2}
placeholder="평가할 AI 답변(선택)" className="border border-slate-200 rounded-lg px-3 py-2 text-sm w-full mt-3" />
<textarea value={fbCorrection} onChange={e => setFbCorrection(e.target.value)} rows={2}
placeholder="교정/올바른 답(선택)" className="border border-slate-200 rounded-lg px-3 py-2 text-sm w-full mt-2" />
<div className="mt-3 flex gap-2">
<button onClick={() => submitFeedback('up')}
className="border border-emerald-300 text-emerald-700 px-4 py-2 rounded-lg text-sm flex items-center gap-1 hover:bg-emerald-50">
<ThumbsUp size={14} />
</button>
<button onClick={() => submitFeedback('down')}
className="border border-rose-300 text-rose-700 px-4 py-2 rounded-lg text-sm flex items-center gap-1 hover:bg-rose-50">
<ThumbsDown size={14} />
</button>
<button onClick={() => submitFeedback('down')}
className="btn-secondary flex items-center gap-1">
<Send size={14} />
</button>
</div>
{fbMsg && <div className="mt-2 text-xs text-slate-500">{fbMsg}</div>}
</div>
</div>
)
}