Class: Pikuri::Tool::Search::Exa

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/tool/search/exa.rb

Overview

Performs an Exa search via the official /search endpoint, returning hits as Result rows. Split into a thin HTTP fetch (#search) and a pure parser (.parse) so tests exercise the parser against fixture JSON. Pikuri::Tool::Search::Engines#search owns the final Markdown rendering.

Constructed with its API key (+Exa.new(api_key:)+); Engines builds one only when an Exa key was configured, so users who never registered don't spend money, then drives it through the same +#search+/+#label+ interface as every provider. pikuri reads no key from the environment (CLAUDE.md "Environment is not a secret store"). Paid; key at https://exa.ai.

Requests type: "auto" (Exa picks neural vs keyword) and +contents: { highlights: true }+ so each result carries a neural-ranked snippet populating Result#body — the analog to Brave's description.

Privacy posture

Exa's Privacy Policy uses Query Data to train/fine-tune its models, and ToS §1.2(c) grants a perpetual and irrevocable, sub-licensable license over User Input. Business customers under an MSA/DPA get carve-outs; the default pay-as-you-go key pikuri uses does not. Bottom line: Exa mines queries to train competing models. If a query would be sensitive in a training set, don't configure an Exa key — Pikuri::Tool::Search::Engines#providers leaves Exa out of the cascade unless its key was supplied.

Constant Summary collapse

ENDPOINT =

Returns Search endpoint (POST, JSON body).

Returns:

  • Search endpoint (POST, JSON body)

'https://api.exa.ai/search'
DEFAULT_MAX_RESULTS =

Returns default number of results returned, matching DuckDuckGo::DEFAULT_MAX_RESULTS.

Returns:

10
LIMITER =

Returns Exa is paid and doesn't aggressively throttle, so no minimum interval; the 5-minute cooldown still applies on Pikuri::Tool::Search::Engines::Unavailable so the budget isn't burned on doomed retries.

Returns:

RateLimiter.new(min_interval: 0.0, cooldown: 300.0)

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(api_key:) ⇒ Exa

Returns a new instance of Exa.

Parameters:

  • Exa API key. Required and non-blank: pikuri reads no key from the environment — the host supplies it (Pikuri::Tool::Search::Engines only constructs an Exa when a key was configured).

Raises:

  • if api_key is blank



49
50
51
52
53
# File 'lib/pikuri/tool/search/exa.rb', line 49

def initialize(api_key:)
  raise ArgumentError, 'Exa Search API key is blank' if api_key.to_s.strip.empty?

  @api_key = api_key
end

Class Method Details

.parse(json, max_results: DEFAULT_MAX_RESULTS) ⇒ Array<Result>

Parse an Exa JSON response into Result rows, body the first non-empty highlights snippet. A genuine "no results" payload (+requestId+ present, empty results) returns [] so Pikuri::Tool::Search::Engines#search renders its no-results stub; any other zero-result shape raises.

Parameters:

  • response body from ENDPOINT

  • (defaults to: DEFAULT_MAX_RESULTS)

    max entries

Returns:

  • hits, possibly empty on a recognized empty-results payload

Raises:

  • on an unrecognized zero-result response



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/pikuri/tool/search/exa.rb', line 111

def self.parse(json, max_results: DEFAULT_MAX_RESULTS)
  data = JSON.parse(json)
  results = Array(data['results']).take(max_results).filter_map do |r|
    href = r['url'].to_s
    next nil if href.empty?

    Result.new(
      url: href,
      title: clean(r['title']) || href,
      body: first_highlight(r['highlights'])
    )
  end

  if results.empty?
    return [] if genuine_no_results?(data)

    raise diagnose_empty(data, json)
  end

  results
end

Instance Method Details

#labelString

Returns short provider label for Pikuri::Tool::Search::Engines logging / fallback messages.

Returns:



57
58
59
# File 'lib/pikuri/tool/search/exa.rb', line 57

def label
  'Exa'
end

#search(query, max_results: DEFAULT_MAX_RESULTS, cancellable: Pikuri::Agent::Control::Cancellable::NEVER) ⇒ Array<Result>

Fetch results for query as an Array<Result>. Circuit-broken for 5 minutes on rate-limit/unavailable (see LIMITER). The caller (Pikuri::Tool::Search::Engines#search) normalizes the query and wraps this in a cache.

Parameters:

  • search query (already normalized)

  • (defaults to: DEFAULT_MAX_RESULTS)

    max entries; Exa's numResults

  • (defaults to: Pikuri::Agent::Control::Cancellable::NEVER)

    makes LIMITER's pacing wait interruptible; see RateLimiter#call.

Returns:

  • hits, possibly empty when Exa matched nothing

Raises:

  • on HTTP 429 or 5xx (the cascade falls back from these), or immediately if LIMITER is in cooldown. Other non-2xx (401/403 bad key) bubble up as RuntimeError.

  • for non-rate-limit HTTP failures, or an unrecognized empty-results shape.



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/pikuri/tool/search/exa.rb', line 75

def search(query, max_results: DEFAULT_MAX_RESULTS,
           cancellable: Pikuri::Agent::Control::Cancellable::NEVER)
  LIMITER.call(cancellable: cancellable) do
    response = Faraday.post(ENDPOINT) do |req|
      req.headers['x-api-key'] = @api_key
      req.headers['Content-Type'] = 'application/json'
      req.headers['Accept'] = 'application/json'
      req.body = JSON.dump(
        query: query,
        type: 'auto',
        numResults: max_results,
        contents: { highlights: true }
      )
    end
    unless response.success?
      if response.status == 429 || response.status >= 500
        raise Engines::Unavailable, "HTTP #{response.status}"
      end

      raise "Exa Search request failed: #{response.status} #{response.body}"
    end

    self.class.parse(response.body, max_results: max_results)
  end
end