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

This commit is contained in:
GUARDiA 2026-07-04 18:22:48 +09:00
parent acf1655650
commit 7804ff8129
10 changed files with 259 additions and 1 deletions

View File

@ -0,0 +1,108 @@
package com.zioinfo.esn.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;
/**
* 얇은 중앙 guardia-rag REST 클라이언트 (WISE AI zioinfo-esn 전용). [ZIOINFO-ESN]
*
* <p><b>핵심 설계</b>: ESN 검색·생성·검증 로직을 Java 재구현하지 않는다. 중앙 Python
* guardia-rag {@code /rag/answer}(검색+근거생성+인용+guardrail) REST 호출하기만 한다.
* 미가용/타임아웃 예외를 전파하지 않고 {@code degraded:true} 폴백 결과를 돌려준다.
*
* <p><b>보안 불변</b>: 온프레미스 전용(서버 내부 루프백). 응답에서 자격증명/PII/스택트레이스 미노출.
* 오류는 1줄 요약만 로깅. 외부 API 아님(중앙 rag 경유만 솔루션에서 직접 LLM 호출 신설 금지).
*/
@Slf4j
@Component
public class RagClient {
/** WISE AI 솔루션 식별자(컬렉션·피드백 격리 키) — 기존 LearningStore.SOLUTION 과 일치. */
private static final String SOLUTION = "zioinfo-esn";
/** /rag/answer 는 소형모델 콜드로드를 감안해 240s. */
private static final Duration ANSWER_TIMEOUT = Duration.ofSeconds(240);
/** 중앙 guardia-rag 베이스 URL(서버 내부 루프백 기본). 외부 URL 설정 금지. */
@Value("${guardia.rag-url:http://localhost:8020}")
private String ragUrl;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final ObjectMapper mapper = new ObjectMapper();
/**
* POST /rag/answer 검색+생성+검증+guardrail (근거+인용, 근거 부족 보류).
*
* @return rag 응답 필드 통과({@code answer, sources, grounded, faithfulness, abstained,
* degraded, degraded_reason, trace_id, answer_id}). 미가용 {@code degraded:true}.
*/
@SuppressWarnings("unchecked")
public Map<String, Object> answer(String query) {
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 payload = mapper.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(trimTrailingSlash(ragUrl) + "/rag/answer"))
.timeout(ANSWER_TIMEOUT)
.header("Content-Type", "application/json")
.header("X-Solution-Key", SOLUTION)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.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({}) — ESN 폴백", resp.statusCode());
return degraded("rag_no_response");
}
Map<String, Object> res = mapper.readValue(resp.body(), Map.class);
return res != null ? res : degraded("rag_no_response");
} catch (Exception e) {
log.warn("중앙 rag /answer 일시 불가 — ESN 폴백: {}", summarize(e));
return degraded("rag_unavailable");
}
}
private 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://localhost: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,33 @@
package com.zioinfo.esn.wise;
import com.zioinfo.esn.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* WISE AI 중앙 guardia-rag {@code /rag/answer} 프록시(인증 사용자). [ZIOINFO-ESN]
*
* <p>브라우저 ESN 백엔드 중앙 rag 경유. 근거·인용 기반 답변, 근거 부족 보류(abstained),
* rag 미가용 degraded 폴백. 기존 {@code /api/ai/*} 불변 컨트롤러는 별개 레이어.
*
* <ul><li>POST /api/wise/ask {query} 근거 답변(sources·abstained·degraded 포함)</li></ul>
*/
@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) {
Object q = req == null ? null : req.get("query");
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

@ -17,6 +17,7 @@ import UserList from './pages/UserList'
import ProductList from './pages/ProductList' import ProductList from './pages/ProductList'
import TenantAdmin from './pages/TenantAdmin' import TenantAdmin from './pages/TenantAdmin'
import AiAnalysis from './pages/AiAnalysis' import AiAnalysis from './pages/AiAnalysis'
import WiseAiPage from './pages/WiseAiPage'
import TagBindingList from './pages/TagBindingList' import TagBindingList from './pages/TagBindingList'
import UpdateQueueList from './pages/UpdateQueueList' import UpdateQueueList from './pages/UpdateQueueList'
import MobileApp from './pages/MobileApp' import MobileApp from './pages/MobileApp'
@ -64,6 +65,7 @@ export default function App() {
<Route path="/users" element={<UserList />} /> <Route path="/users" element={<UserList />} />
<Route path="/tenants" element={<TenantAdmin />} /> <Route path="/tenants" element={<TenantAdmin />} />
<Route path="/ai" element={<AiAnalysis />} /> <Route path="/ai" element={<AiAnalysis />} />
<Route path="/wise-ai" element={<WiseAiPage />} />
{/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */} {/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */}
<Route path="/mobile-app" element={<MobileApp />} /> <Route path="/mobile-app" element={<MobileApp />} />
{/* UIWS(UIMS) 이식: 공통 업무협업 레이어 */} {/* UIWS(UIMS) 이식: 공통 업무협업 레이어 */}

View File

@ -29,6 +29,9 @@ export const login = (username: string, password: string) =>
export const getMe = () => api.get('/api/auth/me') export const getMe = () => api.get('/api/auth/me')
export const logout = () => api.post('/api/auth/logout') export const logout = () => api.post('/api/auth/logout')
// ── WISE AI (중앙 guardia-rag /rag/answer 프록시) ──────────────────────────
export const wiseAsk = (query: string) => unwrap(api.post('/api/wise/ask', { query }))
// ── Dashboard ──────────────────────────────────────────────────────────── // ── Dashboard ────────────────────────────────────────────────────────────
export const getDashboard = (tenantCode?: string) => export const getDashboard = (tenantCode?: string) =>
unwrap(api.get(`/api/dashboard${tenantCode ? `?tenantCode=${tenantCode}` : ''}`)) unwrap(api.get(`/api/dashboard${tenantCode ? `?tenantCode=${tenantCode}` : ''}`))

View File

@ -3,7 +3,7 @@ import {
LayoutDashboard, Store, FileText, RefreshCw, LayoutDashboard, Store, FileText, RefreshCw,
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain, Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
Link2, ListOrdered, BookOpen, CalendarDays, Mail, BarChart3, ShieldCheck, Smartphone, Link2, ListOrdered, BookOpen, CalendarDays, Mail, BarChart3, ShieldCheck, Smartphone,
KeyRound, Tags, Menu as MenuIcon, Network, Briefcase, Bot KeyRound, Tags, Menu as MenuIcon, Network, Briefcase, Bot, Sparkles
} from 'lucide-react' } from 'lucide-react'
const nav = [ const nav = [
@ -21,6 +21,7 @@ const nav = [
{ to: '/users', icon: Users, label: '사용자' }, { to: '/users', icon: Users, label: '사용자' },
{ to: '/tenants', icon: Building2, label: '테넌트 관리' }, { to: '/tenants', icon: Building2, label: '테넌트 관리' },
{ to: '/ai', icon: Brain, label: 'AI 분석' }, { to: '/ai', icon: Brain, label: 'AI 분석' },
{ to: '/wise-ai', icon: Sparkles, label: 'WISE AI' },
// UIWS(UIMS) 이식: 공통 업무협업 레이어 // UIWS(UIMS) 이식: 공통 업무협업 레이어
{ to: '/worklogs', icon: BookOpen, label: '업무일지' }, { to: '/worklogs', icon: BookOpen, label: '업무일지' },
{ to: '/schedules', icon: CalendarDays, label: '일정 관리' }, { to: '/schedules', icon: CalendarDays, label: '일정 관리' },

View File

@ -0,0 +1,111 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { wiseAsk } from '../api/client'
import { Sparkles, Send, Quote, ShieldAlert, Cpu } from 'lucide-react'
/**
* WISE AI guardia-rag · . [ZIOINFO-ESN]
* - (plain text) (sources) (abstained) degraded
* - ( API ). /ai(AI ) .
*/
export default function WiseAiPage() {
const [query, setQuery] = useState('')
const [res, setRes] = useState<any>(null)
const mut = useMutation({
mutationFn: (q: string) => wiseAsk(q),
onSuccess: (data: any) => setRes(data || { degraded: true, degraded_reason: 'no_response' }),
onError: () => setRes({ degraded: true, degraded_reason: 'request_failed' }),
})
const ask = () => {
if (!query.trim() || mut.isPending) return
setRes(null)
mut.mutate(query.trim())
}
const sources: any[] = res?.sources || res?.citations || []
const abstained = res?.abstained === true
const degraded = res?.degraded === true
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white flex items-center gap-2">
<Sparkles size={20} className="text-brand" /> WISE AI
</h1>
<p className="text-xs text-gray-500 mt-0.5">Enterprise AI for Trusted Knowledge</p>
</div>
<span className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full bg-card border border-edge text-emerald-400">
<Cpu size={13} /> guardia-rag · API
</span>
</div>
{/* 질문 입력 */}
<div className="bg-card border border-edge rounded-lg p-5">
<textarea
value={query}
onChange={e => setQuery(e.target.value)}
rows={4}
placeholder="질문을 입력하세요&#10;예: LG이노텍 구미 매장의 ESL 펌웨어 배포 절차를 알려줘"
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-gray-300 placeholder-gray-600 resize-none mb-3"
/>
<button
onClick={ask}
disabled={!query.trim() || mut.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand/20 text-brand border border-brand/40 rounded hover:bg-brand/30 disabled:opacity-40 text-sm"
>
<Send size={14} />
{mut.isPending ? '질의 중...' : '질문하기'}
</button>
</div>
{/* 답변 */}
{res && (
<div className="bg-card border border-edge rounded-lg p-5 space-y-4">
{/* 상태 배지 */}
<div className="flex flex-wrap gap-1.5">
{abstained && (
<span className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/40 text-amber-300">
<ShieldAlert size={13} />
</span>
)}
{degraded && (
<span className="text-xs px-3 py-1.5 rounded-md bg-panel border border-edge text-gray-400">
AI {res?.degraded_reason ? ` (${res.degraded_reason})` : ''}
</span>
)}
</div>
{/* 답변 본문 (plain text) */}
{!abstained && (
<div className="bg-panel border border-edge rounded p-4 text-sm text-gray-200 whitespace-pre-wrap">
{res?.answer || (degraded ? 'AI 서비스가 일시적으로 불가합니다. 잠시 후 다시 시도해주세요.' : '답변이 없습니다.')}
</div>
)}
{/* 인용 카드 */}
<div>
<div className="text-xs text-gray-400 mb-1.5 flex items-center gap-1">
<Quote size={12} />
</div>
{sources.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sources.map((s: any, i: number) => (
<span key={i} className="text-[11px] px-2 py-1 rounded-md bg-panel border border-edge text-gray-300">
{s.source || s.document || s.title || s.chunk_id || `근거 ${i + 1}`}
{s.page != null && ` · p.${s.page}`}
{s.support != null && ` · ${Math.round(s.support * 100)}%`}
</span>
))}
</div>
) : (
<div className="text-xs text-gray-500"> </div>
)}
</div>
</div>
)}
</div>
)
}