feat(wise): WISE AI apply - branded grounded chat with citations/abstain UX

This commit is contained in:
GUARDiA 2026-07-04 18:18:04 +09:00
parent 933c8e71bd
commit e299068832
9 changed files with 296 additions and 0 deletions

View File

@ -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]
*
* <p><b>핵심 설계</b>: HRM LangChain·벡터 로직을 Java 재구현하지 않는다. 중앙 Python
* guardia-rag 검색·근거생성·인용·guardrail {@code POST /rag/answer} 호출하기만 한다.
* 미가용/타임아웃/RAM 부족 예외를 전파하지 않고 {@code degraded:true} 폴백을 돌려준다.
*
* <p><b>보안 불변</b>: 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<String, Object> answer(String query) {
if (!enabled) {
return degraded("rag_disabled");
}
try {
Map<String, Object> options = new LinkedHashMap<>();
options.put("stream", false);
options.put("verify", true);
Map<String, Object> 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<String> 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<String, Object> 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<String, Object> degraded(String reason) {
Map<String, Object> 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;
}
}

View File

@ -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]
*
* <p>{@code POST /api/wise/ask {query}} 근거·인용·환각차단(abstained) 답변. 인증 사용자 전용
* (Spring Security {@code anyRequest().authenticated()} 로그인 JWT 필요). 브라우저는 백엔드만
* 호출하고 rag 서버 내부 루프백으로만 접근한다(외부 노출 금지). 실패는 degraded 요약으로 반환.
*
* <p>기존 {@code /api/hrm/ai/*}(Ollama 직접) 불변. 컨트롤러는 중앙 경유 신규 경로만 추가한다.
*/
@RestController
@RequestMapping("/api/wise")
@RequiredArgsConstructor
public class WiseAskController {
private final RagClient ragClient;
@PostMapping("/ask")
public ApiResponse<Map<String, Object>> ask(@RequestBody Map<String, Object> req, Authentication auth) {
Object q = req != null ? req.get("query") : null;
String query = q != null ? String.valueOf(q) : "";
return ApiResponse.ok(ragClient.answer(query));
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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() {
<Route path="/messages" element={<PrivateRoute><MessageBox /></PrivateRoute>} />
<Route path="/work-stats" element={<PrivateRoute><StatsPivot /></PrivateRoute>} />
<Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} />
{/* WISE AI — 중앙 guardia-rag 근거·인용 질의응답(인증 사용자) */}
<Route path="/wise-ai" element={<PrivateRoute><WiseAiPage /></PrivateRoute>} />
{/* AI 플랫폼 설정(provider/모델·연결테스트·피드백 학습) — 관리자 전용. 백엔드 /api/hrm/admin/** RBAC 강제 */}
<Route path="/ai-platform" element={<AdminRoute><AiPlatformSettings /></AdminRoute>} />
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />

View File

@ -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')

View File

@ -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<any>(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 (
<div className="space-y-4 max-w-3xl">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-slate-800">WISE AI</h1>
<p className="text-sm text-slate-500">Enterprise AI for Trusted Knowledge</p>
</div>
<span className="badge bg-emerald-100 text-emerald-700 ml-auto"> guardia-rag · API </span>
</div>
<div className="card space-y-3">
<label className="block text-xs font-medium text-slate-600"></label>
<textarea
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) ask() }}
placeholder="예) 연차 정산 규정과 이월 한도를 알려줘"
rows={3}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-blue-500" />
<button onClick={ask} disabled={busy}
className="btn-primary disabled:opacity-50">
{busy ? '검색·생성 중…' : '질문하기'}
</button>
</div>
{busy && (
<div className="card flex items-center justify-center py-16">
<div className="text-center space-y-3">
<div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto"></div>
<p className="text-sm text-slate-500"> </p>
</div>
</div>
)}
{err && <div className="card bg-red-50 text-red-600 text-sm">{err}</div>}
{res && !busy && (
<div className="card space-y-4">
{/* 배지 */}
<div className="flex flex-wrap items-center gap-2">
{abstained && (
<span className="text-xs px-3 py-1.5 rounded-full bg-amber-50 border border-amber-300 text-amber-700">
</span>
)}
{degraded && (
<span className="text-xs px-3 py-1.5 rounded-full bg-slate-100 border border-slate-300 text-slate-500">
degraded{res.degraded_reason ? ` · ${res.degraded_reason}` : ''}
</span>
)}
{!abstained && !degraded && res.faithfulness != null && (
<span className="text-xs px-3 py-1.5 rounded-full bg-emerald-50 border border-emerald-300 text-emerald-700">
(faithfulness) {Math.round(Number(res.faithfulness) * 100)}%
</span>
)}
</div>
{/* 답변 */}
{answer
? <p className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">{answer}</p>
: abstained
? <p className="text-sm text-slate-500"> . .</p>
: <p className="text-sm text-slate-400"> .</p>}
{/* 인용 카드 */}
<div>
<p className="text-xs font-medium text-slate-500 mb-1.5"> </p>
{sources.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{sources.map((s, i) => (
<div key={i} className="border border-slate-200 rounded-lg px-3 py-2 bg-slate-50">
<div className="text-xs font-medium text-slate-700 truncate">
{s.source || s.document || s.title || s.chunk_id || `근거 ${i + 1}`}
</div>
{(s.page != null || s.location) && (
<div className="text-[11px] text-slate-400">: {s.page ?? s.location}</div>
)}
{s.support != null && (
<div className="text-[11px] text-slate-400"> {Math.round(Number(s.support) * 100)}%</div>
)}
</div>
))}
</div>
) : (
<p className="text-xs text-slate-400"> </p>
)}
</div>
</div>
)}
</div>
)
}