ask-web-search 0.4.0 → 0.6.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 +4 -4
- data/README.md +32 -0
- data/lib/ask/web_search/tool.rb +13 -3
- data/lib/ask/web_search/version.rb +1 -1
- data/lib/ask/web_search.rb +114 -11
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8843934817bb67da278cc649beebc5beb3ed6af85653209511108f72f06b1da5
|
|
4
|
+
data.tar.gz: c2f2ab3b68106eb47806824b7e8d86153cf934159024535be0c7eb3b3bbc4f5b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: df4ec625002df7c953171cea42654a4b4b5ab10fa365cea9ab149991100913d5b8982d1d14d1204a1a596f00c847dc1cb95714309383cb3496913aa7d8137c78
|
|
7
|
+
data.tar.gz: d1212989bbf9cc31e15acd4f43153fff0c57c23038ccb46a4c557955d62d129ed9ecd0cfaf21c92508d1b985cbb5742dd1849cc2dbc235fa6f49b9109ddfd8a8
|
data/README.md
CHANGED
|
@@ -61,6 +61,38 @@ Results are returned as a numbered markdown-like string:
|
|
|
61
61
|
|
|
62
62
|
If no results are found, returns `"No results found."`.
|
|
63
63
|
|
|
64
|
+
## Freshness windows and verticals
|
|
65
|
+
|
|
66
|
+
`search` (and `search_results` / `search_raw`, and both tool framings)
|
|
67
|
+
accept two optional parameters:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
Ask::WebSearch.search("ruby 4.0 release notes", time_range: "month")
|
|
71
|
+
Ask::WebSearch.search("fed policy", categories: "news")
|
|
72
|
+
Ask::WebSearch.search("retrieval augmented generation", categories: "science")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **`time_range:`** restricts results to a SearXNG freshness window —
|
|
76
|
+
`day`, `week`, `month`, or `year`. Anything else raises `ArgumentError`
|
|
77
|
+
naming the valid values. When a windowed search comes back cleanly
|
|
78
|
+
empty, the result says so — "No results found within the day freshness
|
|
79
|
+
window. Retry with a broader time_range or without one." — instead of a
|
|
80
|
+
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
|
|
94
|
+
engines.
|
|
95
|
+
|
|
64
96
|
## Full documentation
|
|
65
97
|
|
|
66
98
|
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs.
|
data/lib/ask/web_search/tool.rb
CHANGED
|
@@ -24,13 +24,23 @@ module Ask
|
|
|
24
24
|
params(
|
|
25
25
|
type: "object",
|
|
26
26
|
properties: {
|
|
27
|
-
query: { type: "string", description: "The search query" }
|
|
27
|
+
query: { type: "string", description: "The search query" },
|
|
28
|
+
time_range: {
|
|
29
|
+
type: "string",
|
|
30
|
+
enum: Ask::WebSearch::TIME_RANGES,
|
|
31
|
+
description: "Freshness window: only results from the past day, week, month, or year. Use for recency-sensitive queries (news, prices, releases); omit otherwise."
|
|
32
|
+
},
|
|
33
|
+
categories: {
|
|
34
|
+
type: "string",
|
|
35
|
+
enum: %w[general news science],
|
|
36
|
+
description: "Vertical to search: general web results (default), news, or science (research papers). Use news for current events, science for papers."
|
|
37
|
+
}
|
|
28
38
|
},
|
|
29
39
|
required: ["query"]
|
|
30
40
|
)
|
|
31
41
|
|
|
32
|
-
def execute(query:)
|
|
33
|
-
Ask::Result.ok(data: Ask::WebSearch.search(query))
|
|
42
|
+
def execute(query:, time_range: nil, categories: nil)
|
|
43
|
+
Ask::Result.ok(data: Ask::WebSearch.search(query, time_range: time_range, categories: categories))
|
|
34
44
|
end
|
|
35
45
|
end
|
|
36
46
|
end
|
data/lib/ask/web_search.rb
CHANGED
|
@@ -12,6 +12,16 @@ module Ask
|
|
|
12
12
|
# framing — name, parameter schema, result wrapping — lives with the
|
|
13
13
|
# consumers (the MCP server, the agents) that call this library.
|
|
14
14
|
module WebSearch
|
|
15
|
+
# Search errors that carry engine diagnostics — the agent needs to
|
|
16
|
+
# know *which* engines failed and *why* (CAPTCHA, timeout, suspended)
|
|
17
|
+
# instead of just "No results found."
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
# SearXNG answered but every engine failed (CAPTCHA, timeout,
|
|
21
|
+
# suspended). The message lists which engines failed and why, plus
|
|
22
|
+
# what the agent can try next.
|
|
23
|
+
class AllEnginesFailedError < Error; end
|
|
24
|
+
|
|
15
25
|
# Local SearXNG endpoint. Defaults to the standard local instance;
|
|
16
26
|
# override with SEARXNG_URL or WebSearch.searxng_url=.
|
|
17
27
|
def self.searxng_url
|
|
@@ -26,6 +36,12 @@ module Ask
|
|
|
26
36
|
DEFAULT_MAX_RETRIES = 3
|
|
27
37
|
RETRY_BACKOFF = [0.5, 1.0, 2.0].freeze
|
|
28
38
|
|
|
39
|
+
# SearXNG's freshness windows — the valid values of its time_range
|
|
40
|
+
# search parameter. Anything else is rejected up front, so a bad value
|
|
41
|
+
# fails with a message listing these instead of silently searching
|
|
42
|
+
# unfiltered.
|
|
43
|
+
TIME_RANGES = %w[day week month year].freeze
|
|
44
|
+
|
|
29
45
|
def self.max_retries
|
|
30
46
|
return @max_retries if defined?(@max_retries)
|
|
31
47
|
|
|
@@ -36,19 +52,55 @@ module Ask
|
|
|
36
52
|
@max_retries = val
|
|
37
53
|
end
|
|
38
54
|
|
|
39
|
-
# Searches +query+ and returns the results as numbered markdown
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
55
|
+
# Searches +query+ and returns the results as numbered markdown.
|
|
56
|
+
# "No results found." only when SearXNG answered cleanly with zero
|
|
57
|
+
# results and no engine failures. When engines failed, raises
|
|
58
|
+
# AllEnginesFailedError with per-engine diagnostics (see
|
|
59
|
+
# #format_engine_error). Raises on connection/HTTP failures — the
|
|
60
|
+
# caller decides how to surface them.
|
|
61
|
+
#
|
|
62
|
+
# time_range: restricts results to a freshness window — one of
|
|
63
|
+
# TIME_RANGES (day, week, month, year). With a window set, a clean
|
|
64
|
+
# zero-result search says so ("No results found within the day
|
|
65
|
+
# freshness window...") instead of a bare "No results found.", so the
|
|
66
|
+
# agent widens the window rather than concluding the web is silent.
|
|
67
|
+
# categories: scopes the search to a SearXNG vertical — e.g. "news"
|
|
68
|
+
# or "science" (research papers); instances configure their own set,
|
|
69
|
+
# so values pass through unvalidated. A string or anything Array-able
|
|
70
|
+
# ("news,science" form).
|
|
71
|
+
def self.search(query, time_range: nil, categories: nil)
|
|
72
|
+
time_range = normalize_time_range(time_range)
|
|
73
|
+
categories = normalize_categories(categories)
|
|
74
|
+
response = search_raw(query, time_range: time_range, categories: categories)
|
|
75
|
+
raise AllEnginesFailedError, format_engine_error(query, response, time_range: time_range) if response[:results].empty? &&
|
|
76
|
+
response[:unresponsive].any?
|
|
77
|
+
|
|
78
|
+
return format_results(response[:results]) unless response[:results].empty? && time_range
|
|
79
|
+
|
|
80
|
+
"No results found within the #{time_range} freshness window. Retry with a broader time_range or without one."
|
|
44
81
|
end
|
|
45
82
|
|
|
46
83
|
# The raw result list: { url:, title:, content: } entries from the
|
|
47
84
|
# results and infoboxes, deduplicated by url. Retries up to
|
|
48
85
|
# max_retries times on connection/HTTP failures with exponential
|
|
49
|
-
# backoff. Set max_retries to 0 to disable.
|
|
50
|
-
|
|
51
|
-
|
|
86
|
+
# backoff. Set max_retries to 0 to disable. See #search for
|
|
87
|
+
# time_range / categories.
|
|
88
|
+
def self.search_results(query, time_range: nil, categories: nil)
|
|
89
|
+
search_raw(query, time_range: time_range, categories: categories)[:results]
|
|
90
|
+
end
|
|
91
|
+
|
|
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.
|
|
98
|
+
def self.search_raw(query, time_range: nil, categories: nil)
|
|
99
|
+
params = { q: query, format: "json" }
|
|
100
|
+
params[:time_range] = normalize_time_range(time_range) if time_range
|
|
101
|
+
params[:categories] = normalize_categories(categories) if categories
|
|
102
|
+
uri = URI("#{searxng_url}/search")
|
|
103
|
+
uri.query = URI.encode_www_form(params)
|
|
52
104
|
retries = max_retries || 0
|
|
53
105
|
attempt = 0
|
|
54
106
|
begin
|
|
@@ -61,7 +113,7 @@ module Ask
|
|
|
61
113
|
res = http.request(req)
|
|
62
114
|
raise "SearXNG returned #{res.code}: #{res.body}" unless res.code.start_with?("2")
|
|
63
115
|
|
|
64
|
-
|
|
116
|
+
parse_response(JSON.parse(res.body))
|
|
65
117
|
rescue StandardError
|
|
66
118
|
raise if attempt > retries
|
|
67
119
|
|
|
@@ -70,7 +122,50 @@ module Ask
|
|
|
70
122
|
end
|
|
71
123
|
end
|
|
72
124
|
|
|
73
|
-
|
|
125
|
+
# Formats the engine failure into an actionable message: which engines
|
|
126
|
+
# failed and why, plus what the agent can try next. Keeps the raw
|
|
127
|
+
# SearXNG reason strings (CAPTCHA, timeout, suspended) — the caller
|
|
128
|
+
# can see exactly what happened. When a freshness window was set, the
|
|
129
|
+
# hint suggests broadening it.
|
|
130
|
+
def self.format_engine_error(query, response, time_range: nil)
|
|
131
|
+
lines = ["No search results for #{query.inspect} — all #{response[:unresponsive].size} engine(s) failed:"]
|
|
132
|
+
response[:unresponsive].each do |name, reason|
|
|
133
|
+
lines << "- #{name}: #{reason}"
|
|
134
|
+
end
|
|
135
|
+
hint = "Try: a simpler query, or ask-web-fetch for a known URL."
|
|
136
|
+
hint = "Try: a simpler query, a broader time_range, or ask-web-fetch for a known URL." if time_range
|
|
137
|
+
lines << hint
|
|
138
|
+
lines.join("\n")
|
|
139
|
+
end
|
|
140
|
+
private_class_method :format_engine_error
|
|
141
|
+
|
|
142
|
+
# Validates +time_range+ against TIME_RANGES (nil/blank → nil, symbols
|
|
143
|
+
# accepted). Raises ArgumentError for anything else — the request is
|
|
144
|
+
# never sent.
|
|
145
|
+
def self.normalize_time_range(time_range)
|
|
146
|
+
value = time_range.to_s.strip.downcase
|
|
147
|
+
return nil if value.empty?
|
|
148
|
+
|
|
149
|
+
raise ArgumentError, "invalid time_range #{time_range.inspect} — use one of: #{TIME_RANGES.join(', ')}" unless TIME_RANGES.include?(value)
|
|
150
|
+
|
|
151
|
+
value
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Normalizes +categories+ to SearXNG's comma-separated form —
|
|
155
|
+
# ["news", :science] → "news,science"; nil/blank → nil. Values pass
|
|
156
|
+
# through unvalidated: SearXNG instances configure their own category
|
|
157
|
+
# set, and a bad one degrades to a clean empty result, not a failure.
|
|
158
|
+
def self.normalize_categories(categories)
|
|
159
|
+
value = Array(categories).map { |c| c.to_s.strip.downcase }.reject(&:empty?).uniq.join(",")
|
|
160
|
+
value.empty? ? nil : value
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Parses the full SearXNG JSON response into { results:, unresponsive: }.
|
|
164
|
+
# results are { url:, title:, content: } entries from results and
|
|
165
|
+
# infoboxes, deduplicated by url. unresponsive is the raw
|
|
166
|
+
# [[engine_name, reason], ...] list SearXNG returns for failed
|
|
167
|
+
# engines (e.g. [["duckduckgo", "CAPTCHA"], ["mojeek", "timeout"]]).
|
|
168
|
+
def self.parse_response(data)
|
|
74
169
|
results = []
|
|
75
170
|
data.fetch("results", []).each do |r|
|
|
76
171
|
results << { url: r["url"], title: r["title"], content: r["content"] }
|
|
@@ -78,7 +173,15 @@ module Ask
|
|
|
78
173
|
data.fetch("infoboxes", []).each do |ib|
|
|
79
174
|
results << { url: ib["id"], title: ib["infobox"], content: ib["content"] }
|
|
80
175
|
end
|
|
81
|
-
|
|
176
|
+
{
|
|
177
|
+
results: results.uniq { |r| r[:url] },
|
|
178
|
+
unresponsive: Array(data.fetch("unresponsive_engines", []))
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
private_class_method :parse_response
|
|
182
|
+
|
|
183
|
+
def self.parse_results(data)
|
|
184
|
+
parse_response(data)[:results]
|
|
82
185
|
end
|
|
83
186
|
private_class_method :parse_results
|
|
84
187
|
|