feat(wise): WISE AI apply - branded grounded chat with citations/abstain UX
This commit is contained in:
parent
1eec79d860
commit
8f65c0bdd7
@ -65,7 +65,7 @@ public class AiFeedbackController {
|
|||||||
body.put("verdict", req.verdict());
|
body.put("verdict", req.verdict());
|
||||||
body.put("correction", req.correction());
|
body.put("correction", req.correction());
|
||||||
webClientBuilder.baseUrl(ragBaseUrl).build()
|
webClientBuilder.baseUrl(ragBaseUrl).build()
|
||||||
.post().uri("/feedback")
|
.post().uri("/rag/feedback")
|
||||||
.bodyValue(body)
|
.bodyValue(body)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.toBodilessEntity()
|
.toBodilessEntity()
|
||||||
|
|||||||
98
backend/src/main/java/com/zioinfo/mro/rag/RagClient.java
Normal file
98
backend/src/main/java/com/zioinfo/mro/rag/RagClient.java
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package com.zioinfo.mro.rag;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 얇은 중앙 guardia-rag REST 클라이언트 (MRO 전용) — ERP 파일럿 동형.
|
||||||
|
*
|
||||||
|
* <p><b>핵심 설계</b>: MRO 는 LangChain·벡터 로직을 Java 에 재구현하지 않는다. 중앙 Python
|
||||||
|
* guardia-rag 의 검색·근거생성·인용·guardrail 을 REST 로 호출하기만 한다. 미가용/타임아웃/RAM
|
||||||
|
* 부족 시 절대 예외를 전파하지 않고 {@code degraded:true} 폴백 결과를 돌려준다.
|
||||||
|
*
|
||||||
|
* <p><b>보안 불변</b>: 온프레미스 전용(base-url 루프백). 응답에서 자격증명/PII/스택트레이스 미노출.
|
||||||
|
* 오류는 예외 클래스명/요약만 로깅한다.
|
||||||
|
*
|
||||||
|
* <p>호출 계약(중앙): {@code POST /rag/answer} — 검색+근거생성+인용+guardrail
|
||||||
|
* (근거 부족 시 {@code abstained:true} 보류). solution 은 컬렉션 관례(rag_<sol>)와 일치.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class RagClient {
|
||||||
|
|
||||||
|
private final WebClient.Builder builder;
|
||||||
|
private final RagProperties props;
|
||||||
|
|
||||||
|
public RagClient(WebClient.Builder builder, RagProperties props) {
|
||||||
|
this.builder = builder;
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /rag/answer — 검색+생성+검증+guardrail (근거+인용, 근거 부족 시 보류).
|
||||||
|
*
|
||||||
|
* @param query 자연어 질의
|
||||||
|
* @param retrievalMode vector|hybrid|graph (null 이면 vector)
|
||||||
|
* @return 중앙 rag 응답 Map({@code answer, sources[], grounded, faithfulness, abstained, degraded, trace_id}).
|
||||||
|
* 미가용 시 {@code degraded:true} 폴백 Map.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> answer(String query, String retrievalMode) {
|
||||||
|
if (!props.isEnabled()) {
|
||||||
|
return degraded("rag_disabled");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("solution", props.getSolution());
|
||||||
|
body.put("query", query);
|
||||||
|
if (retrievalMode != null && !retrievalMode.isBlank()) {
|
||||||
|
body.put("retrieval_mode", retrievalMode);
|
||||||
|
}
|
||||||
|
Map<String, Object> res = post("/rag/answer", body);
|
||||||
|
return res != null ? res : degraded("rag_no_response");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("RAG /answer 일시 불가 — MRO 폴백: {}", summarize(e));
|
||||||
|
return degraded("rag_unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> post(String path, Map<String, Object> body) {
|
||||||
|
return builder.baseUrl(props.getBaseUrl()).build()
|
||||||
|
.post().uri(path)
|
||||||
|
.header("X-Solution-Key", props.getSolution())
|
||||||
|
.bodyValue(body)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(Map.class)
|
||||||
|
.timeout(Duration.ofMillis(props.getTimeoutMs()))
|
||||||
|
.map(m -> (Map<String, Object>) m)
|
||||||
|
.block();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 중앙 rag 미가용 시 안내 폴백(오류 노출 아님). abstained 와 구분되는 degraded 표기. */
|
||||||
|
private Map<String, Object> degraded(String reason) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("answer", "AI 서비스가 일시적으로 불가합니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
out.put("sources", List.of());
|
||||||
|
out.put("grounded", false);
|
||||||
|
out.put("abstained", false);
|
||||||
|
out.put("degraded", true);
|
||||||
|
out.put("degraded_reason", reason);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 스택트레이스 미노출: 메시지 1줄만 요약. */
|
||||||
|
private String summarize(Exception e) {
|
||||||
|
String m = e.getMessage();
|
||||||
|
if (m == null) {
|
||||||
|
return e.getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
return m.length() > 160 ? m.substring(0, 160) : m;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/main/java/com/zioinfo/mro/rag/RagProperties.java
Normal file
41
backend/src/main/java/com/zioinfo/mro/rag/RagProperties.java
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package com.zioinfo.mro.rag;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중앙 guardia-rag 연동 설정 (MRO 전용) — ERP 파일럿 동형. 기존 {@code guardia.rag.*} yml 재사용.
|
||||||
|
*
|
||||||
|
* <p>보안 불변: base-url 은 <b>온프레미스(중앙 guardia-rag) 전용</b>이다. 외부 엔드포인트를
|
||||||
|
* 가리키도록 설정해서는 안 된다. 기본값은 서버 내부 루프백(127.0.0.1:8020)이다.
|
||||||
|
*
|
||||||
|
* <p>application.yml (기존):
|
||||||
|
* <pre>
|
||||||
|
* guardia:
|
||||||
|
* rag:
|
||||||
|
* base-url: http://127.0.0.1:8020
|
||||||
|
* timeout-ms: 120000
|
||||||
|
* enabled: true
|
||||||
|
* solution: mro
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@ConfigurationProperties(prefix = "guardia.rag")
|
||||||
|
public class RagProperties {
|
||||||
|
|
||||||
|
/** 중앙 guardia-rag 베이스 URL (서버 내부 루프백 기본). */
|
||||||
|
private String baseUrl = "http://127.0.0.1:8020";
|
||||||
|
|
||||||
|
/** 호출 타임아웃(ms). 소형모델 콜드로드 고려 기본 120s. */
|
||||||
|
private long timeoutMs = 120_000L;
|
||||||
|
|
||||||
|
/** RAG 경유 마스터 스위치. false 면 RagClient 가 항상 미가용으로 동작(degraded 폴백). */
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
/** 솔루션 식별자(컬렉션·피드백 격리 키 — rag_<solution>). */
|
||||||
|
private String solution = "mro";
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package com.zioinfo.mro.rag;
|
||||||
|
|
||||||
|
import com.zioinfo.mro.common.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
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 — Enterprise AI for Trusted Knowledge (MRO 배선).
|
||||||
|
*
|
||||||
|
* <p>{@code POST /api/wise/ask {query, retrievalMode?}} → 중앙 guardia-rag {@code /rag/answer}
|
||||||
|
* (solution=mro) 프록시. 근거+인용+환각차단(abstained) 동반. 미가용 시 degraded 안내(스택 미노출).
|
||||||
|
*
|
||||||
|
* <p>인증: SecurityConfig 의 {@code anyRequest().authenticated()} 로 로그인 사용자만 접근.
|
||||||
|
* 기존 {@code /api/mro/ai/*}(Ollama/Claude 직접) 는 불변 — 본 컨트롤러는 중앙 rag 경유 신규 경로만 추가.
|
||||||
|
*/
|
||||||
|
@Tag(name = "WISE AI (중앙 guardia-rag)", description = "근거·인용 기반 답변 — 온프레미스 전용")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/wise")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WiseAskController {
|
||||||
|
|
||||||
|
private final RagClient ragClient;
|
||||||
|
|
||||||
|
@Operation(summary = "WISE AI 질의 (/rag/answer 프록시 · 근거+인용+환각차단)")
|
||||||
|
@PostMapping("/ask")
|
||||||
|
public ApiResponse<Map<String, Object>> ask(@RequestBody AskRequest req) {
|
||||||
|
String query = req == null || req.query() == null ? "" : req.query().trim();
|
||||||
|
if (query.isEmpty()) {
|
||||||
|
return ApiResponse.ok(Map.of(
|
||||||
|
"answer", "질문을 입력해 주세요.",
|
||||||
|
"sources", java.util.List.of(),
|
||||||
|
"grounded", false,
|
||||||
|
"abstained", false,
|
||||||
|
"degraded", true,
|
||||||
|
"degraded_reason", "empty_query"));
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(ragClient.answer(query, req.retrievalMode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WISE AI 질의 요청 — retrievalMode: vector|hybrid|graph (선택). */
|
||||||
|
public record AskRequest(String query, String retrievalMode) {
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GUARDiA MRO — 설비보전·MRO 자재 관리</title>
|
<title>GUARDiA MRO — 설비보전·MRO 자재 관리</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BP-omnwj.js"></script>
|
<script type="module" crossorigin src="/assets/index-C_qcct_1.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DdO2G96-.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CzK34TUd.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
doc/guardia-mro_개발자지침서_v1.0.pptx
Normal file
BIN
doc/guardia-mro_개발자지침서_v1.0.pptx
Normal file
Binary file not shown.
BIN
doc/guardia-mro_사용자지침서_v1.0.pptx
Normal file
BIN
doc/guardia-mro_사용자지침서_v1.0.pptx
Normal file
Binary file not shown.
BIN
doc/guardia-mro_아키텍처설계서_v1.0.pptx
Normal file
BIN
doc/guardia-mro_아키텍처설계서_v1.0.pptx
Normal file
Binary file not shown.
BIN
doc/guardia-mro_운영자지침서_v1.0.pptx
Normal file
BIN
doc/guardia-mro_운영자지침서_v1.0.pptx
Normal file
Binary file not shown.
@ -26,6 +26,7 @@ import Partners from './pages/Partners'
|
|||||||
import Costs from './pages/Costs'
|
import Costs from './pages/Costs'
|
||||||
// 공통 / 관리자
|
// 공통 / 관리자
|
||||||
import AiTools from './pages/AiTools'
|
import AiTools from './pages/AiTools'
|
||||||
|
import WiseAiPage from './pages/WiseAiPage'
|
||||||
import AiPlatformSettings from './pages/AiPlatformSettings'
|
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||||
import UserManagement from './pages/UserManagement'
|
import UserManagement from './pages/UserManagement'
|
||||||
import AuditLog from './pages/AuditLog'
|
import AuditLog from './pages/AuditLog'
|
||||||
@ -66,6 +67,7 @@ export default function App() {
|
|||||||
<Route path="/costs" element={<Costs />} />
|
<Route path="/costs" element={<Costs />} />
|
||||||
{/* 공통 */}
|
{/* 공통 */}
|
||||||
<Route path="/ai-tools" element={<AiTools />} />
|
<Route path="/ai-tools" element={<AiTools />} />
|
||||||
|
<Route path="/wise-ai" element={<WiseAiPage />} />
|
||||||
{/* 관리자 */}
|
{/* 관리자 */}
|
||||||
<Route path="/admin/users" element={
|
<Route path="/admin/users" element={
|
||||||
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>
|
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>
|
||||||
|
|||||||
@ -201,6 +201,10 @@ export const aiParseQuery = (d: object) => unwrap(api.post('/api/mro/ai/parse-qu
|
|||||||
export const aiSafetyStock = (d: object) => unwrap(api.post('/api/mro/ai/safety-stock', d))
|
export const aiSafetyStock = (d: object) => unwrap(api.post('/api/mro/ai/safety-stock', d))
|
||||||
export const aiReliabilityAnomaly = (d: object) => unwrap(api.post('/api/mro/ai/reliability-anomaly', d))
|
export const aiReliabilityAnomaly = (d: object) => unwrap(api.post('/api/mro/ai/reliability-anomaly', d))
|
||||||
|
|
||||||
|
// ── WISE AI (중앙 guardia-rag /rag/answer 프록시, 근거+인용+환각차단) ──
|
||||||
|
export const wiseAsk = (query: string, retrievalMode?: string) =>
|
||||||
|
unwrap(api.post('/api/wise/ask', { query, retrievalMode }))
|
||||||
|
|
||||||
// ── AI 피드백 (인증 사용자 전체, /api/ai/feedback) ─────────────────────
|
// ── AI 피드백 (인증 사용자 전체, /api/ai/feedback) ─────────────────────
|
||||||
export const postAiFeedback = (d: { feature: string; question?: string; answer?: string; verdict: 'up' | 'down'; correction?: string }) =>
|
export const postAiFeedback = (d: { feature: string; question?: string; answer?: string; verdict: 'up' | 'down'; correction?: string }) =>
|
||||||
unwrap(api.post('/api/ai/feedback', d))
|
unwrap(api.post('/api/ai/feedback', d))
|
||||||
|
|||||||
@ -36,6 +36,7 @@ const mroLinks: Link[] = [
|
|||||||
{ to: '/costs', label: 'MRO 비용', icon: Coins },
|
{ to: '/costs', label: 'MRO 비용', icon: Coins },
|
||||||
]
|
]
|
||||||
const commonLinks: Link[] = [
|
const commonLinks: Link[] = [
|
||||||
|
{ to: '/wise-ai', label: 'WISE AI', icon: Cpu },
|
||||||
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
|
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
|
||||||
]
|
]
|
||||||
const adminLinks: Link[] = [
|
const adminLinks: Link[] = [
|
||||||
|
|||||||
149
frontend/src/pages/WiseAiPage.tsx
Normal file
149
frontend/src/pages/WiseAiPage.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Sparkles, ShieldCheck, ShieldAlert, AlertTriangle, FileText, Cpu } from '../components/icons'
|
||||||
|
import { PageHeader, Card, Btn } from '../components/ui'
|
||||||
|
import { wiseAsk } from '../api/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WISE AI — Enterprise AI for Trusted Knowledge.
|
||||||
|
* 중앙 guardia-rag `/rag/answer` 를 MRO 백엔드(POST /api/wise/ask)로 프록시 호출한다.
|
||||||
|
* - 질문 입력 → 스피너 → 답변(plain text)
|
||||||
|
* - 근거 인용 카드(sources[]) · 없으면 "근거 문서 없음"
|
||||||
|
* - abstained=true → 경고 톤 배지("근거가 부족해 답변을 보류") · degraded → 회색 배지(사유)
|
||||||
|
* 외부 API 미사용(온프레미스 rag 경유). 조회·안내 전용.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Source {
|
||||||
|
source?: string; document?: string; title?: string
|
||||||
|
chunk_id?: string; page?: number | string; support?: number
|
||||||
|
}
|
||||||
|
interface AnswerResp {
|
||||||
|
answer?: string
|
||||||
|
sources?: Source[]
|
||||||
|
grounded?: boolean
|
||||||
|
faithfulness?: number
|
||||||
|
abstained?: boolean
|
||||||
|
degraded?: boolean
|
||||||
|
degraded_reason?: string
|
||||||
|
trace_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WiseAiPage() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [resp, setResp] = useState<AnswerResp | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const ask = async () => {
|
||||||
|
const q = query.trim()
|
||||||
|
if (!q || busy) return
|
||||||
|
setBusy(true); setError(''); setResp(null)
|
||||||
|
try {
|
||||||
|
setResp(await wiseAsk(q))
|
||||||
|
} catch {
|
||||||
|
setError('AI 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = resp?.sources ?? []
|
||||||
|
const abstained = resp?.abstained === true
|
||||||
|
const degraded = resp?.degraded === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="WISE AI"
|
||||||
|
subtitle="Enterprise AI for Trusted Knowledge — 근거·인용 기반 답변 (온프레미스 · 외부 API 미사용)"
|
||||||
|
actions={
|
||||||
|
<span className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border text-accent border-accent/30 bg-accent/10">
|
||||||
|
<Cpu size={13} /> 중앙 guardia-rag
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
{/* 질문 입력 */}
|
||||||
|
<Card title="질문" icon={<Sparkles size={15} />}>
|
||||||
|
<textarea
|
||||||
|
className="inp"
|
||||||
|
rows={3}
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
onKeyDown={e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') ask() }}
|
||||||
|
placeholder="예) 정기 PM 지연 시 설비 신뢰성에 미치는 영향과 대응 절차를 알려줘"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<Btn onClick={ask} disabled={busy || !query.trim()} size="sm">
|
||||||
|
<Sparkles size={13} /> {busy ? '분석 중…' : '질문하기'}
|
||||||
|
</Btn>
|
||||||
|
<span className="text-[11px] text-slate-500">Ctrl/⌘ + Enter</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 text-sm text-rose-300 bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 답변 */}
|
||||||
|
{resp && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Card title="답변" icon={<FileText size={15} />}>
|
||||||
|
{/* 배지 */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{abstained && (
|
||||||
|
<span className="flex items-center gap-1 text-[11.5px] font-semibold px-2.5 py-1 rounded-full text-amber-300 bg-amber-500/10 border border-amber-500/40">
|
||||||
|
<ShieldAlert size={12} /> 근거가 부족해 답변을 보류했습니다
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{degraded && (
|
||||||
|
<span className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full text-slate-400 bg-card border border-edge">
|
||||||
|
degraded{resp.degraded_reason ? ` · ${resp.degraded_reason}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{resp.grounded && !abstained && (
|
||||||
|
<span className="flex items-center gap-1 text-[11.5px] font-semibold px-2.5 py-1 rounded-full text-emerald-300 bg-emerald-500/10 border border-emerald-500/40">
|
||||||
|
<ShieldCheck size={12} /> 근거 확인됨
|
||||||
|
{typeof resp.faithfulness === 'number' ? ` · ${Math.round(resp.faithfulness * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm leading-7 text-slate-200 whitespace-pre-wrap">
|
||||||
|
{resp.answer || '응답 내용이 없습니다.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인용 */}
|
||||||
|
<div className="mt-4 border-t border-edge pt-3">
|
||||||
|
<div className="text-[11.5px] font-semibold text-slate-400 mb-2 flex items-center gap-1">
|
||||||
|
<AlertTriangle size={12} /> 근거 인용
|
||||||
|
</div>
|
||||||
|
{sources.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{sources.map((s, i) => {
|
||||||
|
const name = s.source || s.document || s.title || s.chunk_id || `근거 ${i + 1}`
|
||||||
|
const meta = [
|
||||||
|
s.page != null ? `p.${s.page}` : '',
|
||||||
|
s.support != null ? `${Math.round(s.support * 100)}%` : '',
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
return (
|
||||||
|
<div key={i} className="bg-ink border border-edge rounded-lg px-3 py-2 text-xs text-slate-300">
|
||||||
|
<div className="font-semibold break-all">{name}</div>
|
||||||
|
{meta && <div className="text-[11px] text-slate-500 mt-0.5">{meta}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-500">근거 문서 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user