diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchMcpService.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchMcpService.java index d11ca3f3..6dc9059e 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchMcpService.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchMcpService.java @@ -2,11 +2,15 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; import java.util.List; +import java.util.Locale; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,11 +22,38 @@ public class BraveSearchMcpService { private static final String BRAVE_WEB_TOOL = "brave_web_search"; + private static final int MAX_JSON_RECURSION_DEPTH = 3; private final List mcpClients; - private final ObjectMapper objectMapper; + private static boolean looksLikeJson(String s) { + if (s == null) { + return false; + } + String t = s.trim(); + return (!t.isEmpty()) && (t.charAt(0) == '{' || t.charAt(0) == '['); + } + + private static String truncate(String s, int max) { + if (s == null) { + return null; + } + return s.length() > max ? s.substring(0, max) + "…" : s; + } + + private static String getTrimmed(JsonNode obj, String field) { + if (obj == null) { + return null; + } + JsonNode n = obj.get(field); + if (n == null || n.isNull()) { + return null; + } + String v = n.asText(); + return v == null ? null : v.trim(); + } + public JsonNode search(String query, int count, int offset) { McpSyncClient braveClient = resolveBraveClient(); @@ -33,31 +64,210 @@ public JsonNode search(String query, int count, int offset) { CallToolResult result = braveClient.callTool(request); - JsonNode content = objectMapper.valueToTree(result.content()); - log.info("[Brave MCP Response Raw content]: {}", content.toPrettyString()); + JsonNode raw = objectMapper.valueToTree(result.content()); + log.info("[Brave MCP Response Raw content]: {}", raw); + ArrayNode normalized = objectMapper.createArrayNode(); + normalizeContent(raw, normalized); - if (content != null && content.isArray()) { - var root = objectMapper.createObjectNode(); - root.set("results", content); - return root; - } + ObjectNode root = objectMapper.createObjectNode(); + root.set("results", normalized); - return content != null ? content : objectMapper.createObjectNode(); + log.info("Brave 검색 결과 정규화 완료: {}건", normalized.size()); + return root; } + /** + * MCP 클라이언트들 중 brave_web_search 툴을 가진 클라이언트 선택. 초기화되지 않은 경우 1회 initialize() 후 재시도. + */ private McpSyncClient resolveBraveClient() { for (McpSyncClient client : mcpClients) { - ListToolsResult tools = client.listTools(); - if (tools != null && tools.tools() != null) { - boolean found = tools.tools().stream() - .anyMatch(tool -> BRAVE_WEB_TOOL.equalsIgnoreCase(tool.name())); - if (found) { + try { + ListToolsResult tools = client.listTools(); + if (hasBraveTool(tools)) { return client; } + } catch (McpError e) { + String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(Locale.ROOT); + boolean likelyUninitialized = msg.contains("not initialized") + || msg.contains("uninitialized") + || (msg.contains("initialize") && msg.contains("required")); + if (likelyUninitialized) { + // 1회만 초기화 재시도 + try { + log.warn("MCP 클라이언트 미초기화 감지 → initialize() 재시도"); + client.initialize(); + ListToolsResult tools = client.listTools(); + if (hasBraveTool(tools)) { + return client; + } + } catch (Exception initError) { + log.error("MCP 클라이언트 초기화 실패: {}", initError.getMessage()); + } + } else { + log.debug("listTools() 예외: {}", e.getMessage()); + } + } catch (Exception e) { + log.debug("listTools() 일반 예외: {}", e.getMessage()); } } - throw new IllegalStateException("Brave MCP 서버에서 brave_web_search 툴을 찾을 수 없습니다."); } -} \ No newline at end of file + + private boolean hasBraveTool(ListToolsResult tools) { + return tools != null && tools.tools() != null && + tools.tools().stream().anyMatch(t -> BRAVE_WEB_TOOL.equalsIgnoreCase(t.name())); + } + + /** + * raw JSON(any shape) → results[{url,title,description}] + */ + private void normalizeContent(JsonNode raw, ArrayNode out) { + if (raw == null || raw.isNull()) { + return; + } + + if (raw.isArray()) { + for (JsonNode n : raw) { + normalizeOne(n, out); + } + } else { + normalizeOne(raw, out); + } + } + + private void normalizeOne(JsonNode node, ArrayNode out) { + if (node == null || node.isNull()) { + return; + } + + // 이미 {url,title,description} 형태 + if (node.isObject() && (node.has("url") || node.has("title") || node.has("description"))) { + addObjectToResults((ObjectNode) node, out); + return; + } + + // MCP가 주는 content item: { "type":"text", "text":"{...json...}" } 같은 형태를 방어 + if (node.isObject() && node.has("text")) { + String text = node.path("text").asText(""); + if (looksLikeJson(text)) { + parseJsonBlockIntoResults(text, out, 0); + } else { + // "Title: ..." 라인 포맷 등 레거시 텍스트 포맷 처리(옵션) + parseLegacyBlock(text, out); + } + return; + } + + // 순수 문자열이지만 안에 JSON이 들어 있는 경우 + if (node.isTextual() && looksLikeJson(node.asText())) { + parseJsonBlockIntoResults(node.asText(), out, 0); + } + } + + private void parseJsonBlockIntoResults(String json, ArrayNode out, int depth) { + if (depth > MAX_JSON_RECURSION_DEPTH) { + log.warn("JSON 파싱 재귀 깊이 초과: {}", truncate(json, 100)); + return; + } + try { + JsonNode parsed = objectMapper.readTree(json); + + if (parsed.isArray()) { + for (JsonNode n : parsed) { + if (n.isObject()) { + addObjectToResults((ObjectNode) n, out); + } else if (n.isTextual() && looksLikeJson(n.asText())) { + parseJsonBlockIntoResults(n.asText(), out, depth + 1); + } + } + } else if (parsed.isObject()) { + ObjectNode obj = (ObjectNode) parsed; + // 루트에 results 배열이 있는 형태 처리 + if (obj.has("results") && obj.get("results").isArray()) { + for (JsonNode n : obj.get("results")) { + if (n.isObject()) { + addObjectToResults((ObjectNode) n, out); + } else if (n.isTextual() && looksLikeJson(n.asText())) { + parseJsonBlockIntoResults(n.asText(), out, depth + 1); + } + } + return; + } + if (obj.has("text") && obj.get("text").isTextual() && looksLikeJson( + obj.get("text").asText())) { + // {text:"{...}"} 같은 한 번 더 감싼 케이스 + parseJsonBlockIntoResults(obj.get("text").asText(), out, depth + 1); + } else { + addObjectToResults(obj, out); + } + } else if (parsed.isTextual() && looksLikeJson(parsed.asText())) { + parseJsonBlockIntoResults(parsed.asText(), out, depth + 1); + } + } catch (Exception e) { + log.warn("Brave MCP JSON 파싱 실패: {}\n원인: {}", truncate(json, 400), e.getMessage()); + } + } + + private void addObjectToResults(ObjectNode obj, ArrayNode out) { + String url = getTrimmed(obj, "url"); + String title = getTrimmed(obj, "title"); + String desc = getTrimmed(obj, "description"); + + // 세 필드 중 하나라도 있으면 결과로 채택 + if ((url != null && !url.isBlank()) || + (title != null && !title.isBlank()) || + (desc != null && !desc.isBlank())) { + + ObjectNode one = objectMapper.createObjectNode(); + if (url != null) { + one.put("url", url); + } + if (title != null) { + one.put("title", title); + } + if (desc != null) { + one.put("description", desc); + } + out.add(one); + } + } + + // 레거시 "Title:..., URL:..., 본문..." 형태의 텍스트 파서(있으면 도움, 없어도 무방) + private void parseLegacyBlock(String text, ArrayNode out) { + if (text == null || text.isBlank()) { + return; + } + + String[] lines = text.split("\\r?\\n"); + String title = null, url = null; + StringBuilder body = new StringBuilder(); + + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.regionMatches(true, 0, "Title:", 0, 6)) { + if (title != null && url != null && body.length() > 0) { + ObjectNode one = objectMapper.createObjectNode(); + one.put("title", title); + one.put("url", url); + one.put("description", body.toString().trim()); + out.add(one); + body.setLength(0); + } + title = trimmed.substring(6).trim(); + } else if (trimmed.regionMatches(true, 0, "URL:", 0, 4)) { + url = trimmed.substring(4).trim(); + } else { + body.append(line).append('\n'); + } + } + + if (title != null && url != null) { + ObjectNode one = objectMapper.createObjectNode(); + one.put("title", title); + one.put("url", url); + one.put("description", body.toString().trim()); + out.add(one); + } + } +} diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchRagService.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchRagService.java index 5a468cdb..d29bd35b 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchRagService.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchRagService.java @@ -1,6 +1,7 @@ package com.example.cs25service.domain.ai.service; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -13,54 +14,126 @@ @RequiredArgsConstructor public class BraveSearchRagService { + private final ObjectMapper objectMapper = new ObjectMapper(); + public List toDocuments(Optional resultsNodeOpt) { List documents = new ArrayList<>(); + resultsNodeOpt.ifPresent(root -> { + for (JsonNode r : root.path("results")) { + // 1) 표준 필드 우선 사용 + String url = getText(r, "url"); + String title = getText(r, "title"); + String description = getText(r, "description"); - resultsNodeOpt.ifPresent(resultsNode -> { - resultsNode.path("results").forEach(result -> { - String text = result.path("text").asText(""); - if (text.isBlank()) { - return; + // 2) text 필드가 JSON 문자열일 수 있음 + if (isBlank(url) && isBlank(title) && isBlank(description)) { + String text = getText(r, "text"); + if (!isBlank(text) && looksLikeJson(text)) { + try { + JsonNode inner = objectMapper.readTree(text); + url = isBlank(url) ? getText(inner, "url") : url; + title = isBlank(title) ? getText(inner, "title") : title; + description = + isBlank(description) ? getText(inner, "description") : description; + } catch (Exception ignored) { /* fall back below */ } + } } - // 여러 문서가 한 개의 텍스트에 포함되어 있으므로 줄 단위로 분리 - String[] lines = text.split("\\n"); - - String title = null; - String url = null; - StringBuilder contentBuilder = new StringBuilder(); - - for (String line : lines) { - if (line.startsWith("Title:")) { - if (title != null && url != null && contentBuilder.length() > 0) { - // 이전 문서를 저장 - documents.add(new Document( - title, - contentBuilder.toString().trim(), - Map.of("title", title, "url", url) - )); - contentBuilder.setLength(0); + // 3) 레거시 포맷: "Title:/URL:" 라인 파싱 + if (isBlank(url) && isBlank(title) && isBlank(description)) { + String text = getText(r, "text"); + if (!isBlank(text)) { + ParsedLegacy pl = parseLegacyBlock(text); + if (pl != null) { + url = pl.url != null ? pl.url : url; + title = pl.title != null ? pl.title : title; + description = pl.body != null ? pl.body : description; } - title = line.replaceFirst("Title:", "").trim(); - } else if (line.startsWith("URL:")) { - url = line.replaceFirst("URL:", "").trim(); - } else { - contentBuilder.append(line).append("\n"); } } - // 마지막 문서 저장 - if (title != null && url != null && contentBuilder.length() > 0) { - documents.add(new Document( - title, - contentBuilder.toString().trim(), - Map.of("title", title, "url", url) - )); + // 4) 아무 것도 없으면 스킵 + if (isBlank(url) && isBlank(title) && isBlank(description)) { + continue; } - }); - }); + // 5) Document id는 URL > title 우선 + String id = !isBlank(url) ? url : title; + String body = !isBlank(description) ? description + : (!isBlank(title) ? title : (url != null ? url : "")); + + documents.add(new Document( + id, + body, + Map.of( + "title", title == null ? "" : title, + "url", url == null ? "" : url + ) + )); + } + }); return documents; } + /* ----------------- helpers ----------------- */ + + private String getText(JsonNode n, String field) { + return (n != null && n.has(field) && n.get(field).isTextual()) + ? n.get(field).asText().trim() : null; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + private boolean looksLikeJson(String s) { + String t = s.trim(); + return (t.startsWith("{") && t.endsWith("}")) || + (t.startsWith("[") && t.endsWith("]")) || + t.contains("\"url\":") || t.contains("\"title\":") || t.contains("\"description\":"); + } + + /** + * "Title: ..." / "URL: ..." 블록을 관대한 방식으로 파싱 + */ + private ParsedLegacy parseLegacyBlock(String text) { + if (text == null) { + return null; + } + String[] lines = text.split("\\r?\\n"); + String title = null, url = null; + StringBuilder body = new StringBuilder(); + + // "Title:" / "URL:" 키워드는 대소문자 무시 + 앞뒤 공백 관대 처리 + for (String raw : lines) { + String line = raw == null ? "" : raw.trim(); + if (line.regionMatches(true, 0, "Title:", 0, 6)) { + // 이전 누적 flush + // (여기서는 단일 레코드만 기대하므로 flush 없이 교체) + title = line.substring(6).trim(); + } else if (line.regionMatches(true, 0, "URL:", 0, 4)) { + url = line.substring(4).trim(); + } else { + body.append(line).append('\n'); + } + } + String desc = body.toString().trim(); + if (isBlank(title) && isBlank(url) && isBlank(desc)) { + return null; + } + return new ParsedLegacy(title, url, desc); + } + + private static class ParsedLegacy { + + final String title; + final String url; + final String body; + + ParsedLegacy(String title, String url, String body) { + this.title = title; + this.url = url; + this.body = body; + } + } }