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

This commit is contained in:
GUARDiA 2026-07-04 18:24:42 +09:00
parent d8ea204514
commit 6059494b5d
10 changed files with 213 additions and 1 deletions

View File

@ -170,6 +170,71 @@ public class MallRagAiService {
return out; return out;
} }
// -b WISE AI 일반 지식 질의 (중앙 /answer 근거·인용·보류)
/**
* WISE AI 일반 Q&A. 상품 프레이밍 없이 중앙 {@code /rag/answer}(rag_mall 컬렉션) 근거 답변을
* 그대로 전달한다. 근거 미달이면 {@code abstained:true}(환각 차단), 중앙 미가용이면
* {@code degraded:true}. 기존 recommend 로직·필드는 불변(순증 메서드).
*/
@SuppressWarnings("unchecked")
public Map<String, Object> ask(Map<String, Object> req, String actor) {
String query = str(req.get("query"));
if (query.isBlank()) query = str(req.get("q"));
RagToggles t = toggles.current();
Map<String, Object> out = new LinkedHashMap<>();
if (query.isBlank()) {
out.put("answer", null);
out.put("sources", List.of());
out.put("citations", List.of());
out.put("abstained", false);
out.put("degraded", true);
out.put("degraded_reason", "empty_query");
out.putAll(meta(t, "answer", false));
return out;
}
// RAG 미가용/비활성 degraded (AI 서비스 일시 불가). 온프레미스 폴백 없음(일반 지식 검색).
if (!t.ragEnabled || !rag.available()) {
out.put("answer", null);
out.put("sources", List.of());
out.put("citations", List.of());
out.put("abstained", false);
out.put("degraded", true);
out.put("degraded_reason", t.ragEnabled ? "rag_unavailable" : "rag_disabled");
out.put("engine", "NONE");
out.putAll(meta(t, "answer", false));
return out;
}
String mode = toggles.effectiveMode(t);
Map<String, Object> ares = rag.answer(mask(query), mode, t.rerank, t.topK, t.generationModel, true);
if (bool(ares.get("degraded")) || ares.get("answer") == null) {
out.put("answer", null);
out.put("sources", List.of());
out.put("citations", List.of());
out.put("abstained", ares.getOrDefault("abstained", Boolean.FALSE));
out.put("degraded", true);
out.put("degraded_reason", str(ares.getOrDefault("degraded_reason", "answer_empty")));
out.put("engine", "RAG_ANSWER");
out.putAll(meta(t, "answer", false));
return out;
}
out.put("answer", mask(str(ares.get("answer"))));
out.put("grounded", ares.get("grounded"));
out.put("faithfulness", ares.get("faithfulness"));
out.put("citations", ares.get("citations"));
out.put("sources", ares.get("sources"));
out.put("guardrail", ares.get("guardrail"));
out.put("abstained", ares.getOrDefault("abstained", Boolean.FALSE.equals(ares.get("grounded"))));
out.put("answerId", ares.get("answer_id"));
out.put("engine", "RAG_ANSWER");
out.put("degraded", false);
out.putAll(meta(t, "answer", false));
return out;
}
// 수요예측·재고이양 추천 (중앙 /agent tool-use, 제안만) // 수요예측·재고이양 추천 (중앙 /agent tool-use, 제안만)
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public Map<String, Object> demandPlan(Map<String, Object> req, String actor) { public Map<String, Object> demandPlan(Map<String, Object> req, String actor) {

View File

@ -37,6 +37,15 @@ public class RagController {
return ApiResponse.ok(service.recommend(req, auth.getName())); return ApiResponse.ok(service.recommend(req, auth.getName()));
} }
/**
* WISE AI 일반 지식 질의(Q&A) 중앙 {@code /rag/answer}(근거·인용·보류) 프록시. 인증 사용자.
* 순증 엔드포인트로, 기존 recommend/demand-plan 필드는 불변. 요청 {@code {query}}.
*/
@PostMapping("/ask")
public ApiResponse<Map<String, Object>> ask(@RequestBody Map<String, Object> req, Authentication auth) {
return ApiResponse.ok(service.ask(req, auth == null ? "anon" : auth.getName()));
}
/** 수요예측·재고이양 추천 — MANAGER+ (운영 의사결정). */ /** 수요예측·재고이양 추천 — MANAGER+ (운영 의사결정). */
@PostMapping("/demand-plan") @PostMapping("/demand-plan")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')") @PreAuthorize("hasAnyRole('ADMIN','MANAGER')")

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -44,6 +44,7 @@ import AdminApp from './admin/AdminApp'
import AiTechniques from './admin/AiTechniques' import AiTechniques from './admin/AiTechniques'
import AiPlatformSettings from './admin/AiPlatformSettings' import AiPlatformSettings from './admin/AiPlatformSettings'
import MyPage from './admin/MyPage' import MyPage from './admin/MyPage'
import WiseAiPage from './pages/WiseAiPage'
// UIWS 이식 — 업무 모듈(관리자 영역 병합, 고객 쇼핑 화면 무영향) // UIWS 이식 — 업무 모듈(관리자 영역 병합, 고객 쇼핑 화면 무영향)
import UiwsLayout from './pages/uiws/UiwsLayout' import UiwsLayout from './pages/uiws/UiwsLayout'
@ -112,6 +113,7 @@ export default function App() {
<Route path="users" element={<UserManagement />} /> <Route path="users" element={<UserManagement />} />
<Route path="audit" element={<AuditLog />} /> <Route path="audit" element={<AuditLog />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
<Route path="wise-ai" element={<WiseAiPage />} />
<Route path="ai-techniques" element={<AiTechniques />} /> <Route path="ai-techniques" element={<AiTechniques />} />
<Route path="ai-platform" element={<AiPlatformSettings />} /> <Route path="ai-platform" element={<AiPlatformSettings />} />
<Route path="app" element={<AdminApp />} /> <Route path="app" element={<AdminApp />} />

View File

@ -4,7 +4,7 @@ import {
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone, LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone, Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone,
ClipboardList, CalendarDays, Mail, PieChart, Sparkles, Cpu, ClipboardList, CalendarDays, Mail, PieChart, Sparkles, Cpu,
ShieldCheck, KeySquare, ListTree, Menu as MenuIcon, Building2, Briefcase, ShieldCheck, KeySquare, ListTree, Menu as MenuIcon, Building2, Briefcase, BrainCircuit,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getMe } from '../api/client' import { getMe } from '../api/client'
@ -32,6 +32,7 @@ const adminLinks = [
] ]
// 최신 AI 기법(중앙 guardia-rag) 토글 — 라벨 i18n 미의존(고정 표기), 변경은 MANAGER+(USER 차단) // 최신 AI 기법(중앙 guardia-rag) 토글 — 라벨 i18n 미의존(고정 표기), 변경은 MANAGER+(USER 차단)
const aiLinks = [ const aiLinks = [
{ to: '/admin/wise-ai', label: 'WISE AI', icon: BrainCircuit, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/ai-techniques', label: 'AI Techniques', icon: Sparkles, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/ai-techniques', label: 'AI Techniques', icon: Sparkles, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/ai-platform', label: 'AI 플랫폼 설정', icon: Cpu, roles: ['ADMIN'] }, { to: '/admin/ai-platform', label: 'AI 플랫폼 설정', icon: Cpu, roles: ['ADMIN'] },
] ]

View File

@ -263,6 +263,8 @@ export const updateRagToggle = (key: string, value: string | number | boolean) =
u<RagToggles>(api.put(`/api/mall/rag/toggles/${key}`, { value })) u<RagToggles>(api.put(`/api/mall/rag/toggles/${key}`, { value }))
// 추천·자연어 검색(/answer hybrid + /structured, 근거+보류) — 인증 사용자 // 추천·자연어 검색(/answer hybrid + /structured, 근거+보류) — 인증 사용자
export const ragRecommend = (req: object) => u(api.post('/api/mall/rag/recommend', req)) export const ragRecommend = (req: object) => u(api.post('/api/mall/rag/recommend', req))
// WISE AI 일반 지식 질의(Q&A) — /rag/answer 근거·인용·보류. 인증 사용자
export const ragAsk = (query: string) => u(api.post('/api/mall/rag/ask', { query }))
// 수요예측·재고이양 추천(/agent tool-use, 승인 게이트) — MANAGER+ // 수요예측·재고이양 추천(/agent tool-use, 승인 게이트) — MANAGER+
export const ragDemandPlan = (req: object) => u(api.post('/api/mall/rag/demand-plan', req)) export const ragDemandPlan = (req: object) => u(api.post('/api/mall/rag/demand-plan', req))
export const ragFeedback = (req: object) => u(api.post('/api/mall/rag/feedback', req)) export const ragFeedback = (req: object) => u(api.post('/api/mall/rag/feedback', req))

View File

@ -0,0 +1,133 @@
import { useState } from 'react'
import { BrainCircuit, Send, Quote, ShieldAlert, Cpu, FileText } from 'lucide-react'
import { ragAsk } from '../api/client'
/**
* WISE AI Enterprise AI for Trusted Knowledge.
*
* guardia-rag(/rag/answer, rag_mall ) .
* - (plain text)
* - (sources/citations) "근거 문서 없음"
* - abstained=true "근거가 부족해 답변을 보류했습니다"( · )
* - degraded=true ( ) "AI 서비스 일시 불가"
*
* Mall AI(/api/mall/ai/*)·RAG (recommend/demand-plan) .
* RagController /ask . API ( rag ).
*/
export default function WiseAiPage() {
const [q, setQ] = useState('')
const [busy, setBusy] = useState(false)
const [res, setRes] = useState<any>(null)
const [err, setErr] = useState('')
const ask = async () => {
if (!q.trim()) return
setBusy(true); setErr(''); setRes(null)
try {
setRes(await ragAsk(q.trim()))
} catch {
setErr('AI 서비스 일시 불가 — 잠시 후 다시 시도해 주세요.')
} finally {
setBusy(false)
}
}
const onKey = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') ask()
}
const abstained = res?.abstained === true
const degraded = res?.degraded === true
const cites: any[] = res?.citations || res?.sources || []
const answer = res?.answer
return (
<div className="max-w-3xl">
{/* 헤더 + 브랜딩 */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<BrainCircuit className="text-brand" 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-card border border-edge text-emerald-400">
<Cpu size={13} /> guardia-rag · API
</span>
</div>
{/* 질문 입력 */}
<div className="bg-card border border-edge rounded-xl p-5 mb-5">
<textarea
value={q}
onChange={e => setQ(e.target.value)}
onKeyDown={onKey}
placeholder="질문을 입력하세요. 예) 당일배송 가능 지역과 컷오프 시간은?"
rows={3}
className="w-full px-3 py-2 rounded-lg bg-ink border border-edge text-sm focus:border-brand outline-none"
/>
<div className="flex items-center justify-between mt-2">
<span className="text-[11px] text-slate-500">Ctrl/ + Enter </span>
<button
onClick={ask}
disabled={busy || !q.trim()}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand text-ink text-sm font-semibold disabled:opacity-50"
>
<Send size={15} /> {busy ? '검색 중…' : '질문하기'}
</button>
</div>
</div>
{err && (
<div className="bg-slate-500/10 border border-edge text-slate-300 text-sm rounded-lg p-3 mb-5">{err}</div>
)}
{/* 결과 */}
{res && (
<div className="bg-card border border-edge rounded-xl p-5">
{/* degraded (회색) 배지 */}
{degraded && (
<div className="mb-3 flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-slate-500/10 border border-slate-500/40 text-slate-400">
<Cpu size={14} /> AI (degraded{res?.degraded_reason ? ` · ${res.degraded_reason}` : ''}) .
</div>
)}
{/* abstained (환각 차단) 경고 배지 */}
{abstained && (
<div className="mb-3 flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/40 text-amber-300">
<ShieldAlert size={14} /> . ( · )
</div>
)}
{/* 답변 본문 */}
{answer ? (
<>
<div className="text-sm font-semibold mb-2 flex items-center gap-1.5"><FileText size={15} /> </div>
<div className="bg-ink border border-edge rounded-lg p-3 text-sm text-slate-200 whitespace-pre-wrap">{answer}</div>
</>
) : (
!degraded && !abstained && <div className="text-sm text-slate-400"> .</div>
)}
{/* 인용 카드 */}
<div className="mt-4">
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1"><Quote size={12} /> </div>
{cites.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{cites.map((c, i) => (
<span key={i} className="text-[11px] px-2 py-1 rounded-md bg-panel border border-edge text-slate-300">
{c.source || c.title || c.doc_id || c.chunk_id || `근거 ${i + 1}`}
{c.page != null && ` · p.${c.page}`}
{c.support != null && ` · ${Math.round(c.support * 100)}%`}
</span>
))}
</div>
) : (
<div className="text-[11px] text-slate-500"> </div>
)}
</div>
</div>
)}
</div>
)
}