feather-ai 0.3.1 → 0.4.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 +11 -0
- data/README.md +122 -8
- data/lib/feather_ai/configuration.rb +6 -4
- data/lib/feather_ai/consensus.rb +27 -5
- data/lib/feather_ai/identifier.rb +41 -14
- 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 -3
- data/lib/generators/feather_ai/templates/migration.rb.tt +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0fe728ff2f69d47cd1c9d89a9d8a4752dce0ea05ada463168fcca4496496981
|
|
4
|
+
data.tar.gz: 36931f778f677cd40e01472a282a1fb44370ea587bee3faef8a76ea543a3c59e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3db402c6f71e32084333e0730e2850941f439ce92e54c142b3228c1eb25f024ab64eaf69251412038b3a5c8598598f771a717eb45ccc75b570717e46927c218d
|
|
7
|
+
data.tar.gz: 18c4afdb60659f9e7b1604062e68ed967d6b65df7db96ac4309f8dc3ff0d2be783a6e53d292f2a2188841eee783a1a7a263137deaa648de58e96c06c93ac853c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0 - 2026-07-05
|
|
4
|
+
|
|
5
|
+
- Identification now returns ranked `candidates` (up to 3, each `{common_name:, species:, score:}`) on every `Result`, populated straight from the structured-output schema.
|
|
6
|
+
- 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).
|
|
7
|
+
- **Breaking:** consensus disagreement `candidates` changed from an array of `Result` objects to the same ranked-hash shape, scored by vote share.
|
|
8
|
+
- `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.
|
|
9
|
+
- 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).
|
|
10
|
+
|
|
11
|
+
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/README.md
CHANGED
|
@@ -22,10 +22,13 @@ 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 # Default: :anthropic
|
|
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 # Image resolution sent to provider (default)
|
|
31
|
+
c.tools = [] # RubyLLM tools available to every identification (default: none)
|
|
29
32
|
end
|
|
30
33
|
```
|
|
31
34
|
|
|
@@ -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,6 +81,37 @@ 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
|
+
param :species, desc: "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
117
|
Run identification through two models independently. When both agree on species, you get high confidence. When they disagree, you get the candidates:
|
|
@@ -83,7 +123,7 @@ if result.confident?
|
|
|
83
123
|
puts "Both models agree: #{result.species}"
|
|
84
124
|
else
|
|
85
125
|
puts "Models disagree:"
|
|
86
|
-
result.candidates.each { |c| puts " #{c
|
|
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 | Estimated USD cost (based on built-in rate tables, or `nil`) |
|
|
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:
|
|
@@ -3,20 +3,22 @@
|
|
|
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
|
+
attr_accessor :provider, :model, :location, :consensus_models, :tips_model, :media_resolution, :tools
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
@provider = :anthropic
|
|
10
|
-
@model = "claude-sonnet-4"
|
|
10
|
+
@model = "claude-sonnet-4-5"
|
|
11
11
|
@location = nil
|
|
12
|
-
@consensus_models = %w[claude-sonnet-4 claude-haiku-4]
|
|
13
|
-
@tips_model = "claude-haiku-4"
|
|
12
|
+
@consensus_models = %w[claude-sonnet-4-5 claude-haiku-4-5]
|
|
13
|
+
@tips_model = "claude-haiku-4-5"
|
|
14
14
|
@media_resolution = :high
|
|
15
|
+
@tools = []
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def initialize_copy(source)
|
|
18
19
|
super
|
|
19
20
|
@consensus_models = source.consensus_models.dup
|
|
21
|
+
@tools = source.tools.dup
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
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 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,6 +69,7 @@ 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
|
|
@@ -78,7 +82,24 @@ module FeatherAi
|
|
|
78
82
|
confidence: :low,
|
|
79
83
|
region_native: false,
|
|
80
84
|
model_id: nil,
|
|
81
|
-
candidates: results
|
|
85
|
+
candidates: ranked_candidates(results)
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Rank disagreeing identifications by vote share across the consensus models.
|
|
90
|
+
def ranked_candidates(results)
|
|
91
|
+
results.group_by { |r| r.species&.strip&.downcase }
|
|
92
|
+
.values
|
|
93
|
+
.map { |group| vote_candidate(group, results.size) }
|
|
94
|
+
.sort_by { |candidate| -candidate[:score] }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def vote_candidate(group, total)
|
|
98
|
+
primary = group.first
|
|
99
|
+
{
|
|
100
|
+
common_name: primary.common_name,
|
|
101
|
+
species: primary.species,
|
|
102
|
+
score: group.size.to_f / total
|
|
82
103
|
}
|
|
83
104
|
end
|
|
84
105
|
|
|
@@ -112,4 +133,5 @@ module FeatherAi
|
|
|
112
133
|
dup_config
|
|
113
134
|
end
|
|
114
135
|
end
|
|
136
|
+
# rubocop:enable Metrics/ClassLength
|
|
115
137
|
end
|
|
@@ -13,6 +13,15 @@ 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
27
|
# Approximate mid-2025 rates (USD per 1M tokens).
|
|
@@ -27,10 +36,11 @@ module FeatherAi
|
|
|
27
36
|
|
|
28
37
|
# @param image [String, Array<String>, nil] path(s) to image file(s)
|
|
29
38
|
# @param audio [String, nil] path to audio file
|
|
30
|
-
|
|
39
|
+
# @param tools [Array, nil] RubyLLM tools (classes or instances) the model may call for grounding
|
|
40
|
+
def identify(image = nil, audio = nil, location: nil, tools: nil)
|
|
31
41
|
images = normalize_images(image)
|
|
32
42
|
validate_inputs!(images, audio)
|
|
33
|
-
run_identification(images, audio, location || @config.location)
|
|
43
|
+
run_identification(images, audio, location || @config.location, Array(tools || @config.tools))
|
|
34
44
|
end
|
|
35
45
|
|
|
36
46
|
private
|
|
@@ -44,12 +54,12 @@ module FeatherAi
|
|
|
44
54
|
end
|
|
45
55
|
end
|
|
46
56
|
|
|
47
|
-
def run_identification(images, audio, effective_location)
|
|
57
|
+
def run_identification(images, audio, effective_location, tools)
|
|
48
58
|
source = derive_source(images, audio)
|
|
49
59
|
payload = instrumentation_payload(effective_location, images, audio)
|
|
50
60
|
|
|
51
61
|
Instrumentation.instrument("identify.feather_ai", payload) do
|
|
52
|
-
response, duration_ms = perform_identification(images, audio, effective_location)
|
|
62
|
+
response, duration_ms = perform_identification(images, audio, effective_location, tools)
|
|
53
63
|
result = build_result(response, duration_ms, source)
|
|
54
64
|
payload[:result] = result
|
|
55
65
|
result
|
|
@@ -72,8 +82,8 @@ module FeatherAi
|
|
|
72
82
|
}
|
|
73
83
|
end
|
|
74
84
|
|
|
75
|
-
def perform_identification(images, audio, location)
|
|
76
|
-
chat = configure_chat(location)
|
|
85
|
+
def perform_identification(images, audio, location, tools)
|
|
86
|
+
chat = configure_chat(location, tools)
|
|
77
87
|
prompt = build_text_prompt(images, audio)
|
|
78
88
|
attachments = images.any? ? images : nil
|
|
79
89
|
|
|
@@ -84,10 +94,11 @@ module FeatherAi
|
|
|
84
94
|
[response, duration_ms]
|
|
85
95
|
end
|
|
86
96
|
|
|
87
|
-
def configure_chat(location)
|
|
97
|
+
def configure_chat(location, tools)
|
|
88
98
|
chat = RubyLLM.chat(model: @config.model)
|
|
89
|
-
chat.with_instructions(system_prompt(location))
|
|
99
|
+
chat.with_instructions(system_prompt(location, tools))
|
|
90
100
|
chat.with_schema(SCHEMA)
|
|
101
|
+
chat.with_tools(*tools) if tools.any?
|
|
91
102
|
chat.with_params(**generation_params) if generation_params.any?
|
|
92
103
|
chat
|
|
93
104
|
end
|
|
@@ -117,10 +128,21 @@ module FeatherAi
|
|
|
117
128
|
family: parsed["family"],
|
|
118
129
|
confidence: parsed["confidence"],
|
|
119
130
|
region_native: parsed["region_native"],
|
|
131
|
+
candidates: normalize_candidates(parsed["candidates"]),
|
|
120
132
|
photography_tips_loader: tips_loader(parsed)
|
|
121
133
|
}
|
|
122
134
|
end
|
|
123
135
|
|
|
136
|
+
def normalize_candidates(candidates)
|
|
137
|
+
Array(candidates).map do |candidate|
|
|
138
|
+
{
|
|
139
|
+
common_name: candidate["common_name"],
|
|
140
|
+
species: candidate["species"],
|
|
141
|
+
score: candidate["score"]
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
124
146
|
def response_observability_attrs(response, duration_ms, source)
|
|
125
147
|
{
|
|
126
148
|
model_id: response.model_id,
|
|
@@ -163,12 +185,17 @@ module FeatherAi
|
|
|
163
185
|
((input_tokens * rates[:input]) + (output_tokens * rates[:output])) / 1_000_000.0
|
|
164
186
|
end
|
|
165
187
|
|
|
166
|
-
def system_prompt(location)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
def system_prompt(location, tools = [])
|
|
189
|
+
prompt = base_system_prompt
|
|
190
|
+
if location
|
|
191
|
+
prompt += " The observer is located in #{location} — " \
|
|
192
|
+
"prioritise species native to that region and consider regional plumage variations."
|
|
193
|
+
end
|
|
194
|
+
if tools.any?
|
|
195
|
+
prompt += " Use the provided lookup tools to verify species occurrence " \
|
|
196
|
+
"for the observer's region before committing."
|
|
197
|
+
end
|
|
198
|
+
prompt
|
|
172
199
|
end
|
|
173
200
|
|
|
174
201
|
def base_system_prompt
|
|
@@ -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
|
@@ -33,11 +33,12 @@ module FeatherAi
|
|
|
33
33
|
# Identify a bird from image(s) and/or audio.
|
|
34
34
|
# @param image [String, Array<String>, nil] path(s) to image file(s)
|
|
35
35
|
# @param audio [String, nil] path to audio file
|
|
36
|
-
|
|
36
|
+
# @param tools [Array, nil] RubyLLM tools (classes or instances) the model may call for grounding
|
|
37
|
+
def identify(image = nil, audio = nil, location: nil, consensus: false, tools: nil)
|
|
37
38
|
if consensus
|
|
38
|
-
Consensus.new.identify(image, audio, location: location)
|
|
39
|
+
Consensus.new.identify(image, audio, location: location, tools: tools)
|
|
39
40
|
else
|
|
40
|
-
Identifier.new.identify(image, audio, location: location)
|
|
41
|
+
Identifier.new.identify(image, audio, location: location, tools: tools)
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brandyn Britton
|
|
@@ -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: []
|