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 +4 -4
- data/CHANGELOG.md +19 -0
- data/CLAUDE.md +30 -13
- data/README.md +126 -13
- data/lib/feather_ai/configuration.rb +8 -4
- data/lib/feather_ai/consensus.rb +33 -7
- data/lib/feather_ai/identifier.rb +56 -48
- data/lib/feather_ai/photography_tips.rb +2 -2
- data/lib/feather_ai/rails/acts_as_sighting.rb +4 -2
- data/lib/feather_ai/version.rb +1 -1
- data/lib/feather_ai.rb +4 -4
- data/lib/generators/feather_ai/templates/migration.rb.tt +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa4e06ced9d8d48c533d41f97148fba4c26f3ffa478a603c4562960d50e6594f
|
|
4
|
+
data.tar.gz: 6f04ec946376b964fbf0a049a7837f20bae6eab45690e802b22e4f03b3789bc3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 `
|
|
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
|
|
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.
|
|
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
|
|
64
|
-
c.
|
|
65
|
-
c.
|
|
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
|
|
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
|
-
-
|
|
85
|
-
-
|
|
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
|
|
26
|
-
c.model
|
|
27
|
-
c.location
|
|
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://
|
|
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,
|
|
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
|
|
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 |
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/feather_ai/consensus.rb
CHANGED
|
@@ -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
|
|
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:
|
|
76
|
-
species:
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
95
|
+
chat.with_tools(*tools) if tools.any?
|
|
96
|
+
apply_media_resolution(chat)
|
|
92
97
|
chat
|
|
93
98
|
end
|
|
94
99
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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.
|
|
127
|
-
input_tokens: response.
|
|
128
|
-
output_tokens: response.
|
|
129
|
-
cost:
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 =
|
|
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).
|
|
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
|
-
|
|
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)
|
data/lib/feather_ai/version.rb
CHANGED
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
|
-
|
|
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.
|
|
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: '
|
|
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: '
|
|
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.
|
|
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: []
|