diff --git a/backend/src/main/java/com/zioinfo/hrm/wise/RagClient.java b/backend/src/main/java/com/zioinfo/hrm/wise/RagClient.java new file mode 100644 index 0000000..e43fc42 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/hrm/wise/RagClient.java @@ -0,0 +1,116 @@ +package com.zioinfo.hrm.wise; + +import com.fasterxml.jackson.databind.ObjectMapper; +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; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * WISE AI — 얇은 중앙 guardia-rag REST 클라이언트 (HRM 전용). [GUARDiA-HRM] + * + *

핵심 설계: HRM 은 LangChain·벡터 로직을 Java 에 재구현하지 않는다. 중앙 Python + * guardia-rag 의 검색·근거생성·인용·guardrail 을 {@code POST /rag/answer} 로 호출하기만 한다. + * 미가용/타임아웃/RAM 부족 시 예외를 전파하지 않고 {@code degraded:true} 폴백을 돌려준다. + * + *

보안 불변: base-url 은 온프레미스(중앙 guardia-rag) 전용. 외부 LLM 직접 호출 없음. + * 응답에서 자격증명/PII/스택트레이스 미노출. 오류는 1줄 요약만 로깅. + */ +@Slf4j +@Component +public class RagClient { + + /** 솔루션 격리 키(컬렉션 rag_hrm·피드백·설정). */ + private static final String SOLUTION = "hrm"; + + /** /rag/answer 콜드로드 감안 타임아웃(WISE_APPLY_SPEC: 240s). */ + private static final Duration ANSWER_TIMEOUT = Duration.ofSeconds(240); + + /** 중앙 guardia-rag 베이스 URL(서버 내부 루프백 기본). 외부 URL 설정 금지. */ + @Value("${guardia.rag.base-url:http://127.0.0.1:8020}") + private String baseUrl; + + /** RAG 경유 마스터 스위치. false 면 항상 degraded(HRM 화면은 안내 메시지). */ + @Value("${guardia.rag.enabled:true}") + private boolean enabled; + + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * POST /rag/answer — 검색+근거생성+인용+guardrail(근거 부족 시 abstained 보류). + * + * @return {@code {answer, sources[], grounded, faithfulness, abstained, degraded, degraded_reason, trace_id, answer_id}}. + * 미가용 시 {@code {degraded:true, degraded_reason:...}}. + */ + @SuppressWarnings("unchecked") + public Map answer(String query) { + if (!enabled) { + return degraded("rag_disabled"); + } + try { + Map options = new LinkedHashMap<>(); + options.put("stream", false); + options.put("verify", true); + + Map body = new LinkedHashMap<>(); + body.put("solution", SOLUTION); + body.put("query", query == null ? "" : query); + body.put("retrieval_mode", "vector"); + body.put("top_k", 5); + body.put("options", options); + + String json = mapper.writeValueAsString(body); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(trimTrailingSlash(baseUrl) + "/rag/answer")) + .timeout(ANSWER_TIMEOUT) + .header("Content-Type", "application/json") + .header("X-Solution-Key", SOLUTION) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() / 100 != 2 || resp.body() == null || resp.body().isBlank()) { + log.warn("중앙 rag /answer 비2xx 또는 빈 응답({}) — HRM 폴백", resp.statusCode()); + return degraded("rag_unavailable"); + } + Map out = mapper.readValue(resp.body(), Map.class); + return out != null ? out : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("중앙 rag /answer 일시 불가 — HRM 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + private static Map degraded(String reason) { + Map out = new LinkedHashMap<>(); + out.put("degraded", true); + out.put("degraded_reason", reason); + return out; + } + + 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; + } + + /** 스택트레이스 미노출: 메시지 1줄만 요약. */ + private static String summarize(Exception e) { + String m = e.getMessage(); + if (m == null) { + return e.getClass().getSimpleName(); + } + return m.length() > 160 ? m.substring(0, 160) : m; + } +} diff --git a/backend/src/main/java/com/zioinfo/hrm/wise/WiseAskController.java b/backend/src/main/java/com/zioinfo/hrm/wise/WiseAskController.java new file mode 100644 index 0000000..23d2359 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/hrm/wise/WiseAskController.java @@ -0,0 +1,35 @@ +package com.zioinfo.hrm.wise; + +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.Map; + +/** + * WISE AI — 중앙 guardia-rag {@code /rag/answer} 프록시(HRM). [GUARDiA-HRM] + * + *

{@code POST /api/wise/ask {query}} → 근거·인용·환각차단(abstained) 답변. 인증 사용자 전용 + * (Spring Security {@code anyRequest().authenticated()} — 로그인 JWT 필요). 브라우저는 이 백엔드만 + * 호출하고 rag 는 서버 내부 루프백으로만 접근한다(외부 노출 금지). 실패는 degraded 요약으로 반환. + * + *

기존 {@code /api/hrm/ai/*}(Ollama 직접)는 불변. 본 컨트롤러는 중앙 경유 신규 경로만 추가한다. + */ +@RestController +@RequestMapping("/api/wise") +@RequiredArgsConstructor +public class WiseAskController { + + private final RagClient ragClient; + + @PostMapping("/ask") + public ApiResponse> ask(@RequestBody Map req, Authentication auth) { + Object q = req != null ? req.get("query") : null; + String query = q != null ? String.valueOf(q) : ""; + return ApiResponse.ok(ragClient.answer(query)); + } +} diff --git a/doc/guardia-hrm_개발자지침서_v1.1.pptx b/doc/guardia-hrm_개발자지침서_v1.1.pptx new file mode 100644 index 0000000..d29076c Binary files /dev/null and b/doc/guardia-hrm_개발자지침서_v1.1.pptx differ diff --git a/doc/guardia-hrm_사용자지침서_v1.1.pptx b/doc/guardia-hrm_사용자지침서_v1.1.pptx new file mode 100644 index 0000000..3971085 Binary files /dev/null and b/doc/guardia-hrm_사용자지침서_v1.1.pptx differ diff --git a/doc/guardia-hrm_아키텍처설계서_v1.1.pptx b/doc/guardia-hrm_아키텍처설계서_v1.1.pptx new file mode 100644 index 0000000..0426598 Binary files /dev/null and b/doc/guardia-hrm_아키텍처설계서_v1.1.pptx differ diff --git a/doc/guardia-hrm_운영자지침서_v1.1.pptx b/doc/guardia-hrm_운영자지침서_v1.1.pptx new file mode 100644 index 0000000..c0cb865 Binary files /dev/null and b/doc/guardia-hrm_운영자지침서_v1.1.pptx differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6b72a11..9e98f89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import PerformancePage from './pages/PerformancePage' import RecruitmentPage from './pages/RecruitmentPage' import TrainingPage from './pages/TrainingPage' import AiPage from './pages/AiPage' +import WiseAiPage from './pages/WiseAiPage' import AiPlatformSettings from './pages/AiPlatformSettings' import AdminPage from './pages/AdminPage' import MyPage from './pages/MyPage' @@ -40,6 +41,7 @@ const MENU = [ { path: '/messages', label: '쪽지함', icon: '✉️' }, { path: '/work-stats', label: '업무통계', icon: '📈' }, { path: '/ai', label: 'AI 인사분석', icon: '🤖' }, + { path: '/wise-ai', label: 'WISE AI', icon: '✨' }, { path: '/ai-platform', label: 'AI 플랫폼 설정', icon: '🧠', adminOnly: true }, { path: '/admin', label: '시스템관리', icon: '⚙️' }, // UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용) @@ -143,6 +145,8 @@ export default function App() { } /> } /> } /> + {/* WISE AI — 중앙 guardia-rag 근거·인용 질의응답(인증 사용자) */} + } /> {/* AI 플랫폼 설정(provider/모델·연결테스트·피드백 학습) — 관리자 전용. 백엔드 /api/hrm/admin/** RBAC 강제 */} } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6690016..c35df8e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,17 @@ api.interceptors.response.use( export default api +// ── WISE AI (중앙 guardia-rag 프록시) ────────────────────────────────── +// 별도 baseURL(/api/wise) — 공용 api(/api/hrm)와 경로가 다르므로 전용 인스턴스. +// 인증 사용자 전용(로그인 JWT). 응답은 ApiResponse 봉투(r.data.data 에 rag 결과). +const wiseApi = axios.create({ baseURL: '/api/wise' }) +wiseApi.interceptors.request.use(cfg => { + const token = localStorage.getItem('hrm_token') + if (token) cfg.headers.Authorization = `Bearer ${token}` + return cfg +}) +export const wiseAsk = (query: string) => wiseApi.post('/ask', { query }) + // ── Auth / Me ───────────────────────────────────────────────────────── // client baseURL='/api/hrm' → 경로는 상대(/auth/...·/admin/...). export const getMe = () => api.get('/auth/me') diff --git a/frontend/src/pages/WiseAiPage.tsx b/frontend/src/pages/WiseAiPage.tsx new file mode 100644 index 0000000..26d8725 --- /dev/null +++ b/frontend/src/pages/WiseAiPage.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react' +import { wiseAsk } from '../api/client' + +/** + * WISE AI — Enterprise AI for Trusted Knowledge. [GUARDiA-HRM] + * + * 중앙 guardia-rag(/rag/answer) 경유 근거·인용 기반 질의응답 화면. + * - 질문 입력 → 스피너 → 답변(plain text) + * - sources[] 인용 카드(없으면 "근거 문서 없음" 표기) + * - abstained=true → 경고 톤 안내 배지(오류 아님) + * - degraded → 회색 배지(사유 코드) + * 외부 API 미사용(온프레미스 rag 프록시). 기존 AI 인사분석(/ai) 화면과 별개 레이어. + */ +export default function WiseAiPage() { + const [query, setQuery] = useState('') + const [busy, setBusy] = useState(false) + const [res, setRes] = useState(null) + const [err, setErr] = useState('') + + const ask = async () => { + if (!query.trim()) return + setBusy(true); setErr(''); setRes(null) + try { + const r = await wiseAsk(query.trim()) + setRes(r.data?.data ?? r.data) + } catch (e: any) { + setErr(e.response?.data?.message || 'AI 서비스 일시 불가 — 잠시 후 다시 시도해주세요.') + } finally { + setBusy(false) + } + } + + const sources: any[] = Array.isArray(res?.sources) ? res.sources + : Array.isArray(res?.citations) ? res.citations : [] + const abstained = res?.abstained === true + const degraded = res?.degraded === true + const answer: string = res?.answer ?? '' + + return ( +

+
+
+

WISE AI

+

Enterprise AI for Trusted Knowledge

+
+ 중앙 guardia-rag · 외부 API 미사용 +
+ +
+ +