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

This commit is contained in:
GUARDiA 2026-07-04 18:34:07 +09:00
parent 1eec79d860
commit 8f65c0bdd7
16 changed files with 402 additions and 57 deletions

View File

@ -65,7 +65,7 @@ public class AiFeedbackController {
body.put("verdict", req.verdict());
body.put("correction", req.correction());
webClientBuilder.baseUrl(ragBaseUrl).build()
.post().uri("/feedback")
.post().uri("/rag/feedback")
.bodyValue(body)
.retrieve()
.toBodilessEntity()

View 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_&lt;sol&gt;) 일치.
*/
@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;
}
}

View 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_&lt;solution&gt;). */
private String solution = "mro";
}

View File

@ -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

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GUARDiA MRO — 설비보전·MRO 자재 관리</title>
<script type="module" crossorigin src="/assets/index-BP-omnwj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DdO2G96-.css">
<script type="module" crossorigin src="/assets/index-C_qcct_1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CzK34TUd.css">
</head>
<body>
<div id="root"></div>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -26,6 +26,7 @@ import Partners from './pages/Partners'
import Costs from './pages/Costs'
// 공통 / 관리자
import AiTools from './pages/AiTools'
import WiseAiPage from './pages/WiseAiPage'
import AiPlatformSettings from './pages/AiPlatformSettings'
import UserManagement from './pages/UserManagement'
import AuditLog from './pages/AuditLog'
@ -66,6 +67,7 @@ export default function App() {
<Route path="/costs" element={<Costs />} />
{/* 공통 */}
<Route path="/ai-tools" element={<AiTools />} />
<Route path="/wise-ai" element={<WiseAiPage />} />
{/* 관리자 */}
<Route path="/admin/users" element={
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>

View File

@ -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 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) ─────────────────────
export const postAiFeedback = (d: { feature: string; question?: string; answer?: string; verdict: 'up' | 'down'; correction?: string }) =>
unwrap(api.post('/api/ai/feedback', d))

View File

@ -36,6 +36,7 @@ const mroLinks: Link[] = [
{ to: '/costs', label: 'MRO 비용', icon: Coins },
]
const commonLinks: Link[] = [
{ to: '/wise-ai', label: 'WISE AI', icon: Cpu },
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
]
const adminLinks: Link[] = [

View 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>
)
}