feat(wise): WISE AI apply - branded grounded chat with citations/abstain UX
This commit is contained in:
parent
c9c5d43c49
commit
8515e59088
@ -50,6 +50,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||||
// AI 피드백 수집은 인증 사용자
|
// AI 피드백 수집은 인증 사용자
|
||||||
.requestMatchers("/api/ai/**").authenticated()
|
.requestMatchers("/api/ai/**").authenticated()
|
||||||
|
// WISE AI(중앙 guardia-rag 프록시)는 인증 사용자
|
||||||
|
.requestMatchers("/api/wise/**").authenticated()
|
||||||
.requestMatchers("/api/fa/**").authenticated()
|
.requestMatchers("/api/fa/**").authenticated()
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
|
|||||||
115
backend/src/main/java/com/zioinfo/fa/wise/RagClient.java
Normal file
115
backend/src/main/java/com/zioinfo/fa/wise/RagClient.java
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package com.zioinfo.fa.wise;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 얇은 중앙 guardia-rag REST 클라이언트 (WISE AI · FA 전용). [GUARDiA-FA]
|
||||||
|
*
|
||||||
|
* <p><b>핵심 설계</b>: FA 는 LangChain·벡터 로직을 Java 에 재구현하지 않는다. 중앙 Python
|
||||||
|
* guardia-rag 의 검색·근거생성·인용·환각차단(guardrail)을 REST({@code POST /rag/answer})로 호출만 한다.
|
||||||
|
* 미가용/타임아웃/RAM 부족 시 예외를 전파하지 않고 {@code degraded:true} 폴백 결과를 돌려준다.
|
||||||
|
*
|
||||||
|
* <p><b>보안 불변</b>: 온프레미스 전용(base-url 은 서버 내부 루프백). 외부 API 아님. 응답에서
|
||||||
|
* 자격증명/PII/스택트레이스 미노출 — 오류는 1줄 요약만 로깅.
|
||||||
|
*
|
||||||
|
* <p>호출 계약(중앙): {@code POST /rag/answer} → {@code {answer, sources[], grounded, faithfulness,
|
||||||
|
* abstained, degraded, degraded_reason, trace_id, answer_id}}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class RagClient {
|
||||||
|
|
||||||
|
/** 솔루션 식별자(컬렉션 rag_fa · 피드백 격리 키와 일치). */
|
||||||
|
private static final String SOLUTION = "fa";
|
||||||
|
|
||||||
|
/** /rag/answer 는 소형모델 콜드로드를 감안해 별도 긴 타임아웃(피드백 2.5s 재사용 금지). */
|
||||||
|
private static final Duration ANSWER_TIMEOUT = Duration.ofSeconds(240);
|
||||||
|
|
||||||
|
@Value("${guardia.rag.base-url:http://localhost:8020}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
@Value("${guardia.rag.enabled:true}")
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
private final HttpClient http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /rag/answer — 검색+근거생성+인용+guardrail. 근거 부족 시 {@code abstained:true} 로 보류된다.
|
||||||
|
*
|
||||||
|
* @return 중앙 응답 Map(패스스루). 미가용/실패 시 {@code {degraded:true, degraded_reason:...}}.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> answer(String query) {
|
||||||
|
if (!enabled) {
|
||||||
|
return degraded("rag_disabled");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> options = new LinkedHashMap<>();
|
||||||
|
options.put("stream", false);
|
||||||
|
options.put("verify", true);
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("solution", SOLUTION);
|
||||||
|
body.put("query", query == null ? "" : query);
|
||||||
|
body.put("retrieval_mode", "vector");
|
||||||
|
body.put("top_k", 5);
|
||||||
|
body.put("options", options);
|
||||||
|
|
||||||
|
String payload = mapper.writeValueAsString(body);
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(trimTrailingSlash(baseUrl) + "/rag/answer"))
|
||||||
|
.timeout(ANSWER_TIMEOUT)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Solution-Key", SOLUTION)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(payload))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (resp.statusCode() / 100 != 2 || resp.body() == null || resp.body().isBlank()) {
|
||||||
|
log.warn("WISE /rag/answer 비2xx({}) — FA 폴백", resp.statusCode());
|
||||||
|
return degraded("rag_no_response");
|
||||||
|
}
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> out = mapper.readValue(resp.body(), Map.class);
|
||||||
|
return out != null ? out : degraded("rag_no_response");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("WISE /rag/answer 일시 불가 — FA 폴백: {}", summarize(e));
|
||||||
|
return degraded("rag_unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> degraded(String reason) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("degraded", true);
|
||||||
|
out.put("degraded_reason", reason);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimTrailingSlash(String s) {
|
||||||
|
if (s == null || s.isBlank()) {
|
||||||
|
return "http://localhost:8020";
|
||||||
|
}
|
||||||
|
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 스택트레이스 미노출: 메시지 1줄만 요약. */
|
||||||
|
private static String summarize(Exception e) {
|
||||||
|
String m = e.getMessage();
|
||||||
|
if (m == null) {
|
||||||
|
return e.getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
return m.length() > 160 ? m.substring(0, 160) : m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.zioinfo.fa.wise;
|
||||||
|
|
||||||
|
import com.zioinfo.fa.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WISE AI — 중앙 guardia-rag {@code /rag/answer} 프록시(FA). [GUARDiA-FA]
|
||||||
|
*
|
||||||
|
* <p>{@code POST /api/wise/ask {query}} → {@link RagClient#answer(String)} → 근거·인용·환각차단이 담긴
|
||||||
|
* 중앙 응답을 그대로 전달한다. 브라우저는 rag 를 직접 호출하지 않고 이 백엔드를 경유한다.
|
||||||
|
*
|
||||||
|
* <p>인증: SecurityConfig {@code /api/wise/** authenticated} 게이트(로그인 사용자). 온프레미스 전용.
|
||||||
|
* 슬로건: Enterprise AI for Trusted Knowledge.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/wise")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WiseAskController {
|
||||||
|
|
||||||
|
private final RagClient ragClient;
|
||||||
|
|
||||||
|
@PostMapping("/ask")
|
||||||
|
public ApiResponse<Map<String, Object>> ask(@RequestBody Map<String, Object> req) {
|
||||||
|
Object q = req == null ? null : req.get("query");
|
||||||
|
String query = q == null ? "" : String.valueOf(q);
|
||||||
|
return ApiResponse.ok(ragClient.answer(query));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<title>GUARDiA FA — Factory Automation Platform</title>
|
<title>GUARDiA FA — Factory Automation Platform</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DGCZGcqo.js"></script>
|
<script type="module" crossorigin src="/assets/index-BLcAwA2O.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bw_KpBbg.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-B8zbZ_VS.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
doc/guardia-fa_개발자지침서_v1.1.pptx
Normal file
BIN
doc/guardia-fa_개발자지침서_v1.1.pptx
Normal file
Binary file not shown.
BIN
doc/guardia-fa_사용자지침서_v1.1.pptx
Normal file
BIN
doc/guardia-fa_사용자지침서_v1.1.pptx
Normal file
Binary file not shown.
BIN
doc/guardia-fa_아키텍처설계서_v1.1.pptx
Normal file
BIN
doc/guardia-fa_아키텍처설계서_v1.1.pptx
Normal file
Binary file not shown.
BIN
doc/guardia-fa_운영자지침서_v1.1.pptx
Normal file
BIN
doc/guardia-fa_운영자지침서_v1.1.pptx
Normal file
Binary file not shown.
@ -3,7 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate, NavLink } from 'react-router-do
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Map, Monitor, QrCode, Package,
|
LayoutDashboard, Map, Monitor, QrCode, Package,
|
||||||
AlertTriangle, ShieldCheck, Wrench, Boxes, Brain, LogOut, Cpu,
|
AlertTriangle, ShieldCheck, Wrench, Boxes, Brain, LogOut, Cpu,
|
||||||
Users, UserCircle
|
Users, UserCircle, Sparkles
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
@ -15,6 +15,7 @@ import AndonBoard from './pages/AndonBoard'
|
|||||||
import QualityControl from './pages/QualityControl'
|
import QualityControl from './pages/QualityControl'
|
||||||
import EquipmentStatus from './pages/EquipmentStatus'
|
import EquipmentStatus from './pages/EquipmentStatus'
|
||||||
import AiFactory from './pages/AiFactory'
|
import AiFactory from './pages/AiFactory'
|
||||||
|
import WiseAiPage from './pages/WiseAiPage'
|
||||||
import AiPlatformSettings from './pages/AiPlatformSettings'
|
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||||
import MyPage from './pages/MyPage'
|
import MyPage from './pages/MyPage'
|
||||||
import UserManagement from './pages/UserManagement'
|
import UserManagement from './pages/UserManagement'
|
||||||
@ -30,6 +31,7 @@ const NAV_ITEMS = [
|
|||||||
{ to: '/equipment', icon: Wrench, label: '설비 현황' },
|
{ to: '/equipment', icon: Wrench, label: '설비 현황' },
|
||||||
{ to: '/inventory', icon: Boxes, label: '재고 관리' },
|
{ to: '/inventory', icon: Boxes, label: '재고 관리' },
|
||||||
{ to: '/ai', icon: Brain, label: 'AI 공장 분석' },
|
{ to: '/ai', icon: Brain, label: 'AI 공장 분석' },
|
||||||
|
{ to: '/wise', icon: Sparkles, label: 'WISE AI' },
|
||||||
{ to: '/ai-settings', icon: Cpu, label: 'AI 플랫폼 설정' },
|
{ to: '/ai-settings', icon: Cpu, label: 'AI 플랫폼 설정' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -109,6 +111,7 @@ export default function App() {
|
|||||||
<Route path="/equipment" element={<EquipmentStatus />} />
|
<Route path="/equipment" element={<EquipmentStatus />} />
|
||||||
<Route path="/inventory" element={<AiFactory />} />
|
<Route path="/inventory" element={<AiFactory />} />
|
||||||
<Route path="/ai" element={<AiFactory />} />
|
<Route path="/ai" element={<AiFactory />} />
|
||||||
|
<Route path="/wise" element={<WiseAiPage />} />
|
||||||
<Route path="/ai-settings" element={<AiPlatformSettings />} />
|
<Route path="/ai-settings" element={<AiPlatformSettings />} />
|
||||||
<Route path="/mypage" element={<MyPage />} />
|
<Route path="/mypage" element={<MyPage />} />
|
||||||
<Route path="/admin/users" element={<UserManagement />} />
|
<Route path="/admin/users" element={<UserManagement />} />
|
||||||
|
|||||||
36
frontend/src/api/wise.ts
Normal file
36
frontend/src/api/wise.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// WISE AI API — /api/wise/ask (인증 사용자). [GUARDiA-FA]
|
||||||
|
// 질문 → 중앙 guardia-rag /rag/answer 프록시 → 근거·인용·환각차단(abstained)·degraded 포함 응답.
|
||||||
|
// 기본 client(baseURL '/api/fa')는 못 쓰므로 절대경로용 별도 인스턴스 사용(aiFeedback.ts 패턴).
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const wiseApi = axios.create({ timeout: 250000 }) // 콜드로드(240s) 감안
|
||||||
|
wiseApi.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('fa_token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface WiseSource {
|
||||||
|
source?: string
|
||||||
|
chunk_id?: string
|
||||||
|
page?: number | string
|
||||||
|
support?: number
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WiseAnswer {
|
||||||
|
answer?: string
|
||||||
|
sources?: WiseSource[]
|
||||||
|
grounded?: boolean
|
||||||
|
faithfulness?: number
|
||||||
|
abstained?: boolean
|
||||||
|
degraded?: boolean
|
||||||
|
degraded_reason?: string
|
||||||
|
trace_id?: string
|
||||||
|
answer_id?: string
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/wise/ask — 질문 전송. 봉투 { data } 안의 페이로드를 반환. */
|
||||||
|
export const askWise = (query: string) =>
|
||||||
|
wiseApi.post('/api/wise/ask', { query }).then(r => (r.data?.data ?? {}) as WiseAnswer)
|
||||||
140
frontend/src/pages/WiseAiPage.tsx
Normal file
140
frontend/src/pages/WiseAiPage.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Sparkles, Send, Quote, ShieldAlert, Cpu, FileText } from 'lucide-react'
|
||||||
|
import { askWise, WiseAnswer, WiseSource } from '../api/wise'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WISE AI — Enterprise AI for Trusted Knowledge. [GUARDiA-FA]
|
||||||
|
* 질문 → 중앙 guardia-rag(/rag/answer) 프록시 → 근거 있는 답변 + 인용.
|
||||||
|
* - abstained: 근거 부족 시 답변 보류(경고 톤, 오류 아님)
|
||||||
|
* - degraded : 중앙 RAG 미가용 시 회색 배지(사유 코드)
|
||||||
|
* 자동 실행/외부 API 없음 — 온프레미스 중앙 rag 경유만.
|
||||||
|
*/
|
||||||
|
export default function WiseAiPage() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [res, setRes] = useState<WiseAnswer | null>(null)
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
if (!query.trim() || busy) return
|
||||||
|
setBusy(true)
|
||||||
|
setRes(null)
|
||||||
|
try {
|
||||||
|
setRes(await askWise(query.trim()))
|
||||||
|
} catch {
|
||||||
|
setRes({ degraded: true, degraded_reason: 'request_failed' })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKey = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources: WiseSource[] = res?.sources || []
|
||||||
|
const abstained = res?.abstained === true
|
||||||
|
const degraded = res?.degraded === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
{/* 헤더 (브랜딩) */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold flex items-center gap-2 text-white">
|
||||||
|
<Sparkles className="text-blue-400" 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-slate-800 border border-slate-700 text-emerald-400">
|
||||||
|
<Cpu size={13} /> 중앙 guardia-rag · 외부 API 미사용
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 */}
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 mb-5">
|
||||||
|
<textarea
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKey}
|
||||||
|
placeholder="사내 문서·규정·업무 지식에 대해 질문하세요. 예) e-Paper 펌웨어 업데이트 절차 알려줘"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-sm text-slate-100 outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-[11px] text-slate-500">Ctrl/⌘ + Enter 로 전송</span>
|
||||||
|
<button
|
||||||
|
onClick={run}
|
||||||
|
disabled={busy || !query.trim()}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? <><span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" /> 검색 중…</>
|
||||||
|
: <><Send size={15} /> 질문하기</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결과 */}
|
||||||
|
{res && (
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5">
|
||||||
|
{/* 상태 배지 */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{abstained && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full bg-amber-500/10 border border-amber-500/40 text-amber-300">
|
||||||
|
<ShieldAlert size={13} /> 근거가 부족해 답변을 보류했습니다
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{degraded && (
|
||||||
|
<span className="text-xs px-3 py-1.5 rounded-full bg-slate-700 border border-slate-600 text-slate-300">
|
||||||
|
degraded{res.degraded_reason ? ` · ${res.degraded_reason}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!abstained && !degraded && res.faithfulness != null && (
|
||||||
|
<span className="text-xs px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/40 text-emerald-300">
|
||||||
|
신뢰도 {Math.round(Number(res.faithfulness) * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 답변 (plain text) */}
|
||||||
|
{abstained ? (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
확인된 근거 문서가 충분하지 않아 답변을 제공하지 않았습니다. 질문을 더 구체화하거나 관련 문서 등록 후 다시 시도해 주세요.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap text-sm text-slate-100 leading-relaxed font-sans">
|
||||||
|
{res.answer || (degraded ? 'AI 서비스가 일시적으로 불가합니다. 잠시 후 다시 시도해 주세요.' : '')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 인용 카드 */}
|
||||||
|
{!abstained && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1">
|
||||||
|
<Quote size={12} /> 근거 인용
|
||||||
|
</div>
|
||||||
|
{sources.length > 0 ? (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{sources.map((s, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 text-xs bg-slate-900 border border-slate-700 rounded-lg px-3 py-2">
|
||||||
|
<FileText size={14} className="text-blue-400 mt-0.5 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-slate-200 truncate">{s.source || s.chunk_id || `근거 ${i + 1}`}</div>
|
||||||
|
<div className="text-slate-500">
|
||||||
|
{s.page != null && `p.${s.page}`}
|
||||||
|
{s.support != null && `${s.page != null ? ' · ' : ''}${Math.round(Number(s.support) * 100)}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-500">근거 문서 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user