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

This commit is contained in:
GUARDiA 2026-07-04 17:59:08 +09:00
parent fc922f74a1
commit 8846e93527
7 changed files with 168 additions and 1 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -20,6 +20,7 @@ import Members from './pages/Members'
import Analytics from './pages/Analytics' import Analytics from './pages/Analytics'
import AiTools from './pages/AiTools' import AiTools from './pages/AiTools'
import AiTechniques from './pages/AiTechniques' import AiTechniques from './pages/AiTechniques'
import WiseAiPage from './pages/WiseAiPage'
import UserManagement from './pages/UserManagement' import UserManagement from './pages/UserManagement'
import AuditLog from './pages/AuditLog' import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings' import SystemSettings from './pages/SystemSettings'
@ -50,6 +51,7 @@ export default function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/wise-ai" element={<WiseAiPage />} />
<Route path="/mypage" element={<MyPage />} /> <Route path="/mypage" element={<MyPage />} />
<Route path="/contents" element={<Contents />} /> <Route path="/contents" element={<Contents />} />
<Route path="/contents/new" element={<ContentEditor />} /> <Route path="/contents/new" element={<ContentEditor />} />

View File

@ -5,7 +5,7 @@ import {
Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound, Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound,
Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack, Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack,
BookOpen, CalendarDays, Mail, Smartphone, Cpu, BookOpen, CalendarDays, Mail, Smartphone, Cpu,
KeyRound, ListChecks, Hash, SquareMenu, Network, Building2, KeyRound, ListChecks, Hash, SquareMenu, Network, Building2, BrainCircuit,
} from 'lucide-react' } from 'lucide-react'
import { currentRole, hasRole } from './rbac' import { currentRole, hasRole } from './rbac'
import ThemeToggle from './uiws/ThemeToggle' import ThemeToggle from './uiws/ThemeToggle'
@ -109,6 +109,13 @@ export default function Sidebar() {
</div> </div>
</div> </div>
<nav className="flex-1 py-2 overflow-auto"> <nav className="flex-1 py-2 overflow-auto">
<NavLink to="/wise-ai" className={linkClass}>
<BrainCircuit size={17} />
<span className="flex flex-col leading-tight">
<span>WISE AI</span>
<span className="text-[10px] text-slate-500">Enterprise AI for Trusted Knowledge</span>
</span>
</NavLink>
<Section links={contentLinks} /> <Section links={contentLinks} />
<Section title="사이트" links={siteLinks} /> <Section title="사이트" links={siteLinks} />
<Section title="참여 · 분석" links={engageLinks} /> <Section title="참여 · 분석" links={engageLinks} />

View File

@ -0,0 +1,158 @@
import { useState } from 'react'
import {
BrainCircuit, Send, Quote, ThumbsUp, ThumbsDown, ShieldAlert, Info, Cpu, FileText,
} from 'lucide-react'
import { ragContentDraft, ragFeedback } from '../api/client'
/**
* WISE AI Enterprise AI for Trusted Knowledge (CMS)
*
* guardia-rag( ) . (AiTechniques,
* /ai-techniques) .
* (·) (abstained, ) · (degraded) .
* rag (ragContentDraft = /rag/answer ) · API 0.
*/
export default function WiseAiPage() {
const [query, setQuery] = useState('')
const [result, setResult] = useState<any>(null)
const [busy, setBusy] = useState(false)
const [fbMsg, setFbMsg] = useState('')
const ask = async () => {
if (!query.trim()) return
setBusy(true); setResult(null); setFbMsg('')
try { setResult(await ragContentDraft('page', query, '')) }
catch { setResult({ error: 'AI 서비스 일시 불가 — 잠시 후 다시 시도하세요.' }) }
finally { setBusy(false) }
}
const sendFeedback = async (verdict: 'up' | 'down') => {
if (!result) return
try {
await ragFeedback({ answerId: result.answerId, query, answer: String(result.draft ?? ''), verdict })
setFbMsg(verdict === 'up' ? '피드백 감사합니다 👍' : '학습에 반영합니다 👎')
} catch { setFbMsg('피드백 저장 보류(중앙 RAG 일시 제한)') }
}
const cites: any[] = (result?.citations?.length ? result.citations : result?.sources) || []
const applied = result?.applied
return (
<div className="space-y-6 max-w-3xl">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<BrainCircuit size={22} className="text-brand" /> WISE AI
</h1>
<p className="text-sm text-slate-400 mt-0.5">Enterprise AI for Trusted Knowledge · · ( guardia-rag · API 0)</p>
</div>
{/* 질문 입력 */}
<div className="bg-card border border-edge rounded-xl p-5 space-y-3">
<div className="text-sm font-semibold text-slate-300 flex items-center gap-2"><FileText size={16} /> </div>
<textarea value={query} onChange={e => setQuery(e.target.value)} rows={3}
onKeyDown={e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') ask() }}
placeholder="콘텐츠·상품·정책 문서에 대해 자연어로 질문하세요 (예: 봄맞이 신상 화분 소개 문구를 근거와 함께 작성해줘)"
className="w-full bg-panel border border-edge rounded-lg px-3 py-2 text-sm focus:border-brand outline-none" />
<button onClick={ask} disabled={busy || !query.trim()}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand text-ink text-sm font-semibold disabled:opacity-40">
<Send size={15} /> {busy ? '검색·생성 중…' : '질문하기'}
</button>
</div>
{/* 답변 */}
{result && !result.error && (
<div className="bg-card border border-edge rounded-xl p-5 space-y-3">
{/* 상태 배지 */}
<div className="flex flex-wrap gap-1.5 items-center">
<span className="text-[11px] px-2 py-0.5 rounded-full bg-panel border border-edge text-slate-400 flex items-center gap-1"><Cpu size={11} /> {result.engine || 'RAG'}</span>
{applied?.retrievalMode && <Chip>mode: {applied.retrievalMode}</Chip>}
{applied?.rerank && <Chip>rerank</Chip>}
{applied?.graphrag && <Chip>graph</Chip>}
{applied?.toolUse && <Chip>tool-use</Chip>}
{typeof result.faithfulness === 'number' && <Chip> {result.faithfulness}</Chip>}
{result.degraded && (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-slate-500/15 border border-slate-500/40 text-slate-300">
degraded · {result.degraded_reason ? ` (${result.degraded_reason})` : ''}
</span>
)}
</div>
{/* 근거 부족 보류 안내 (오류 아님) */}
{result.abstained && (
<div className="bg-amber-500/10 border border-amber-500/40 text-amber-300 text-sm rounded-lg px-4 py-3 flex items-start gap-2">
<ShieldAlert size={16} className="mt-0.5 shrink-0" />
<div>
<div className="font-semibold"> </div>
<div className="text-xs text-amber-300/80 mt-0.5"> . . ( )</div>
</div>
</div>
)}
{/* 답변 본문 */}
{result.draft && (
<pre className="bg-panel border border-edge rounded-lg p-4 text-sm text-slate-200 whitespace-pre-wrap font-sans leading-relaxed">{String(result.draft)}</pre>
)}
{/* 인용 */}
<Citations cites={cites} />
{/* 피드백 */}
{!result.degraded && (
<div className="flex items-center gap-2 pt-1">
<button onClick={() => sendFeedback('up')} className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-panel border border-edge text-emerald-300 hover:border-emerald-400"><ThumbsUp size={13} /> </button>
<button onClick={() => sendFeedback('down')} className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-panel border border-edge text-rose-300 hover:border-rose-400"><ThumbsDown size={13} /> </button>
{fbMsg && <span className="text-xs text-slate-400">{fbMsg}</span>}
</div>
)}
</div>
)}
{result?.error && (
<div className="bg-rose-500/10 border border-rose-500/40 text-rose-300 text-sm rounded-lg px-4 py-3 flex items-center gap-2">
<Info size={16} /> {result.error}
</div>
)}
</div>
)
}
function Chip({ children }: { children: React.ReactNode }) {
return <span className="text-[11px] px-2 py-0.5 rounded-full bg-panel border border-edge text-slate-400">{children}</span>
}
function citeName(c: any): string {
return c?.source || c?.document || c?.title || c?.doc || c?.chunk_id || '문서'
}
function citeLoc(c: any): string {
const loc = c?.page ?? c?.loc ?? c?.position ?? c?.chunk_id
return loc != null ? String(loc) : ''
}
function citeScore(c: any): number | null {
const s = c?.score ?? c?.support
return typeof s === 'number' ? Math.round(s * 100) / 100 : null
}
function Citations({ cites }: { cites: any[] }) {
return (
<div>
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1"><Quote size={12} /> {cites.length > 0 ? `(${cites.length})` : ''}</div>
{cites.length === 0 ? (
<div className="text-xs text-slate-500"> </div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{cites.map((c, i) => {
const loc = citeLoc(c); const sc = citeScore(c)
return (
<div key={i} className="bg-panel border border-edge rounded-lg px-3 py-2">
<div className="text-sm text-slate-200 truncate flex items-center gap-1.5"><FileText size={13} className="text-brand shrink-0" /> {citeName(c)}</div>
<div className="text-[11px] text-slate-500 mt-0.5">
{loc && <span>: {loc}</span>}{loc && sc != null && ' · '}{sc != null && <span> {sc}</span>}
</div>
</div>
)
})}
</div>
)}
</div>
)
}