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:
parent
ef7400632e
commit
0922e5660b
@ -0,0 +1,7 @@
|
||||
package com.zioinfo.kintex.work.search;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/** 자연어 통합검색 요청. */
|
||||
public record AiSearchRequest(@NotBlank String q, Integer limit) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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)구조화 추출(Claude→Ollama, 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);
|
||||
}
|
||||
}
|
||||
@ -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<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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, { label: string; Icon: typeof IconDocument }> = {
|
||||
@ -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<string, number> = { ALL: results.length };
|
||||
for (const r of results) c[r.type] = (c[r.type] ?? 0) + 1;
|
||||
@ -130,7 +137,7 @@ export function SearchPage() {
|
||||
<button
|
||||
className={`kx-search__ai-toggle ${aiMode ? '' : 'is-off'}`}
|
||||
onClick={() => setAiMode((v) => !v)}
|
||||
title="AI 자연어 검색(준비 중)"
|
||||
title="AI 자연어 검색 — 질문을 이해해 관련 데이터를 찾고 요약합니다"
|
||||
>
|
||||
<IconSpark size={14} /> AI 자연어 검색
|
||||
</button>
|
||||
@ -138,14 +145,37 @@ export function SearchPage() {
|
||||
<Button onClick={() => setQuery(input)}>검색</Button>
|
||||
</div>
|
||||
|
||||
{aiMode && (
|
||||
{aiMode && !query.trim() && (
|
||||
<div className="kx-meet__ai-card" role="note">
|
||||
<AiLabel>AI 자연어 검색</AiLabel>{' '}
|
||||
<span className="kx-list-table__muted">
|
||||
자연어 검색은 준비 중입니다. 현재는 키워드 검색으로 동작합니다.
|
||||
"이번 달 KOAA 관련 회의록 찾아줘"처럼 질문하면 관련 데이터를 찾아 요약합니다.
|
||||
</span>
|
||||
</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() ? (
|
||||
<EmptyState
|
||||
@ -176,7 +206,7 @@ export function SearchPage() {
|
||||
|
||||
{/* 결과 */}
|
||||
<section aria-label="검색 결과" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{searchQ.isLoading &&
|
||||
{activeQ.isLoading &&
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div className="kx-search__result" key={i}>
|
||||
<Skeleton height={36} width={36} radius={6} />
|
||||
@ -186,10 +216,10 @@ export function SearchPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{searchQ.isError && (
|
||||
<ErrorState message={errMessage(searchQ.error)} onRetry={() => searchQ.refetch()} />
|
||||
{activeQ.isError && (
|
||||
<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}'에 대한 결과를 찾지 못했습니다.`} />
|
||||
)}
|
||||
{filtered.map((r: SearchResultItem) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user