feather-ai 0.4.0 → 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: a0fe728ff2f69d47cd1c9d89a9d8a4752dce0ea05ada463168fcca4496496981
4
- data.tar.gz: 36931f778f677cd40e01472a282a1fb44370ea587bee3faef8a76ea543a3c59e
3
+ metadata.gz: fa4e06ced9d8d48c533d41f97148fba4c26f3ffa478a603c4562960d50e6594f
4
+ data.tar.gz: 6f04ec946376b964fbf0a049a7837f20bae6eab45690e802b22e4f03b3789bc3
5
5
  SHA512:
6
- metadata.gz: 3db402c6f71e32084333e0730e2850941f439ce92e54c142b3228c1eb25f024ab64eaf69251412038b3a5c8598598f771a717eb45ccc75b570717e46927c218d
7
- data.tar.gz: 18c4afdb60659f9e7b1604062e68ed967d6b65df7db96ac4309f8dc3ff0d2be783a6e53d292f2a2188841eee783a1a7a263137deaa648de58e96c06c93ac853c
6
+ metadata.gz: 943e45ed67690b5791d953ff443e3d94ba9b1866f1143373ca08dc2ef59826720cbdd4a34eeb6d91cf8d2a626822738e068a9cae4750f92fbe8a4aa2a91b2cb7
7
+ data.tar.gz: 903b3b7968b1ebebc411e5e8c518649ca8b5cf617461cb630725b861a3dfaba8eed7e914b01803ff5b65c05d92d1806ac6607e4cc5ce235db5f74a9ed59d0d9e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
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
+
3
11
  ## 0.4.0 - 2026-07-05
4
12
 
5
13
  - Identification now returns ranked `candidates` (up to 3, each `{common_name:, species:, score:}`) on every `Result`, populated straight from the structured-output schema.
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,17 +22,17 @@ gem install feather-ai
22
22
 
23
23
  ```ruby
24
24
  FeatherAi.configure do |c|
25
- c.provider = :anthropic # Default: :anthropic
25
+ c.provider = :anthropic # Deprecated no-op; RubyLLM 2 resolves the provider from the model
26
26
  c.model = "claude-sonnet-4-5" # Default: "claude-sonnet-4-5"
27
27
  c.location = "Perth, WA" # Optional: biases results to local species
28
28
  c.consensus_models = ["claude-sonnet-4-5", "claude-haiku-4-5"] # Models used in consensus mode
29
29
  c.tips_model = "claude-haiku-4-5" # Model for photography tips (default)
30
- c.media_resolution = :high # Image resolution sent to provider (default)
30
+ c.media_resolution = :high # Gemini only (generationConfig.mediaResolution); ignored elsewhere
31
31
  c.tools = [] # RubyLLM tools available to every identification (default: none)
32
32
  end
33
33
  ```
34
34
 
35
- 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.
36
36
 
37
37
  ## Usage
38
38
 
@@ -100,7 +100,7 @@ Pass RubyLLM tools so the model can verify its identification against real data
100
100
  ```ruby
101
101
  class SpeciesLookupTool < RubyLLM::Tool
102
102
  description "Looks up whether a species occurs in a region"
103
- param :species, desc: "Scientific species name"
103
+ parameter :species, description: "Scientific species name"
104
104
 
105
105
  def execute(species:)
106
106
  Species.find_by(scientific_name: species)&.slice(:regions, :description) || { found: false }
@@ -114,7 +114,7 @@ Tools can also be set globally via `c.tools` in configuration. Note: Gemini reje
114
114
 
115
115
  ### Consensus Mode
116
116
 
117
- 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:
118
118
 
119
119
  ```ruby
120
120
  result = FeatherAi.identify("path/to/bird.jpg", consensus: true)
@@ -122,7 +122,7 @@ result = FeatherAi.identify("path/to/bird.jpg", consensus: true)
122
122
  if result.confident?
123
123
  puts "Both models agree: #{result.species}"
124
124
  else
125
- puts "Models disagree:"
125
+ puts "Models disagree — best guess: #{result.species}"
126
126
  result.candidates.each { |c| puts " #{c[:common_name]} (#{c[:species]}) — #{c[:score]}" }
127
127
  end
128
128
  ```
@@ -172,7 +172,7 @@ Every result also carries observability data from the LLM call:
172
172
  | `model_id` | String | Model that produced the identification |
173
173
  | `input_tokens` | Integer | Tokens sent to the model |
174
174
  | `output_tokens` | Integer | Tokens received from the model |
175
- | `cost` | Float | Estimated USD cost (based on built-in rate tables, or `nil`) |
175
+ | `cost` | Float | USD cost from RubyLLM's model pricing registry, or `nil` when unknown |
176
176
  | `duration_ms` | Integer | Wall-clock time of the LLM call in milliseconds |
177
177
  | `source` | Symbol | `:vision`, `:audio`, or `:multimodal` |
178
178
  | `consensus_models` | Array | Models used when consensus mode was enabled |
@@ -303,8 +303,7 @@ after { FeatherAi.reset! }
303
303
  ```ruby
304
304
  # In an initialiser or boot file — before any threads are created
305
305
  FeatherAi.configure do |c|
306
- c.provider = :anthropic
307
- c.model = "claude-sonnet-4"
306
+ c.model = "claude-sonnet-4-5"
308
307
  end
309
308
  ```
310
309
 
@@ -3,6 +3,8 @@
3
3
  module FeatherAi
4
4
  # Configuration object for FeatherAi gem settings.
5
5
  class Configuration
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.
6
8
  attr_accessor :provider, :model, :location, :consensus_models, :tips_model, :media_resolution, :tools
7
9
 
8
10
  def initialize
@@ -2,7 +2,7 @@
2
2
 
3
3
  module FeatherAi
4
4
  # Multi-model consensus identification to improve accuracy.
5
- # rubocop:disable Metrics/ClassLength
5
+ # rubocop:disable-next Metrics/ClassLength
6
6
  class Consensus
7
7
  def initialize(config: FeatherAi.configuration)
8
8
  @config = config
@@ -74,24 +74,29 @@ module FeatherAi
74
74
  }
75
75
  end
76
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.
77
80
  def disagreed_result_attrs(results)
81
+ candidates = ranked_candidates(results)
78
82
  {
79
- common_name: nil,
80
- species: nil,
83
+ common_name: candidates.first[:common_name],
84
+ species: candidates.first[:species],
81
85
  family: calculate_agreed_family(results),
82
86
  confidence: :low,
83
87
  region_native: false,
84
88
  model_id: nil,
85
- candidates: ranked_candidates(results)
89
+ candidates: candidates
86
90
  }
87
91
  end
88
92
 
89
- # Rank disagreeing identifications by vote share across the consensus models.
93
+ # Rank disagreeing identifications by vote share across the consensus
94
+ # models. Ties break by consensus_models order (sort_by isn't stable).
90
95
  def ranked_candidates(results)
91
96
  results.group_by { |r| r.species&.strip&.downcase }
92
97
  .values
93
98
  .map { |group| vote_candidate(group, results.size) }
94
- .sort_by { |candidate| -candidate[:score] }
99
+ .sort_by.with_index { |candidate, index| [-candidate[:score], index] }
95
100
  end
96
101
 
97
102
  def vote_candidate(group, total)
@@ -133,5 +138,4 @@ module FeatherAi
133
138
  dup_config
134
139
  end
135
140
  end
136
- # rubocop:enable Metrics/ClassLength
137
141
  end
@@ -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"
@@ -24,12 +24,6 @@ module FeatherAi
24
24
  end
25
25
  end
26
26
 
27
- # Approximate mid-2025 rates (USD per 1M tokens).
28
- # Use your provider's dashboard for billing accuracy — these are estimates.
29
- PROVIDER_RATES = {
30
- anthropic: { input: 3.00, output: 15.00 }
31
- }.freeze
32
-
33
27
  def initialize(config: FeatherAi.configuration)
34
28
  @config = config
35
29
  end
@@ -99,21 +93,20 @@ module FeatherAi
99
93
  chat.with_instructions(system_prompt(location, tools))
100
94
  chat.with_schema(SCHEMA)
101
95
  chat.with_tools(*tools) if tools.any?
102
- chat.with_params(**generation_params) if generation_params.any?
96
+ apply_media_resolution(chat)
103
97
  chat
104
98
  end
105
99
 
106
- def generation_params
107
- params = {}
108
- if @config.media_resolution
109
- resolution = "MEDIA_RESOLUTION_#{@config.media_resolution.to_s.upcase}"
110
- params[:generationConfig] = { mediaResolution: resolution }
111
- end
112
- 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 })
113
106
  end
114
107
 
115
108
  def build_result(response, duration_ms, source)
116
- parsed = response.content
109
+ parsed = response.parsed
117
110
  Result.new(
118
111
  **parsed_identification_attrs(parsed),
119
112
  **response_observability_attrs(response, duration_ms, source)
@@ -145,10 +138,10 @@ module FeatherAi
145
138
 
146
139
  def response_observability_attrs(response, duration_ms, source)
147
140
  {
148
- model_id: response.model_id,
149
- input_tokens: response.input_tokens,
150
- output_tokens: response.output_tokens,
151
- 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,
152
145
  duration_ms: duration_ms,
153
146
  source: source
154
147
  }
@@ -174,17 +167,6 @@ module FeatherAi
174
167
  end
175
168
  end
176
169
 
177
- # Returns a USD cost estimate based on token counts, or nil when the count
178
- # is unavailable or the configured provider has no rate table defined here.
179
- def compute_cost(input_tokens, output_tokens)
180
- return nil if input_tokens.nil? || output_tokens.nil?
181
-
182
- rates = PROVIDER_RATES[@config.provider]
183
- return nil if rates.nil?
184
-
185
- ((input_tokens * rates[:input]) + (output_tokens * rates[:output])) / 1_000_000.0
186
- end
187
-
188
170
  def system_prompt(location, tools = [])
189
171
  prompt = base_system_prompt
190
172
  if location
@@ -214,7 +196,7 @@ module FeatherAi
214
196
  def build_text_prompt(images, audio)
215
197
  parts = []
216
198
  if audio
217
- transcript = RubyLLM.transcribe(audio)
199
+ transcript = RubyLLM.transcribe(audio).text
218
200
  parts << "Bird call/song transcript: #{transcript}"
219
201
  end
220
202
  parts << identification_prompt(images.size, has_audio: !audio.nil?)
@@ -231,5 +213,4 @@ module FeatherAi
231
213
  end
232
214
  end
233
215
  end
234
- # rubocop:enable Metrics/ClassLength
235
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FeatherAi
4
- VERSION = "0.4.0"
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"
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.4.0
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.