feather-ai 0.3.1 → 0.5.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: 9f2195b84828584d6764f60823b99fbbbe9961c1c0ebee3d26679b6450a0dd53
4
- data.tar.gz: '008c505376ab82635e2a8fdae0475a3adab609c5b9bef588f6a48573c238036c'
3
+ metadata.gz: fa4e06ced9d8d48c533d41f97148fba4c26f3ffa478a603c4562960d50e6594f
4
+ data.tar.gz: 6f04ec946376b964fbf0a049a7837f20bae6eab45690e802b22e4f03b3789bc3
5
5
  SHA512:
6
- metadata.gz: be14496f9c58080371192aa146f521178f563433e9bb28e4ecd720c3f802e4264e3a5efef7281813e6a1f18d2e03998eb69e4279dee5c156052879cb2510ed2b
7
- data.tar.gz: 14f4968e7363d889e2d0590b675141ec4868385cd58138fbb4de4910acabe61385a14b079136ec0c10d93ba709b2a656de76e2e39bc50016b9faa98672134627
6
+ metadata.gz: 943e45ed67690b5791d953ff443e3d94ba9b1866f1143373ca08dc2ef59826720cbdd4a34eeb6d91cf8d2a626822738e068a9cae4750f92fbe8a4aa2a91b2cb7
7
+ data.tar.gz: 903b3b7968b1ebebc411e5e8c518649ca8b5cf617461cb630725b861a3dfaba8eed7e914b01803ff5b65c05d92d1806ac6607e4cc5ce235db5f74a9ed59d0d9e
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.5.0 - 2026-10-02
4
+
5
+ - **Breaking:** requires `ruby_llm` 2.x (fixes the ReDoS advisory in 1.x). Dev dependencies updated to clear all `bundle-audit` findings (rubocop no longer pulls in `mcp`).
6
+ - `Result#cost` now comes from RubyLLM's per-model pricing registry instead of a hardcoded Anthropic-only table, so Haiku and non-Anthropic models are priced correctly; `nil` when the registry has no price.
7
+ - `config.provider` is now a no-op kept for compatibility; RubyLLM 2 resolves the provider from the model.
8
+ - Fix: `media_resolution` (a Gemini `generationConfig` field) is only sent to Gemini models. It was previously sent to every provider, which Anthropic rejects.
9
+ - Fix: audio transcripts are now interpolated as text; previously the `RubyLLM::Transcription` object was interpolated.
10
+
11
+ ## 0.4.0 - 2026-07-05
12
+
13
+ - Identification now returns ranked `candidates` (up to 3, each `{common_name:, species:, score:}`) on every `Result`, populated straight from the structured-output schema.
14
+ - New `tools:` keyword on `FeatherAi.identify` (and `FeatherAi.configure { |c| c.tools = [...] }`) forwards RubyLLM tools to the chat, so identification can ground itself in real data (e.g. a species/region lookup backed by your app's database).
15
+ - **Breaking:** consensus disagreement `candidates` changed from an array of `Result` objects to the same ranked-hash shape, scored by vote share.
16
+ - `acts_as_sighting`'s `identify!` persists `candidates` when the table has a `candidates` column (new migrations add it as `jsonb`); existing tables without the column are unaffected.
17
+ - Default models bumped to `claude-sonnet-4-5` / `claude-haiku-4-5` (Anthropic structured outputs require Sonnet 4.5+; the old `claude-haiku-4` id no longer resolves).
18
+
19
+ Note: on Gemini models, combining a response schema with tools is rejected by the API on many models — the `tools:` option is tested against the Anthropic defaults.
data/CLAUDE.md CHANGED
@@ -42,35 +42,51 @@ bundle exec rake release
42
42
 
43
43
  ## Architecture
44
44
 
45
- The gem's only runtime dependency is `ruby_llm`. All classes live under the `FeatherAi` module in `lib/feather_ai/`.
45
+ The gem's only runtime dependency is `ruby_llm` 2.x (which pulls in `schematist` for the schema DSL). All classes live under the `FeatherAi` module in `lib/feather_ai/`.
46
46
 
47
47
  ### Data Flow
48
48
 
49
- `FeatherAi.identify(image, audio, location:, consensus:)` is the top-level entry point defined in `lib/feather_ai.rb`. It delegates to:
49
+ `FeatherAi.identify(image, audio, location:, consensus:)` is the top-level entry point defined in `lib/feather_ai.rb`. `image` accepts a single path or an array of paths (multiple angles of the same bird). It delegates to:
50
50
 
51
- 1. **Identifier** (`lib/feather_ai/identifier.rb`) — Core identification logic. Uses RubyLLM's vision for images and `RubyLLM.transcribe` for audio. When both inputs are provided, they're combined into a single multi-modal prompt. Uses `RubyLLM::Schema` for structured output so results are always clean `Result` objects, not raw LLM prose.
51
+ 1. **Identifier** (`lib/feather_ai/identifier.rb`) — Core identification logic. Uses RubyLLM's vision for images and `RubyLLM.transcribe` for audio. When both inputs are provided, they're combined into a single multi-modal prompt. Uses `Schematist::Schema` for structured output (including a `reasoning` field that forces step-by-step visual analysis before the identification); the parsed Hash is read via `response.parsed`, tokens via `response.tokens`, and USD cost via `response.cost.total` (RubyLLM's pricing registry). `config.media_resolution` is only sent to Gemini models.
52
52
 
53
- 2. **Consensus** (`lib/feather_ai/consensus.rb`) — When `consensus: true`, runs identification through two configurable models independently. If they agree on species, returns `confident: true`. If they disagree, returns both as `candidates` with uncertain confidence.
53
+ 2. **Consensus** (`lib/feather_ai/consensus.rb`) — When `consensus: true`, runs identification through the configured `consensus_models` in parallel threads. Agreement is compared on normalized species name. If they agree, returns `confident: true`; if they disagree, returns both as `candidates` with low confidence. Token counts, cost, and duration are summed across models.
54
54
 
55
- 3. **Result** (`lib/feather_ai/result.rb`) — Immutable value object wrapping all identification output. Exposes `common_name`, `species`, `family`, `confidence` (`:high`/`:medium`/`:low`), `confident?`, `region_native?`, `candidates`, `photography_tips`, and `to_h`. Photography tips are lazy-loaded via a second cheap LLM call only when accessed.
55
+ 3. **Result** (`lib/feather_ai/result.rb`) — Immutable value object wrapping all identification output. Identification fields: `common_name`, `species`, `family`, `confidence` (`:high`/`:medium`/`:low`), `confident?`, `region_native?`, `reasoning`, `candidates`, `photography_tips`, `to_h`. Observability fields from the LLM call: `model_id`, `input_tokens`, `output_tokens`, `cost`, `duration_ms`, `source` (`:vision`/`:audio`/`:multimodal`), `consensus_models`. Photography tips are lazy-loaded via a second cheap LLM call only when accessed.
56
56
 
57
- 4. **PhotographyTips** (`lib/feather_ai/photography_tips.rb`) — Separate LLM call (small model) returning structured shooting advice for the identified species. Only invoked when `result.photography_tips` is called.
57
+ 4. **PhotographyTips** (`lib/feather_ai/photography_tips.rb`) — Separate LLM call (uses `tips_model`, a small model) returning structured shooting advice for the identified species. Only invoked when `result.photography_tips` is called.
58
+
59
+ ### Instrumentation
60
+
61
+ `Instrumentation.instrument` (`lib/feather_ai/instrumentation.rb`) wraps identification in `ActiveSupport::Notifications` when ActiveSupport is loaded, and is a plain `yield` otherwise. Events: `identify.feather_ai` (Identifier) and `consensus.feather_ai` (Consensus); the `Result` is added to the payload after the call completes.
62
+
63
+ ### Errors
64
+
65
+ All errors inherit from `FeatherAi::Error`: `ConfigurationError` (e.g. no image or audio provided) and `IdentificationError` (LLM call failure).
58
66
 
59
67
  ### Configuration
60
68
 
61
69
  ```ruby
62
70
  FeatherAi.configure do |c|
63
- c.provider = :anthropic
64
- c.location = "Perth, Western Australia" # biases results to local species
65
- c.model = "claude-sonnet-4"
71
+ c.provider = :anthropic # default
72
+ c.model = "claude-sonnet-4" # default
73
+ c.location = "Perth, Western Australia" # biases results to local species
74
+ c.consensus_models = ["claude-sonnet-4", "claude-haiku-4"] # default
75
+ c.tips_model = "claude-haiku-4" # default
76
+ c.media_resolution = :high # default; Gemini-only image resolution
66
77
  end
67
78
  ```
68
79
 
69
- Location can be set globally or per-call via `location:` keyword. It's injected into the system prompt to reduce false positives.
80
+ `FeatherAi.configuration` is a lazily-initialized process singleton; `FeatherAi.reset!` clears it (used before every spec). Location can be set globally or per-call via the `location:` keyword. It's injected into the system prompt to reduce false positives.
70
81
 
71
82
  ### Rails Integration
72
83
 
73
- `lib/feather_ai/rails/` contains a Railtie and `acts_as_sighting` mixin. When included in an ActiveRecord model, it expects `photo` (ActiveStorage) and `location` (string) attributes, and adds an `identify!` method that populates species fields on the record. A generator (`bin/rails generate feather_ai:install`) scaffolds the migration.
84
+ `lib/feather_ai/rails/` contains a Railtie and `acts_as_sighting` mixin. When included in an ActiveRecord model, it expects `photo` (ActiveStorage) and `location` (string) attributes, and adds:
85
+
86
+ - `identify!` — downloads the photo, runs identification, persists species fields, returns the `Result`.
87
+ - `correct!(attrs)` / `corrected?` / `correction_delta` — human corrections of AI identifications, stored in `corrected_*` columns with a `corrected_at` timestamp. `correction_delta` always diffs against the original AI values.
88
+
89
+ Generators: `rails generate feather_ai:install [model_name]` scaffolds the identification-columns migration; `rails generate feather_ai:add_corrections [model_name]` adds the correction columns. Templates live in `lib/generators/feather_ai/templates/`.
74
90
 
75
91
  ## Code Style
76
92
 
@@ -81,7 +97,8 @@ Location can be set globally or per-call via `location:` keyword. It's injected
81
97
 
82
98
  ## Testing
83
99
 
84
- - Tests use VCR + WebMock to record/replay real LLM responses — no API keys needed in CI
85
- - Sample bird images (WA birds) live in `spec/support/fixtures/`
100
+ - Specs stub RubyLLM directly with `instance_double`s (e.g. `allow(RubyLLM).to receive(:chat)`) — no API keys needed. VCR + WebMock are configured in `spec_helper.rb` but mainly serve to block real HTTP; there are no recorded cassettes.
101
+ - `spec/support/helpers.rb` provides `build_result(overrides)` for constructing `FeatherAi::Result` test objects.
102
+ - `FeatherAi.reset!` runs before every example, so config is always at defaults unless the spec configures it.
86
103
  - SimpleCov for coverage reporting
87
104
  - Dev test dependencies go in the `Gemfile`, not the gemspec
data/README.md CHANGED
@@ -22,14 +22,17 @@ gem install feather-ai
22
22
 
23
23
  ```ruby
24
24
  FeatherAi.configure do |c|
25
- c.provider = :anthropic # Default: :anthropic
26
- c.model = "claude-sonnet-4" # Default: "claude-sonnet-4"
27
- c.location = "Perth, WA" # Optional: biases results to local species
28
- c.consensus_models = ["claude-sonnet-4", "claude-haiku-4"] # Models used in consensus mode
25
+ c.provider = :anthropic # Deprecated no-op; RubyLLM 2 resolves the provider from the model
26
+ c.model = "claude-sonnet-4-5" # Default: "claude-sonnet-4-5"
27
+ c.location = "Perth, WA" # Optional: biases results to local species
28
+ c.consensus_models = ["claude-sonnet-4-5", "claude-haiku-4-5"] # Models used in consensus mode
29
+ c.tips_model = "claude-haiku-4-5" # Model for photography tips (default)
30
+ c.media_resolution = :high # Gemini only (generationConfig.mediaResolution); ignored elsewhere
31
+ c.tools = [] # RubyLLM tools available to every identification (default: none)
29
32
  end
30
33
  ```
31
34
 
32
- RubyLLM must be configured with your provider credentials before using FeatherAi. See the [RubyLLM docs](https://github.com/coelacanth/ruby_llm) for setup.
35
+ Requires RubyLLM 2.x. RubyLLM must be configured with your provider credentials before using FeatherAi. See the [RubyLLM docs](https://rubyllm.com) for setup.
33
36
 
34
37
  ## Usage
35
38
 
@@ -54,6 +57,12 @@ Identify from audio:
54
57
  result = FeatherAi.identify(nil, "path/to/bird_call.mp3")
55
58
  ```
56
59
 
60
+ Identify from multiple images at once:
61
+
62
+ ```ruby
63
+ result = FeatherAi.identify(["front.jpg", "side.jpg"])
64
+ ```
65
+
57
66
  Identify from both image and audio:
58
67
 
59
68
  ```ruby
@@ -72,9 +81,40 @@ result.region_native? # => true/false based on species range
72
81
 
73
82
  A default location can also be set globally in configuration.
74
83
 
84
+ ### Ranked Candidates
85
+
86
+ Every identification returns up to three candidate species ranked by likelihood, with the top pick first:
87
+
88
+ ```ruby
89
+ result = FeatherAi.identify("path/to/bird.jpg")
90
+
91
+ result.candidates
92
+ # => [{ common_name: "Splendid Fairywren", species: "Malurus splendens", score: 0.9 },
93
+ # { common_name: "Superb Fairywren", species: "Malurus cyaneus", score: 0.1 }]
94
+ ```
95
+
96
+ ### Grounded Identification (Tools)
97
+
98
+ Pass RubyLLM tools so the model can verify its identification against real data — for example a species/region lookup backed by your own database:
99
+
100
+ ```ruby
101
+ class SpeciesLookupTool < RubyLLM::Tool
102
+ description "Looks up whether a species occurs in a region"
103
+ parameter :species, description: "Scientific species name"
104
+
105
+ def execute(species:)
106
+ Species.find_by(scientific_name: species)&.slice(:regions, :description) || { found: false }
107
+ end
108
+ end
109
+
110
+ result = FeatherAi.identify("path/to/bird.jpg", location: "Perth, WA", tools: [SpeciesLookupTool])
111
+ ```
112
+
113
+ Tools can also be set globally via `c.tools` in configuration. Note: Gemini rejects structured-output schemas combined with tools on many models — the `tools:` option is tested against the Anthropic defaults.
114
+
75
115
  ### Consensus Mode
76
116
 
77
- Run identification through two models independently. When both agree on species, you get high confidence. When they disagree, you get the candidates:
117
+ Run identification through two models independently. When both agree on species, you get high confidence. When they disagree, the top-vote candidate (ties break in `consensus_models` order) is still returned as the primary identification with `:low` confidence, and `candidates` carries the full ranked list:
78
118
 
79
119
  ```ruby
80
120
  result = FeatherAi.identify("path/to/bird.jpg", consensus: true)
@@ -82,8 +122,8 @@ result = FeatherAi.identify("path/to/bird.jpg", consensus: true)
82
122
  if result.confident?
83
123
  puts "Both models agree: #{result.species}"
84
124
  else
85
- puts "Models disagree:"
86
- result.candidates.each { |c| puts " #{c.common_name} (#{c.species})" }
125
+ puts "Models disagree — best guess: #{result.species}"
126
+ result.candidates.each { |c| puts " #{c[:common_name]} (#{c[:species]}) — #{c[:score]}" }
87
127
  end
88
128
  ```
89
129
 
@@ -91,7 +131,7 @@ Consensus models are configurable:
91
131
 
92
132
  ```ruby
93
133
  FeatherAi.configure do |c|
94
- c.consensus_models = ["claude-sonnet-4", "claude-haiku-4"]
134
+ c.consensus_models = ["claude-sonnet-4-5", "claude-haiku-4-5"]
95
135
  end
96
136
  ```
97
137
 
@@ -120,10 +160,23 @@ All identification calls return a `FeatherAi::Result`:
120
160
  | `confidence` | Symbol | `:high`, `:medium`, or `:low` |
121
161
  | `confident?` | Boolean | `true` when confidence is `:high` |
122
162
  | `region_native?` | Boolean | Whether species is native to the given region |
123
- | `candidates` | Array | Alternative results when consensus disagrees |
163
+ | `candidates` | Array | Ranked candidate hashes (`common_name`, `species`, `score`); vote-share ranked on consensus disagreement |
124
164
  | `photography_tips` | Hash | Lazy-loaded shooting advice |
125
165
  | `to_h` | Hash | All fields as a plain hash |
126
166
 
167
+ Every result also carries observability data from the LLM call:
168
+
169
+ | Method | Type | Description |
170
+ |---|---|---|
171
+ | `reasoning` | String | Step-by-step visual analysis the model performed |
172
+ | `model_id` | String | Model that produced the identification |
173
+ | `input_tokens` | Integer | Tokens sent to the model |
174
+ | `output_tokens` | Integer | Tokens received from the model |
175
+ | `cost` | Float | USD cost from RubyLLM's model pricing registry, or `nil` when unknown |
176
+ | `duration_ms` | Integer | Wall-clock time of the LLM call in milliseconds |
177
+ | `source` | Symbol | `:vision`, `:audio`, or `:multimodal` |
178
+ | `consensus_models` | Array | Models used when consensus mode was enabled |
179
+
127
180
  ## Rails Integration
128
181
 
129
182
  ### Setup
@@ -153,7 +206,7 @@ class Sighting < ApplicationRecord
153
206
  end
154
207
  ```
155
208
 
156
- The generator adds these columns to the model's table: `common_name`, `species`, `family`, `confidence`, `region_native`.
209
+ The generator adds these columns to the model's table: `common_name`, `species`, `family`, `confidence`, `region_native`, `candidates` (jsonb). On existing tables, `identify!` persists ranked candidates only when a `candidates` column is present — add one in your own migration or skip it.
157
210
 
158
211
  ### Identifying Records
159
212
 
@@ -170,6 +223,61 @@ sighting.confident? # => true (delegated through result)
170
223
 
171
224
  `identify!` downloads the attached photo, calls `FeatherAi.identify`, updates the record's identification columns, and returns the `FeatherAi::Result`.
172
225
 
226
+ ### Corrections
227
+
228
+ Users or moderators can correct AI identifications. First, run the corrections generator to add the necessary columns:
229
+
230
+ ```bash
231
+ rails generate feather_ai:add_corrections
232
+ # or with a custom model name:
233
+ rails generate feather_ai:add_corrections observation
234
+ ```
235
+
236
+ Then apply corrections to a record:
237
+
238
+ ```ruby
239
+ sighting.correct!(common_name: "Australian Magpie", species: "Gymnorhina tibicen dorsalis")
240
+
241
+ sighting.corrected? # => true
242
+ sighting.corrected_at # => 2026-03-25 12:00:00 UTC
243
+
244
+ sighting.correction_delta
245
+ # => { common_name: { from: "Western Magpie", to: "Australian Magpie" },
246
+ # species: { from: "Gymnorhina tibicen", to: "Gymnorhina tibicen dorsalis" } }
247
+ ```
248
+
249
+ Correctable fields: `common_name`, `species`, `family`, `confidence`, `region_native`.
250
+
251
+ ## Instrumentation
252
+
253
+ When `ActiveSupport::Notifications` is available (e.g. in Rails), every identification emits an `identify.feather_ai` event. Without ActiveSupport the instrumentation is a no-op.
254
+
255
+ ```ruby
256
+ ActiveSupport::Notifications.subscribe("identify.feather_ai") do |_name, _start, _finish, _id, payload|
257
+ Rails.logger.info "Identified #{payload[:result].common_name} " \
258
+ "with model=#{payload[:model]} in #{payload[:result].duration_ms}ms"
259
+ end
260
+ ```
261
+
262
+ Payload keys: `model`, `location`, `has_image`, `image_count`, `has_audio`, and `result` (the `FeatherAi::Result`).
263
+
264
+ ## Error Handling
265
+
266
+ FeatherAi raises specific error classes, all inheriting from `FeatherAi::Error`:
267
+
268
+ - `FeatherAi::ConfigurationError` — invalid or missing configuration (e.g. no image or audio provided)
269
+ - `FeatherAi::IdentificationError` — failure during the LLM identification call
270
+
271
+ ```ruby
272
+ begin
273
+ FeatherAi.identify("path/to/bird.jpg")
274
+ rescue FeatherAi::ConfigurationError => e
275
+ # handle bad config
276
+ rescue FeatherAi::IdentificationError => e
277
+ # handle LLM failure
278
+ end
279
+ ```
280
+
173
281
  ## Development
174
282
 
175
283
  ```bash
@@ -182,6 +290,12 @@ bin/console # Interactive console with gem loaded
182
290
 
183
291
  Tests use VCR + WebMock to record and replay LLM responses — no API keys are required to run the test suite.
184
292
 
293
+ Use `FeatherAi.reset!` to clear configuration between test examples:
294
+
295
+ ```ruby
296
+ after { FeatherAi.reset! }
297
+ ```
298
+
185
299
  ## Thread Safety
186
300
 
187
301
  `FeatherAi.configuration` is a process-level singleton initialised lazily with `||=`. Under MRI Ruby, the Global VM Lock (GVL) makes this safe in practice. If you use JRuby or Ractors, initialise configuration eagerly at boot time before spawning threads:
@@ -189,8 +303,7 @@ Tests use VCR + WebMock to record and replay LLM responses — no API keys are r
189
303
  ```ruby
190
304
  # In an initialiser or boot file — before any threads are created
191
305
  FeatherAi.configure do |c|
192
- c.provider = :anthropic
193
- c.model = "claude-sonnet-4"
306
+ c.model = "claude-sonnet-4-5"
194
307
  end
195
308
  ```
196
309
 
@@ -3,20 +3,24 @@
3
3
  module FeatherAi
4
4
  # Configuration object for FeatherAi gem settings.
5
5
  class Configuration
6
- attr_accessor :provider, :model, :location, :consensus_models, :tips_model, :media_resolution
6
+ # provider: kept for backwards compatibility; RubyLLM 2 resolves the provider from the model.
7
+ # media_resolution: Gemini-only (generationConfig.mediaResolution); ignored for other providers.
8
+ attr_accessor :provider, :model, :location, :consensus_models, :tips_model, :media_resolution, :tools
7
9
 
8
10
  def initialize
9
11
  @provider = :anthropic
10
- @model = "claude-sonnet-4"
12
+ @model = "claude-sonnet-4-5"
11
13
  @location = nil
12
- @consensus_models = %w[claude-sonnet-4 claude-haiku-4]
13
- @tips_model = "claude-haiku-4"
14
+ @consensus_models = %w[claude-sonnet-4-5 claude-haiku-4-5]
15
+ @tips_model = "claude-haiku-4-5"
14
16
  @media_resolution = :high
17
+ @tools = []
15
18
  end
16
19
 
17
20
  def initialize_copy(source)
18
21
  super
19
22
  @consensus_models = source.consensus_models.dup
23
+ @tools = source.tools.dup
20
24
  end
21
25
  end
22
26
  end
@@ -2,17 +2,18 @@
2
2
 
3
3
  module FeatherAi
4
4
  # Multi-model consensus identification to improve accuracy.
5
+ # rubocop:disable-next Metrics/ClassLength
5
6
  class Consensus
6
7
  def initialize(config: FeatherAi.configuration)
7
8
  @config = config
8
9
  @models = config.consensus_models
9
10
  end
10
11
 
11
- def identify(image = nil, audio = nil, location: nil)
12
+ def identify(image = nil, audio = nil, location: nil, tools: nil)
12
13
  payload = { models: @models, location: location || @config.location }
13
14
 
14
15
  Instrumentation.instrument("consensus.feather_ai", payload) do
15
- results = fetch_results_from_models(image, audio, location)
16
+ results = fetch_results_from_models(image, audio, location, tools)
16
17
  shared_attrs = aggregate_metrics(results)
17
18
  result = build_consensus_result(results, shared_attrs)
18
19
 
@@ -24,10 +25,12 @@ module FeatherAi
24
25
 
25
26
  private
26
27
 
27
- def fetch_results_from_models(image, audio, location)
28
+ def fetch_results_from_models(image, audio, location, tools)
28
29
  @models.map do |model|
29
30
  config_for_model = config_with_model(model)
30
- Thread.new { Identifier.new(config: config_for_model).identify(image, audio, location: location) }
31
+ Thread.new do
32
+ Identifier.new(config: config_for_model).identify(image, audio, location: location, tools: tools)
33
+ end
31
34
  end.map(&:value)
32
35
  end
33
36
 
@@ -66,19 +69,42 @@ module FeatherAi
66
69
  confidence: :high,
67
70
  region_native: primary.region_native?,
68
71
  model_id: primary.model_id,
72
+ candidates: primary.candidates,
69
73
  photography_tips_loader: tips_loader_for(primary)
70
74
  }
71
75
  end
72
76
 
77
+ # On disagreement the top-ranked candidate is promoted to the primary
78
+ # identification (still :low confidence) so consumers always get a usable
79
+ # name — the full ranked list stays in candidates.
73
80
  def disagreed_result_attrs(results)
81
+ candidates = ranked_candidates(results)
74
82
  {
75
- common_name: nil,
76
- species: nil,
83
+ common_name: candidates.first[:common_name],
84
+ species: candidates.first[:species],
77
85
  family: calculate_agreed_family(results),
78
86
  confidence: :low,
79
87
  region_native: false,
80
88
  model_id: nil,
81
- candidates: results
89
+ candidates: candidates
90
+ }
91
+ end
92
+
93
+ # Rank disagreeing identifications by vote share across the consensus
94
+ # models. Ties break by consensus_models order (sort_by isn't stable).
95
+ def ranked_candidates(results)
96
+ results.group_by { |r| r.species&.strip&.downcase }
97
+ .values
98
+ .map { |group| vote_candidate(group, results.size) }
99
+ .sort_by.with_index { |candidate, index| [-candidate[:score], index] }
100
+ end
101
+
102
+ def vote_candidate(group, total)
103
+ primary = group.first
104
+ {
105
+ common_name: primary.common_name,
106
+ species: primary.species,
107
+ score: group.size.to_f / total
82
108
  }
83
109
  end
84
110
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  module FeatherAi
4
4
  # Core bird identification using LLM vision and audio transcription.
5
- # rubocop:disable Metrics/ClassLength
5
+ # rubocop:disable-next Metrics/ClassLength
6
6
  class Identifier
7
- SCHEMA = RubyLLM::Schema.create do
7
+ SCHEMA = Schematist::Schema.create do
8
8
  string :reasoning,
9
9
  description: "Step-by-step visual analysis: describe body size, bill shape, " \
10
10
  "plumage, markings, and rule out similar species before identifying"
@@ -13,24 +13,28 @@ module FeatherAi
13
13
  string :family, description: "Bird family name"
14
14
  string :confidence, description: "Identification confidence: high, medium, or low"
15
15
  boolean :region_native, description: "Whether this species is native to the given region"
16
+ array :candidates, min_items: 1, max_items: 3,
17
+ description: "Candidate species ranked most to least likely; " \
18
+ "the first entry must match your identification" do
19
+ object do
20
+ string :common_name, description: "Common name of the candidate"
21
+ string :species, description: "Scientific species name (Genus species)"
22
+ number :score, minimum: 0, maximum: 1, description: "Relative likelihood of this candidate"
23
+ end
24
+ end
16
25
  end
17
26
 
18
- # Approximate mid-2025 rates (USD per 1M tokens).
19
- # Use your provider's dashboard for billing accuracy — these are estimates.
20
- PROVIDER_RATES = {
21
- anthropic: { input: 3.00, output: 15.00 }
22
- }.freeze
23
-
24
27
  def initialize(config: FeatherAi.configuration)
25
28
  @config = config
26
29
  end
27
30
 
28
31
  # @param image [String, Array<String>, nil] path(s) to image file(s)
29
32
  # @param audio [String, nil] path to audio file
30
- def identify(image = nil, audio = nil, location: nil)
33
+ # @param tools [Array, nil] RubyLLM tools (classes or instances) the model may call for grounding
34
+ def identify(image = nil, audio = nil, location: nil, tools: nil)
31
35
  images = normalize_images(image)
32
36
  validate_inputs!(images, audio)
33
- run_identification(images, audio, location || @config.location)
37
+ run_identification(images, audio, location || @config.location, Array(tools || @config.tools))
34
38
  end
35
39
 
36
40
  private
@@ -44,12 +48,12 @@ module FeatherAi
44
48
  end
45
49
  end
46
50
 
47
- def run_identification(images, audio, effective_location)
51
+ def run_identification(images, audio, effective_location, tools)
48
52
  source = derive_source(images, audio)
49
53
  payload = instrumentation_payload(effective_location, images, audio)
50
54
 
51
55
  Instrumentation.instrument("identify.feather_ai", payload) do
52
- response, duration_ms = perform_identification(images, audio, effective_location)
56
+ response, duration_ms = perform_identification(images, audio, effective_location, tools)
53
57
  result = build_result(response, duration_ms, source)
54
58
  payload[:result] = result
55
59
  result
@@ -72,8 +76,8 @@ module FeatherAi
72
76
  }
73
77
  end
74
78
 
75
- def perform_identification(images, audio, location)
76
- chat = configure_chat(location)
79
+ def perform_identification(images, audio, location, tools)
80
+ chat = configure_chat(location, tools)
77
81
  prompt = build_text_prompt(images, audio)
78
82
  attachments = images.any? ? images : nil
79
83
 
@@ -84,25 +88,25 @@ module FeatherAi
84
88
  [response, duration_ms]
85
89
  end
86
90
 
87
- def configure_chat(location)
91
+ def configure_chat(location, tools)
88
92
  chat = RubyLLM.chat(model: @config.model)
89
- chat.with_instructions(system_prompt(location))
93
+ chat.with_instructions(system_prompt(location, tools))
90
94
  chat.with_schema(SCHEMA)
91
- chat.with_params(**generation_params) if generation_params.any?
95
+ chat.with_tools(*tools) if tools.any?
96
+ apply_media_resolution(chat)
92
97
  chat
93
98
  end
94
99
 
95
- def generation_params
96
- params = {}
97
- if @config.media_resolution
98
- resolution = "MEDIA_RESOLUTION_#{@config.media_resolution.to_s.upcase}"
99
- params[:generationConfig] = { mediaResolution: resolution }
100
- end
101
- params
100
+ # generationConfig.mediaResolution is a Gemini request field; other providers reject unknown keys.
101
+ def apply_media_resolution(chat)
102
+ return unless @config.media_resolution && chat.provider.slug == "gemini"
103
+
104
+ resolution = "MEDIA_RESOLUTION_#{@config.media_resolution.to_s.upcase}"
105
+ chat.with_provider_options(generationConfig: { mediaResolution: resolution })
102
106
  end
103
107
 
104
108
  def build_result(response, duration_ms, source)
105
- parsed = response.content
109
+ parsed = response.parsed
106
110
  Result.new(
107
111
  **parsed_identification_attrs(parsed),
108
112
  **response_observability_attrs(response, duration_ms, source)
@@ -117,16 +121,27 @@ module FeatherAi
117
121
  family: parsed["family"],
118
122
  confidence: parsed["confidence"],
119
123
  region_native: parsed["region_native"],
124
+ candidates: normalize_candidates(parsed["candidates"]),
120
125
  photography_tips_loader: tips_loader(parsed)
121
126
  }
122
127
  end
123
128
 
129
+ def normalize_candidates(candidates)
130
+ Array(candidates).map do |candidate|
131
+ {
132
+ common_name: candidate["common_name"],
133
+ species: candidate["species"],
134
+ score: candidate["score"]
135
+ }
136
+ end
137
+ end
138
+
124
139
  def response_observability_attrs(response, duration_ms, source)
125
140
  {
126
- model_id: response.model_id,
127
- input_tokens: response.input_tokens,
128
- output_tokens: response.output_tokens,
129
- cost: compute_cost(response.input_tokens, response.output_tokens),
141
+ model_id: response.model,
142
+ input_tokens: response.tokens.input,
143
+ output_tokens: response.tokens.output,
144
+ cost: response.cost.total,
130
145
  duration_ms: duration_ms,
131
146
  source: source
132
147
  }
@@ -152,23 +167,17 @@ module FeatherAi
152
167
  end
153
168
  end
154
169
 
155
- # Returns a USD cost estimate based on token counts, or nil when the count
156
- # is unavailable or the configured provider has no rate table defined here.
157
- def compute_cost(input_tokens, output_tokens)
158
- return nil if input_tokens.nil? || output_tokens.nil?
159
-
160
- rates = PROVIDER_RATES[@config.provider]
161
- return nil if rates.nil?
162
-
163
- ((input_tokens * rates[:input]) + (output_tokens * rates[:output])) / 1_000_000.0
164
- end
165
-
166
- def system_prompt(location)
167
- base = base_system_prompt
168
- return base unless location
169
-
170
- "#{base} The observer is located in #{location} — " \
171
- "prioritise species native to that region and consider regional plumage variations."
170
+ def system_prompt(location, tools = [])
171
+ prompt = base_system_prompt
172
+ if location
173
+ prompt += " The observer is located in #{location} — " \
174
+ "prioritise species native to that region and consider regional plumage variations."
175
+ end
176
+ if tools.any?
177
+ prompt += " Use the provided lookup tools to verify species occurrence " \
178
+ "for the observer's region before committing."
179
+ end
180
+ prompt
172
181
  end
173
182
 
174
183
  def base_system_prompt
@@ -187,7 +196,7 @@ module FeatherAi
187
196
  def build_text_prompt(images, audio)
188
197
  parts = []
189
198
  if audio
190
- transcript = RubyLLM.transcribe(audio)
199
+ transcript = RubyLLM.transcribe(audio).text
191
200
  parts << "Bird call/song transcript: #{transcript}"
192
201
  end
193
202
  parts << identification_prompt(images.size, has_audio: !audio.nil?)
@@ -204,5 +213,4 @@ module FeatherAi
204
213
  end
205
214
  end
206
215
  end
207
- # rubocop:enable Metrics/ClassLength
208
216
  end
@@ -3,7 +3,7 @@
3
3
  module FeatherAi
4
4
  # Generates photography tips for identified bird species.
5
5
  class PhotographyTips
6
- SCHEMA = RubyLLM::Schema.create do
6
+ SCHEMA = Schematist::Schema.create do
7
7
  string :time_of_day, description: "Best time of day to photograph this species"
8
8
  string :approach, description: "How to approach without disturbing the bird"
9
9
  string :settings, description: "Recommended camera settings (shutter speed, aperture, ISO)"
@@ -32,7 +32,7 @@ module FeatherAi
32
32
  def fetch_from_llm
33
33
  chat = RubyLLM.chat(model: @config.tips_model)
34
34
  chat.with_schema(SCHEMA)
35
- chat.ask(prompt).content
35
+ chat.ask(prompt).parsed
36
36
  end
37
37
 
38
38
  def build_tips_hash(parsed)
@@ -33,13 +33,15 @@ module FeatherAi
33
33
  private
34
34
 
35
35
  def update_from_result!(result)
36
- update!(
36
+ attrs = {
37
37
  common_name: result.common_name,
38
38
  species: result.species,
39
39
  family: result.family,
40
40
  confidence: result.confidence.to_s,
41
41
  region_native: result.region_native?
42
- )
42
+ }
43
+ attrs[:candidates] = result.candidates if has_attribute?("candidates")
44
+ update!(attrs)
43
45
  end
44
46
 
45
47
  def close_photo_file(photo_file)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FeatherAi
4
- VERSION = "0.3.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/feather_ai.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_llm"
4
- require "ruby_llm/schema"
5
4
 
6
5
  require_relative "feather_ai/version"
7
6
  require_relative "feather_ai/configuration"
@@ -33,11 +32,12 @@ module FeatherAi
33
32
  # Identify a bird from image(s) and/or audio.
34
33
  # @param image [String, Array<String>, nil] path(s) to image file(s)
35
34
  # @param audio [String, nil] path to audio file
36
- def identify(image = nil, audio = nil, location: nil, consensus: false)
35
+ # @param tools [Array, nil] RubyLLM tools (classes or instances) the model may call for grounding
36
+ def identify(image = nil, audio = nil, location: nil, consensus: false, tools: nil)
37
37
  if consensus
38
- Consensus.new.identify(image, audio, location: location)
38
+ Consensus.new.identify(image, audio, location: location, tools: tools)
39
39
  else
40
- Identifier.new.identify(image, audio, location: location)
40
+ Identifier.new.identify(image, audio, location: location, tools: tools)
41
41
  end
42
42
  end
43
43
  end
@@ -5,5 +5,6 @@ class AddFeatherAiFieldsTo<%= model_name.camelize.pluralize %> < ActiveRecord::M
5
5
  add_column :<%= model_name.underscore.pluralize %>, :family, :string
6
6
  add_column :<%= model_name.underscore.pluralize %>, :confidence, :string
7
7
  add_column :<%= model_name.underscore.pluralize %>, :region_native, :boolean
8
+ add_column :<%= model_name.underscore.pluralize %>, :candidates, :jsonb
8
9
  end
9
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feather-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brandyn Britton
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '1.0'
18
+ version: '2.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '1.0'
25
+ version: '2.0'
26
26
  description: A Ruby gem for identifying birds from photos and audio using RubyLLM.
27
27
  Adds multi-modal identification, location-aware results, multi-model consensus,
28
28
  and a Rails integration.
@@ -40,6 +40,7 @@ files:
40
40
  - ".rspec"
41
41
  - ".rubocop.yml"
42
42
  - ".ruby-version"
43
+ - CHANGELOG.md
43
44
  - CLAUDE.md
44
45
  - CODE_OF_CONDUCT.md
45
46
  - LICENSE.txt
@@ -83,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
84
  - !ruby/object:Gem::Version
84
85
  version: '0'
85
86
  requirements: []
86
- rubygems_version: 4.0.8
87
+ rubygems_version: 4.0.10
87
88
  specification_version: 4
88
89
  summary: Identify birds from photos and audio using LLMs
89
90
  test_files: []