feat(wise): WISE AI apply - branded grounded chat with citations/abstain UX
This commit is contained in:
parent
d8ea204514
commit
6059494b5d
@ -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) {
|
||||||
|
|||||||
@ -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')")
|
||||||
|
|||||||
BIN
doc/guardia-mall_개발자지침서_v1.1.pptx
Normal file
BIN
doc/guardia-mall_개발자지침서_v1.1.pptx
Normal file
Binary file not shown.
BIN
doc/guardia-mall_사용자지침서_v1.1.pptx
Normal file
BIN
doc/guardia-mall_사용자지침서_v1.1.pptx
Normal file
Binary file not shown.
BIN
doc/guardia-mall_아키텍처설계서_v1.1.pptx
Normal file
BIN
doc/guardia-mall_아키텍처설계서_v1.1.pptx
Normal file
Binary file not shown.
BIN
doc/guardia-mall_운영자지침서_v1.1.pptx
Normal file
BIN
doc/guardia-mall_운영자지침서_v1.1.pptx
Normal file
Binary file not shown.
@ -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 />} />
|
||||||
|
|||||||
@ -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'] },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
133
frontend/src/pages/WiseAiPage.tsx
Normal file
133
frontend/src/pages/WiseAiPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user