327 lines
18 KiB
TypeScript
327 lines
18 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import {
|
|
Sparkles, AlertTriangle, TrendingUp, Wrench, LineChart, Search, Boxes, CalendarClock,
|
|
Cpu, ThumbsUp, ThumbsDown, Quote,
|
|
} from 'lucide-react'
|
|
import { Card, Btn, Field } from '../components/ui'
|
|
import {
|
|
getAiStatus, aiDefectRootCause, aiForecast, aiPredictiveMaintenance,
|
|
aiSpcAnomaly, aiParseQuery, aiSafetyStock, aiScheduleOptimize,
|
|
ragDefectAnalysis, ragPredictAnalysis, ragFeedback, postAiFeedback,
|
|
} from '../api/client'
|
|
|
|
/** 적용된 기법 배지 — 응답의 applied metadata 를 시각화(토글 effect 관측). */
|
|
function AppliedBadge({ data }: { data: any }) {
|
|
const a = data?.applied
|
|
if (!a) return null
|
|
const chips: string[] = [`mode:${a.retrievalMode}`, `기법:${a.technique}`]
|
|
if (a.rerank) chips.push('rerank')
|
|
if (a.graphrag) chips.push('graphrag')
|
|
if (a.hybrid) chips.push('hybrid')
|
|
if (a.toolUse) chips.push(`agent(${a.maxSteps})`)
|
|
if (a.structured) chips.push('structured')
|
|
if (a.stream) chips.push('stream')
|
|
if (data?.degraded) chips.push('⚠ degraded(폴백)')
|
|
return (
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{chips.map((c, i) => (
|
|
<span key={i} className={`text-[10px] px-1.5 py-0.5 rounded border ${c.startsWith('⚠') ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-brand/10 text-brand border-brand/30'}`}>{c}</span>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 👍/👎 피드백 (중앙 /rag/feedback, solution=mes 격리). answerId 가 있을 때만 노출. */
|
|
function FeedbackBar({ answerId, query }: { answerId?: string; query?: string }) {
|
|
const [done, setDone] = useState('')
|
|
if (!answerId) return null
|
|
const send = async (verdict: 'up' | 'down') => {
|
|
try { await ragFeedback({ answerId, query, verdict }); setDone(verdict === 'up' ? '👍 반영됨' : '👎 반영됨') } catch { setDone('전송 실패') }
|
|
}
|
|
return (
|
|
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
|
<span>이 답변이 도움이 됐나요?</span>
|
|
<button onClick={() => send('up')} className="p-1 rounded hover:bg-brand/10 hover:text-brand"><ThumbsUp size={13} /></button>
|
|
<button onClick={() => send('down')} className="p-1 rounded hover:bg-rose-500/10 hover:text-rose-300"><ThumbsDown size={13} /></button>
|
|
{done && <span className="text-brand">{done}</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 👍/👎 로컬 학습 피드백 (POST /api/ai/feedback → 로컬 DuckDB + 중앙 rag). 결과가 있을 때만 노출. */
|
|
function LocalFeedbackBar({ feature, question, answer }: { feature: string; question?: string; answer?: any }) {
|
|
const [done, setDone] = useState('')
|
|
if (answer == null) return null
|
|
const ans = typeof answer === 'string' ? answer : JSON.stringify(answer)
|
|
const send = async (verdict: 'up' | 'down') => {
|
|
try { await postAiFeedback({ feature, question, answer: ans, verdict }); setDone(verdict === 'up' ? '👍 반영됨' : '👎 반영됨') }
|
|
catch { setDone('전송 실패') }
|
|
}
|
|
return (
|
|
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
|
<span>이 결과가 도움이 됐나요?</span>
|
|
<button onClick={() => send('up')} className="p-1 rounded hover:bg-brand/10 hover:text-brand"><ThumbsUp size={13} /></button>
|
|
<button onClick={() => send('down')} className="p-1 rounded hover:bg-rose-500/10 hover:text-rose-300"><ThumbsDown size={13} /></button>
|
|
{done && <span className="text-brand">{done}</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 인용 라벨 — 문서명·위치·근거지지도. citation/source 객체 형태 방어적 처리. */
|
|
function citeLabel(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}`
|
|
}
|
|
|
|
/**
|
|
* WISE 근거 UX — 인용 카드(sources/citations) + 환각차단(abstained) 배지.
|
|
* node = 응답의 defect / interpretation 서브객체. 근거·보류 정보가 있을 때만 노출.
|
|
*/
|
|
function WiseEvidence({ node }: { node: any }) {
|
|
if (node == null) return null
|
|
const citations: any[] = Array.isArray(node.citations) ? node.citations : []
|
|
const sources: any[] = Array.isArray(node.sources) ? node.sources : []
|
|
const items = citations.length ? citations : sources
|
|
const abstained = node.abstained === true
|
|
if (!abstained && items.length === 0) return null
|
|
return (
|
|
<div className="mt-3">
|
|
{abstained && (
|
|
<div className="mb-2 flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-300">
|
|
<AlertTriangle size={13} /> 근거가 부족해 답변을 보류했습니다 (오류 아님 · 안내)
|
|
</div>
|
|
)}
|
|
{items.length > 0 ? (
|
|
<>
|
|
<div className="text-[11px] text-slate-400 mb-1.5 flex items-center gap-1"><Quote size={12} /> 근거 인용 ({items.length})</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{items.map((c, i) => (
|
|
<span key={i} className="text-[10px] px-2 py-1 rounded-md bg-card border border-edge text-slate-300">{citeLabel(c)}</span>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-[11px] text-slate-500">근거 문서 없음</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Output({ data }: { data: any }) {
|
|
if (data == null) return null
|
|
if (typeof data === 'string') return <pre className="mt-3 text-xs text-slate-300 bg-ink border border-edge rounded p-3 whitespace-pre-wrap max-h-56 overflow-auto">{data}</pre>
|
|
return <pre className="mt-3 text-xs text-slate-300 bg-ink border border-edge rounded p-3 whitespace-pre-wrap max-h-56 overflow-auto">{JSON.stringify(data, null, 2)}</pre>
|
|
}
|
|
|
|
export default function AiTools() {
|
|
const [ollama, setOllama] = useState<boolean | null>(null)
|
|
useEffect(() => { getAiStatus().then(d => setOllama(!!(d?.ollamaAvailable ?? d?.available))).catch(() => setOllama(false)) }, [])
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-xl font-bold flex items-center gap-2"><Sparkles className="text-accent" size={20} /> WISE AI</h1>
|
|
<p className="text-sm text-slate-400 mt-0.5">Enterprise AI for Trusted Knowledge · 근거·인용·환각차단(중앙 guardia-rag) + 불량분석·생산예측·예지보전·SPC이상 (Ollama 온프레미스 + Java 폴백)</p>
|
|
</div>
|
|
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${ollama ? 'text-accent border-accent/30 bg-accent/10' : 'text-slate-400 border-edge bg-card'}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} />
|
|
Ollama {ollama === null ? '확인 중' : ollama ? '온라인' : '오프라인 (Java 폴백)'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* ── 최신 기법 (중앙 guardia-rag 경유) — 대표 2개 기능 ───────────── */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-sm font-semibold flex items-center gap-2 text-brand"><Cpu size={15} /> WISE AI · 근거 기반 분석 (RAG · 에이전틱 · 구조화)</h2>
|
|
<Link to="/ai-techniques" className="text-xs text-slate-400 hover:text-brand underline">기법 토글 설정 →</Link>
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 mb-8">
|
|
<RagDefectTool /><RagPredictTool />
|
|
</div>
|
|
|
|
{/* ── 기존 AI 도구 (불변, Ollama + Java 폴백) ───────────────────── */}
|
|
<h2 className="text-sm font-semibold text-slate-300 mb-3">기본 AI 도구</h2>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
|
<DefectTool /><ForecastTool /><MaintenanceTool /><SpcTool />
|
|
<QueryTool /><SafetyStockTool /><ScheduleTool />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 대표 1: 불량 원인분석 + SPC 이상감지 (SPC 수치=결정론, 원인서술=/rag/agent+structured). */
|
|
function RagDefectTool() {
|
|
const [code, setCode] = useState(''); const [ctx, setCtx] = useState('')
|
|
const [values, setValues] = useState(''); const [ucl, setUcl] = useState(''); const [lcl, setLcl] = useState(''); const [cl, setCl] = useState('')
|
|
const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
|
|
const run = async () => {
|
|
setBusy(true)
|
|
try {
|
|
const vals = values.split(',').map(s => s.trim()).filter(Boolean).map(Number)
|
|
const req: any = { defectCode: code, context: ctx ? [{ note: ctx }] : [] }
|
|
if (vals.length) { req.values = vals; req.ucl = Number(ucl) || 0; req.lcl = Number(lcl) || 0; req.cl = Number(cl) || 0 }
|
|
setOut(await ragDefectAnalysis(req))
|
|
} finally { setBusy(false) }
|
|
}
|
|
return (
|
|
<Card title="불량 원인분석 + SPC 이상감지 (RAG)" icon={<AlertTriangle size={15} />}>
|
|
<Field label="불량 코드"><input className="inp" value={code} onChange={e => setCode(e.target.value)} placeholder="예: D-DIM-01" /></Field>
|
|
<Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field>
|
|
<Field label="SPC 표본값(쉼표구분, 선택)"><input className="inp" value={values} onChange={e => setValues(e.target.value)} placeholder="예: 10.1, 10.3, 9.8, 10.5" /></Field>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Field label="UCL"><input className="inp" value={ucl} onChange={e => setUcl(e.target.value)} /></Field>
|
|
<Field label="LCL"><input className="inp" value={lcl} onChange={e => setLcl(e.target.value)} /></Field>
|
|
<Field label="CL"><input className="inp" value={cl} onChange={e => setCl(e.target.value)} /></Field>
|
|
</div>
|
|
<Btn onClick={run} disabled={busy} size="sm"><Cpu size={13} /> {busy ? '분석 중…' : 'RCA + SPC 분석'}</Btn>
|
|
<AppliedBadge data={out} />
|
|
<Output data={out} />
|
|
<WiseEvidence node={out?.defect} />
|
|
<FeedbackBar answerId={out?.defect?.answerId} query={code} />
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
/** 대표 2: 설비 예지보전 / 수요·생산 예측 (수치=결정론 베이스라인, 해석=/rag/agent). */
|
|
function RagPredictTool() {
|
|
const [kind, setKind] = useState<'pdm' | 'forecast'>('forecast')
|
|
const [eq, setEq] = useState(''); const [avail, setAvail] = useState(''); const [dt, setDt] = useState(''); const [mtbf, setMtbf] = useState('')
|
|
const [series, setSeries] = useState(''); const [horizon, setHorizon] = useState(7)
|
|
const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
|
|
const run = async () => {
|
|
setBusy(true)
|
|
try {
|
|
const req: any = { kind }
|
|
if (kind === 'pdm') { req.equipmentCode = eq; req.availability = Number(avail) || 0; req.downtimeCount = Number(dt) || 0; req.mtbfHours = Number(mtbf) || 0 }
|
|
else { req.series = series.split(',').map(s => s.trim()).filter(Boolean).map(Number); req.horizon = horizon }
|
|
setOut(await ragPredictAnalysis(req))
|
|
} finally { setBusy(false) }
|
|
}
|
|
return (
|
|
<Card title="예지보전 / 수요·생산 예측 (RAG)" icon={<TrendingUp size={15} />}>
|
|
<div className="flex gap-2 mb-2">
|
|
{(['forecast', 'pdm'] as const).map(k => (
|
|
<button key={k} onClick={() => setKind(k)} className={`px-3 py-1.5 text-xs rounded-lg border ${kind === k ? 'bg-brand text-ink border-brand' : 'bg-card border-edge text-slate-300'}`}>
|
|
{k === 'forecast' ? '수요/생산 예측' : '설비 예지보전'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{kind === 'pdm' ? (
|
|
<>
|
|
<Field label="설비 코드"><input className="inp" value={eq} onChange={e => setEq(e.target.value)} placeholder="예: EQ-CNC-01" /></Field>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Field label="가동률(0~1)"><input className="inp" value={avail} onChange={e => setAvail(e.target.value)} /></Field>
|
|
<Field label="비가동 횟수"><input className="inp" value={dt} onChange={e => setDt(e.target.value)} /></Field>
|
|
<Field label="MTBF(시간)"><input className="inp" value={mtbf} onChange={e => setMtbf(e.target.value)} /></Field>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Field label="시계열(쉼표구분)"><input className="inp" value={series} onChange={e => setSeries(e.target.value)} placeholder="예: 100, 120, 95, 130" /></Field>
|
|
<Field label="예측 기간(일)"><input type="number" className="inp" value={horizon} onChange={e => setHorizon(Number(e.target.value))} /></Field>
|
|
</>
|
|
)}
|
|
<Btn onClick={run} disabled={busy} size="sm"><Cpu size={13} /> {busy ? '분석 중…' : '예측 + 해석'}</Btn>
|
|
<AppliedBadge data={out} />
|
|
<Output data={out} />
|
|
<WiseEvidence node={out?.interpretation} />
|
|
<FeedbackBar answerId={out?.interpretation?.answerId} query={kind} />
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function DefectTool() {
|
|
const [code, setCode] = useState(''); const [ctx, setCtx] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
|
|
const run = async () => { setBusy(true); try { setOut(await aiDefectRootCause({ defectCode: code, context: ctx })) } finally { setBusy(false) } }
|
|
return (
|
|
<Card title="불량 원인 분석" icon={<AlertTriangle size={15} />}>
|
|
<Field label="불량 코드"><input className="inp" value={code} onChange={e => setCode(e.target.value)} placeholder="예: D-CRACK-01" /></Field>
|
|
<Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field>
|
|
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '분석 중…' : '원인 분석'}</Btn>
|
|
<Output data={out} />
|
|
<LocalFeedbackBar feature="defect-root-cause" question={code} answer={out} />
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function ForecastTool() {
|
|
const [item, setItem] = useState(''); const [days, setDays] = useState(30); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
|
|
const run = async () => { setBusy(true); try { setOut(await aiForecast({ itemCode: item, days })) } finally { setBusy(false) } }
|
|
return (
|
|
<Card title="수요·생산량 예측" icon={<TrendingUp size={15} />}>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="품목코드"><input className="inp" value={item} onChange={e => setItem(e.target.value)} /></Field>
|
|
<Field label="예측 기간(일)"><input type="number" className="inp" value={days} onChange={e => setDays(Number(e.target.value))} /></Field>
|
|
</div>
|
|
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '예측 중…' : '예측'}</Btn>
|
|
<Output data={out} />
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function MaintenanceTool() {
|
|
const [eq, setEq] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
|
|
const run = async () => { setBusy(true); try { setOut(await aiPredictiveMaintenance({ equipmentCode: eq })) } finally { setBusy(false) } }
|
|
return (
|
|
<Card title="설비 예지보전" icon={<Wrench size={15} />}>
|
|
<Field label="설비 코드"><input className="inp" value={eq} onChange={e => setEq(e.target.value)} placeholder="예: EQ-CNC-01" /></Field>
|
|
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '분석 중…' : '정비 권고'}</Btn>
|
|
<Output data={out} />
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function SpcTool() {
|
|
const [code, setCode] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
|
|
const run = async () => { setBusy(true); try { setOut(await aiSpcAnomaly({ chartCode: code })) } finally { setBusy(false) } }
|
|
return (
|
|
<Card title="SPC 이상 감지" icon={<LineChart size={15} />}>
|
|
<Field label="관리도 코드"><input className="inp" value={code} onChange={e => setCode(e.target.value)} /></Field>
|
|
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '감지 중…' : '이상 감지'}</Btn>
|
|
<Output data={out} />
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function QueryTool() {
|
|
const [q, setQ] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
|
|
const run = async () => { setBusy(true); try { setOut(await aiParseQuery({ query: q })) } finally { setBusy(false) } }
|
|
return (
|
|
<Card title="작업지시·재고 자연어 조회" icon={<Search size={15} />}>
|
|
<Field label="자연어 질의"><input className="inp" value={q} onChange={e => setQ(e.target.value)} placeholder="예: 지난주 지연된 작업지시" onKeyDown={e => e.key === 'Enter' && run()} /></Field>
|
|
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '해석 중…' : '질의 해석'}</Btn>
|
|
<Output data={out} />
|
|
<LocalFeedbackBar feature="parse-query" question={q} answer={out} />
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function SafetyStockTool() {
|
|
const [item, setItem] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
|
|
const run = async () => { setBusy(true); try { setOut(await aiSafetyStock({ itemCode: item })) } finally { setBusy(false) } }
|
|
return (
|
|
<Card title="재고 최적화·안전재고 추천" icon={<Boxes size={15} />}>
|
|
<Field label="품목코드"><input className="inp" value={item} onChange={e => setItem(e.target.value)} /></Field>
|
|
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '분석 중…' : '추천'}</Btn>
|
|
<Output data={out} />
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function ScheduleTool() {
|
|
const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
|
|
const run = async () => { setBusy(true); try { setOut(await aiScheduleOptimize({})) } finally { setBusy(false) } }
|
|
return (
|
|
<Card title="생산 일정 최적화" icon={<CalendarClock size={15} />}>
|
|
<p className="text-xs text-slate-400 mb-2">현재 작업지시·설비 부하 기반으로 일정 최적화를 제안합니다.</p>
|
|
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '최적화 중…' : '일정 최적화 제안'}</Btn>
|
|
<Output data={out} />
|
|
</Card>
|
|
)
|
|
}
|