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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56161d32f9534d056405cc0fd382fd6c07be300ae35084e0bea4a5017b1fe353
4
- data.tar.gz: cefb6a830bff2b3998174d2b9f9d75d4bcf42f00958766b55bc9d4679c2d8c17
3
+ metadata.gz: 8843934817bb67da278cc649beebc5beb3ed6af85653209511108f72f06b1da5
4
+ data.tar.gz: c2f2ab3b68106eb47806824b7e8d86153cf934159024535be0c7eb3b3bbc4f5b
5
5
  SHA512:
6
- metadata.gz: 1d0b196f7b4be2b6f0da74c271aa2c2446c5d5d6ec45b16a0b4b9eac2a46abf37e8461895c1f679b3b78041f5d472c6ed6590a6a6347da1e2db636e422485a29
7
- data.tar.gz: fe6766f5f91bd11b82ea6d555c01248bbdbc93c152b657ce042c1011967d75994bfff13dce23730360bdde5eed3c14e28859749764f34c12fee81afaebf04fb2
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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module WebSearch
3
- VERSION = "0.4.0"
3
+ VERSION = "0.6.0"
4
4
  end
5
5
  end
@@ -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
- # ("No results found." when SearXNG found nothing). Raises on
41
- # connection/HTTP failures the caller decides how to surface them.
42
- def self.search(query)
43
- format_results(search_results(query))
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
- def self.search_results(query)
51
- uri = URI("#{searxng_url}/search?q=#{URI.encode_www_form_component(query)}&format=json")
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
- parse_results(JSON.parse(res.body))
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
- def self.parse_results(data)
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
- results.uniq { |r| r[:url] }
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
 
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.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto