ask-web-search 0.6.0 → 0.7.0

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: 8843934817bb67da278cc649beebc5beb3ed6af85653209511108f72f06b1da5
4
- data.tar.gz: c2f2ab3b68106eb47806824b7e8d86153cf934159024535be0c7eb3b3bbc4f5b
3
+ metadata.gz: 0e72b7a5d69234d5b2a98512629e00c7887e690abf6cf312f02f24c092f9e464
4
+ data.tar.gz: 37348a846dbf38c41f41df76d52b475d6fb94e29a0c70033b70bc6ca43b743fa
5
5
  SHA512:
6
- metadata.gz: df4ec625002df7c953171cea42654a4b4b5ab10fa365cea9ab149991100913d5b8982d1d14d1204a1a596f00c847dc1cb95714309383cb3496913aa7d8137c78
7
- data.tar.gz: d1212989bbf9cc31e15acd4f43153fff0c57c23038ccb46a4c557955d62d129ed9ecd0cfaf21c92508d1b985cbb5742dd1849cc2dbc235fa6f49b9109ddfd8a8
6
+ metadata.gz: 489ae3316e2c88daf33cd31a478ec6a5e774fcd802a4b0c746577934af5db49fe94de9ffa12f1aa9ccde625e81129bc3d86263c9bc5ba259bf602c047153f046
7
+ data.tar.gz: c99af3305cc4b3417dcfc15bf5f3752d55823fd8e73baac2923431b479ac23cfdee6f3f4d81789a88d2faa9aa3216c95d2c2254dde57dea2f3b5aaaa830728a8
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.0"
3
+ VERSION = "0.7.0"
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,49 @@ 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
+ TINYFISH_SEARCH_URL = "https://api.search.tinyfish.ai/client.search.query"
52
+
53
+ # Our time_range → TinyFish's recency_minutes (SearXNG's month/year
54
+ # are approximate engine filters anyway; these are the equivalents).
55
+ TIME_RANGE_TO_MINUTES = {
56
+ "day" => 1440,
57
+ "week" => 10_080,
58
+ "month" => 43_200,
59
+ "year" => 525_600
60
+ }.freeze
61
+
62
+ # Our categories → TinyFish's single-value domain_type. The FIRST
63
+ # recognized value wins (TinyFish is one-domain-per-query); unknown
64
+ # categories are omitted so TinyFish falls back to its web default.
65
+ CATEGORIES_TO_DOMAIN_TYPE = {
66
+ "general" => "web",
67
+ "web" => "web",
68
+ "news" => "news",
69
+ "science" => "research_paper",
70
+ "research_paper" => "research_paper"
71
+ }.freeze
72
+
73
+ # True when TinyFish should be the primary backend: a key is present
74
+ # and TINYFISH_SEARCH=0 has not disabled it. Read fresh on every
75
+ # call so tests and config reloads don't need memo resets.
76
+ def self.use_tinyfish?
77
+ return false if ENV["TINYFISH_SEARCH"] == "0"
78
+
79
+ !ENV["TINYFISH_API_KEY"].to_s.empty?
80
+ end
81
+
82
+ # True when a SearXNG endpoint was configured explicitly (SEARXNG_URL
83
+ # or searxng_url=). Gates the TinyFish → SearXNG fallback so a failed
84
+ # TinyFish search never probes a default localhost instance that may
85
+ # not exist.
86
+ def self.searxng_configured?
87
+ return true if @searxng_url_set
88
+
89
+ !ENV["SEARXNG_URL"].to_s.empty?
90
+ end
91
+
45
92
  def self.max_retries
46
93
  return @max_retries if defined?(@max_retries)
47
94
 
@@ -89,22 +136,43 @@ module Ask
89
136
  search_raw(query, time_range: time_range, categories: categories)[:results]
90
137
  end
91
138
 
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.
139
+ # The full response: { results: [...], unresponsive: [[name,
140
+ # reason], ...] }. Routes to TinyFish when #use_tinyfish?, falling
141
+ # back to SearXNG (only when explicitly configured) if TinyFish
142
+ # raises; otherwise straight to SearXNG — byte-for-byte the 0.6.x
143
+ # behavior for keyless users. Same retry logic either way, but
144
+ # preserves the engine diagnostics the caller needs to explain an
145
+ # empty result. time_range / categories are normalized by the
146
+ # backends that build requests — idempotent, so callers may pass
147
+ # raw or already-normalized values.
98
148
  def self.search_raw(query, time_range: nil, categories: nil)
149
+ return searxng_search_raw(query, time_range: time_range, categories: categories) unless use_tinyfish?
150
+
151
+ begin
152
+ tinyfish_search_raw(query, time_range: time_range, categories: categories)
153
+ rescue StandardError => primary_error
154
+ raise primary_error unless searxng_configured?
155
+
156
+ begin
157
+ searxng_search_raw(query, time_range: time_range, categories: categories)
158
+ rescue StandardError => fallback_error
159
+ raise Error, "search failed: TinyFish → #{primary_error.message}; " \
160
+ "SearXNG fallback → #{fallback_error.message}"
161
+ end
162
+ end
163
+ end
164
+
165
+ # The SearXNG backend: GET {searxng_url}/search with the metasearch
166
+ # params, parsed into { results:, unresponsive: }. Call directly to
167
+ # bypass the router (and any TinyFish routing/fallback).
168
+ def self.searxng_search_raw(query, time_range: nil, categories: nil)
99
169
  params = { q: query, format: "json" }
100
170
  params[:time_range] = normalize_time_range(time_range) if time_range
101
171
  params[:categories] = normalize_categories(categories) if categories
102
172
  uri = URI("#{searxng_url}/search")
103
173
  uri.query = URI.encode_www_form(params)
104
- retries = max_retries || 0
105
- attempt = 0
106
- begin
107
- attempt += 1
174
+
175
+ with_retries do
108
176
  http = Net::HTTP.new(uri.host, uri.port)
109
177
  http.open_timeout = 5
110
178
  http.read_timeout = 10
@@ -114,11 +182,44 @@ module Ask
114
182
  raise "SearXNG returned #{res.code}: #{res.body}" unless res.code.start_with?("2")
115
183
 
116
184
  parse_response(JSON.parse(res.body))
117
- rescue StandardError
118
- raise if attempt > retries
185
+ end
186
+ end
119
187
 
120
- sleep RETRY_BACKOFF[[attempt - 1, RETRY_BACKOFF.size - 1].min]
121
- retry
188
+ # The TinyFish backend: GET the hosted search API (needs
189
+ # TINYFISH_API_KEY), normalized into the same { results:,
190
+ # unresponsive: } contract — snippet → content; a single hosted
191
+ # API has no per-engine status, so unresponsive is always empty
192
+ # (transport failures raise instead, which the router's fallback
193
+ # catches). time_range maps to recency_minutes; the FIRST
194
+ # recognized categories value maps to domain_type.
195
+ def self.tinyfish_search_raw(query, time_range: nil, categories: nil)
196
+ params = { query: query }
197
+ normalized_range = normalize_time_range(time_range)
198
+ params[:recency_minutes] = TIME_RANGE_TO_MINUTES[normalized_range] if normalized_range
199
+ normalized_categories = normalize_categories(categories)
200
+ if normalized_categories
201
+ domain = CATEGORIES_TO_DOMAIN_TYPE[normalized_categories.split(",").first]
202
+ params[:domain_type] = domain if domain
203
+ end
204
+ uri = URI(TINYFISH_SEARCH_URL)
205
+ uri.query = URI.encode_www_form(params)
206
+
207
+ with_retries do
208
+ http = Net::HTTP.new(uri.host, uri.port)
209
+ http.use_ssl = true
210
+ http.open_timeout = 5
211
+ http.read_timeout = 10
212
+ req = Net::HTTP::Get.new(uri)
213
+ req["X-API-Key"] = ENV["TINYFISH_API_KEY"]
214
+ req["User-Agent"] = "ask-web-search/#{Ask::WebSearch::VERSION}"
215
+ res = http.request(req)
216
+ raise Error, "TinyFish returned #{res.code}: #{res.body[0, 200]}" unless res.code.start_with?("2")
217
+
218
+ data = JSON.parse(res.body)
219
+ results = data.fetch("results", []).map do |r|
220
+ { url: r["url"], title: r["title"], content: r["snippet"] }
221
+ end
222
+ { results: results, unresponsive: [] }
122
223
  end
123
224
  end
124
225
 
@@ -160,6 +261,24 @@ module Ask
160
261
  value.empty? ? nil : value
161
262
  end
162
263
 
264
+ # Retries the block up to max_retries times on any StandardError
265
+ # with exponential backoff (RETRY_BACKOFF). Param building and
266
+ # validation happen OUTSIDE the block in the backends, so an
267
+ # ArgumentError never sleeps and retries.
268
+ def self.with_retries
269
+ attempt = 0
270
+ begin
271
+ attempt += 1
272
+ yield
273
+ rescue StandardError
274
+ raise if attempt > (max_retries || 0)
275
+
276
+ sleep RETRY_BACKOFF[[attempt - 1, RETRY_BACKOFF.size - 1].min]
277
+ retry
278
+ end
279
+ end
280
+ private_class_method :with_retries
281
+
163
282
  # Parses the full SearXNG JSON response into { results:, unresponsive: }.
164
283
  # results are { url:, title:, content: } entries from results and
165
284
  # 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.0
4
+ version: 0.7.0
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: []