ask-web-search 0.5.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: d8bd1c41d870cf5aa9f13332ba6b2d43fbcf6d6bbebb13e5390f2d9e8c0f6529
4
- data.tar.gz: b1cc5035874cd122e9d4a8d1c4feec1bc1d146d12842714cbfe4672c43a4dba6
3
+ metadata.gz: 8843934817bb67da278cc649beebc5beb3ed6af85653209511108f72f06b1da5
4
+ data.tar.gz: c2f2ab3b68106eb47806824b7e8d86153cf934159024535be0c7eb3b3bbc4f5b
5
5
  SHA512:
6
- metadata.gz: b80273643aa1f7e4a875832dcef9bfec8a2df3886af9be0f92651f55e8b6873364b59586a9bfa9e6ba5eb95394b686425ca7e7032ad69677ba1fab085fb42498
7
- data.tar.gz: abae84930f02b3adbe06a136b676a79bb8e3ae2416aa7c94fd94da2d22862672ae35818b92434cb03a163e7a2770f63bfcfd7d10766a447a5f4de97fdf35a57d
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.5.0"
3
+ VERSION = "0.6.0"
4
4
  end
5
5
  end
@@ -36,6 +36,12 @@ module Ask
36
36
  DEFAULT_MAX_RETRIES = 3
37
37
  RETRY_BACKOFF = [0.5, 1.0, 2.0].freeze
38
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
+
39
45
  def self.max_retries
40
46
  return @max_retries if defined?(@max_retries)
41
47
 
@@ -52,27 +58,49 @@ module Ask
52
58
  # AllEnginesFailedError with per-engine diagnostics (see
53
59
  # #format_engine_error). Raises on connection/HTTP failures — the
54
60
  # caller decides how to surface them.
55
- def self.search(query)
56
- response = search_raw(query)
57
- raise AllEnginesFailedError, format_engine_error(query, response) if response[:results].empty? &&
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? &&
58
76
  response[:unresponsive].any?
59
77
 
60
- format_results(response[:results])
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."
61
81
  end
62
82
 
63
83
  # The raw result list: { url:, title:, content: } entries from the
64
84
  # results and infoboxes, deduplicated by url. Retries up to
65
85
  # max_retries times on connection/HTTP failures with exponential
66
- # backoff. Set max_retries to 0 to disable.
67
- def self.search_results(query)
68
- search_raw(query)[:results]
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]
69
90
  end
70
91
 
71
92
  # The full SearXNG response: { results: [...], unresponsive: [[name,
72
93
  # reason], ...] }. Same retry logic as #search_results, but preserves
73
94
  # the engine diagnostics the caller needs to explain an empty result.
74
- def self.search_raw(query)
75
- uri = URI("#{searxng_url}/search?q=#{URI.encode_www_form_component(query)}&format=json")
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)
76
104
  retries = max_retries || 0
77
105
  attempt = 0
78
106
  begin
@@ -97,17 +125,41 @@ module Ask
97
125
  # Formats the engine failure into an actionable message: which engines
98
126
  # failed and why, plus what the agent can try next. Keeps the raw
99
127
  # SearXNG reason strings (CAPTCHA, timeout, suspended) — the caller
100
- # can see exactly what happened.
101
- def self.format_engine_error(query, response)
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)
102
131
  lines = ["No search results for #{query.inspect} — all #{response[:unresponsive].size} engine(s) failed:"]
103
132
  response[:unresponsive].each do |name, reason|
104
133
  lines << "- #{name}: #{reason}"
105
134
  end
106
- lines << "Try: a simpler query, or ask-web-fetch for a known URL."
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
107
138
  lines.join("\n")
108
139
  end
109
140
  private_class_method :format_engine_error
110
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
+
111
163
  # Parses the full SearXNG JSON response into { results:, unresponsive: }.
112
164
  # results are { url:, title:, content: } entries from results and
113
165
  # 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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto