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

This commit is contained in:
GUARDiA 2026-07-04 18:04:33 +09:00
parent 0dbcf198bd
commit f455563328
6 changed files with 63 additions and 11 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -43,7 +43,7 @@ const groups: { title: string; items: NavItem[] }[] = [
{ {
title: 'AI · 시스템', title: 'AI · 시스템',
items: [ items: [
{ to: '/ai', icon: 'ai', label: 'AI 어시스턴트' }, { to: '/ai', icon: 'ai', label: 'WISE AI' },
{ to: '/ai-platform', icon: 'cpu', label: 'AI 플랫폼 설정' }, { to: '/ai-platform', icon: 'cpu', label: 'AI 플랫폼 설정' },
{ to: '/audit', icon: 'audit', label: '감사 로그' }, { to: '/audit', icon: 'audit', label: '감사 로그' },
{ to: '/settings', icon: 'settings', label: '시스템 설정' }, { to: '/settings', icon: 'settings', label: '시스템 설정' },

View File

@ -4,32 +4,83 @@ import { PageHeader, Button } from '../components/ui'
import { Icon } from '../lib/icons' import { Icon } from '../lib/icons'
import { aiChat, aiAnalyzeAlarm, aiGenerateSignText, aiRecommendAd } from '../api/client' import { aiChat, aiAnalyzeAlarm, aiGenerateSignText, aiRecommendAd } from '../api/client'
// AI 어시스턴트 — 온프레미스/Claude(AiTextRouter) 경유. 알람 분석·간판 문구·광고 추천·챗. // WISE AI 어시스턴트 — 온프레미스/Claude(AiTextRouter) 경유. 알람 분석·간판 문구·광고 추천·챗.
type Msg = { role: 'user' | 'ai'; text: string } // 응답에 근거(citations/sources)·환각차단(abstained)·degraded 필드가 있으면 인용/보류 UX 로 표기.
type Meta = { citations?: any[]; sources?: any[]; abstained?: boolean; degraded?: boolean }
type Msg = { role: 'user' | 'ai'; text: string; meta?: Meta }
// 인용 라벨 — 문서명·위치·근거지지도. citation/source 객체 형태 방어적 처리.
function sgnCite(c: any): string {
if (c == null) return '문서'
if (typeof c === 'string') return c
const src = c.source || c.document || c.doc || c.title || c.chunk_id || c.id || '문서'
const loc = c.page != null ? ` p.${c.page}` : (c.location ? ` ${c.location}` : '')
const sup = c.support != null ? ` · ${Math.round(Number(c.support) * 100)}%` : ''
return `${src}${loc}${sup}`
}
// 근거/보류 배지 블록 — 근거·보류·degraded 정보가 있을 때만 노출(없으면 null).
function Evidence({ meta }: { meta?: Meta }) {
if (!meta) return null
const items = (meta.citations?.length ? meta.citations : meta.sources) || []
const abstained = meta.abstained === true
const degraded = meta.degraded === true
if (!abstained && !degraded && items.length === 0) return null
return (
<div className="mt-1.5 max-w-[75%] space-y-1">
{abstained && (
<div className="text-[11px] px-2 py-1 rounded bg-amber-500/10 border border-amber-500/30 text-amber-300">
( · )
</div>
)}
{degraded && !abstained && (
<div className="text-[11px] px-2 py-1 rounded bg-gray-500/10 border border-edge text-gray-400">
degraded ·
</div>
)}
{items.length > 0 && (
<div>
<div className="text-[10px] text-gray-500 mb-1"> ({items.length})</div>
<div className="flex flex-wrap gap-1">
{items.map((c: any, i: number) => (
<span key={i} className="text-[10px] px-1.5 py-0.5 rounded bg-panel border border-edge text-gray-300">{sgnCite(c)}</span>
))}
</div>
</div>
)}
</div>
)
}
export default function AiAssistant() { export default function AiAssistant() {
const [msgs, setMsgs] = useState<Msg[]>([]) const [msgs, setMsgs] = useState<Msg[]>([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const push = (m: Msg) => setMsgs(p => [...p, m]) const push = (m: Msg) => setMsgs(p => [...p, m])
const asText = (r: any) => r?.text || r?.result || r?.analysis || r?.recommendation || r?.message || JSON.stringify(r) const asText = (r: any) => r?.text || r?.result || r?.analysis || r?.recommendation || r?.response || r?.answer || r?.message || JSON.stringify(r)
const metaOf = (r: any): Meta => ({
citations: Array.isArray(r?.citations) ? r.citations : undefined,
sources: Array.isArray(r?.sources) ? r.sources : undefined,
abstained: r?.abstained === true,
degraded: r?.degraded === true,
})
const chatMut = useMutation({ const chatMut = useMutation({
mutationFn: (m: string) => aiChat(m), mutationFn: (m: string) => aiChat(m),
onSuccess: r => push({ role: 'ai', text: asText(r) }), onSuccess: r => push({ role: 'ai', text: asText(r), meta: metaOf(r) }),
onError: () => push({ role: 'ai', text: 'AI 응답을 가져오지 못했습니다. (degraded)' }), onError: () => push({ role: 'ai', text: 'AI 응답을 가져오지 못했습니다. (일시 불가)', meta: { degraded: true } }),
}) })
const alarmMut = useMutation({ const alarmMut = useMutation({
mutationFn: () => aiAnalyzeAlarm({ message: input || '게이트웨이 오프라인 다수 발생', alarmType: 'DEVICE', severity: 'HIGH' }), mutationFn: () => aiAnalyzeAlarm({ message: input || '게이트웨이 오프라인 다수 발생', alarmType: 'DEVICE', severity: 'HIGH' }),
onSuccess: r => push({ role: 'ai', text: '[알람 분석]\n' + asText(r) }), onSuccess: r => push({ role: 'ai', text: '[알람 분석]\n' + asText(r), meta: metaOf(r) }),
}) })
const signMut = useMutation({ const signMut = useMutation({
mutationFn: () => aiGenerateSignText({ bizName: input || '지오분식', message: '신메뉴 출시' }), mutationFn: () => aiGenerateSignText({ bizName: input || '지오분식', message: '신메뉴 출시' }),
onSuccess: r => push({ role: 'ai', text: '[간판 문구]\n' + asText(r) }), onSuccess: r => push({ role: 'ai', text: '[간판 문구]\n' + asText(r), meta: metaOf(r) }),
}) })
const adMut = useMutation({ const adMut = useMutation({
mutationFn: () => aiRecommendAd({ storeCode: 'STORE001', season: '여름' }), mutationFn: () => aiRecommendAd({ storeCode: 'STORE001', season: '여름' }),
onSuccess: r => push({ role: 'ai', text: '[광고 추천]\n' + asText(r) }), onSuccess: r => push({ role: 'ai', text: '[광고 추천]\n' + asText(r), meta: metaOf(r) }),
}) })
const send = () => { const send = () => {
@ -43,7 +94,7 @@ export default function AiAssistant() {
return ( return (
<div> <div>
<PageHeader title="AI 어시스턴트" subtitle="온프레미스 sLLM / Claude(AiTextRouter) 경유 · 외부 호출은 Claude 프로바이더 예외만" /> <PageHeader title="WISE AI" subtitle="Enterprise AI for Trusted Knowledge · 온프레미스 sLLM / Claude(AiTextRouter) 경유 · 근거·인용·환각차단" />
<div className="flex gap-2 mb-4 flex-wrap"> <div className="flex gap-2 mb-4 flex-wrap">
<Button variant="ghost" icon="alarm" onClick={() => alarmMut.mutate()} disabled={busy}> </Button> <Button variant="ghost" icon="alarm" onClick={() => alarmMut.mutate()} disabled={busy}> </Button>
@ -59,10 +110,11 @@ export default function AiAssistant() {
</div> </div>
)} )}
{msgs.map((m, i) => ( {msgs.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}> <div key={i} className={`flex flex-col ${m.role === 'user' ? 'items-end' : 'items-start'}`}>
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap ${m.role === 'user' ? 'bg-brand text-white' : 'bg-panel border border-edge text-gray-200'}`}> <div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap ${m.role === 'user' ? 'bg-brand text-white' : 'bg-panel border border-edge text-gray-200'}`}>
{m.text} {m.text}
</div> </div>
{m.role === 'ai' && <Evidence meta={m.meta} />}
</div> </div>
))} ))}
{busy && <div className="text-gray-500 text-sm">AI ...</div>} {busy && <div className="text-gray-500 text-sm">AI ...</div>}