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