Class: Pikuri::Tool::Search::Engines

Inherits:
Object
  • Object
show all
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_ENGINES or PIKURI_LOG.

Returns:

  • (Logger)
Pikuri.logger_for('Engines')
ENGINE_NAMES =

Every engine name accepted in an engines: selection, in the order #providers reports them. Presentation only — #search shuffles.

Returns:

  • (Array<Symbol>)
%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.

Returns:

  • (Hash{Symbol => Symbol})
{ 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.

Returns:

  • (Array<Symbol>)
%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.

Returns:

UrlCache.new(ttl: UrlCache::DEFAULT_TTL, dir: "#{UrlCache::ROOT_DIR}/web_search")

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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+.

Parameters:

  • engines (Array<String, Symbol>, nil) (defaults to: nil)

    engine names; nil derives the set from the keys. See resolve.

  • brave_key (String, nil) (defaults to: nil)

    Brave token.

  • exa_key (String, nil) (defaults to: nil)

    Exa key.

  • cache (UrlCache, #fetch) (defaults to: self.class.cache)

    result store; defaults to cache.

Raises:



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

#providersArray<#search, #label> (readonly)

Returns the selected provider instances, in ENGINE_NAMES order (the cascade shuffles them per call).

Returns:

  • (Array<#search, #label>)

    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

.cacheUrlCache, #fetch

Accessor for CACHE, the constructor's cache: default; specs swap in UrlCache::NULL.

Returns:



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.

Parameters:

  • config (Hash{String, Symbol => Object}, nil)

    the search: block, with String or Symbol keys. nil — what a host reading an absent config key gets — yields an empty Hash, so no caller needs a || {}.

Returns:

Raises:

  • (ArgumentError)

    on a non-Hash, an unknown field, or a key field that is not a String.



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.

Parameters:

  • keyless (Array<Symbol>)

    requested engines with no usable key.

Raises:

  • (ArgumentError)

    unless keyless is empty.



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.

Parameters:

  • names (Array<Symbol>)

    the requested engine names.

Raises:

  • (ArgumentError)

    if any name appears twice (which would put one provider into the cascade twice, weighting the shuffle).



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.

Parameters:

  • names (Array<Symbol>)

    the requested engine names.

Raises:



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.

Parameters:

  • engines (Array<String, Symbol>, nil)

    engine names, or nil to derive the set from the keys.

  • brave_key (String, nil) (defaults to: nil)

    Brave token; blank counts as absent.

  • exa_key (String, nil) (defaults to: nil)

    Exa key; blank counts as absent.

Returns:

  • (Array<Symbol>)

    names drawn from ENGINE_NAMES, in that order.

Raises:

  • (ArgumentError)

    if engines is not an Array, is empty, holds an unknown or repeated name, or names a KEYED_ENGINES engine whose key is blank.



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.

Parameters:

  • query (String)

    search query

  • max_results (Integer)

    maximum result entries

  • cancellable (Pikuri::Agent::Control::Cancellable, nil) (defaults to: Pikuri::Agent::Control::Cancellable::NEVER)

    handed to each provider so its RateLimiter pacing wait is interruptible. A cache hit never reaches a provider, so it never waits at all.

Returns:

  • (String)

    Markdown result list, or "Error: ..." when all providers are exhausted

Raises:

  • (ArgumentError)

    if the query is empty after normalization

  • (Pikuri::Agent::Control::Cancellable::Cancelled)

    if cancellable trips while a provider is pacing. It is not caught as a provider failure — the cascade stops rather than trying the next one, and nothing is cached.



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.message})"
    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.message}"
end