diff --git a/cs25-service/Dockerfile b/cs25-service/Dockerfile index d839cf7b..20916674 100644 --- a/cs25-service/Dockerfile +++ b/cs25-service/Dockerfile @@ -22,37 +22,27 @@ LABEL type="application" module="cs25-service" # 작업 디렉토리 WORKDIR /apps -# Node.js 22 설치 + 공식 Brave MCP 서버 설치 + 래퍼 스크립트 생성 -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates gnupg bash \ - && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - \ - # 공식 패키지 설치 (deprecated 패키지 제거) - && npm install -g @brave/brave-search-mcp-server \ - \ - # 전역 모듈 경로 계산 - && NPM_PREFIX="$(npm prefix -g)" \ - && SRCDIR="${NPM_PREFIX}/lib/node_modules/@brave/brave-search-mcp-server" \ - \ -# 실행 래퍼 (args는 전부 "$@"로 위임) -&& { \ - echo '#!/bin/sh'; \ - echo 'NODE=$(command -v node || echo /usr/bin/node)'; \ - echo 'exec "$NODE" "'"$SRCDIR"'/dist/index.js" "$@"'; \ -} > /usr/local/bin/server-brave-search \ -&& chmod 0755 /usr/local/bin/server-brave-search \ - \ - # 설치/실행 점검 - && echo "=== which server-brave-search ===" && which server-brave-search \ - && echo "=== server-brave-search --help ===" && server-brave-search --help || (echo "[ERROR] server-brave-search 실행 실패" && exit 1) \ - \ - # 정리 - && npm cache clean --force \ - && apt-get autoremove -y --purge \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - +# Node.js 22 + Brave MCP 서버 설치 + 실행 래퍼 생성 +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends curl ca-certificates gnupg bash; \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash -; \ + apt-get install -y --no-install-recommends nodejs; \ + npm install -g @brave/brave-search-mcp-server; \ + NPM_PREFIX="$(npm prefix -g)"; \ + SRCDIR="${NPM_PREFIX}/lib/node_modules/@brave/brave-search-mcp-server"; \ + # 실행 래퍼 (전달 인자 전부 위임) + printf '%s\n' '#!/bin/sh' \ + 'exec node "'"$SRCDIR"'/dist/index.js" "$@"' \ + > /usr/local/bin/server-brave-search; \ + chmod 0755 /usr/local/bin/server-brave-search; \ + # sanity check (없으면 빌드 실패) + /usr/local/bin/server-brave-search --help >/dev/null; \ + # 정리 + npm cache clean --force; \ + apt-get purge -y gnupg; \ + apt-get autoremove -y --purge; \ + rm -rf /var/lib/apt/lists/* # jar 복사 COPY --from=builder /build/cs25-service/build/libs/*.jar app.jar diff --git a/cs25-service/build.gradle b/cs25-service/build.gradle index f9215094..4159c5c4 100644 --- a/cs25-service/build.gradle +++ b/cs25-service/build.gradle @@ -33,6 +33,12 @@ dependencies { implementation "org.springframework.ai:spring-ai-starter-mcp-client:1.0.0" implementation "org.springframework.ai:spring-ai-starter-mcp-client-webflux:1.0.0" + // resilience4j + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.3.0' + implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.3.0' + implementation 'io.github.resilience4j:resilience4j-retry:2.3.0' + implementation 'io.github.resilience4j:resilience4j-reactor:2.3.0' + //JavaMailSender implementation 'jakarta.mail:jakarta.mail-api:2.1.0' diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java index 8ef9e3f8..8b618aab 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java @@ -2,6 +2,7 @@ import com.example.cs25service.domain.ai.exception.AiException; import com.example.cs25service.domain.ai.exception.AiExceptionCode; +import com.example.cs25service.domain.ai.resilience.AiResilience; import org.springframework.ai.chat.client.ChatClient; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; @@ -11,18 +12,23 @@ public class ClaudeChatClient implements AiChatClient { private final ChatClient anthropicChatClient; + private final AiResilience resilience; - public ClaudeChatClient(@Qualifier("anthropicChatClient") ChatClient anthropicChatClient) { + public ClaudeChatClient(@Qualifier("anthropicChatClient") ChatClient anthropicChatClient, + AiResilience resilience) { this.anthropicChatClient = anthropicChatClient; + this.resilience = resilience; } @Override public String call(String systemPrompt, String userPrompt) { - return anthropicChatClient.prompt() - .system(systemPrompt) - .user(userPrompt) - .call() - .content(); + return resilience.executeSync("claude", () -> + anthropicChatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .call() + .content() + ); } @Override @@ -32,13 +38,12 @@ public ChatClient raw() { @Override public Flux stream(String systemPrompt, String userPrompt) { - return anthropicChatClient.prompt() - .system(systemPrompt) - .user(userPrompt) - .stream() - .content() - .onErrorResume(error -> { - throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); - }); + return resilience.executeStream("claude", () -> + anthropicChatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .stream() + .content() + ).onErrorMap(e -> new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR)); } } diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java index 7ca1c63d..7f99f60a 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java @@ -2,6 +2,7 @@ import com.example.cs25service.domain.ai.exception.AiException; import com.example.cs25service.domain.ai.exception.AiExceptionCode; +import com.example.cs25service.domain.ai.resilience.AiResilience; import org.springframework.ai.chat.client.ChatClient; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; @@ -11,19 +12,25 @@ public class OpenAiChatClient implements AiChatClient { private final ChatClient openAiChatClient; + private final AiResilience resilience; - public OpenAiChatClient(@Qualifier("openAiChatModelClient") ChatClient openAiChatClient) { + public OpenAiChatClient( + @Qualifier("openAiChatModelClient") ChatClient openAiChatClient, + AiResilience resilience) { this.openAiChatClient = openAiChatClient; + this.resilience = resilience; } @Override public String call(String systemPrompt, String userPrompt) { - return openAiChatClient.prompt() - .system(systemPrompt) - .user(userPrompt) - .call() - .content() - .trim(); + return resilience.executeSync("openai", () -> + openAiChatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .call() + .content() + .trim() + ); } @Override @@ -33,13 +40,12 @@ public ChatClient raw() { @Override public Flux stream(String systemPrompt, String userPrompt) { - return openAiChatClient.prompt() - .system(systemPrompt) - .user(userPrompt) - .stream() - .content() - .onErrorResume(error -> { - throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); - }); + return resilience.executeStream("openai", () -> + openAiChatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .stream() + .content() + ).onErrorMap(e -> new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR)); } } diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/resilience/AiResilience.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/resilience/AiResilience.java new file mode 100644 index 00000000..d6802272 --- /dev/null +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/resilience/AiResilience.java @@ -0,0 +1,45 @@ +package com.example.cs25service.domain.ai.resilience; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator; +import io.github.resilience4j.reactor.retry.RetryOperator; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryRegistry; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; + +@Component +@RequiredArgsConstructor +public class AiResilience { + + private final CircuitBreakerRegistry cbRegistry; + private final RetryRegistry retryRegistry; + + /** + * 동기 호출: Retry → CircuitBreaker 순서 + */ + public T executeSync(String name, Supplier supplier) { + CircuitBreaker cb = cbRegistry.circuitBreaker(name); + Retry retry = retryRegistry.retry(name); + + Supplier withRetry = Retry.decorateSupplier(retry, supplier); + Supplier withCb = CircuitBreaker.decorateSupplier(cb, withRetry); + + return withCb.get(); + } + + /** + * Flux 스트리밍: RetryOperator → CircuitBreakerOperator 순서 + */ + public Flux executeStream(String name, Supplier> supplier) { + CircuitBreaker cb = cbRegistry.circuitBreaker(name); + Retry retry = retryRegistry.retry(name); + + return supplier.get() + .transformDeferred(RetryOperator.of(retry)) + .transformDeferred(CircuitBreakerOperator.of(cb)); + } +} 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; + } + } } diff --git a/cs25-service/src/main/resources/application.properties b/cs25-service/src/main/resources/application.properties index eed4922b..39649f39 100644 --- a/cs25-service/src/main/resources/application.properties +++ b/cs25-service/src/main/resources/application.properties @@ -67,13 +67,39 @@ spring.ai.mcp.client.enabled=true spring.ai.mcp.client.type=SYNC spring.ai.mcp.client.request-timeout=60s spring.ai.mcp.client.root-change-notification=false -# STDIO Connect: Brave Search +# Brave Search spring.ai.mcp.client.stdio.connections.brave.command=server-brave-search spring.ai.mcp.client.stdio.connections.brave.args[0]=--transport spring.ai.mcp.client.stdio.connections.brave.args[1]=stdio spring.ai.mcp.client.stdio.connections.brave.env.BRAVE_API_KEY=${BRAVE_API_KEY} -spring.ai.mcp.client.initialized=false spring.autoconfigure.exclude=org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration +# CircuitBreaker +resilience4j.circuitbreaker.configs.default.slidingWindowType=COUNT_BASED +resilience4j.circuitbreaker.configs.default.slidingWindowSize=10 +resilience4j.circuitbreaker.configs.default.failureRateThreshold=50 +resilience4j.circuitbreaker.configs.default.slowCallDurationThreshold=5s +resilience4j.circuitbreaker.configs.default.slowCallRateThreshold=70 +resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=6 +resilience4j.circuitbreaker.configs.default.waitDurationInOpenState=30m +resilience4j.circuitbreaker.configs.default.permittedNumberOfCallsInHalfOpenState=1 +resilience4j.circuitbreaker.configs.default.automaticTransitionFromOpenToHalfOpenEnabled=true +resilience4j.circuitbreaker.instances.openai.baseConfig=default +resilience4j.circuitbreaker.instances.claude.baseConfig=default +resilience4j.circuitbreaker.configs.default.ignoreExceptions[0]=org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest +resilience4j.circuitbreaker.configs.default.ignoreExceptions[1]=org.springframework.web.reactive.function.client.WebClientResponseException.Unauthorized +resilience4j.circuitbreaker.configs.default.ignoreExceptions[2]=org.springframework.web.reactive.function.client.WebClientResponseException.Forbidden +resilience4j.circuitbreaker.configs.default.ignoreExceptions[3]=org.springframework.web.reactive.function.client.WebClientResponseException.NotFound +# Retry +resilience4j.retry.configs.default.maxAttempts=2 +resilience4j.retry.configs.default.waitDuration=200ms +resilience4j.retry.configs.default.enableExponentialBackoff=true +resilience4j.retry.configs.default.exponentialBackoffMultiplier=2 +resilience4j.retry.instances.openai.baseConfig=default +resilience4j.retry.instances.claude.baseConfig=default +resilience4j.retry.configs.default.ignoreExceptions[0]=org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest +resilience4j.retry.configs.default.ignoreExceptions[1]=org.springframework.web.reactive.function.client.WebClientResponseException.Unauthorized +resilience4j.retry.configs.default.ignoreExceptions[2]=org.springframework.web.reactive.function.client.WebClientResponseException.Forbidden +resilience4j.retry.configs.default.ignoreExceptions[3]=org.springframework.web.reactive.function.client.WebClientResponseException.NotFound #MAIL spring.mail.host=smtp.gmail.com spring.mail.port=587