ask-web-search 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 27cc1945774b036bb0f3c442c0624c22a5960efa565c8b3950acbee2274cd295
4
- data.tar.gz: 994dd290d09b68063cd856f17704cdbf2af9dae4f80e1a0510fe513b4762e5c8
3
+ metadata.gz: 254d229de3235963082784b2300a04a5c6a9fad33df6d733fa511bc51fa59d8e
4
+ data.tar.gz: e2fca871ad9b9ce5b7ca3f1b4407514f267bfda66658f2219c1b906812a9f4f1
5
5
  SHA512:
6
- metadata.gz: 19879b3d3007047be07e1e4279ac1887cb3a5540559fefeba9b532ae877db206e903a12c3f79cd996a4a73d243e3d45ec8d6171490ce0941bd35ba843f9a8963
7
- data.tar.gz: 368042b50d1241d69d59ac74bf879743f2dd51669ad9267ed9d60d1ddce1885d3a7c0fbed65d77d9ca902198e03d2de27599d2cc4d071987c7a8fcb027701308
6
+ metadata.gz: 4cd05c99d6ff688497fe27bca0215db4d5f68f4607ed07c3880447f44376d9b8294a149483447095130854bf5ad50418edf379b7b408c9655aca5acd48ab03de
7
+ data.tar.gz: 4781a15d45be0e99cb27c4185255b06272bb397a21d3403f854c779ecfcca2a5b0181614c23f545831407721223c13089d1bf1bb2c89d0c1a9c9594735566d33
data/README.md CHANGED
@@ -3,23 +3,45 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/ask-web-search.svg)](https://badge.fury.io/rb/ask-web-search)
4
4
 
5
5
  A web search tool for the ask-rb ecosystem. It provides
6
- `Ask::Tools::WebSearch`, which searches the web via a local
7
- [SearXNG](https://docs.searxng.org/) instance and returns numbered markdown
8
- results for LLM consumption. It has no Rails dependencies; it depends only on
9
- ask-tools.
6
+ `Ask::Tools::WebSearch`, which searches the web and returns numbered
7
+ markdown results for LLM consumption. It has no Rails dependencies; it
8
+ depends only on ask-tools.
10
9
 
11
- ## Prerequisites
10
+ ## Backends
12
11
 
13
- A running SearXNG instance. The default is `http://localhost:8888`.
12
+ Two interchangeable backends, chosen automatically at call time:
14
13
 
15
- Start one with Docker:
14
+ 1. **TinyFish (recommended — no self-hosting).** Get a free API key at
15
+ [agent.tinyfish.ai](https://agent.tinyfish.ai/api-keys) and export it:
16
16
 
17
- ```sh
18
- docker run -d --name searxng -p 8888:8080 searxng/searxng
19
- ```
17
+ ```sh
18
+ export TINYFISH_API_KEY=...
19
+ ```
20
+
21
+ That's the whole setup — no Docker, no SearXNG instance. Live
22
+ browser-rendered results, freshness windows, and the news /
23
+ research-paper verticals all work out of the box.
24
+
25
+ 2. **SearXNG (self-hosted).** For users who want queries to stay local,
26
+ and as the automatic fallback when TinyFish fails: a running
27
+ [SearXNG](https://docs.searxng.org/) instance, default
28
+ `http://localhost:8888`. Start one with Docker:
29
+
30
+ ```sh
31
+ docker run -d --name searxng -p 8888:8080 searxng/searxng
32
+ ```
33
+
34
+ Or use the provided `docker-compose.yml` in the `searxng` directory of
35
+ this repository.
36
+
37
+ Routing rules:
20
38
 
21
- Or use the provided `docker-compose.yml` in the `searxng` directory of this
22
- repository:
39
+ - `TINYFISH_API_KEY` set → TinyFish primary; if TinyFish fails **and** a
40
+ SearXNG endpoint is explicitly configured, the call falls back to
41
+ SearXNG automatically (combined errors surface both failures).
42
+ - No key → SearXNG only, exactly as in 0.6.x.
43
+ - `TINYFISH_SEARCH=0` disables TinyFish outright (SearXNG only, even
44
+ with a key).
23
45
 
24
46
  ## Installation
25
47
 
@@ -29,13 +51,14 @@ gem "ask-web-search"
29
51
 
30
52
  ## Configuration
31
53
 
32
- Set the `SEARXNG_URL` environment variable to point to your SearXNG instance:
33
-
34
54
  ```sh
35
- export SEARXNG_URL=http://localhost:8888
55
+ export TINYFISH_API_KEY=... # TinyFish (recommended): free key, instant search
56
+ export SEARXNG_URL=http://localhost:8888 # SearXNG endpoint (default shown)
36
57
  ```
37
58
 
38
- Defaults to `http://localhost:8888`.
59
+ The SearXNG endpoint can also be set in code: `Ask::WebSearch.searxng_url=`.
60
+ TinyFish reads `TINYFISH_API_KEY` from the environment only.
61
+ `Ask::WebSearch.max_retries = 0` disables the retry-with-backoff loop.
39
62
 
40
63
  ## Quick Start
41
64
 
@@ -72,27 +95,65 @@ Ask::WebSearch.search("fed policy", categories: "news")
72
95
  Ask::WebSearch.search("retrieval augmented generation", categories: "science")
73
96
  ```
74
97
 
75
- - **`time_range:`** restricts results to a SearXNG freshness window —
76
- `day`, `week`, `month`, or `year`. Anything else raises `ArgumentError`
98
+ - **`time_range:`** restricts results to a freshness window — `day`,
99
+ `week`, `month`, or `year`. Anything else raises `ArgumentError`
77
100
  naming the valid values. When a windowed search comes back cleanly
78
101
  empty, the result says so — "No results found within the day freshness
79
102
  window. Retry with a broader time_range or without one." — instead of a
80
103
  bare "No results found.", and engine-failure diagnostics suggest a
81
- broader window. Caveat: SearXNG delegates date filtering to the engines,
82
- and as of SearXNG 2026.6.x every date-capable engine returns zero
83
- results under a date filter, so windows currently fail soft with that
84
- message. No gem change is needed when the engines are fixed upstream.
85
- - **`categories:`** scopes the search to a SearXNG category — `news`,
86
- `science` (research papers), or any category the instance configures.
87
- Accepts a string or anything Array-able (`[:news, :science]` →
88
- `news,science`). Values pass through unvalidated: instances configure
89
- their own category set, and an unknown category degrades to a clean
90
- empty result rather than a failure. The instance must have vertical
91
- engines enabled — the `searxng/` compose config in this repository
92
- enables bing news + google news (news) and arxiv + pubmed (science);
93
- without them SearXNG silently resolves the request against the general
104
+ broader window. TinyFish maps this to its `recency_minutes` and
105
+ honors it properly. Caveat on the **SearXNG backend**: SearXNG
106
+ delegates date filtering to the engines, and as of SearXNG 2026.6.x
107
+ every date-capable engine returns zero results under a date filter, so
108
+ windows there fail soft with that message. No gem change is needed
109
+ when the engines are fixed upstream.
110
+ - **`categories:`** scopes the search to a vertical — `news`, `science`
111
+ (research papers), or general web (default). Accepts a string or
112
+ anything Array-able (`[:news, :science]` → `news,science`). TinyFish
113
+ maps the first recognized value to its `domain_type`
114
+ (general→web, news→news, science→research_paper; unknown categories
115
+ are omitted so TinyFish defaults to web). On the **SearXNG backend**
116
+ values pass through unvalidated: instances configure their own
117
+ category set, and an unknown category degrades to a clean empty
118
+ result rather than a failure. The instance must have vertical engines
119
+ enabled — the `searxng/` compose config in this repository enables
120
+ bing news + google news (news) and arxiv + pubmed (science); without
121
+ them SearXNG silently resolves the request against the general
94
122
  engines.
95
123
 
124
+ ## SafeSearch and adult content
125
+
126
+ The gem returns results exactly as SearXNG produces them — it does no
127
+ content filtering of its own. Whether adult sites appear in ordinary
128
+ searches is decided entirely by the SearXNG instance's SafeSearch
129
+ setting, which SearXNG defaults to **off**. To keep adult sites out of
130
+ ordinary results, configure the instance:
131
+
132
+ ```yaml
133
+ # /etc/searxng/settings.yml
134
+ preferences:
135
+ lock:
136
+ - safesearch
137
+
138
+ search:
139
+ safe_search: 2
140
+ ```
141
+
142
+ - `safe_search: 2` is strict filtering.
143
+ - Locking the `safesearch` preference forces that level onto every
144
+ request, so neither this gem, the JSON API, nor the web UI can relax
145
+ it back to 0.
146
+ - Filtering is enforced **per engine**: SearXNG forwards the level
147
+ upstream and engines that don't implement SafeSearch (e.g.
148
+ `duckduckgo_web`, which carries an upstream `TODO: support safesearch`)
149
+ pass adult results through regardless of the setting. Prefer engines
150
+ that support it (`bing`, `duckduckgo`, `google`). The `searxng/` compose
151
+ config in this repository uses `duckduckgo` for this reason.
152
+
153
+ SafeSearch is best-effort upstream filtering — strict is reliable in
154
+ practice but not a guarantee. Consumers needing a hard guarantee should
155
+ post-filter results by domain.
156
+
96
157
  ## Full documentation
97
158
 
98
159
  The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs.
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module WebSearch
3
- VERSION = "0.6.1"
3
+ VERSION = "0.7.1"
4
4
  end
5
5
  end
@@ -6,11 +6,14 @@ require "json"
6
6
  require_relative "web_search/version"
7
7
 
8
8
  module Ask
9
- # Searches the web via a local SearXNG instance and returns the results
10
- # as clean, numbered markdown for LLM consumption. The capability layer:
11
- # one entry point (WebSearch.search) and a configurable endpoint. Tool
12
- # framing — name, parameter schema, result wrapping — lives with the
13
- # consumers (the MCP server, the agents) that call this library.
9
+ # Searches the web and returns the results as clean, numbered markdown
10
+ # for LLM consumption. Two interchangeable backends: TinyFish's hosted
11
+ # search API — primary when TINYFISH_API_KEY is set, so newcomers need
12
+ # no self-hosted infrastructure — and a local SearXNG instance for
13
+ # self-hosters, which is also the automatic fallback when TinyFish
14
+ # fails. The capability layer: one entry point (WebSearch.search).
15
+ # Tool framing — name, parameter schema, result wrapping — lives with
16
+ # the consumers (the MCP server, the agents) that call this library.
14
17
  module WebSearch
15
18
  # Search errors that carry engine diagnostics — the agent needs to
16
19
  # know *which* engines failed and *why* (CAPTCHA, timeout, suspended)
@@ -30,6 +33,7 @@ module Ask
30
33
 
31
34
  def self.searxng_url=(url)
32
35
  @searxng_url = url
36
+ @searxng_url_set = !url.nil?
33
37
  end
34
38
 
35
39
  # Retry configuration. Set max_retries to 0 to disable retries.
@@ -42,6 +46,51 @@ module Ask
42
46
  # unfiltered.
43
47
  TIME_RANGES = %w[day week month year].freeze
44
48
 
49
+ # TinyFish's hosted search endpoint — the primary backend for anyone
50
+ # holding a (free) TINYFISH_API_KEY, so no SearXNG instance is needed.
51
+ # The bare host root per the official docs curl example; the
52
+ # /client.search.query path seen on the marketing pages 404s.
53
+ TINYFISH_SEARCH_URL = "https://api.search.tinyfish.ai"
54
+
55
+ # Our time_range → TinyFish's recency_minutes (SearXNG's month/year
56
+ # are approximate engine filters anyway; these are the equivalents).
57
+ TIME_RANGE_TO_MINUTES = {
58
+ "day" => 1440,
59
+ "week" => 10_080,
60
+ "month" => 43_200,
61
+ "year" => 525_600
62
+ }.freeze
63
+
64
+ # Our categories → TinyFish's single-value domain_type. The FIRST
65
+ # recognized value wins (TinyFish is one-domain-per-query); unknown
66
+ # categories are omitted so TinyFish falls back to its web default.
67
+ CATEGORIES_TO_DOMAIN_TYPE = {
68
+ "general" => "web",
69
+ "web" => "web",
70
+ "news" => "news",
71
+ "science" => "research_paper",
72
+ "research_paper" => "research_paper"
73
+ }.freeze
74
+
75
+ # True when TinyFish should be the primary backend: a key is present
76
+ # and TINYFISH_SEARCH=0 has not disabled it. Read fresh on every
77
+ # call so tests and config reloads don't need memo resets.
78
+ def self.use_tinyfish?
79
+ return false if ENV["TINYFISH_SEARCH"] == "0"
80
+
81
+ !ENV["TINYFISH_API_KEY"].to_s.empty?
82
+ end
83
+
84
+ # True when a SearXNG endpoint was configured explicitly (SEARXNG_URL
85
+ # or searxng_url=). Gates the TinyFish → SearXNG fallback so a failed
86
+ # TinyFish search never probes a default localhost instance that may
87
+ # not exist.
88
+ def self.searxng_configured?
89
+ return true if @searxng_url_set
90
+
91
+ !ENV["SEARXNG_URL"].to_s.empty?
92
+ end
93
+
45
94
  def self.max_retries
46
95
  return @max_retries if defined?(@max_retries)
47
96
 
@@ -89,22 +138,43 @@ module Ask
89
138
  search_raw(query, time_range: time_range, categories: categories)[:results]
90
139
  end
91
140
 
92
- # The full SearXNG response: { results: [...], unresponsive: [[name,
93
- # reason], ...] }. Same retry logic as #search_results, but preserves
94
- # the engine diagnostics the caller needs to explain an empty result.
95
- # time_range / categories are normalized here — the single point that
96
- # builds the request — and are idempotent, so callers may pass raw or
97
- # already-normalized values.
141
+ # The full response: { results: [...], unresponsive: [[name,
142
+ # reason], ...] }. Routes to TinyFish when #use_tinyfish?, falling
143
+ # back to SearXNG (only when explicitly configured) if TinyFish
144
+ # raises; otherwise straight to SearXNG — byte-for-byte the 0.6.x
145
+ # behavior for keyless users. Same retry logic either way, but
146
+ # preserves the engine diagnostics the caller needs to explain an
147
+ # empty result. time_range / categories are normalized by the
148
+ # backends that build requests — idempotent, so callers may pass
149
+ # raw or already-normalized values.
98
150
  def self.search_raw(query, time_range: nil, categories: nil)
151
+ return searxng_search_raw(query, time_range: time_range, categories: categories) unless use_tinyfish?
152
+
153
+ begin
154
+ tinyfish_search_raw(query, time_range: time_range, categories: categories)
155
+ rescue StandardError => primary_error
156
+ raise primary_error unless searxng_configured?
157
+
158
+ begin
159
+ searxng_search_raw(query, time_range: time_range, categories: categories)
160
+ rescue StandardError => fallback_error
161
+ raise Error, "search failed: TinyFish → #{primary_error.message}; " \
162
+ "SearXNG fallback → #{fallback_error.message}"
163
+ end
164
+ end
165
+ end
166
+
167
+ # The SearXNG backend: GET {searxng_url}/search with the metasearch
168
+ # params, parsed into { results:, unresponsive: }. Call directly to
169
+ # bypass the router (and any TinyFish routing/fallback).
170
+ def self.searxng_search_raw(query, time_range: nil, categories: nil)
99
171
  params = { q: query, format: "json" }
100
172
  params[:time_range] = normalize_time_range(time_range) if time_range
101
173
  params[:categories] = normalize_categories(categories) if categories
102
174
  uri = URI("#{searxng_url}/search")
103
175
  uri.query = URI.encode_www_form(params)
104
- retries = max_retries || 0
105
- attempt = 0
106
- begin
107
- attempt += 1
176
+
177
+ with_retries do
108
178
  http = Net::HTTP.new(uri.host, uri.port)
109
179
  http.open_timeout = 5
110
180
  http.read_timeout = 10
@@ -114,11 +184,44 @@ module Ask
114
184
  raise "SearXNG returned #{res.code}: #{res.body}" unless res.code.start_with?("2")
115
185
 
116
186
  parse_response(JSON.parse(res.body))
117
- rescue StandardError
118
- raise if attempt > retries
187
+ end
188
+ end
119
189
 
120
- sleep RETRY_BACKOFF[[attempt - 1, RETRY_BACKOFF.size - 1].min]
121
- retry
190
+ # The TinyFish backend: GET the hosted search API (needs
191
+ # TINYFISH_API_KEY), normalized into the same { results:,
192
+ # unresponsive: } contract — snippet → content; a single hosted
193
+ # API has no per-engine status, so unresponsive is always empty
194
+ # (transport failures raise instead, which the router's fallback
195
+ # catches). time_range maps to recency_minutes; the FIRST
196
+ # recognized categories value maps to domain_type.
197
+ def self.tinyfish_search_raw(query, time_range: nil, categories: nil)
198
+ params = { query: query }
199
+ normalized_range = normalize_time_range(time_range)
200
+ params[:recency_minutes] = TIME_RANGE_TO_MINUTES[normalized_range] if normalized_range
201
+ normalized_categories = normalize_categories(categories)
202
+ if normalized_categories
203
+ domain = CATEGORIES_TO_DOMAIN_TYPE[normalized_categories.split(",").first]
204
+ params[:domain_type] = domain if domain
205
+ end
206
+ uri = URI(TINYFISH_SEARCH_URL)
207
+ uri.query = URI.encode_www_form(params)
208
+
209
+ with_retries do
210
+ http = Net::HTTP.new(uri.host, uri.port)
211
+ http.use_ssl = true
212
+ http.open_timeout = 5
213
+ http.read_timeout = 10
214
+ req = Net::HTTP::Get.new(uri)
215
+ req["X-API-Key"] = ENV["TINYFISH_API_KEY"]
216
+ req["User-Agent"] = "ask-web-search/#{Ask::WebSearch::VERSION}"
217
+ res = http.request(req)
218
+ raise Error, "TinyFish returned #{res.code}: #{res.body[0, 200]}" unless res.code.start_with?("2")
219
+
220
+ data = JSON.parse(res.body)
221
+ results = data.fetch("results", []).map do |r|
222
+ { url: r["url"], title: r["title"], content: r["snippet"] }
223
+ end
224
+ { results: results, unresponsive: [] }
122
225
  end
123
226
  end
124
227
 
@@ -160,6 +263,24 @@ module Ask
160
263
  value.empty? ? nil : value
161
264
  end
162
265
 
266
+ # Retries the block up to max_retries times on any StandardError
267
+ # with exponential backoff (RETRY_BACKOFF). Param building and
268
+ # validation happen OUTSIDE the block in the backends, so an
269
+ # ArgumentError never sleeps and retries.
270
+ def self.with_retries
271
+ attempt = 0
272
+ begin
273
+ attempt += 1
274
+ yield
275
+ rescue StandardError
276
+ raise if attempt > (max_retries || 0)
277
+
278
+ sleep RETRY_BACKOFF[[attempt - 1, RETRY_BACKOFF.size - 1].min]
279
+ retry
280
+ end
281
+ end
282
+ private_class_method :with_retries
283
+
163
284
  # Parses the full SearXNG JSON response into { results:, unresponsive: }.
164
285
  # results are { url:, title:, content: } entries from results and
165
286
  # infoboxes, deduplicated by url. unresponsive is the raw
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-web-search
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -79,10 +79,11 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '13.0'
82
- description: Searches the web via a local SearXNG instance and returns the results
83
- as clean numbered markdown. The capability layer (WebSearch.search); the native
84
- Ask::Tools::WebSearch agent tool is an optional integration that registers when
85
- ask-tools is present. Configure endpoint via SEARXNG_URL env var.
82
+ description: 'Web search returning results as clean numbered markdown. Two backends:
83
+ TinyFish''s hosted API (primary with TINYFISH_API_KEY — no self-hosting) and a local
84
+ SearXNG instance (SEARXNG_URL, also the automatic fallback). The capability layer
85
+ (WebSearch.search); the native Ask::Tools::WebSearch agent tool is an optional integration
86
+ that registers when ask-tools is present.'
86
87
  email:
87
88
  - kaka@myrrlabs.com
88
89
  executables: []