Class: Pikuri::Tool::Search::Engines
- Inherits:
-
Object
- Object
- Pikuri::Tool::Search::Engines
- Defined in:
- lib/pikuri/tool/search/engines.rb
Overview
Search-orchestration object: the cascade across selected providers, the result cache, and the Unavailable marker the cascade falls back on. WebSearch.build constructs one and wires its #search into a Pikuri::Tool. Each provider raises Unavailable to hand off to the next.
Engines.new(engines: [:brave], brave_key: 'BSA...') # Brave alone
Engines.new(brave_key: 'BSA...') # DuckDuckGo + Brave
Engines.new # DuckDuckGo alone
Engines.resolve owns which engines that yields and which selections are
refused; Engines.config_from_h maps a config file's search: block onto
these keywords.
Provider keys are constructor config, not environment
Brave and Exa are paid and need a key; DuckDuckGo needs none. An
Engines is built with the keys it should use (+brave_key:+ /
exa_key:, both optional) — pikuri reads no key from the environment,
so a host supplies them. See CLAUDE.md "Environment is not a secret
store".
Defined Under Namespace
Classes: Unavailable
Constant Summary collapse
- LOGGER =
Subsystem logger; level via
PIKURI_LOG_ENGINESorPIKURI_LOG. Pikuri.logger_for('Engines')
- ENGINE_NAMES =
Every engine name accepted in an
engines:selection, in the order #providers reports them. Presentation only — #search shuffles. %i[duckduckgo brave exa].freeze
- KEYED_ENGINES =
Engine name → the constructor keyword carrying its API key. Naming one of these without its key is a refusal (resolve); DuckDuckGo is absent because it needs none.
{ brave: :brave_key, exa: :exa_key }.freeze
- CONFIG_FIELDS =
The fields config_from_h accepts from a config file's
search:block, matching this class's keywords. %i[engines brave_key exa_key].freeze
- CACHE =
Process-shared on-disk cache backing #search, at class level so every engine dedupes into one directory; the constructor's
cache:injects a different store for tests. UrlCache.new(ttl: UrlCache::DEFAULT_TTL, dir: "#{UrlCache::ROOT_DIR}/web_search")
Instance Attribute Summary collapse
-
#providers ⇒ Array<#search, #label>
readonly
The selected provider instances, in ENGINE_NAMES order (the cascade shuffles them per call).
Class Method Summary collapse
-
.cache ⇒ UrlCache, #fetch
Accessor for CACHE, the constructor's
cache:default; specs swap in UrlCache::NULL. -
.config_from_h(config) ⇒ Hash{Symbol => Object}
Map a config file's
search:block onto this class's keywords, so a host hands over what it read:. - .refuse_keyless(keyless) ⇒ void
- .refuse_repeats(names) ⇒ void
- .refuse_unknown(names) ⇒ void
-
.resolve(engines:, brave_key: nil, exa_key: nil) ⇒ Array<Symbol>
Resolve an
engines:selection into the engine names to build, refusing a selection that cannot work:.
Instance Method Summary collapse
-
#initialize(engines: nil, brave_key: nil, exa_key: nil, cache: self.class.cache) ⇒ Engines
constructor
Builds the provider cascade once, from the engines Engines.resolve selects.
-
#search(query, max_results:, cancellable: Pikuri::Agent::Control::Cancellable::NEVER) ⇒ String
Run
querythrough the providers in random order, falling back on each Unavailable.
Constructor Details
#initialize(engines: nil, brave_key: nil, exa_key: nil, cache: self.class.cache) ⇒ Engines
Builds the provider cascade once, from the engines resolve selects. From here each provider is just an object answering +#search+/+#label+.
213 214 215 216 217 218 219 220 221 222 223 |
# File 'lib/pikuri/tool/search/engines.rb', line 213 def initialize(engines: nil, brave_key: nil, exa_key: nil, cache: self.class.cache) @providers = self.class.resolve(engines: engines, brave_key: brave_key, exa_key: exa_key).map do |name| case name when :duckduckgo then DuckDuckGo.new when :brave then Brave.new(api_key: brave_key) when :exa then Exa.new(api_key: exa_key) end end @cache = cache @last_logged_providers = nil end |
Instance Attribute Details
#providers ⇒ Array<#search, #label> (readonly)
Returns the selected provider instances, in ENGINE_NAMES order (the cascade shuffles them per call).
227 228 229 |
# File 'lib/pikuri/tool/search/engines.rb', line 227 def providers @providers end |
Class Method Details
.cache ⇒ UrlCache, #fetch
Accessor for CACHE, the constructor's cache: default; specs swap in
UrlCache::NULL.
70 71 72 |
# File 'lib/pikuri/tool/search/engines.rb', line 70 def self.cache CACHE end |
.config_from_h(config) ⇒ Hash{Symbol => Object}
Map a config file's search: block onto this class's keywords, so a
host hands over what it read:
# ~/.pikuri-examples-config.yaml
search:
brave_key: BSA...
exa_key: xyz
engines: [brave, duckduckgo]
Tool::WebSearch.from_h(YAML.safe_load_file(path)['search'])
Nothing here knows what YAML is; a host reading TOML, JSON or its own settings object uses the same method. Every field is validated rather than coerced, because the person writing a config file cannot type-check it — and a mistyped engine name that silently meant "no Brave" would show up only as worse search results.
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/pikuri/tool/search/engines.rb', line 177 def self.config_from_h(config) return {} if config.nil? unless config.is_a?(Hash) raise ArgumentError, 'search config: expected a Hash of ' \ "#{CONFIG_FIELDS.join(', ')}, got #{config.class}" end fields = config.transform_keys(&:to_sym) unknown = fields.keys - CONFIG_FIELDS unless unknown.empty? raise ArgumentError, "search config: unknown field(s) #{unknown.join(', ')} — " \ "known fields are #{CONFIG_FIELDS.join(', ')}" end KEYED_ENGINES.each_value do |field| value = fields[field] next if value.nil? || value.is_a?(String) # The class, never the value: an error message about a key field is # one paste away from a bug report. raise ArgumentError, "search config: #{field} must be a String, got #{value.class}" end fields end |
.refuse_keyless(keyless) ⇒ void
This method returns an undefined value.
145 146 147 148 149 150 |
# File 'lib/pikuri/tool/search/engines.rb', line 145 def self.refuse_keyless(keyless) return if keyless.empty? needed = keyless.map { |name| "#{name} needs #{KEYED_ENGINES[name]}" }.join(', ') raise ArgumentError, "search engines: #{needed} — supply the key or drop the engine from the list" end |
.refuse_repeats(names) ⇒ void
This method returns an undefined value.
135 136 137 138 139 140 |
# File 'lib/pikuri/tool/search/engines.rb', line 135 def self.refuse_repeats(names) repeated = names.tally.select { |_, count| count > 1 }.keys return if repeated.empty? raise ArgumentError, "search engines: #{repeated.join(', ')} listed more than once" end |
.refuse_unknown(names) ⇒ void
This method returns an undefined value.
123 124 125 126 127 128 129 |
# File 'lib/pikuri/tool/search/engines.rb', line 123 def self.refuse_unknown(names) unknown = names - ENGINE_NAMES return if unknown.empty? raise ArgumentError, "search engines: unknown engine(s) #{unknown.join(', ')} — " \ "known engines are #{ENGINE_NAMES.join(', ')}" end |
.resolve(engines:, brave_key: nil, exa_key: nil) ⇒ Array<Symbol>
Resolve an engines: selection into the engine names to build,
refusing a selection that cannot work:
resolve(engines: nil, brave_key: 'B') # => [:duckduckgo, :brave]
resolve(engines: nil) # => [:duckduckgo]
resolve(engines: %w[brave], brave_key: 'B') # => [:brave]
resolve(engines: [:exa], brave_key: 'B') # raises — exa has no key
nil means "whatever the keys say": DuckDuckGo plus every engine
whose key is present, which is what a host that never heard of
engines: gets. An explicit list is a set — it is normalized to
ENGINE_NAMES order and #search shuffles it per call anyway, so
[brave, duckduckgo] expresses no preference for Brave. Names are
matched case-insensitively.
Supplying a key without listing its engine is fine — parking a key you are not using today is not a mistake.
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/pikuri/tool/search/engines.rb', line 100 def self.resolve(engines:, brave_key: nil, exa_key: nil) keys = { brave: brave_key, exa: exa_key } keyless = ->(name) { KEYED_ENGINES.key?(name) && keys[name].to_s.strip.empty? } return ENGINE_NAMES.reject(&keyless) if engines.nil? unless engines.is_a?(Array) && !engines.empty? # A list of engine names is safe to echo; anything else might be a # key the writer put in the wrong field, so show only its class. got = engines.is_a?(Array) ? engines.inspect : engines.class raise ArgumentError, 'search engines: expected a non-empty list of engine names ' \ "(known: #{ENGINE_NAMES.join(', ')}), got #{got}" end names = engines.map { |name| name.to_s.strip.downcase.to_sym } refuse_unknown(names) refuse_repeats(names) refuse_keyless(names.select(&keyless)) ENGINE_NAMES & names end |
Instance Method Details
#search(query, max_results:, cancellable: Pikuri::Agent::Control::Cancellable::NEVER) ⇒ String
Run query through the providers in random order, falling back on each
Unavailable. The shuffle spreads load so one provider isn't always
hit (and exhausted) first. The query is whitespace-normalized first;
the winning provider's Array<Result> is rendered to Markdown and
cached on disk keyed by the cleaned query (a hit short-circuits the
cascade). max_results is not in the cache key, so a non-default
value may return a previously-cached size.
If every provider is unavailable, returns an "Error: ..." string
(the tool convention) rather than raising. Any non-Unavailable
exception (network, parser, bad key) bubbles up.
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 |
# File 'lib/pikuri/tool/search/engines.rb', line 253 def search(query, max_results:, cancellable: Pikuri::Agent::Control::Cancellable::NEVER) cleaned = query.to_s.strip.gsub(/\s+/, ' ') raise ArgumentError, 'query is empty' if cleaned.empty? current_providers = providers log_providers(current_providers) hit = true result = @cache.fetch(cleaned) do hit = false failures = [] results = nil chosen = nil current_providers.shuffle.each do |provider| results = provider.search(cleaned, max_results: max_results, cancellable: cancellable) chosen = provider break rescue Unavailable => e failures << "#{provider.label} (#{e.})" end # Raise so {UrlCache#fetch} does NOT persist the all-unavailable # message — else it would block every future search for this query # until the TTL expires. The outer rescue turns it into "Error: …". chosen or raise Unavailable, "all search providers temporarily unavailable: #{failures.join('; ')}" LOGGER.info do "engine=#{chosen.label} query=#{cleaned.inspect} results=#{results.size}" end render(results) end LOGGER.info { "cache=hit query=#{cleaned.inspect} bytes=#{result.bytesize}" } if hit result rescue Unavailable => e "Error: #{e.}" end |