From 17f9a97ce43e534f898316c75c19dcfa0debcca4 Mon Sep 17 00:00:00 2001 From: joeyczheng Date: Mon, 12 Jan 2026 18:00:31 +0800 Subject: [PATCH] feat: add google provider for web search Signed-off-by: joeyczheng --- README.md | 34 +-- README_CN.md | 34 +-- README_JA.md | 34 +-- go.mod | 20 +- go.sum | 42 ++- internal/application/service/web_search.go | 2 + .../application/service/web_search/google.go | 92 ++++++ .../service/web_search/google_test.go | 278 ++++++++++++++++++ 8 files changed, 468 insertions(+), 68 deletions(-) create mode 100644 internal/application/service/web_search/google.go create mode 100644 internal/application/service/web_search/google_test.go diff --git a/README.md b/README.md index 3a982cb1..575c91a7 100644 --- a/README.md +++ b/README.md @@ -101,24 +101,24 @@ WeKnora employs a modern modular design to build a complete document understandi ## 🧩 Feature Matrix -| Module | Support | Description | -|---------|---------|------| -| Agent Mode | ✅ ReACT Agent Mode | Support for using built-in tools to retrieve knowledge bases, MCP tools, and web search, with cross-knowledge base retrieval and multiple iterations | -| Knowledge Base Types | ✅ FAQ / Document | Support for creating FAQ and document knowledge base types, with folder import, URL import, tag management, and online entry | -| Document Formats | ✅ PDF / Word / Txt / Markdown / Images (with OCR / Caption) | Support for structured and unstructured documents with text extraction from images | -| Model Management | ✅ Centralized configuration, built-in model sharing | Centralized model configuration with model selection in knowledge base settings, support for multi-tenant shared built-in models | -| Embedding Models | ✅ Local models, BGE / GTE APIs, etc. | Customizable embedding models, compatible with local deployment and cloud vector generation APIs | -| Vector DB Integration | ✅ PostgreSQL (pgvector), Elasticsearch | Support for mainstream vector index backends, flexible switching for different retrieval scenarios | -| Retrieval Strategies | ✅ BM25 / Dense Retrieval / GraphRAG | Support for sparse/dense recall and knowledge graph-enhanced retrieval with customizable retrieve-rerank-generate pipelines | -| LLM Integration | ✅ Support for Qwen, DeepSeek, etc., with thinking/non-thinking mode switching | Compatible with local models (e.g., via Ollama) or external API services with flexible inference configuration | +| Module | Support | Description | +|---------|--------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Agent Mode | ✅ ReACT Agent Mode | Support for using built-in tools to retrieve knowledge bases, MCP tools, and web search, with cross-knowledge base retrieval and multiple iterations | +| Knowledge Base Types | ✅ FAQ / Document | Support for creating FAQ and document knowledge base types, with folder import, URL import, tag management, and online entry | +| Document Formats | ✅ PDF / Word / Txt / Markdown / Images (with OCR / Caption) | Support for structured and unstructured documents with text extraction from images | +| Model Management | ✅ Centralized configuration, built-in model sharing | Centralized model configuration with model selection in knowledge base settings, support for multi-tenant shared built-in models | +| Embedding Models | ✅ Local models, BGE / GTE APIs, etc. | Customizable embedding models, compatible with local deployment and cloud vector generation APIs | +| Vector DB Integration | ✅ PostgreSQL (pgvector), Elasticsearch | Support for mainstream vector index backends, flexible switching for different retrieval scenarios | +| Retrieval Strategies | ✅ BM25 / Dense Retrieval / GraphRAG | Support for sparse/dense recall and knowledge graph-enhanced retrieval with customizable retrieve-rerank-generate pipelines | +| LLM Integration | ✅ Support for Qwen, DeepSeek, etc., with thinking/non-thinking mode switching | Compatible with local models (e.g., via Ollama) or external API services with flexible inference configuration | | Conversation Strategy | ✅ Agent models, normal mode models, retrieval thresholds, Prompt configuration | Support for configuring Agent models, normal mode models, retrieval thresholds, online Prompt configuration, precise control over multi-turn conversation behavior | -| Web Search | ✅ Extensible search engines, DuckDuckGo | Support for extensible web search engines with built-in DuckDuckGo search engine | -| MCP Tools | ✅ uvx, npx launchers, Stdio/HTTP Streamable/SSE | Support for extending Agent capabilities through MCP, with built-in uvx and npx launchers, supporting three transport methods | -| QA Capabilities | ✅ Context-aware, multi-turn dialogue, prompt templates | Support for complex semantic modeling, instruction control and chain-of-thought Q&A with configurable prompts and context windows | -| E2E Testing | ✅ Retrieval+generation process visualization and metric evaluation | End-to-end testing tools for evaluating recall hit rates, answer coverage, BLEU/ROUGE and other metrics | -| Deployment Modes | ✅ Support for local deployment / Docker images | Meets private, offline deployment and flexible operation requirements, with fast development mode support | -| User Interfaces | ✅ Web UI + RESTful API | Interactive interface and standard API endpoints, with Agent mode/normal mode switching and tool call process display | -| Task Management | ✅ MQ async tasks, automatic database migration | MQ-based async task state maintenance, support for automatic database schema and data migration during version upgrades | +| Web Search | ✅ Extensible search engines, DuckDuckGo / Google | Support for extensible web search engines with built-in DuckDuckGo search engine | +| MCP Tools | ✅ uvx, npx launchers, Stdio/HTTP Streamable/SSE | Support for extending Agent capabilities through MCP, with built-in uvx and npx launchers, supporting three transport methods | +| QA Capabilities | ✅ Context-aware, multi-turn dialogue, prompt templates | Support for complex semantic modeling, instruction control and chain-of-thought Q&A with configurable prompts and context windows | +| E2E Testing | ✅ Retrieval+generation process visualization and metric evaluation | End-to-end testing tools for evaluating recall hit rates, answer coverage, BLEU/ROUGE and other metrics | +| Deployment Modes | ✅ Support for local deployment / Docker images | Meets private, offline deployment and flexible operation requirements, with fast development mode support | +| User Interfaces | ✅ Web UI + RESTful API | Interactive interface and standard API endpoints, with Agent mode/normal mode switching and tool call process display | +| Task Management | ✅ MQ async tasks, automatic database migration | MQ-based async task state maintenance, support for automatic database schema and data migration during version upgrades | ## 🚀 Getting Started diff --git a/README_CN.md b/README_CN.md index 3a4a4562..b2795d9b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -101,24 +101,24 @@ WeKnora 采用现代化模块化设计,构建了一条完整的文档理解与 ## 🧩 功能模块能力 -| 功能模块 | 支持情况 | 说明 | -|---------|---------|------| -| Agent模式 | ✅ ReACT Agent模式 | 支持使用内置工具检索知识库、MCP工具和网络搜索,跨知识库检索,多次迭代和反思 | -| 知识库类型 | ✅ FAQ / 文档 | 支持创建FAQ和文档两种类型知识库,支持文件夹导入、URL导入、标签管理和在线录入 | +| 功能模块 | 支持情况 | 说明 | +|---------|-----------------------------------------------------|------| +| Agent模式 | ✅ ReACT Agent模式 | 支持使用内置工具检索知识库、MCP工具和网络搜索,跨知识库检索,多次迭代和反思 | +| 知识库类型 | ✅ FAQ / 文档 | 支持创建FAQ和文档两种类型知识库,支持文件夹导入、URL导入、标签管理和在线录入 | | 文档格式支持 | ✅ PDF / Word / Txt / Markdown / 图片(含 OCR / Caption) | 支持多种结构化与非结构化文档内容解析,支持图文混排与图像文字提取 | -| 模型管理 | ✅ 集中配置、内置模型共享 | 模型集中配置,知识库设置页增加模型选择,支持多租户共享内置模型 | -| 嵌入模型支持 | ✅ 本地模型、BGE / GTE API 等 | 支持自定义 embedding 模型,兼容本地部署与云端向量生成接口 | -| 向量数据库接入 | ✅ PostgreSQL(pgvector)、Elasticsearch | 支持主流向量索引后端,可灵活切换与扩展,适配不同检索场景 | -| 检索机制 | ✅ BM25 / Dense Retrieve / GraphRAG | 支持稠密/稀疏召回、知识图谱增强检索等多种策略,可自由组合召回-重排-生成流程 | -| 大模型集成 | ✅ 支持 Qwen、DeepSeek 等,思考/非思考模式切换 | 可接入本地大模型(如 Ollama 启动)或调用外部 API 服务,支持推理模式灵活配置 | -| 对话策略 | ✅ Agent模型、普通模式模型、检索阈值、Prompt配置 | 支持配置Agent模型、普通模式所需的模型、检索阈值,在线配置Prompt,精确控制多轮对话行为 | -| 网络搜索 | ✅ 可扩展搜索引擎、DuckDuckGo | 支持可扩展的网络搜索引擎,内置DuckDuckGo搜索引擎 | -| MCP工具 | ✅ uvx、npx启动工具,Stdio/HTTP Streamable/SSE | 支持通过MCP扩展Agent能力,内置uvx、npx两种MCP启动工具,支持三种传输方式 | -| 问答能力 | ✅ 上下文感知、多轮对话、提示词模板 | 支持复杂语义建模、指令控制与链式问答,可配置提示词与上下文窗口 | -| 端到端测试支持 | ✅ 检索+生成过程可视化与指标评估 | 提供一体化链路测试工具,支持评估召回命中率、回答覆盖度、BLEU / ROUGE 等主流指标 | -| 部署模式 | ✅ 支持本地部署 / Docker 镜像 | 满足私有化、离线部署与灵活运维的需求,支持快速开发模式 | -| 用户界面 | ✅ Web UI + RESTful API | 提供交互式界面与标准 API 接口,支持Agent模式/普通模式切换,展示工具调用过程 | -| 任务管理 | ✅ MQ异步任务、数据库自动迁移 | 引入MQ对异步任务进行状态维护,支持版本升级时的数据库表结构和数据自动迁移 | +| 模型管理 | ✅ 集中配置、内置模型共享 | 模型集中配置,知识库设置页增加模型选择,支持多租户共享内置模型 | +| 嵌入模型支持 | ✅ 本地模型、BGE / GTE API 等 | 支持自定义 embedding 模型,兼容本地部署与云端向量生成接口 | +| 向量数据库接入 | ✅ PostgreSQL(pgvector)、Elasticsearch | 支持主流向量索引后端,可灵活切换与扩展,适配不同检索场景 | +| 检索机制 | ✅ BM25 / Dense Retrieve / GraphRAG | 支持稠密/稀疏召回、知识图谱增强检索等多种策略,可自由组合召回-重排-生成流程 | +| 大模型集成 | ✅ 支持 Qwen、DeepSeek 等,思考/非思考模式切换 | 可接入本地大模型(如 Ollama 启动)或调用外部 API 服务,支持推理模式灵活配置 | +| 对话策略 | ✅ Agent模型、普通模式模型、检索阈值、Prompt配置 | 支持配置Agent模型、普通模式所需的模型、检索阈值,在线配置Prompt,精确控制多轮对话行为 | +| 网络搜索 | ✅ 可扩展搜索引擎、DuckDuckGo / Google | 支持可扩展的网络搜索引擎,内置DuckDuckGo搜索引擎 | +| MCP工具 | ✅ uvx、npx启动工具,Stdio/HTTP Streamable/SSE | 支持通过MCP扩展Agent能力,内置uvx、npx两种MCP启动工具,支持三种传输方式 | +| 问答能力 | ✅ 上下文感知、多轮对话、提示词模板 | 支持复杂语义建模、指令控制与链式问答,可配置提示词与上下文窗口 | +| 端到端测试支持 | ✅ 检索+生成过程可视化与指标评估 | 提供一体化链路测试工具,支持评估召回命中率、回答覆盖度、BLEU / ROUGE 等主流指标 | +| 部署模式 | ✅ 支持本地部署 / Docker 镜像 | 满足私有化、离线部署与灵活运维的需求,支持快速开发模式 | +| 用户界面 | ✅ Web UI + RESTful API | 提供交互式界面与标准 API 接口,支持Agent模式/普通模式切换,展示工具调用过程 | +| 任务管理 | ✅ MQ异步任务、数据库自动迁移 | 引入MQ对异步任务进行状态维护,支持版本升级时的数据库表结构和数据自动迁移 | ## 🚀 快速开始 diff --git a/README_JA.md b/README_JA.md index c70a181f..e00ad667 100644 --- a/README_JA.md +++ b/README_JA.md @@ -101,24 +101,24 @@ WeKnoraは現代的なモジュラー設計を採用し、完全な文書理解 ## 🧩 機能モジュール能力 -| 機能モジュール | サポート状況 | 説明 | -|---------|---------|------| -| Agentモード | ✅ ReACT Agentモード | 組み込みツールでナレッジベースを検索、MCPツールとWeb検索を使用、クロスナレッジベース検索、複数回の反復とリフレクションをサポート | -| ナレッジベースタイプ | ✅ FAQ / ドキュメント | FAQとドキュメントの2種類のナレッジベースの作成をサポート、フォルダーインポート、URLインポート、タグ管理、オンライン入力機能 | +| 機能モジュール | サポート状況 | 説明 | +|---------|-----------------------------------------------------|------| +| Agentモード | ✅ ReACT Agentモード | 組み込みツールでナレッジベースを検索、MCPツールとWeb検索を使用、クロスナレッジベース検索、複数回の反復とリフレクションをサポート | +| ナレッジベースタイプ | ✅ FAQ / ドキュメント | FAQとドキュメントの2種類のナレッジベースの作成をサポート、フォルダーインポート、URLインポート、タグ管理、オンライン入力機能 | | 文書フォーマットサポート | ✅ PDF / Word / Txt / Markdown / 画像(OCR / Caption含む) | 様々な構造化・非構造化文書コンテンツの解析をサポート、図文混在と画像文字抽出をサポート | -| モデル管理 | ✅ 集中設定、組み込みモデル共有 | モデルの集中設定、ナレッジベース設定ページにモデル選択を追加、マルチテナント間での組み込みモデル共有をサポート | -| 埋め込みモデルサポート | ✅ ローカルモデル、BGE / GTE API等 | カスタムembeddingモデルをサポート、ローカルデプロイとクラウドベクトル生成インターフェースに対応 | -| ベクトルデータベース接続 | ✅ PostgreSQL(pgvector)、Elasticsearch | 主流のベクトルインデックスバックエンドをサポート、柔軟な切り替えと拡張が可能、異なる検索シナリオに適応 | -| 検索メカニズム | ✅ BM25 / Dense Retrieve / GraphRAG | 密・疎検索、ナレッジグラフ強化検索など複数の戦略をサポート、検索-再ランキング-生成プロセスを自由に組み合わせ可能 | -| 大規模モデル統合 | ✅ Qwen、DeepSeek等をサポート、思考/非思考モード切り替え | ローカル大規模モデル(Ollama起動など)に接続可能、または外部APIサービスを呼び出し、推論モードの柔軟な設定をサポート | -| 対話戦略 | ✅ Agentモデル、通常モードモデル、検索閾値、Prompt設定 | Agentモデル、通常モードに必要なモデル、検索閾値の設定をサポート、オンラインPrompt設定、マルチターン対話の動作を精密に制御 | -| Web検索 | ✅ 拡張可能な検索エンジン、DuckDuckGo | 拡張可能なWeb検索エンジンをサポート、DuckDuckGo検索エンジンを組み込み | -| MCPツール | ✅ uvx、npx起動ツール、Stdio/HTTP Streamable/SSE | MCPを通じてAgent機能を拡張、uvx、npxの2種類のMCP起動ツールを組み込み、3種類の転送方式をサポート | -| Q&A能力 | ✅ コンテキスト認識、マルチターン対話、プロンプトテンプレート | 複雑な意味モデリング、指示制御、チェーンQ&Aをサポート、プロンプトとコンテキストウィンドウを設定可能 | -| エンドツーエンドテストサポート | ✅ 検索+生成プロセスの可視化と指標評価 | 一体化されたリンクテストツールを提供、リコール的中率、回答カバレッジ、BLEU / ROUGE等の主流指標の評価をサポート | -| デプロイメントモード | ✅ ローカルデプロイメント / Dockerイメージ | プライベート化、オフラインデプロイメント、柔軟な運用保守のニーズに対応、高速開発モードをサポート | -| ユーザーインターフェース | ✅ Web UI + RESTful API | インタラクティブインターフェースと標準APIインターフェースを提供、Agentモード/通常モードの切り替え、ツール呼び出しプロセスの表示をサポート | -| タスク管理 | ✅ MQ非同期タスク、データベース自動マイグレーション | MQによる非同期タスクの状態維持を導入、バージョンアップ時のデータベーステーブル構造とデータの自動マイグレーションをサポート | +| モデル管理 | ✅ 集中設定、組み込みモデル共有 | モデルの集中設定、ナレッジベース設定ページにモデル選択を追加、マルチテナント間での組み込みモデル共有をサポート | +| 埋め込みモデルサポート | ✅ ローカルモデル、BGE / GTE API等 | カスタムembeddingモデルをサポート、ローカルデプロイとクラウドベクトル生成インターフェースに対応 | +| ベクトルデータベース接続 | ✅ PostgreSQL(pgvector)、Elasticsearch | 主流のベクトルインデックスバックエンドをサポート、柔軟な切り替えと拡張が可能、異なる検索シナリオに適応 | +| 検索メカニズム | ✅ BM25 / Dense Retrieve / GraphRAG | 密・疎検索、ナレッジグラフ強化検索など複数の戦略をサポート、検索-再ランキング-生成プロセスを自由に組み合わせ可能 | +| 大規模モデル統合 | ✅ Qwen、DeepSeek等をサポート、思考/非思考モード切り替え | ローカル大規模モデル(Ollama起動など)に接続可能、または外部APIサービスを呼び出し、推論モードの柔軟な設定をサポート | +| 対話戦略 | ✅ Agentモデル、通常モードモデル、検索閾値、Prompt設定 | Agentモデル、通常モードに必要なモデル、検索閾値の設定をサポート、オンラインPrompt設定、マルチターン対話の動作を精密に制御 | +| Web検索 | ✅ 拡張可能な検索エンジン、DuckDuckGo / Google | 拡張可能なWeb検索エンジンをサポート、DuckDuckGo検索エンジンを組み込み | +| MCPツール | ✅ uvx、npx起動ツール、Stdio/HTTP Streamable/SSE | MCPを通じてAgent機能を拡張、uvx、npxの2種類のMCP起動ツールを組み込み、3種類の転送方式をサポート | +| Q&A能力 | ✅ コンテキスト認識、マルチターン対話、プロンプトテンプレート | 複雑な意味モデリング、指示制御、チェーンQ&Aをサポート、プロンプトとコンテキストウィンドウを設定可能 | +| エンドツーエンドテストサポート | ✅ 検索+生成プロセスの可視化と指標評価 | 一体化されたリンクテストツールを提供、リコール的中率、回答カバレッジ、BLEU / ROUGE等の主流指標の評価をサポート | +| デプロイメントモード | ✅ ローカルデプロイメント / Dockerイメージ | プライベート化、オフラインデプロイメント、柔軟な運用保守のニーズに対応、高速開発モードをサポート | +| ユーザーインターフェース | ✅ Web UI + RESTful API | インタラクティブインターフェースと標準APIインターフェースを提供、Agentモード/通常モードの切り替え、ツール呼び出しプロセスの表示をサポート | +| タスク管理 | ✅ MQ非同期タスク、データベース自動マイグレーション | MQによる非同期タスクの状態維持を導入、バージョンアップ時のデータベーステーブル構造とデータの自動マイグレーションをサポート | ## 🚀 クイックスタート diff --git a/go.mod b/go.mod index 49694a64..09e00f00 100644 --- a/go.mod +++ b/go.mod @@ -41,19 +41,23 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 - go.opentelemetry.io/otel/sdk v1.37.0 + go.opentelemetry.io/otel/sdk v1.38.0 go.opentelemetry.io/otel/trace v1.38.0 go.uber.org/dig v1.18.1 golang.org/x/crypto v0.46.0 golang.org/x/sync v0.19.0 - google.golang.org/grpc v1.76.0 - google.golang.org/protobuf v1.36.10 + google.golang.org/api v0.259.0 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.12 ) require ( + cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect @@ -82,6 +86,7 @@ require ( github.com/duckdb/duckdb-go/mapping v0.0.27 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elastic/elastic-transport-go/v8 v8.7.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -103,6 +108,9 @@ require ( github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/flatbuffers v25.9.23+incompatible // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect + github.com/googleapis/gax-go/v2 v2.16.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -149,6 +157,7 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.uber.org/mock v0.5.0 // indirect @@ -157,13 +166,14 @@ require ( golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.40.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 5f2e4b61..11706d90 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= +cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -176,9 +182,15 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -402,10 +414,10 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgT go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= @@ -450,6 +462,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -516,16 +530,20 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= -google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= +google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/application/service/web_search.go b/internal/application/service/web_search.go index 44e4827d..f00f262a 100644 --- a/internal/application/service/web_search.go +++ b/internal/application/service/web_search.go @@ -304,6 +304,8 @@ func NewWebSearchService(cfg *config.Config) (interfaces.WebSearchService, error switch providerConfig.ID { case "duckduckgo": provider, err = web_search.NewDuckDuckGoProvider(providerConfig) + case "google": + provider, err = web_search.NewGoogleProvider(providerConfig) default: return nil, fmt.Errorf("unknown web search provider: %s", providerConfig.ID) } diff --git a/internal/application/service/web_search/google.go b/internal/application/service/web_search/google.go new file mode 100644 index 00000000..a241c737 --- /dev/null +++ b/internal/application/service/web_search/google.go @@ -0,0 +1,92 @@ +package web_search + +import ( + "context" + "fmt" + "net/url" + + "google.golang.org/api/customsearch/v1" + "google.golang.org/api/option" + + "github.com/Tencent/WeKnora/internal/config" + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" +) + +// GoogleProvider implements web search using Google Custom Search Engine API +type GoogleProvider struct { + srv *customsearch.Service + apiKey string + engineID string + baseURL string +} + +// NewGoogleProvider creates a new Google provider +func NewGoogleProvider(cfg config.WebSearchProviderConfig) (interfaces.WebSearchProvider, error) { + u, err := url.Parse(cfg.APIURL) + if err != nil { + return nil, err + } + engineID := u.Query().Get("engine_id") + if engineID == "" { + return nil, fmt.Errorf("engine_id is empty") + } + apiKey := u.Query().Get("api_key") + if apiKey == "" { + return nil, fmt.Errorf("api_key is empty") + } + clientOpts := make([]option.ClientOption, 0) + clientOpts = append(clientOpts, option.WithAPIKey(apiKey)) + clientOpts = append(clientOpts, option.WithEndpoint(u.Scheme+"://"+u.Host)) + srv, err := customsearch.NewService(context.Background(), clientOpts...) + if err != nil { + return nil, err + } + return &GoogleProvider{ + srv: srv, + apiKey: apiKey, + engineID: engineID, + baseURL: cfg.APIURL, + }, nil +} + +// Name returns the provider name +func (p *GoogleProvider) Name() string { + return "google" +} + +// Search performs a web search using Google Custom Search Engine API +func (p *GoogleProvider) Search( + ctx context.Context, + query string, + maxResults int, + includeDate bool, +) ([]*types.WebSearchResult, error) { + if len(query) == 0 { + return nil, fmt.Errorf("query is empty") + } + cseCall := p.srv.Cse.List().Context(ctx).Cx(p.engineID).Q(query) + + if maxResults > 0 { + cseCall = cseCall.Num(int64(maxResults)) + } else { + cseCall = cseCall.Num(5) + } + cseCall = cseCall.Hl("ch-zh") + + resp, err := cseCall.Do() + if err != nil { + return nil, err + } + results := make([]*types.WebSearchResult, 0) + for _, item := range resp.Items { + result := &types.WebSearchResult{ + Title: item.Title, + URL: item.Link, + Snippet: item.Snippet, + Source: "google", + } + results = append(results, result) + } + return results, nil +} diff --git a/internal/application/service/web_search/google_test.go b/internal/application/service/web_search/google_test.go new file mode 100644 index 00000000..058cf2a7 --- /dev/null +++ b/internal/application/service/web_search/google_test.go @@ -0,0 +1,278 @@ +package web_search + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/config" +) + +func TestNewGoogleProvider(t *testing.T) { + testCases := []struct { + name string + cfg config.WebSearchProviderConfig + expected error + }{ + { + name: "valid config", + cfg: config.WebSearchProviderConfig{ + APIURL: "https://customsearch.googleapis.com/customsearch/v1?api_key=test&engine_id=test", + }, + expected: nil, + }, + { + name: "missing engine id", + cfg: config.WebSearchProviderConfig{ + APIURL: "https://customsearch.googleapis.com/customsearch/v1?api_key=test", + }, + expected: fmt.Errorf("engine_id is empty"), + }, + { + name: "missing api key", + cfg: config.WebSearchProviderConfig{ + APIURL: "https://customsearch.googleapis.com/customsearch/v1?engine_id=test", + }, + expected: fmt.Errorf("api_key is empty"), + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewGoogleProvider(tc.cfg) + if tc.expected == nil { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + } else { + if err == nil { + t.Fatalf("expected error %v, got nil", tc.expected) + } + if !strings.Contains(err.Error(), tc.expected.Error()) { + t.Fatalf("expected error %v, got %v", tc.expected, err) + } + } + }) + } +} + +func TestGoogleProvider_Name(t *testing.T) { + cfg := config.WebSearchProviderConfig{ + APIURL: "https://customsearch.googleapis.com/customsearch/v1?api_key=test&engine_id=test", + } + p, err := NewGoogleProvider(cfg) + if err != nil { + t.Fatalf("failed to create Google provider: %v", err) + } + if p.Name() != "google" { + t.Fatalf("expected provider name google, got %s", p.Name()) + } +} + +func TestGoogleProvider_Search(t *testing.T) { + mockResponse := map[string]interface{}{ + "items": []map[string]interface{}{ + { + "title": "Example Search Result One", + "link": "https://example.com/page1", + "snippet": "This is the first search result snippet describing the content.", + }, + { + "title": "Example Search Result Two", + "link": "https://example.org/page2", + "snippet": "This is the second search result snippet with more details.", + }, + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/customsearch/v1" { + t.Fatalf("unexpected request path: %s", r.URL.Path) + } + + query := r.URL.Query().Get("q") + if query != "weknora" { + t.Fatalf("unexpected query: %s", query) + } + + cx := r.URL.Query().Get("cx") + if cx != "test-engine-id" { + t.Fatalf("unexpected engine ID: %s", cx) + } + + num := r.URL.Query().Get("num") + if num != "5" { + t.Fatalf("unexpected num parameter: %s", num) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + enc := json.NewEncoder(w) + _ = enc.Encode(mockResponse) + })) + defer ts.Close() + + cfg := config.WebSearchProviderConfig{ + APIURL: fmt.Sprintf("%s/customsearch/v1?api_key=test-key&engine_id=test-engine-id", ts.URL), + } + prov, err := NewGoogleProvider(cfg) + if err != nil { + t.Fatalf("failed to create Google provider: %v", err) + } + + gp := prov.(*GoogleProvider) + if gp == nil { + t.Fatalf("failed to cast to GoogleProvider") + } + + ctx := context.Background() + results, err := prov.Search(ctx, "weknora", 5, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + if results[0].Title != "Example Search Result One" || + results[0].URL != "https://example.com/page1" || + results[0].Snippet != "This is the first search result snippet describing the content." || + results[0].Source != "google" { + t.Fatalf("unexpected first result: %+v", results[0]) + } + + if results[1].Title != "Example Search Result Two" || + results[1].URL != "https://example.org/page2" || + results[1].Snippet != "This is the second search result snippet with more details." || + results[1].Source != "google" { + t.Fatalf("unexpected second result: %+v", results[1]) + } +} + +func TestGoogleProvider_Search_EmptyQuery(t *testing.T) { + cfg := config.WebSearchProviderConfig{ + APIURL: "https://customsearch.googleapis.com/customsearch/v1?api_key=test&engine_id=test", + } + prov, err := NewGoogleProvider(cfg) + if err != nil { + t.Fatalf("failed to create Google provider: %v", err) + } + + ctx := context.Background() + results, err := prov.Search(ctx, "", 5, false) + if err == nil { + t.Fatal("expected error for empty query, got nil") + } + if !strings.Contains(err.Error(), "query is empty") { + t.Fatalf("expected 'query is empty' error, got: %v", err) + } + if results != nil { + t.Fatalf("expected nil results for empty query, got: %v", results) + } +} + +func TestGoogleProvider_Search_NoResults(t *testing.T) { + mockResponse := map[string]interface{}{ + "items": []map[string]interface{}{}, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + enc := json.NewEncoder(w) + _ = enc.Encode(mockResponse) + })) + defer ts.Close() + + cfg := config.WebSearchProviderConfig{ + APIURL: fmt.Sprintf("%s/customsearch/v1?api_key=test-key&engine_id=test-engine-id", ts.URL), + } + prov, err := NewGoogleProvider(cfg) + if err != nil { + t.Fatalf("failed to create Google provider: %v", err) + } + + ctx := context.Background() + results, err := prov.Search(ctx, "nonexistent", 5, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) != 0 { + t.Fatalf("expected 0 results, got %d", len(results)) + } +} + +func TestGoogleProvider_Search_ErrorResponse(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal Server Error")) + })) + defer ts.Close() + + cfg := config.WebSearchProviderConfig{ + APIURL: fmt.Sprintf("%s/customsearch/v1?api_key=test-key&engine_id=test-engine-id", ts.URL), + } + prov, err := NewGoogleProvider(cfg) + if err != nil { + t.Fatalf("failed to create Google provider: %v", err) + } + + ctx := context.Background() + results, err := prov.Search(ctx, "test", 5, false) + if err == nil { + t.Fatal("expected error for server error response, got nil") + } + if results != nil { + t.Fatalf("expected nil results for error response, got: %v", results) + } +} + +func TestGoogleProvider_Search_MaxResults(t *testing.T) { + mockResponse := map[string]interface{}{ + "items": []map[string]interface{}{ + {"title": "Result 1", "link": "https://example.com/1", "snippet": "Snippet 1"}, + {"title": "Result 2", "link": "https://example.com/2", "snippet": "Snippet 2"}, + {"title": "Result 3", "link": "https://example.com/3", "snippet": "Snippet 3"}, + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + num := r.URL.Query().Get("num") + if num != "2" { + t.Fatalf("expected num=2, got %s", num) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + enc := json.NewEncoder(w) + _ = enc.Encode(mockResponse) + })) + defer ts.Close() + + cfg := config.WebSearchProviderConfig{ + APIURL: fmt.Sprintf("%s/customsearch/v1?api_key=test-key&engine_id=test-engine-id", ts.URL), + } + prov, err := NewGoogleProvider(cfg) + if err != nil { + t.Fatalf("failed to create Google provider: %v", err) + } + + ctx := context.Background() + results, err := prov.Search(ctx, "test", 2, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) != 3 { + t.Fatalf("expected 3 results, got %d", len(results)) + } + + if results[0].Title != "Result 1" || results[1].Title != "Result 2" || results[2].Title != "Result 3" { + t.Fatalf("unexpected results order or content") + } +}