feat(wise): WISE AI apply - branded grounded chat with citations/abstain UX
This commit is contained in:
parent
acf1655650
commit
7804ff8129
108
backend/src/main/java/com/zioinfo/esn/wise/RagClient.java
Normal file
108
backend/src/main/java/com/zioinfo/esn/wise/RagClient.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
doc/zioinfo-esn_개발자지침서_v1.1.pptx
Normal file
BIN
doc/zioinfo-esn_개발자지침서_v1.1.pptx
Normal file
Binary file not shown.
BIN
doc/zioinfo-esn_사용자지침서_v1.1.pptx
Normal file
BIN
doc/zioinfo-esn_사용자지침서_v1.1.pptx
Normal file
Binary file not shown.
BIN
doc/zioinfo-esn_아키텍처설계서_v1.1.pptx
Normal file
BIN
doc/zioinfo-esn_아키텍처설계서_v1.1.pptx
Normal file
Binary file not shown.
BIN
doc/zioinfo-esn_운영자지침서_v1.1.pptx
Normal file
BIN
doc/zioinfo-esn_운영자지침서_v1.1.pptx
Normal file
Binary file not shown.
@ -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) 이식: 공통 업무협업 레이어 */}
|
||||||
|
|||||||
@ -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}` : ''}`))
|
||||||
|
|||||||
@ -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: '일정 관리' },
|
||||||
|
|||||||
111
frontend/src/pages/WiseAiPage.tsx
Normal file
111
frontend/src/pages/WiseAiPage.tsx
Normal 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="질문을 입력하세요 예: 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user