feat(search): AI natural-language search over work data (grounded)

SearchAiService interprets the query via AiTextRouter and grounds
results on existing search providers; degraded fallback keeps plain
search working when AI is unavailable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 06:26:31 +09:00
parent ef7400632e
commit 0922e5660b
5 changed files with 327 additions and 11 deletions

View File

@ -0,0 +1,7 @@
package com.zioinfo.kintex.work.search;
import jakarta.validation.constraints.NotBlank;
/** 자연어 통합검색 요청. */
public record AiSearchRequest(@NotBlank String q, Integer limit) {
}

View File

@ -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<String> keywords, List<String> types,
String summary, List<SearchResultItem> results, int total,
boolean degraded, String provider) {
}

View File

@ -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) 기존 키워드 검색을
* 조합 호출하고, 실제 결과를 근거로 요약을 생성한다.
*
* <p><b>파이프라인</b>: 질문 (1)구조화 추출(ClaudeOllama, JSON) (2)키워드별 {@link SearchMapper} 검색 병합·중복제거·유형필터
* (3)결과 제목 근거 요약(2차 호출, 결과 있을 때만). 어느 단계든 AI 미가용이면 <b>원문 키워드 검색으로 강등</b>(degraded=true).
*
* <p><b>환각 방지</b>: 요약은 실제 조회된 결과 제목만 근거로 생성하도록 지시하고, 없는 사실을 지어내지 않게 한다.
* 결과 자체는 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<String> 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<String> keywords = new ArrayList<>();
List<String> 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<String> typeFilter = new LinkedHashSet<>(types);
Map<String, SearchResultItem> merged = new LinkedHashMap<>();
for (String kw : keywords) {
if (kw == null || kw.isBlank()) {
continue;
}
List<Map<String, Object>> rows = mapper.searchAll(kw.trim(), principal.userId(), seeAll, size);
for (Map<String, Object> 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<SearchResultItem> 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<SearchResultItem> 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<String> keywords, List<String> types) {
}
private Parsed parse(String raw) {
String json = stripToJson(raw);
if (json == null) {
return null;
}
try {
JsonNode root = MAPPER.readTree(json);
List<String> 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<String> 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);
}
}

View File

@ -3,21 +3,24 @@ package com.zioinfo.kintex.work.search;
import com.zioinfo.kintex.auth.EventAccessGuard; import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse; import com.zioinfo.kintex.common.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
/** 통합검색 API (/api/work/search). 인증 사용자. */ /** 통합검색 API (/api/work/search). 인증 사용자. 키워드(GET) + 자연어 AI(POST). */
@RestController @RestController
@RequestMapping("/api/work/search") @RequestMapping("/api/work/search")
public class SearchController { public class SearchController {
private final SearchService service; private final SearchService service;
private final SearchAiService aiService;
private final EventAccessGuard guard; private final EventAccessGuard guard;
public SearchController(SearchService service, EventAccessGuard guard) { public SearchController(SearchService service, SearchAiService aiService, EventAccessGuard guard) {
this.service = service; this.service = service;
this.aiService = aiService;
this.guard = guard; this.guard = guard;
} }
@ -28,4 +31,13 @@ public class SearchController {
guard.require(principal); guard.require(principal);
return ApiResponse.ok(service.search(principal, q, limit)); return ApiResponse.ok(service.search(principal, q, limit));
} }
/** G-07 자연어 통합검색 — 질문을 AI 로 구조화 후 키워드 검색 조합 + 근거 요약. AI 불가 시 키워드 검색 강등. */
@PostMapping("/ai")
public ApiResponse<AiSearchResult> 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));
}
} }

View File

@ -19,6 +19,7 @@ import {
IconTag, IconTag,
} from '../../components/ui/icons'; } from '../../components/ui/icons';
import { fmtDateTime, errMessage } from './workShared'; import { fmtDateTime, errMessage } from './workShared';
import { searchAiApi } from './workAiApi';
import './work.css'; import './work.css';
const TYPE_META: Record<string, { label: string; Icon: typeof IconDocument }> = { const TYPE_META: Record<string, { label: string; Icon: typeof IconDocument }> = {
@ -75,10 +76,16 @@ export function SearchPage() {
const searchQ = useQuery({ const searchQ = useQuery({
queryKey: ['search', query], queryKey: ['search', query],
queryFn: () => searchApi.search(query, 50), 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 counts = useMemo(() => {
const c: Record<string, number> = { ALL: results.length }; const c: Record<string, number> = { ALL: results.length };
for (const r of results) c[r.type] = (c[r.type] ?? 0) + 1; for (const r of results) c[r.type] = (c[r.type] ?? 0) + 1;
@ -130,7 +137,7 @@ export function SearchPage() {
<button <button
className={`kx-search__ai-toggle ${aiMode ? '' : 'is-off'}`} className={`kx-search__ai-toggle ${aiMode ? '' : 'is-off'}`}
onClick={() => setAiMode((v) => !v)} onClick={() => setAiMode((v) => !v)}
title="AI 자연어 검색(준비 중)" title="AI 자연어 검색 — 질문을 이해해 관련 데이터를 찾고 요약합니다"
> >
<IconSpark size={14} /> AI <IconSpark size={14} /> AI
</button> </button>
@ -138,14 +145,37 @@ export function SearchPage() {
<Button onClick={() => setQuery(input)}></Button> <Button onClick={() => setQuery(input)}></Button>
</div> </div>
{aiMode && ( {aiMode && !query.trim() && (
<div className="kx-meet__ai-card" role="note"> <div className="kx-meet__ai-card" role="note">
<AiLabel>AI </AiLabel>{' '} <AiLabel>AI </AiLabel>{' '}
<span className="kx-list-table__muted"> <span className="kx-list-table__muted">
. . "이번 달 KOAA 관련 회의록 찾아줘" .
</span> </span>
</div> </div>
)} )}
{aiMode && query.trim() && aiQ.data && (
<div className="kx-meet__ai-card" role="note">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<AiLabel>AI </AiLabel>
{aiQ.data.keywords.length > 0 && (
<span className="kx-list-table__muted">
: {aiQ.data.keywords.join(', ')}
{aiQ.data.types.length > 0 &&
` · 대상: ${aiQ.data.types.map((t) => TYPE_META[t]?.label ?? t).join(', ')}`}
</span>
)}
</div>
{aiQ.data.summary ? (
<p style={{ margin: '8px 0 0' }}>{aiQ.data.summary}</p>
) : (
<span className="kx-list-table__muted" style={{ display: 'block', marginTop: 6 }}>
{aiQ.data.degraded
? 'AI 해석을 사용할 수 없어 키워드 검색으로 결과를 표시합니다.'
: '검색 결과를 기반으로 표시합니다.'}
</span>
)}
</div>
)}
{!query.trim() ? ( {!query.trim() ? (
<EmptyState <EmptyState
@ -176,7 +206,7 @@ export function SearchPage() {
{/* 결과 */} {/* 결과 */}
<section aria-label="검색 결과" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <section aria-label="검색 결과" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{searchQ.isLoading && {activeQ.isLoading &&
Array.from({ length: 5 }).map((_, i) => ( Array.from({ length: 5 }).map((_, i) => (
<div className="kx-search__result" key={i}> <div className="kx-search__result" key={i}>
<Skeleton height={36} width={36} radius={6} /> <Skeleton height={36} width={36} radius={6} />
@ -186,10 +216,10 @@ export function SearchPage() {
</div> </div>
</div> </div>
))} ))}
{searchQ.isError && ( {activeQ.isError && (
<ErrorState message={errMessage(searchQ.error)} onRetry={() => searchQ.refetch()} /> <ErrorState message={errMessage(activeQ.error)} onRetry={() => activeQ.refetch()} />
)} )}
{!searchQ.isLoading && !searchQ.isError && filtered.length === 0 && ( {!activeQ.isLoading && !activeQ.isError && filtered.length === 0 && (
<EmptyState title="검색 결과가 없습니다" description={`'${query}'에 대한 결과를 찾지 못했습니다.`} /> <EmptyState title="검색 결과가 없습니다" description={`'${query}'에 대한 결과를 찾지 못했습니다.`} />
)} )}
{filtered.map((r: SearchResultItem) => { {filtered.map((r: SearchResultItem) => {