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

This commit is contained in:
GUARDiA 2026-07-04 18:20:24 +09:00
parent c9c5d43c49
commit 8515e59088
14 changed files with 442 additions and 92 deletions

View File

@ -50,6 +50,8 @@ public class SecurityConfig {
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// AI 피드백 수집은 인증 사용자
.requestMatchers("/api/ai/**").authenticated()
// WISE AI(중앙 guardia-rag 프록시) 인증 사용자
.requestMatchers("/api/wise/**").authenticated()
.requestMatchers("/api/fa/**").authenticated()
.anyRequest().permitAll()
)

View File

@ -0,0 +1,115 @@
package com.zioinfo.fa.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 · FA 전용). [GUARDiA-FA]
*
* <p><b>핵심 설계</b>: FA LangChain·벡터 로직을 Java 재구현하지 않는다. 중앙 Python
* guardia-rag 검색·근거생성·인용·환각차단(guardrail) REST({@code POST /rag/answer}) 호출만 한다.
* 미가용/타임아웃/RAM 부족 예외를 전파하지 않고 {@code degraded:true} 폴백 결과를 돌려준다.
*
* <p><b>보안 불변</b>: 온프레미스 전용(base-url 서버 내부 루프백). 외부 API 아님. 응답에서
* 자격증명/PII/스택트레이스 미노출 오류는 1줄 요약만 로깅.
*
* <p>호출 계약(중앙): {@code POST /rag/answer} {@code {answer, sources[], grounded, faithfulness,
* abstained, degraded, degraded_reason, trace_id, answer_id}}.
*/
@Slf4j
@Component
public class RagClient {
/** 솔루션 식별자(컬렉션 rag_fa · 피드백 격리 키와 일치). */
private static final String SOLUTION = "fa";
/** /rag/answer 는 소형모델 콜드로드를 감안해 별도 긴 타임아웃(피드백 2.5s 재사용 금지). */
private static final Duration ANSWER_TIMEOUT = Duration.ofSeconds(240);
@Value("${guardia.rag.base-url:http://localhost:8020}")
private String baseUrl;
@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. 근거 부족 {@code abstained:true} 보류된다.
*
* @return 중앙 응답 Map(패스스루). 미가용/실패 {@code {degraded:true, degraded_reason:...}}.
*/
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 payload = 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(payload))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() / 100 != 2 || resp.body() == null || resp.body().isBlank()) {
log.warn("WISE /rag/answer 비2xx({}) — FA 폴백", resp.statusCode());
return degraded("rag_no_response");
}
@SuppressWarnings("unchecked")
Map<String, Object> out = mapper.readValue(resp.body(), Map.class);
return out != null ? out : degraded("rag_no_response");
} catch (Exception e) {
log.warn("WISE /rag/answer 일시 불가 — FA 폴백: {}", 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,34 @@
package com.zioinfo.fa.wise;
import com.zioinfo.fa.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} 프록시(FA). [GUARDiA-FA]
*
* <p>{@code POST /api/wise/ask {query}} {@link RagClient#answer(String)} 근거·인용·환각차단이 담긴
* 중앙 응답을 그대로 전달한다. 브라우저는 rag 직접 호출하지 않고 백엔드를 경유한다.
*
* <p>인증: SecurityConfig {@code /api/wise/** authenticated} 게이트(로그인 사용자). 온프레미스 전용.
* 슬로건: Enterprise AI for Trusted Knowledge.
*/
@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));
}
}

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 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>GUARDiA FA — Factory Automation Platform</title>
<script type="module" crossorigin src="/assets/index-DGCZGcqo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bw_KpBbg.css">
<script type="module" crossorigin src="/assets/index-BLcAwA2O.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B8zbZ_VS.css">
</head>
<body>
<div id="root"></div>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -3,7 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate, NavLink } from 'react-router-do
import {
LayoutDashboard, Map, Monitor, QrCode, Package,
AlertTriangle, ShieldCheck, Wrench, Boxes, Brain, LogOut, Cpu,
Users, UserCircle
Users, UserCircle, Sparkles
} from 'lucide-react'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
@ -15,6 +15,7 @@ import AndonBoard from './pages/AndonBoard'
import QualityControl from './pages/QualityControl'
import EquipmentStatus from './pages/EquipmentStatus'
import AiFactory from './pages/AiFactory'
import WiseAiPage from './pages/WiseAiPage'
import AiPlatformSettings from './pages/AiPlatformSettings'
import MyPage from './pages/MyPage'
import UserManagement from './pages/UserManagement'
@ -30,6 +31,7 @@ const NAV_ITEMS = [
{ to: '/equipment', icon: Wrench, label: '설비 현황' },
{ to: '/inventory', icon: Boxes, label: '재고 관리' },
{ to: '/ai', icon: Brain, label: 'AI 공장 분석' },
{ to: '/wise', icon: Sparkles, label: 'WISE AI' },
{ to: '/ai-settings', icon: Cpu, label: 'AI 플랫폼 설정' },
]
@ -109,6 +111,7 @@ export default function App() {
<Route path="/equipment" element={<EquipmentStatus />} />
<Route path="/inventory" element={<AiFactory />} />
<Route path="/ai" element={<AiFactory />} />
<Route path="/wise" element={<WiseAiPage />} />
<Route path="/ai-settings" element={<AiPlatformSettings />} />
<Route path="/mypage" element={<MyPage />} />
<Route path="/admin/users" element={<UserManagement />} />

36
frontend/src/api/wise.ts Normal file
View File

@ -0,0 +1,36 @@
// WISE AI API — /api/wise/ask (인증 사용자). [GUARDiA-FA]
// 질문 → 중앙 guardia-rag /rag/answer 프록시 → 근거·인용·환각차단(abstained)·degraded 포함 응답.
// 기본 client(baseURL '/api/fa')는 못 쓰므로 절대경로용 별도 인스턴스 사용(aiFeedback.ts 패턴).
import axios from 'axios'
const wiseApi = axios.create({ timeout: 250000 }) // 콜드로드(240s) 감안
wiseApi.interceptors.request.use((config) => {
const token = localStorage.getItem('fa_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export interface WiseSource {
source?: string
chunk_id?: string
page?: number | string
support?: number
[k: string]: unknown
}
export interface WiseAnswer {
answer?: string
sources?: WiseSource[]
grounded?: boolean
faithfulness?: number
abstained?: boolean
degraded?: boolean
degraded_reason?: string
trace_id?: string
answer_id?: string
[k: string]: unknown
}
/** POST /api/wise/ask — 질문 전송. 봉투 { data } 안의 페이로드를 반환. */
export const askWise = (query: string) =>
wiseApi.post('/api/wise/ask', { query }).then(r => (r.data?.data ?? {}) as WiseAnswer)

View File

@ -0,0 +1,140 @@
import React, { useState } from 'react'
import { Sparkles, Send, Quote, ShieldAlert, Cpu, FileText } from 'lucide-react'
import { askWise, WiseAnswer, WiseSource } from '../api/wise'
/**
* WISE AI Enterprise AI for Trusted Knowledge. [GUARDiA-FA]
* guardia-rag(/rag/answer) + .
* - abstained: 근거 ( , )
* - degraded : 중앙 RAG ( )
* / API rag .
*/
export default function WiseAiPage() {
const [query, setQuery] = useState('')
const [busy, setBusy] = useState(false)
const [res, setRes] = useState<WiseAnswer | null>(null)
const run = async () => {
if (!query.trim() || busy) return
setBusy(true)
setRes(null)
try {
setRes(await askWise(query.trim()))
} catch {
setRes({ degraded: true, degraded_reason: 'request_failed' })
} finally {
setBusy(false)
}
}
const onKey = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) run()
}
const sources: WiseSource[] = res?.sources || []
const abstained = res?.abstained === true
const degraded = res?.degraded === true
return (
<div className="max-w-4xl">
{/* 헤더 (브랜딩) */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2 text-white">
<Sparkles className="text-blue-400" size={20} /> WISE AI
</h1>
<p className="text-sm text-slate-400 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-slate-800 border border-slate-700 text-emerald-400">
<Cpu size={13} /> guardia-rag · API
</span>
</div>
{/* 입력 */}
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 mb-5">
<textarea
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onKey}
placeholder="사내 문서·규정·업무 지식에 대해 질문하세요. 예) e-Paper 펌웨어 업데이트 절차 알려줘"
rows={3}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-sm text-slate-100 outline-none focus:border-blue-500"
/>
<div className="flex items-center justify-between mt-2">
<span className="text-[11px] text-slate-500">Ctrl/ + Enter </span>
<button
onClick={run}
disabled={busy || !query.trim()}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold disabled:opacity-50"
>
{busy
? <><span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" /> </>
: <><Send size={15} /> </>}
</button>
</div>
</div>
{/* 결과 */}
{res && (
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5">
{/* 상태 배지 */}
<div className="flex flex-wrap gap-1.5 mb-3">
{abstained && (
<span className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full 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-full bg-slate-700 border border-slate-600 text-slate-300">
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-500/10 border border-emerald-500/40 text-emerald-300">
{Math.round(Number(res.faithfulness) * 100)}%
</span>
)}
</div>
{/* 답변 (plain text) */}
{abstained ? (
<p className="text-sm text-slate-400">
. .
</p>
) : (
<pre className="whitespace-pre-wrap text-sm text-slate-100 leading-relaxed font-sans">
{res.answer || (degraded ? 'AI 서비스가 일시적으로 불가합니다. 잠시 후 다시 시도해 주세요.' : '')}
</pre>
)}
{/* 인용 카드 */}
{!abstained && (
<div className="mt-4">
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1">
<Quote size={12} />
</div>
{sources.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{sources.map((s, i) => (
<div key={i} className="flex items-start gap-2 text-xs bg-slate-900 border border-slate-700 rounded-lg px-3 py-2">
<FileText size={14} className="text-blue-400 mt-0.5 shrink-0" />
<div className="min-w-0">
<div className="text-slate-200 truncate">{s.source || s.chunk_id || `근거 ${i + 1}`}</div>
<div className="text-slate-500">
{s.page != null && `p.${s.page}`}
{s.support != null && `${s.page != null ? ' · ' : ''}${Math.round(Number(s.support) * 100)}%`}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-xs text-slate-500"> </div>
)}
</div>
)}
</div>
)}
</div>
)
}