diff --git a/backend/src/main/java/com/zioinfo/esn/wise/RagClient.java b/backend/src/main/java/com/zioinfo/esn/wise/RagClient.java new file mode 100644 index 0000000..7355e31 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/wise/RagClient.java @@ -0,0 +1,108 @@ +package com.zioinfo.esn.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 — zioinfo-esn 전용). [ZIOINFO-ESN] + * + *

핵심 설계: ESN 은 검색·생성·검증 로직을 Java 에 재구현하지 않는다. 중앙 Python + * guardia-rag 의 {@code /rag/answer}(검색+근거생성+인용+guardrail) 를 REST 로 호출하기만 한다. + * 미가용/타임아웃 시 예외를 전파하지 않고 {@code degraded:true} 폴백 결과를 돌려준다. + * + *

보안 불변: 온프레미스 전용(서버 내부 루프백). 응답에서 자격증명/PII/스택트레이스 미노출. + * 오류는 1줄 요약만 로깅. 외부 API 아님(중앙 rag 경유만 — 솔루션에서 직접 LLM 호출 신설 금지). + */ +@Slf4j +@Component +public class RagClient { + + /** WISE AI 솔루션 식별자(컬렉션·피드백 격리 키) — 기존 LearningStore.SOLUTION 과 일치. */ + private static final String SOLUTION = "zioinfo-esn"; + + /** /rag/answer 는 소형모델 콜드로드를 감안해 240s. */ + private static final Duration ANSWER_TIMEOUT = Duration.ofSeconds(240); + + /** 중앙 guardia-rag 베이스 URL(서버 내부 루프백 기본). 외부 URL 설정 금지. */ + @Value("${guardia.rag-url:http://localhost:8020}") + private String ragUrl; + + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * POST /rag/answer — 검색+생성+검증+guardrail (근거+인용, 근거 부족 시 보류). + * + * @return rag 응답 필드 통과({@code answer, sources, grounded, faithfulness, abstained, + * degraded, degraded_reason, trace_id, answer_id}). 미가용 시 {@code degraded:true}. + */ + @SuppressWarnings("unchecked") + public Map answer(String query) { + try { + Map options = new LinkedHashMap<>(); + options.put("stream", false); + options.put("verify", true); + + Map 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(ragUrl) + "/rag/answer")) + .timeout(ANSWER_TIMEOUT) + .header("Content-Type", "application/json") + .header("X-Solution-Key", SOLUTION) + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() / 100 != 2 || resp.body() == null || resp.body().isBlank()) { + log.warn("중앙 rag /answer 비2xx({}) — ESN 폴백", resp.statusCode()); + return degraded("rag_no_response"); + } + Map res = mapper.readValue(resp.body(), Map.class); + return res != null ? res : degraded("rag_no_response"); + } catch (Exception e) { + log.warn("중앙 rag /answer 일시 불가 — ESN 폴백: {}", summarize(e)); + return degraded("rag_unavailable"); + } + } + + private Map degraded(String reason) { + Map 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; + } +} diff --git a/backend/src/main/java/com/zioinfo/esn/wise/WiseAskController.java b/backend/src/main/java/com/zioinfo/esn/wise/WiseAskController.java new file mode 100644 index 0000000..5e1e1ae --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/wise/WiseAskController.java @@ -0,0 +1,33 @@ +package com.zioinfo.esn.wise; + +import com.zioinfo.esn.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} 프록시(인증 사용자). [ZIOINFO-ESN] + * + *

브라우저 → ESN 백엔드 → 중앙 rag 경유. 근거·인용 기반 답변, 근거 부족 시 보류(abstained), + * rag 미가용 시 degraded 폴백. 기존 {@code /api/ai/*} 는 불변 — 본 컨트롤러는 별개 레이어. + * + *

+ */ +@RestController +@RequestMapping("/api/wise") +@RequiredArgsConstructor +public class WiseAskController { + + private final RagClient ragClient; + + @PostMapping("/ask") + public ApiResponse> ask(@RequestBody Map req) { + Object q = req == null ? null : req.get("query"); + String query = q == null ? "" : String.valueOf(q); + return ApiResponse.ok(ragClient.answer(query)); + } +} diff --git a/doc/zioinfo-esn_개발자지침서_v1.1.pptx b/doc/zioinfo-esn_개발자지침서_v1.1.pptx new file mode 100644 index 0000000..a2e363c Binary files /dev/null and b/doc/zioinfo-esn_개발자지침서_v1.1.pptx differ diff --git a/doc/zioinfo-esn_사용자지침서_v1.1.pptx b/doc/zioinfo-esn_사용자지침서_v1.1.pptx new file mode 100644 index 0000000..396445d Binary files /dev/null and b/doc/zioinfo-esn_사용자지침서_v1.1.pptx differ diff --git a/doc/zioinfo-esn_아키텍처설계서_v1.1.pptx b/doc/zioinfo-esn_아키텍처설계서_v1.1.pptx new file mode 100644 index 0000000..0278d9b Binary files /dev/null and b/doc/zioinfo-esn_아키텍처설계서_v1.1.pptx differ diff --git a/doc/zioinfo-esn_운영자지침서_v1.1.pptx b/doc/zioinfo-esn_운영자지침서_v1.1.pptx new file mode 100644 index 0000000..f3870b7 Binary files /dev/null and b/doc/zioinfo-esn_운영자지침서_v1.1.pptx differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cb3332d..b954456 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import UserList from './pages/UserList' import ProductList from './pages/ProductList' import TenantAdmin from './pages/TenantAdmin' import AiAnalysis from './pages/AiAnalysis' +import WiseAiPage from './pages/WiseAiPage' import TagBindingList from './pages/TagBindingList' import UpdateQueueList from './pages/UpdateQueueList' import MobileApp from './pages/MobileApp' @@ -64,6 +65,7 @@ export default function App() { } /> } /> } /> + } /> {/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */} } /> {/* UIWS(UIMS) 이식: 공통 업무협업 레이어 */} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 90f339f..0235d50 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -29,6 +29,9 @@ export const login = (username: string, password: string) => export const getMe = () => api.get('/api/auth/me') export const logout = () => api.post('/api/auth/logout') +// ── WISE AI (중앙 guardia-rag /rag/answer 프록시) ────────────────────────── +export const wiseAsk = (query: string) => unwrap(api.post('/api/wise/ask', { query })) + // ── Dashboard ──────────────────────────────────────────────────────────── export const getDashboard = (tenantCode?: string) => unwrap(api.get(`/api/dashboard${tenantCode ? `?tenantCode=${tenantCode}` : ''}`)) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 705ea26..359ab42 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import { LayoutDashboard, Store, FileText, RefreshCw, Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain, Link2, ListOrdered, BookOpen, CalendarDays, Mail, BarChart3, ShieldCheck, Smartphone, - KeyRound, Tags, Menu as MenuIcon, Network, Briefcase, Bot + KeyRound, Tags, Menu as MenuIcon, Network, Briefcase, Bot, Sparkles } from 'lucide-react' const nav = [ @@ -21,6 +21,7 @@ const nav = [ { to: '/users', icon: Users, label: '사용자' }, { to: '/tenants', icon: Building2, label: '테넌트 관리' }, { to: '/ai', icon: Brain, label: 'AI 분석' }, + { to: '/wise-ai', icon: Sparkles, label: 'WISE AI' }, // UIWS(UIMS) 이식: 공통 업무협업 레이어 { to: '/worklogs', icon: BookOpen, label: '업무일지' }, { to: '/schedules', icon: CalendarDays, label: '일정 관리' }, diff --git a/frontend/src/pages/WiseAiPage.tsx b/frontend/src/pages/WiseAiPage.tsx new file mode 100644 index 0000000..3c3ae7d --- /dev/null +++ b/frontend/src/pages/WiseAiPage.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { wiseAsk } from '../api/client' +import { Sparkles, Send, Quote, ShieldAlert, Cpu } from 'lucide-react' + +/** + * WISE AI — 중앙 guardia-rag 경유 근거·인용 기반 질의응답. [ZIOINFO-ESN] + * - 질문 → 답변(plain text) → 인용 카드(sources) → 근거 부족 시 보류(abstained) → 미가용 시 degraded + * - 온프레미스 전용(외부 API 미사용). 기존 /ai(AI 분석) 페이지는 그대로 보존 — 본 화면은 별개 레이어. + */ +export default function WiseAiPage() { + const [query, setQuery] = useState('') + const [res, setRes] = useState(null) + + const mut = useMutation({ + mutationFn: (q: string) => wiseAsk(q), + onSuccess: (data: any) => setRes(data || { degraded: true, degraded_reason: 'no_response' }), + onError: () => setRes({ degraded: true, degraded_reason: 'request_failed' }), + }) + + const ask = () => { + if (!query.trim() || mut.isPending) return + setRes(null) + mut.mutate(query.trim()) + } + + const sources: any[] = res?.sources || res?.citations || [] + const abstained = res?.abstained === true + const degraded = res?.degraded === true + + return ( +
+
+
+

+ WISE AI +

+

Enterprise AI for Trusted Knowledge

+
+ + 중앙 guardia-rag · 외부 API 미사용 + +
+ + {/* 질문 입력 */} +
+