diff --git a/framework/fel/java/fel-community/pom.xml b/framework/fel/java/fel-community/pom.xml
index ddc631e23..981790984 100644
--- a/framework/fel/java/fel-community/pom.xml
+++ b/framework/fel/java/fel-community/pom.xml
@@ -17,5 +17,6 @@
该工具通过 You.com 的搜索接口,为智能体提供联网检索能力,返回格式化后的中文文本结果。
+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +@Group(name = "youcom_search_service") +public interface YouComSearchService { + /** + * 使用 You.com 检索互联网上的相关信息。 + * + * @param query 表示检索关键字的 {@link String}。 + * @param count 表示期望返回结果条数的 {@link Integer},默认值为 {@code 5},实际会被限制在 + * {@code 1} 到 {@code 20} 之间。 + * @param freshness 表示结果时效性过滤条件的 {@link String},可选,取值参考 You.com 官方文档 + * (例如 {@code day}、{@code week}、{@code month}、{@code year})。 + * @return 表示格式化后的检索结果的 {@link String};当密钥未配置或调用失败时,返回对应的中文提示信息,不会抛出异常。 + */ + @ToolMethod(namespace = "youcom", name = "web_search", description = "使用 You.com 联网搜索获取实时信息") + @Genericable("modelengine.fel.community.tool.youcom.web_search") + String webSearch(@Property(description = "检索关键字", required = true) String query, + @Property(description = "期望返回的结果条数,默认 5 条,最多 20 条", defaultValue = "5") Integer count, + @Property(description = "结果时效性,可选,如 day、week、month、year") String freshness); +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/YouComSearchServiceImpl.java b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/YouComSearchServiceImpl.java new file mode 100644 index 000000000..4249cd3ba --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/YouComSearchServiceImpl.java @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2026 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fel.community.tool.youcom; + +import static modelengine.fitframework.inspection.Validation.notNull; + +import modelengine.fel.community.tool.youcom.api.YouComSearchApi; +import modelengine.fel.community.tool.youcom.config.YouComSearchConfig; +import modelengine.fel.community.tool.youcom.entity.YouComSearchResponse; +import modelengine.fel.community.tool.youcom.entity.YouComSearchResultItem; +import modelengine.fit.http.client.HttpClassicClient; +import modelengine.fit.http.client.HttpClassicClientFactory; +import modelengine.fit.http.client.HttpClassicClientRequest; +import modelengine.fit.http.client.HttpClassicClientResponse; +import modelengine.fit.http.entity.Entity; +import modelengine.fit.http.entity.ObjectEntity; +import modelengine.fit.http.entity.TextEntity; +import modelengine.fit.http.protocol.HttpRequestMethod; +import modelengine.fitframework.annotation.Component; +import modelengine.fitframework.annotation.Fit; +import modelengine.fitframework.annotation.Fitable; +import modelengine.fitframework.exception.ClientException; +import modelengine.fitframework.exception.TimeoutException; +import modelengine.fitframework.log.Logger; +import modelengine.fitframework.resource.UrlUtils; +import modelengine.fitframework.serialization.ObjectSerializer; +import modelengine.fitframework.serialization.SerializationException; +import modelengine.fitframework.util.CollectionUtils; +import modelengine.fitframework.util.ObjectUtils; +import modelengine.fitframework.util.StringUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * 表示 {@link YouComSearchService} 的默认实现。 + *密钥仅从环境变量 {@value YouComSearchApi#API_KEY_ENV_NAME} 读取;当密钥缺失或请求失败时, + * 均返回中文提示字符串,不会抛出异常,避免影响宿主智能体的正常流程。
+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +@Component +public class YouComSearchServiceImpl implements YouComSearchService { + private static final Logger log = Logger.get(YouComSearchServiceImpl.class); + private static final int DEFAULT_COUNT = 5; + private static final int MIN_COUNT = 1; + private static final int MAX_COUNT = 20; + private static final String NO_KEY_MESSAGE = + "未检测到 You.com 搜索密钥,请设置环境变量 YDC_API_KEY 后重试。"; + private static final String TIMEOUT_MESSAGE = "You.com 搜索请求超时,请稍后重试。"; + private static final String NETWORK_ERROR_MESSAGE = "You.com 搜索请求失败,请检查网络连接后重试。"; + private static final String PARSE_ERROR_MESSAGE = "You.com 响应解析失败,请稍后重试。"; + private static final String EMPTY_RESULT_MESSAGE = "You.com 未检索到与查询相关的结果。"; + + private final HttpClassicClientFactory httpClientFactory; + private final YouComSearchConfig config; + private final ObjectSerializer serializer; + private final Supplier密钥不在此配置范围内:出于安全约定,密钥只能通过环境变量 + * {@value YouComSearchApi#API_KEY_ENV_NAME} 提供,不支持配置文件覆盖。
+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +@Component +@AcceptConfigValues("fel.youcom-search") +public class YouComSearchConfig { + private String apiBase = YouComSearchApi.DEFAULT_API_BASE; + private int connectTimeout = 10000; + private int readTimeout = 10000; + + /** + * 获取 You.com 搜索服务地址。 + * + * @return 表示服务地址的 {@link String}。 + */ + public String getApiBase() { + return this.apiBase; + } + + /** + * 设置 You.com 搜索服务地址。 + * + * @param apiBase 表示服务地址的 {@link String}。 + */ + public void setApiBase(String apiBase) { + this.apiBase = apiBase; + } + + /** + * 获取连接建立的超时时间。 + * + * @return 表示连接建立超时时间的 {@code int},单位毫秒。 + */ + public int getConnectTimeout() { + return this.connectTimeout; + } + + /** + * 设置连接建立的超时时间。 + * + * @param connectTimeout 表示连接建立超时时间的 {@code int},单位毫秒。 + */ + public void setConnectTimeout(int connectTimeout) { + this.connectTimeout = connectTimeout; + } + + /** + * 获取读取响应的超时时间。 + * + * @return 表示读取响应超时时间的 {@code int},单位毫秒。 + */ + public int getReadTimeout() { + return this.readTimeout; + } + + /** + * 设置读取响应的超时时间。 + * + * @param readTimeout 表示读取响应超时时间的 {@code int},单位毫秒。 + */ + public void setReadTimeout(int readTimeout) { + this.readTimeout = readTimeout; + } +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/entity/YouComSearchResponse.java b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/entity/YouComSearchResponse.java new file mode 100644 index 000000000..ce8da3a8d --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/entity/YouComSearchResponse.java @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2026 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fel.community.tool.youcom.entity; + +/** + * 表示 You.com 搜索接口的响应。 + *仅映射当前工具需要用到的字段,其余字段(如 {@code metadata})按需忽略。
+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +public class YouComSearchResponse { + private YouComSearchResults results; + + /** + * 获取搜索结果集合。 + * + * @return 表示搜索结果集合的 {@link YouComSearchResults},可能为 {@code null}。 + */ + public YouComSearchResults results() { + return this.results; + } +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/entity/YouComSearchResultItem.java b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/entity/YouComSearchResultItem.java new file mode 100644 index 000000000..2a05f1955 --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/entity/YouComSearchResultItem.java @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2026 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fel.community.tool.youcom.entity; + +import java.util.List; + +/** + * 表示 You.com 搜索接口中单条网页或新闻结果。 + *除 {@code url}、{@code title}、{@code description}、{@code snippets} 外的其余字段 + * (如 {@code page_age}、{@code contents}、{@code authors})均视为可选,当前工具不做映射。
+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +public class YouComSearchResultItem { + private String url; + private String title; + private String description; + private List{@code web} 与 {@code news} 均可能缺失,调用方需要防御式处理。
+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +public class YouComSearchResults { + private List通过 {@link #scenario} 控制返回的场景,供单元测试驱动不同的响应分支。
+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +@Component +public class TestYouComSearchController { + /** + * 表示测试可以驱动的响应场景。 + */ + public enum Scenario { + /** 网页与新闻结果都存在。 */ + SUCCESS_WEB_AND_NEWS, + /** 仅存在网页结果。 */ + SUCCESS_WEB_ONLY, + /** 结果缺失可选字段(描述、摘录)。 */ + SUCCESS_MISSING_OPTIONAL, + /** 网页与新闻均为空列表。 */ + EMPTY, + /** 模拟 401 鉴权失败。 */ + STATUS_401, + /** 模拟 403 拒绝访问。 */ + STATUS_403, + /** 模拟 422 参数不合法。 */ + STATUS_422, + /** 模拟 429 限流。 */ + STATUS_429, + /** 模拟 500 服务异常。 */ + STATUS_500, + /** 返回不合法的 Json 文本。 */ + MALFORMED, + /** 睡眠超过客户端超时时间,模拟超时。 */ + SLOW + } + + private volatile Scenario scenario = Scenario.SUCCESS_WEB_AND_NEWS; + private volatile String capturedApiKey; + private volatile String capturedQuery; + private volatile Integer capturedCount; + private volatile String capturedFreshness; + + /** + * 设置下一次请求要驱动的场景。 + * + * @param scenario 表示待驱动场景的 {@link Scenario}。 + */ + public void scenario(Scenario scenario) { + this.scenario = scenario; + } + + /** + * 获取上一次请求捕获到的密钥请求头。 + * + * @return 表示密钥的 {@link String}。 + */ + public String capturedApiKey() { + return this.capturedApiKey; + } + + /** + * 获取上一次请求捕获到的检索关键字。 + * + * @return 表示检索关键字的 {@link String}。 + */ + public String capturedQuery() { + return this.capturedQuery; + } + + /** + * 获取上一次请求捕获到的返回条数。 + * + * @return 表示返回条数的 {@link Integer}。 + */ + public Integer capturedCount() { + return this.capturedCount; + } + + /** + * 获取上一次请求捕获到的时效性参数。 + * + * @return 表示时效性参数的 {@link String}。 + */ + public String capturedFreshness() { + return this.capturedFreshness; + } + + /** + * 模拟 You.com 搜索接口。 + * + * @param apiKey 表示请求头中携带的密钥的 {@link String}。 + * @param query 表示检索关键字的 {@link String}。 + * @param count 表示返回条数的 {@link Integer}。 + * @param freshness 表示时效性参数的 {@link String}。 + * @return 表示响应内容的 {@link Object},错误场景返回 {@link HttpResponse},其余场景返回响应体文本。 + */ + @GetMapping(YouComSearchApi.SEARCH_ENDPOINT) + public Object search(@RequestHeader(value = "X-API-Key", required = false) String apiKey, + @RequestQuery("query") String query, @RequestQuery("count") Integer count, + @RequestQuery(value = "freshness", required = false) String freshness) { + this.capturedApiKey = apiKey; + this.capturedQuery = query; + this.capturedCount = count; + this.capturedFreshness = freshness; + switch (this.scenario) { + case STATUS_401: + return HttpResponse.create(HttpResponseStatus.UNAUTHORIZED, ""); + case STATUS_403: + return HttpResponse.create(HttpResponseStatus.FORBIDDEN, ""); + case STATUS_422: + return HttpResponse.create(HttpResponseStatus.UNPROCESSABLE_ENTITY, ""); + case STATUS_429: + return HttpResponse.create(HttpResponseStatus.TOO_MANY_REQUESTS, ""); + case STATUS_500: + return HttpResponse.create(HttpResponseStatus.INTERNAL_SERVER_ERROR, ""); + case MALFORMED: + return "{not-a-valid-json"; + case EMPTY: + return "{\"results\":{\"web\":[],\"news\":[]}}"; + case SUCCESS_WEB_ONLY: + return "{\"results\":{\"web\":[{\"url\":\"https://a.example.com\",\"title\":\"标题一\"," + + "\"description\":\"描述一\",\"snippets\":[\"摘录一\",\"摘录二\"]}]}}"; + case SUCCESS_MISSING_OPTIONAL: + return "{\"results\":{\"web\":[{\"url\":\"https://b.example.com\",\"title\":\"标题二\"}]}}"; + case SLOW: + this.sleepQuietly(); + return "{\"results\":{\"web\":[{\"url\":\"https://c.example.com\",\"title\":\"标题三\"}]}}"; + case SUCCESS_WEB_AND_NEWS: + default: + return "{\"results\":{\"web\":[{\"url\":\"https://a.example.com\",\"title\":\"标题一\"," + + "\"description\":\"描述一\",\"snippets\":[\"摘录一\"]}]," + + "\"news\":[{\"url\":\"https://n.example.com\",\"title\":\"新闻标题\"," + + "\"description\":\"新闻描述\",\"snippets\":[\"新闻摘录\"]}]}}"; + } + } + + private void sleepQuietly() { + try { + Thread.sleep(1500L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/YouComSearchServiceImplTest.java b/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/YouComSearchServiceImplTest.java new file mode 100644 index 000000000..f99a9a232 --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/YouComSearchServiceImplTest.java @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2026 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fel.community.tool.youcom; + +import static org.assertj.core.api.Assertions.assertThat; + +import modelengine.fel.community.tool.youcom.config.YouComSearchConfig; +import modelengine.fit.http.client.HttpClassicClientFactory; +import modelengine.fitframework.annotation.Fit; +import modelengine.fitframework.serialization.ObjectSerializer; +import modelengine.fitframework.test.annotation.MvcTest; +import modelengine.fitframework.test.domain.mvc.MockMvc; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * {@link YouComSearchServiceImpl} 的单元测试。 + * + * @author Amos Mwangi + * @since 2026-07-08 + */ +@MvcTest(classes = TestYouComSearchController.class) +public class YouComSearchServiceImplTest { + private static final String FAKE_API_KEY = "fake-test-key"; + + @Fit + private HttpClassicClientFactory httpClientFactory; + + @Fit + private ObjectSerializer serializer; + + @Fit + private MockMvc mockMvc; + + @Fit + private TestYouComSearchController testController; + + private YouComSearchServiceImpl service; + + @BeforeEach + void setUp() { + YouComSearchConfig config = new YouComSearchConfig(); + config.setApiBase("http://localhost:" + this.mockMvc.getPort()); + config.setConnectTimeout(500); + config.setReadTimeout(500); + this.service = + new YouComSearchServiceImpl(this.httpClientFactory, config, this.serializer, () -> FAKE_API_KEY); + this.testController.scenario(TestYouComSearchController.Scenario.SUCCESS_WEB_AND_NEWS); + } + + @Test + @DisplayName("当密钥未配置时,返回友好的中文提示,且不发起请求") + void shouldReturnFriendlyMessageWhenApiKeyMissing() { + YouComSearchServiceImpl noKeyService = + new YouComSearchServiceImpl(this.httpClientFactory, this.buildConfig(), this.serializer, () -> null); + String result = noKeyService.webSearch("天气", 5, null); + assertThat(result).contains("YDC_API_KEY"); + } + + @Test + @DisplayName("请求应携带正确的密钥请求头、查询关键字与返回条数") + void shouldSendCorrectHeaderAndQueryParameters() { + this.service.webSearch("Huawei FIT framework", 3, "week"); + assertThat(this.testController.capturedApiKey()).isEqualTo(FAKE_API_KEY); + assertThat(this.testController.capturedQuery()).isEqualTo("Huawei FIT framework"); + assertThat(this.testController.capturedCount()).isEqualTo(3); + assertThat(this.testController.capturedFreshness()).isEqualTo("week"); + } + + @Test + @DisplayName("当未指定返回条数时,应使用默认值 5") + void shouldUseDefaultCountWhenCountNotProvided() { + this.service.webSearch("test", null, null); + assertThat(this.testController.capturedCount()).isEqualTo(5); + } + + @Test + @DisplayName("当指定的返回条数超过上限时,应被截断为 20") + void shouldCapCountAtMaximumWhenExceeded() { + this.service.webSearch("test", 999, null); + assertThat(this.testController.capturedCount()).isEqualTo(20); + } + + @Test + @DisplayName("当网页与新闻结果都存在时,应返回包含两者的格式化文本") + void shouldFormatBothWebAndNewsResults() { + this.testController.scenario(TestYouComSearchController.Scenario.SUCCESS_WEB_AND_NEWS); + String result = this.service.webSearch("test", 5, null); + assertThat(result).contains("检索完成,共 2 条结果") + .contains("标题一") + .contains("https://a.example.com") + .contains("摘录一") + .contains("新闻标题") + .contains("https://n.example.com"); + } + + @Test + @DisplayName("当只有网页结果时,应正确映射且不报错") + void shouldFormatWebOnlyResults() { + this.testController.scenario(TestYouComSearchController.Scenario.SUCCESS_WEB_ONLY); + String result = this.service.webSearch("test", 5, null); + assertThat(result).contains("检索完成,共 1 条结果").contains("标题一").contains("摘录一").contains("摘录二"); + } + + @Test + @DisplayName("当结果缺少描述与摘录等可选字段时,应正常降级显示") + void shouldFormatResultsWithMissingOptionalFields() { + this.testController.scenario(TestYouComSearchController.Scenario.SUCCESS_MISSING_OPTIONAL); + String result = this.service.webSearch("test", 5, null); + assertThat(result).contains("检索完成,共 1 条结果").contains("标题二").contains("https://b.example.com"); + assertThat(result).doesNotContain("摘录"); + } + + @Test + @DisplayName("当网页与新闻结果均为空时,应返回未检索到结果的提示") + void shouldReturnEmptyMessageWhenNoResults() { + this.testController.scenario(TestYouComSearchController.Scenario.EMPTY); + String result = this.service.webSearch("test", 5, null); + assertThat(result).isEqualTo("You.com 未检索到与查询相关的结果。"); + } + + @Test + @DisplayName("当接口返回 401 时,应返回鉴权失败提示且不泄露密钥") + void shouldMapUnauthorizedStatus() { + this.testController.scenario(TestYouComSearchController.Scenario.STATUS_401); + String result = this.service.webSearch("test", 5, null); + assertThat(result).contains("鉴权失败").doesNotContain(FAKE_API_KEY); + } + + @Test + @DisplayName("当接口返回 403 时,应返回拒绝访问提示") + void shouldMapForbiddenStatus() { + this.testController.scenario(TestYouComSearchController.Scenario.STATUS_403); + String result = this.service.webSearch("test", 5, null); + assertThat(result).contains("拒绝"); + } + + @Test + @DisplayName("当接口返回 422 时,应返回参数不合法提示") + void shouldMapUnprocessableEntityStatus() { + this.testController.scenario(TestYouComSearchController.Scenario.STATUS_422); + String result = this.service.webSearch("test", 5, null); + assertThat(result).contains("参数不合法"); + } + + @Test + @DisplayName("当接口返回 429 时,应返回限流提示") + void shouldMapTooManyRequestsStatus() { + this.testController.scenario(TestYouComSearchController.Scenario.STATUS_429); + String result = this.service.webSearch("test", 5, null); + assertThat(result).contains("过于频繁"); + } + + @Test + @DisplayName("当接口返回 500 时,应返回服务不可用提示") + void shouldMapServerErrorStatus() { + this.testController.scenario(TestYouComSearchController.Scenario.STATUS_500); + String result = this.service.webSearch("test", 5, null); + assertThat(result).contains("暂时不可用"); + } + + @Test + @DisplayName("当响应体不是合法 Json 时,应返回解析失败提示且不抛出异常") + void shouldReturnParseErrorMessageWhenBodyMalformed() { + this.testController.scenario(TestYouComSearchController.Scenario.MALFORMED); + String result = this.service.webSearch("test", 5, null); + assertThat(result).isEqualTo("You.com 响应解析失败,请稍后重试。"); + } + + @Test + @DisplayName("当请求超时时,应返回超时提示且不抛出异常") + void shouldReturnTimeoutMessageWhenRequestTimesOut() { + this.testController.scenario(TestYouComSearchController.Scenario.SLOW); + String result = this.service.webSearch("test", 5, null); + assertThat(result).isIn("You.com 搜索请求超时,请稍后重试。", "You.com 搜索请求失败,请检查网络连接后重试。"); + } + + private YouComSearchConfig buildConfig() { + YouComSearchConfig config = new YouComSearchConfig(); + config.setApiBase("http://localhost:" + this.mockMvc.getPort()); + return config; + } +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/YouComSearchServiceLiveTest.java b/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/YouComSearchServiceLiveTest.java new file mode 100644 index 000000000..da9d9b811 --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/YouComSearchServiceLiveTest.java @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2026 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fel.community.tool.youcom; + +import static org.assertj.core.api.Assertions.assertThat; + +import modelengine.fel.community.tool.youcom.api.YouComSearchApi; +import modelengine.fel.community.tool.youcom.config.YouComSearchConfig; +import modelengine.fit.http.client.HttpClassicClientFactory; +import modelengine.fit.http.client.okhttp.OkHttpClassicClientFactory; +import modelengine.fit.serialization.json.jackson.JacksonObjectSerializer; +import modelengine.fit.value.fastjson.FastJsonValueHandler; +import modelengine.fitframework.serialization.ObjectSerializer; +import modelengine.fitframework.util.MapBuilder; +import modelengine.fitframework.value.ValueFetcher; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.util.Map; + +/** + * {@link YouComSearchServiceImpl} 的真实接口联调测试。 + *仅当环境变量 {@value YouComSearchApi#API_KEY_ENV_NAME} 存在时才会执行, + * 避免在没有密钥的流水线环境中失败;执行时仅发起一次真实的网络请求。
+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +@EnabledIfEnvironmentVariable(named = YouComSearchApi.API_KEY_ENV_NAME, matches = ".+") +public class YouComSearchServiceLiveTest { + @Test + @DisplayName("使用真实密钥调用 You.com 接口,应返回非空的检索结果") + void shouldReturnRealResultWhenApiKeyPresent() { + ObjectSerializer jsonSerializer = new JacksonObjectSerializer(null, null, null, false); + Map该测试不依赖 HTTP 或容器启动,直接通过 {@link Tool.Metadata#fromMethod(Method)} 验证注解语义, + * 证明该接口可以被 {@code tool-discoverer} 正常发现和注册。
+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +@DisplayName("测试 YouComSearchService 的工具元数据") +public class YouComSearchServiceMetadataTest { + @Test + @DisplayName("应正确解析组名、命名空间、工具名与描述") + void shouldResolveGroupNamespaceNameAndDescription() throws NoSuchMethodException { + Method method = YouComSearchService.class.getDeclaredMethod("webSearch", String.class, Integer.class, + String.class); + + Group group = YouComSearchService.class.getAnnotation(Group.class); + assertThat(group.name()).isEqualTo("youcom_search_service"); + + ToolMethod toolMethod = method.getAnnotation(ToolMethod.class); + assertThat(toolMethod.namespace()).isEqualTo("youcom"); + assertThat(toolMethod.name()).isEqualTo("web_search"); + assertThat(toolMethod.description()).isEqualTo("使用 You.com 联网搜索获取实时信息"); + + Genericable genericable = method.getAnnotation(Genericable.class); + assertThat(genericable.value()).isEqualTo("modelengine.fel.community.tool.youcom.web_search"); + + Tool.Metadata metadata = Tool.Metadata.fromMethod(method); + assertThat(metadata.definitionGroupName()).isEqualTo("youcom_search_service"); + assertThat(metadata.description()).isEqualTo("使用 You.com 联网搜索获取实时信息"); + } + + @Test + @DisplayName("应正确解析参数顺序与必填参数") + void shouldResolveParameterOrderAndRequiredParameters() throws NoSuchMethodException { + Method method = YouComSearchService.class.getDeclaredMethod("webSearch", String.class, Integer.class, + String.class); + Tool.Metadata metadata = Tool.Metadata.fromMethod(method); + + assertThat(metadata.parameterOrder()).containsExactly("query", "count", "freshness"); + assertThat(metadata.requiredParameters()).containsExactly("query"); + assertThat(metadata.parameterTypes()).containsExactly(String.class, Integer.class, String.class); + } + + @Test + @DisplayName("应正确解析返回值类型为字符串") + void shouldResolveReturnTypeAsString() throws NoSuchMethodException { + Method method = YouComSearchService.class.getDeclaredMethod("webSearch", String.class, Integer.class, + String.class); + assertThat(method.getReturnType()).isEqualTo(String.class); + } +}