diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/AiSearchRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/AiSearchRequest.java new file mode 100644 index 0000000..7fdfbbe --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/AiSearchRequest.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.search; + +import jakarta.validation.constraints.NotBlank; + +/** 자연어 통합검색 요청. */ +public record AiSearchRequest(@NotBlank String q, Integer limit) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/AiSearchResult.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/AiSearchResult.java new file mode 100644 index 0000000..2853f6d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/AiSearchResult.java @@ -0,0 +1,20 @@ +package com.zioinfo.kintex.work.search; + +import java.util.List; + +/** + * 자연어 통합검색 결과. + * + * @param question 원 질문 + * @param keywords AI 가 추출한 검색 키워드(적용값) + * @param types AI 가 지목한 대상 유형(WORKLOG|NOTICE|MEETING|REPORT|OPINION|EVENT|COMPANY|AUCTION|BOOTH|LEAD|CMS). 빈 배열=전체 + * @param summary 검색 결과 근거 기반 요약(nullable — 결과 없음/요약 미가용 시 null) + * @param results 키워드 검색 결과(병합·중복 제거·유형 필터 적용) + * @param total 결과 수 + * @param degraded AI 해석 미가용으로 원문 키워드 검색으로 강등했는지 여부 + * @param provider 실제 응답 provider("claude"|"ollama"|"none") + */ +public record AiSearchResult(String question, List keywords, List types, + String summary, List results, int total, + boolean degraded, String provider) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchAiService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchAiService.java new file mode 100644 index 0000000..ff830e1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchAiService.java @@ -0,0 +1,247 @@ +package com.zioinfo.kintex.work.search; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zioinfo.kintex.ai.AiTextRouter; +import com.zioinfo.kintex.ai.AiTextRouter.AiResult; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * G-07 자연어 통합검색 — 질문을 {@link AiTextRouter}로 구조화(키워드·대상 유형 JSON)한 뒤 기존 키워드 검색을 + * 조합 호출하고, 실제 결과를 근거로 요약을 생성한다. + * + *

파이프라인: 질문 → (1)구조화 추출(Claude→Ollama, JSON) → (2)키워드별 {@link SearchMapper} 검색 병합·중복제거·유형필터 + * → (3)결과 제목 근거 요약(2차 호출, 결과 있을 때만). 어느 단계든 AI 미가용이면 원문 키워드 검색으로 강등(degraded=true). + * + *

환각 방지: 요약은 실제 조회된 결과 제목만 근거로 생성하도록 지시하고, 없는 사실을 지어내지 않게 한다. + * 결과 자체는 DB 검색이 권위이며 AI 는 해석·요약만 담당한다. + */ +@Service +public class SearchAiService { + + private static final Logger log = LoggerFactory.getLogger(SearchAiService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 검색 가능한 유형(AI 지목값 검증 화이트리스트). */ + private static final Set VALID_TYPES = Set.of( + "WORKLOG", "NOTICE", "MEETING", "REPORT", "OPINION", + "EVENT", "COMPANY", "AUCTION", "BOOTH", "LEAD", "CMS"); + + private final SearchMapper mapper; + private final SystemAccessGuard scope; + private final AiTextRouter router; + + public SearchAiService(SearchMapper mapper, SystemAccessGuard scope, AiTextRouter router) { + this.mapper = mapper; + this.scope = scope; + this.router = router; + } + + public AiSearchResult search(KintexPrincipal principal, String question, int limit) { + if (question == null || question.isBlank()) { + return new AiSearchResult("", List.of(), List.of(), null, List.of(), 0, true, "none"); + } + int size = limit <= 0 ? 30 : Math.min(limit, 100); + boolean seeAll = scope.canSeeAll(principal); + String q = question.trim(); + + // (1) 구조화 추출 + AiResult ext = router.generate(buildExtractPrompt(q), 384); + List keywords = new ArrayList<>(); + List types = new ArrayList<>(); + String provider = ext.provider(); + boolean degraded = !ext.usable(); + if (!degraded) { + Parsed parsed = parse(ext.text()); + if (parsed == null) { + degraded = true; + } else { + keywords = parsed.keywords(); + types = parsed.types(); + } + } + // 강등: 원문 전체를 단일 키워드로 검색. + if (degraded || keywords.isEmpty()) { + keywords = new ArrayList<>(List.of(q)); + if (degraded) { + types = new ArrayList<>(); + } + } + + // (2) 키워드별 검색 병합(중복 제거) + 유형 필터. + Set typeFilter = new LinkedHashSet<>(types); + Map merged = new LinkedHashMap<>(); + for (String kw : keywords) { + if (kw == null || kw.isBlank()) { + continue; + } + List> rows = mapper.searchAll(kw.trim(), principal.userId(), seeAll, size); + for (Map r : rows) { + String type = str(r.get("type")); + if (!typeFilter.isEmpty() && (type == null || !typeFilter.contains(type))) { + continue; + } + String id = str(r.get("id")); + SearchResultItem item = new SearchResultItem(type, id, str(r.get("title")), + str(r.get("createdAt")), routeFor(type, id)); + merged.putIfAbsent(type + "|" + id, item); + if (merged.size() >= size) { + break; + } + } + if (merged.size() >= size) { + break; + } + } + List results = new ArrayList<>(merged.values()); + + // (3) 근거 요약(결과 있고 AI 해석이 살아 있을 때만 시도). + String summary = null; + if (!degraded && !results.isEmpty()) { + AiResult sum = router.generate(buildSummaryPrompt(q, results), 384); + if (sum.usable()) { + summary = sum.text().trim(); + provider = sum.provider(); + } + } + + return new AiSearchResult(q, keywords, new ArrayList<>(typeFilter), summary, + results, results.size(), degraded, provider); + } + + // ── 프롬프트 ───────────────────────────────────────────────────── + + private String buildExtractPrompt(String question) { + return "당신은 킨텍스 전시운영 통합검색의 질의 분석기다. 사용자의 자연어 질문에서 검색에 쓸 키워드와 대상 유형을 뽑아 JSON 객체 하나로만 응답하라.\n" + + "대상 유형(정확히 이 값만 사용): WORKLOG(업무일지), NOTICE(공지), MEETING(회의록), REPORT(업무보고), OPINION(의견), " + + "EVENT(행사), COMPANY(업체), AUCTION(옥션), BOOTH(부스), LEAD(리드), CMS(콘텐츠).\n" + + "규칙(엄수):\n" + + "1) 키는 정확히 keywords(핵심 검색어 문자열 배열, 1~5개, 조사·불용어 제외), types(대상 유형 문자열 배열, 특정할 수 없으면 빈 배열) 2개.\n" + + "2) 없는 유형을 지어내지 말고, 유형이 불분명하면 types 를 빈 배열로 둔다.\n" + + "3) 설명·머리말·마크다운·코드펜스 없이 JSON 객체 텍스트만 출력하라.\n\n" + + "질문: " + question; + } + + private String buildSummaryPrompt(String question, List results) { + StringBuilder sb = new StringBuilder(); + int n = Math.min(results.size(), 20); + for (int i = 0; i < n; i++) { + SearchResultItem r = results.get(i); + sb.append("- [").append(typeLabel(r.type())).append("] ").append(r.title()).append('\n'); + } + return "당신은 통합검색 결과 요약기다. 아래 '검색 결과 목록'만 근거로 사용자의 질문에 대한 2~3문장 한국어 요약을 작성하라.\n" + + "규칙: 목록에 없는 사실·수치를 지어내지 마라. 목록이 질문과 관련 없으면 관련 결과가 없다고 답하라. 요약 문장만 출력(머리말 없이).\n\n" + + "질문: " + question + "\n\n검색 결과 목록:\n" + sb; + } + + // ── 파싱 ───────────────────────────────────────────────────────── + + private record Parsed(List keywords, List types) { + } + + private Parsed parse(String raw) { + String json = stripToJson(raw); + if (json == null) { + return null; + } + try { + JsonNode root = MAPPER.readTree(json); + List keywords = new ArrayList<>(); + JsonNode kw = root.get("keywords"); + if (kw != null && kw.isArray()) { + for (JsonNode n : kw) { + String v = text(n); + if (v != null && !v.isBlank()) { + keywords.add(v.trim()); + } + } + } + List types = new ArrayList<>(); + JsonNode tp = root.get("types"); + if (tp != null && tp.isArray()) { + for (JsonNode n : tp) { + String v = text(n); + if (v != null) { + String u = v.trim().toUpperCase(); + if (VALID_TYPES.contains(u) && !types.contains(u)) { + types.add(u); + } + } + } + } + return new Parsed(keywords, types); + } catch (Exception e) { + return null; + } + } + + private static String stripToJson(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + String s = raw.trim(); + int start = s.indexOf('{'); + int end = s.lastIndexOf('}'); + return (start < 0 || end <= start) ? null : s.substring(start, end + 1); + } + + /** SearchService 와 동일한 route 파생(민감정보 미포함). */ + private static String routeFor(String type, String id) { + if (type == null) { + return null; + } + return switch (type) { + case "WORKLOG" -> "/work/worklog"; + case "NOTICE" -> "/work/notice"; + case "MEETING" -> "/work/meeting"; + case "REPORT" -> "/work/report"; + case "OPINION" -> "/work/opinion"; + case "EVENT" -> "/schedule"; + case "COMPANY" -> "/admin/companies"; + case "AUCTION" -> id == null ? "/auctions" : "/auctions/" + id; + case "BOOTH" -> "/booth-sales"; + case "LEAD" -> "/leads"; + case "CMS" -> "/cms"; + default -> null; + }; + } + + private static String typeLabel(String type) { + if (type == null) { + return ""; + } + return switch (type) { + case "WORKLOG" -> "업무일지"; + case "NOTICE" -> "공지"; + case "MEETING" -> "회의록"; + case "REPORT" -> "업무보고"; + case "OPINION" -> "의견"; + case "EVENT" -> "행사"; + case "COMPANY" -> "업체"; + case "AUCTION" -> "옥션"; + case "BOOTH" -> "부스"; + case "LEAD" -> "리드"; + case "CMS" -> "콘텐츠"; + default -> type; + }; + } + + private static String text(JsonNode n) { + return (n == null || n.isNull()) ? null : n.asText(); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchController.java index 6420bb8..1fab6b7 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchController.java @@ -3,21 +3,24 @@ package com.zioinfo.kintex.work.search; import com.zioinfo.kintex.auth.EventAccessGuard; import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.common.ApiResponse; +import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; -/** 통합검색 API (/api/work/search). 인증 사용자. */ +/** 통합검색 API (/api/work/search). 인증 사용자. 키워드(GET) + 자연어 AI(POST). */ @RestController @RequestMapping("/api/work/search") public class SearchController { private final SearchService service; + private final SearchAiService aiService; private final EventAccessGuard guard; - public SearchController(SearchService service, EventAccessGuard guard) { + public SearchController(SearchService service, SearchAiService aiService, EventAccessGuard guard) { this.service = service; + this.aiService = aiService; this.guard = guard; } @@ -28,4 +31,13 @@ public class SearchController { guard.require(principal); return ApiResponse.ok(service.search(principal, q, limit)); } + + /** G-07 자연어 통합검색 — 질문을 AI 로 구조화 후 키워드 검색 조합 + 근거 요약. AI 불가 시 키워드 검색 강등. */ + @PostMapping("/ai") + public ApiResponse aiSearch(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody AiSearchRequest req) { + guard.require(principal); + int limit = req.limit() == null ? 30 : req.limit(); + return ApiResponse.ok(aiService.search(principal, req.q(), limit)); + } } diff --git a/src/frontend/src/screens/work/SearchPage.tsx b/src/frontend/src/screens/work/SearchPage.tsx index af02804..3b41f91 100644 --- a/src/frontend/src/screens/work/SearchPage.tsx +++ b/src/frontend/src/screens/work/SearchPage.tsx @@ -19,6 +19,7 @@ import { IconTag, } from '../../components/ui/icons'; import { fmtDateTime, errMessage } from './workShared'; +import { searchAiApi } from './workAiApi'; import './work.css'; const TYPE_META: Record = { @@ -75,10 +76,16 @@ export function SearchPage() { const searchQ = useQuery({ queryKey: ['search', query], queryFn: () => searchApi.search(query, 50), - enabled: query.trim().length > 0, + enabled: !aiMode && query.trim().length > 0, + }); + const aiQ = useQuery({ + queryKey: ['aiSearch', query], + queryFn: () => searchAiApi.ask(query, 50), + enabled: aiMode && query.trim().length > 0, }); - const results = searchQ.data ?? []; + const activeQ = aiMode ? aiQ : searchQ; + const results = (aiMode ? aiQ.data?.results : searchQ.data) ?? []; const counts = useMemo(() => { const c: Record = { ALL: results.length }; for (const r of results) c[r.type] = (c[r.type] ?? 0) + 1; @@ -130,7 +137,7 @@ export function SearchPage() { @@ -138,14 +145,37 @@ export function SearchPage() { - {aiMode && ( + {aiMode && !query.trim() && (

AI 자연어 검색{' '} - 자연어 검색은 준비 중입니다. 현재는 키워드 검색으로 동작합니다. + "이번 달 KOAA 관련 회의록 찾아줘"처럼 질문하면 관련 데이터를 찾아 요약합니다.
)} + {aiMode && query.trim() && aiQ.data && ( +
+
+ AI 자연어 검색 + {aiQ.data.keywords.length > 0 && ( + + 키워드: {aiQ.data.keywords.join(', ')} + {aiQ.data.types.length > 0 && + ` · 대상: ${aiQ.data.types.map((t) => TYPE_META[t]?.label ?? t).join(', ')}`} + + )} +
+ {aiQ.data.summary ? ( +

{aiQ.data.summary}

+ ) : ( + + {aiQ.data.degraded + ? 'AI 해석을 사용할 수 없어 키워드 검색으로 결과를 표시합니다.' + : '검색 결과를 기반으로 표시합니다.'} + + )} +
+ )} {!query.trim() ? ( - {searchQ.isLoading && + {activeQ.isLoading && Array.from({ length: 5 }).map((_, i) => (
@@ -186,10 +216,10 @@ export function SearchPage() {
))} - {searchQ.isError && ( - searchQ.refetch()} /> + {activeQ.isError && ( + activeQ.refetch()} /> )} - {!searchQ.isLoading && !searchQ.isError && filtered.length === 0 && ( + {!activeQ.isLoading && !activeQ.isError && filtered.length === 0 && ( )} {filtered.map((r: SearchResultItem) => {