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 +4 -4
- data/README.md +92 -31
- data/lib/ask/web_search/version.rb +1 -1
- data/lib/ask/web_search.rb +140 -19
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 254d229de3235963082784b2300a04a5c6a9fad33df6d733fa511bc51fa59d8e
|
|
4
|
+
data.tar.gz: e2fca871ad9b9ce5b7ca3f1b4407514f267bfda66658f2219c1b906812a9f4f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4cd05c99d6ff688497fe27bca0215db4d5f68f4607ed07c3880447f44376d9b8294a149483447095130854bf5ad50418edf379b7b408c9655aca5acd48ab03de
|
|
7
|
+
data.tar.gz: 4781a15d45be0e99cb27c4185255b06272bb397a21d3403f854c779ecfcca2a5b0181614c23f545831407721223c13089d1bf1bb2c89d0c1a9c9594735566d33
|
data/README.md
CHANGED
|
@@ -3,23 +3,45 @@
|
|
|
3
3
|
[](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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
##
|
|
10
|
+
## Backends
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
Two interchangeable backends, chosen automatically at call time:
|
|
14
13
|
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
76
|
-
`
|
|
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.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
data/lib/ask/web_search.rb
CHANGED
|
@@ -6,11 +6,14 @@ require "json"
|
|
|
6
6
|
require_relative "web_search/version"
|
|
7
7
|
|
|
8
8
|
module Ask
|
|
9
|
-
# Searches the web
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
|
93
|
-
# reason], ...] }.
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
118
|
-
|
|
187
|
+
end
|
|
188
|
+
end
|
|
119
189
|
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
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:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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: []
|