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 @@ model-openai tokenizer-hanlp + tool-youcom-search \ No newline at end of file diff --git a/framework/fel/java/fel-community/tool-youcom-search/pom.xml b/framework/fel/java/fel-community/tool-youcom-search/pom.xml new file mode 100644 index 000000000..b5398af22 --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/pom.xml @@ -0,0 +1,126 @@ + + + 4.0.0 + + + org.fitframework.fel + fel-community-parent + 3.7.0-SNAPSHOT + + + fel-tool-youcom-search-plugin + + FEL Tool You.com Search + + + + + org.fitframework + fit-api + + + org.fitframework + fit-util + + + org.fitframework.service + fit-http-classic + + + org.fitframework.service + fit-http-protocol + + + + + org.fitframework.fel + tool-service + + + + + org.fitframework.plugin + fit-message-serializer-json-jackson + ${fit.version} + test + + + org.fitframework.plugin + fit-http-client-okhttp + ${fit.version} + test + + + org.fitframework.plugin + fit-value-fastjson + ${fit.version} + test + + + + + org.junit.jupiter + junit-jupiter + + + org.assertj + assertj-core + + + org.mockito + mockito-core + + + org.fitframework + fit-test-framework + + + + + + + org.fitframework + fit-build-maven-plugin + ${fit.version} + + system + 7 + + + + build-plugin + + build-plugin + + + + package-plugin + + package-plugin + + + + + + org.apache.maven.plugins + maven-antrun-plugin + ${maven.antrun.version} + + + package + + + + + + + run + + + + + + + diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/YouComSearchService.java b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/YouComSearchService.java new file mode 100644 index 000000000..b6eaa7765 --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/YouComSearchService.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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 modelengine.fel.tool.annotation.Group; +import modelengine.fel.tool.annotation.ToolMethod; +import modelengine.fitframework.annotation.Genericable; +import modelengine.fitframework.annotation.Property; + +/** + * 表示 You.com 联网搜索工具的接口定义。 + *

该工具通过 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 apiKeyProvider; + + /** + * 创建 {@link YouComSearchServiceImpl} 的实例。 + * + * @param httpClientFactory 表示 http 客户端工厂的 {@link HttpClassicClientFactory}。 + * @param config 表示 You.com 搜索工具可选配置的 {@link YouComSearchConfig}。 + * @param serializer 表示对象序列化器的 {@link ObjectSerializer}。 + * @throws IllegalArgumentException 当 {@code httpClientFactory}、{@code config}、{@code serializer} + * 为 {@code null} 时。 + */ + public YouComSearchServiceImpl(HttpClassicClientFactory httpClientFactory, YouComSearchConfig config, + @Fit(alias = "json") ObjectSerializer serializer) { + this(httpClientFactory, config, serializer, () -> System.getenv(YouComSearchApi.API_KEY_ENV_NAME)); + } + + /** + * 创建 {@link YouComSearchServiceImpl} 的实例,允许自定义密钥来源,供单元测试使用。 + * + * @param httpClientFactory 表示 http 客户端工厂的 {@link HttpClassicClientFactory}。 + * @param config 表示 You.com 搜索工具可选配置的 {@link YouComSearchConfig}。 + * @param serializer 表示对象序列化器的 {@link ObjectSerializer}。 + * @param apiKeyProvider 表示密钥提供者的 {@link Supplier}{@code <}{@link String}{@code >}。 + * @throws IllegalArgumentException 当任意参数为 {@code null} 时。 + */ + YouComSearchServiceImpl(HttpClassicClientFactory httpClientFactory, YouComSearchConfig config, + ObjectSerializer serializer, Supplier apiKeyProvider) { + this.httpClientFactory = notNull(httpClientFactory, "The http client factory cannot be null."); + this.config = notNull(config, "The config cannot be null."); + this.serializer = notNull(serializer, "The serializer cannot be null."); + this.apiKeyProvider = notNull(apiKeyProvider, "The api key provider cannot be null."); + } + + @Override + @Fitable("default") + public String webSearch(String query, Integer count, String freshness) { + notNull(query, "The query cannot be null."); + String apiKey = this.apiKeyProvider.get(); + if (StringUtils.isBlank(apiKey)) { + return NO_KEY_MESSAGE; + } + int actualCount = normalizeCount(count); + String url = buildUrl(this.config.getApiBase(), query, actualCount, freshness); + HttpClassicClient client = this.httpClientFactory.create(HttpClassicClientFactory.Config.builder() + .connectTimeout(this.config.getConnectTimeout()) + .socketTimeout(this.config.getReadTimeout()) + .build()); + HttpClassicClientRequest request = client.createRequest(HttpRequestMethod.GET, url); + request.headers().set(YouComSearchApi.API_KEY_HEADER, apiKey); + try (HttpClassicClientResponse response = + request.exchange(YouComSearchResponse.class)) { + if (response.statusCode() != 200) { + log.warn("Failed to call You.com search api. [code={}, reason={}]", + response.statusCode(), + response.reasonPhrase()); + return mapErrorMessage(response.statusCode()); + } + return formatSuccess(this.parseResponse(response), actualCount); + } catch (TimeoutException e) { + log.warn("You.com search request timed out.", e); + return TIMEOUT_MESSAGE; + } catch (SerializationException e) { + log.warn("Failed to parse You.com search response.", e); + return PARSE_ERROR_MESSAGE; + } catch (ClientException | IOException e) { + log.warn("Failed to request You.com search api.", e); + return NETWORK_ERROR_MESSAGE; + } catch (RuntimeException e) { + log.warn("Failed to handle You.com search response.", e); + return PARSE_ERROR_MESSAGE; + } + } + + private YouComSearchResponse parseResponse(HttpClassicClientResponse response) { + Entity entity = response.entity().orElse(null); + if (entity instanceof ObjectEntity) { + return ObjectUtils.cast(((ObjectEntity) entity).object()); + } + if (entity instanceof TextEntity) { + return this.serializer.deserialize(((TextEntity) entity).content(), YouComSearchResponse.class); + } + return null; + } + + private static int normalizeCount(Integer count) { + if (count == null || count < MIN_COUNT) { + return DEFAULT_COUNT; + } + return Math.min(count, MAX_COUNT); + } + + private static String buildUrl(String apiBase, String query, int count, String freshness) { + StringBuilder url = new StringBuilder(UrlUtils.combine(apiBase, YouComSearchApi.SEARCH_ENDPOINT)); + url.append('?') + .append(YouComSearchApi.PARAM_QUERY) + .append('=') + .append(UrlUtils.encodeForm(query)) + .append('&') + .append(YouComSearchApi.PARAM_COUNT) + .append('=') + .append(count); + if (StringUtils.isNotBlank(freshness)) { + url.append('&') + .append(YouComSearchApi.PARAM_FRESHNESS) + .append('=') + .append(UrlUtils.encodeForm(freshness)); + } + return url.toString(); + } + + private static String mapErrorMessage(int statusCode) { + switch (statusCode) { + case 401: + return "You.com 鉴权失败,请检查 YDC_API_KEY 是否正确。"; + case 403: + return "You.com 请求被拒绝,请检查服务地址或访问权限。"; + case 422: + return "You.com 请求参数不合法,请检查查询参数组合。"; + case 429: + return "You.com 请求过于频繁,请稍后重试。"; + default: + if (statusCode >= 500) { + return "You.com 服务暂时不可用,请稍后重试。"; + } + return StringUtils.format("You.com 搜索失败(状态码:{0}),请稍后重试。", statusCode); + } + } + + private static String formatSuccess(YouComSearchResponse response, int requestedCount) { + List merged = new ArrayList<>(); + if (response != null && response.results() != null) { + if (CollectionUtils.isNotEmpty(response.results().web())) { + merged.addAll(response.results().web()); + } + if (CollectionUtils.isNotEmpty(response.results().news())) { + merged.addAll(response.results().news()); + } + } + if (merged.isEmpty()) { + return EMPTY_RESULT_MESSAGE; + } + List limited = + merged.size() > requestedCount ? merged.subList(0, requestedCount) : merged; + StringBuilder text = new StringBuilder(); + text.append(StringUtils.format("检索完成,共 {0} 条结果:", limited.size())); + for (int i = 0; i < limited.size(); i++) { + YouComSearchResultItem item = limited.get(i); + text.append('\n') + .append(StringUtils.format("{0}. 标题:{1}", i + 1, StringUtils.blankIf(item.title(), "无标题"))); + text.append('\n').append(StringUtils.format(" 链接:{0}", StringUtils.blankIf(item.url(), "无"))); + if (CollectionUtils.isNotEmpty(item.snippets())) { + text.append('\n').append(" 摘录:").append(String.join(" ", item.snippets())); + } + } + return text.toString(); + } +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/api/YouComSearchApi.java b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/api/YouComSearchApi.java new file mode 100644 index 000000000..c2b2349e9 --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/api/YouComSearchApi.java @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * 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.api; + +/** + * 提供 You.com 联网搜索接口的相关常量。 + * + * @author Amos Mwangi + * @since 2026-07-08 + */ +public interface YouComSearchApi { + /** + * You.com 搜索服务默认的地址。 + */ + String DEFAULT_API_BASE = "https://ydc-index.io"; + + /** + * 搜索请求的端点。 + */ + String SEARCH_ENDPOINT = "/v1/search"; + + /** + * 请求头中携带密钥的字段名。 + */ + String API_KEY_HEADER = "X-API-Key"; + + /** + * 查询关键字的请求参数名。 + */ + String PARAM_QUERY = "query"; + + /** + * 返回条数的请求参数名。 + */ + String PARAM_COUNT = "count"; + + /** + * 结果新鲜度的请求参数名。 + */ + String PARAM_FRESHNESS = "freshness"; + + /** + * 密钥来源的环境变量名。 + */ + String API_KEY_ENV_NAME = "YDC_API_KEY"; +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/config/YouComSearchConfig.java b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/config/YouComSearchConfig.java new file mode 100644 index 000000000..ba44dbb3a --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/config/YouComSearchConfig.java @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * 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.config; + +import modelengine.fel.community.tool.youcom.api.YouComSearchApi; +import modelengine.fitframework.annotation.AcceptConfigValues; +import modelengine.fitframework.annotation.Component; + +/** + * 表示 You.com 搜索工具的可选配置。 + *

密钥不在此配置范围内:出于安全约定,密钥只能通过环境变量 + * {@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 snippets; + + /** + * 获取结果的链接地址。 + * + * @return 表示链接地址的 {@link String},可能为 {@code null}。 + */ + public String url() { + return this.url; + } + + /** + * 获取结果的标题。 + * + * @return 表示标题的 {@link String},可能为 {@code null}。 + */ + public String title() { + return this.title; + } + + /** + * 获取结果的摘要描述。 + * + * @return 表示摘要描述的 {@link String},可能为 {@code null}。 + */ + public String description() { + return this.description; + } + + /** + * 获取结果的正文摘录列表。 + * + * @return 表示正文摘录列表的 {@link List}{@code <}{@link String}{@code >},可能为 {@code null}。 + */ + public List snippets() { + return this.snippets; + } +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/entity/YouComSearchResults.java b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/entity/YouComSearchResults.java new file mode 100644 index 000000000..27eb29808 --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/main/java/modelengine/fel/community/tool/youcom/entity/YouComSearchResults.java @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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 results} 字段。 + *

{@code web} 与 {@code news} 均可能缺失,调用方需要防御式处理。

+ * + * @author Amos Mwangi + * @since 2026-07-08 + */ +public class YouComSearchResults { + private List web; + private List news; + + /** + * 获取网页搜索结果列表。 + * + * @return 表示网页搜索结果列表的 {@link List}{@code <}{@link YouComSearchResultItem}{@code >},可能为 {@code null}。 + */ + public List web() { + return this.web; + } + + /** + * 获取新闻搜索结果列表。 + * + * @return 表示新闻搜索结果列表的 {@link List}{@code <}{@link YouComSearchResultItem}{@code >},可能为 {@code null}。 + */ + public List news() { + return this.news; + } +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/main/resources/application.yml b/framework/fel/java/fel-community/tool-youcom-search/src/main/resources/application.yml new file mode 100644 index 000000000..238ac9d78 --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/main/resources/application.yml @@ -0,0 +1,4 @@ +fit: + beans: + packages: + - 'modelengine.fel.community.tool.youcom' diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/TestYouComSearchController.java b/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/TestYouComSearchController.java new file mode 100644 index 000000000..bdf7a95ca --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/TestYouComSearchController.java @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * 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 modelengine.fel.community.tool.youcom.api.YouComSearchApi; +import modelengine.fit.http.annotation.GetMapping; +import modelengine.fit.http.annotation.RequestHeader; +import modelengine.fit.http.annotation.RequestQuery; +import modelengine.fit.http.protocol.HttpResponse; +import modelengine.fit.http.protocol.HttpResponseStatus; +import modelengine.fitframework.annotation.Component; + +/** + * 表示测试用的模拟 You.com 搜索服务端。 + *

通过 {@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 serializers = + MapBuilder.get().put("json", jsonSerializer).build(); + ValueFetcher valueFetcher = new FastJsonValueHandler(); + HttpClassicClientFactory httpClientFactory = new OkHttpClassicClientFactory(serializers, valueFetcher, 1); + YouComSearchConfig config = new YouComSearchConfig(); + YouComSearchServiceImpl service = new YouComSearchServiceImpl(httpClientFactory, config, jsonSerializer); + String result = service.webSearch("ModelEngine FIT framework", 3, null); + assertThat(result).isNotBlank(); + assertThat(result).doesNotContain("未检测到 You.com 搜索密钥"); + assertThat(result).contains("检索完成"); + } +} diff --git a/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/YouComSearchServiceMetadataTest.java b/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/YouComSearchServiceMetadataTest.java new file mode 100644 index 000000000..1bbf676b4 --- /dev/null +++ b/framework/fel/java/fel-community/tool-youcom-search/src/test/java/modelengine/fel/community/tool/youcom/YouComSearchServiceMetadataTest.java @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * 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.tool.Tool; +import modelengine.fel.tool.annotation.Group; +import modelengine.fel.tool.annotation.ToolMethod; +import modelengine.fitframework.annotation.Genericable; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +/** + * 验证 {@link YouComSearchService} 上的工具注解能够被 FEL 工具框架正确解析。 + *

该测试不依赖 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); + } +}