Project

feather-ai

0.0
The project is in a healthy, maintained state
A Ruby gem for identifying birds from photos and audio using RubyLLM. Adds multi-modal identification, location-aware results, multi-model consensus, and a Rails integration.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 1.0
 Project Readme

FeatherAi

Gem Version

A Ruby gem for identifying birds from photos and audio using RubyLLM. FeatherAi adds multi-modal identification, location-aware results, multi-model consensus, and a Rails integration on top of RubyLLM.

Installation

Add to your Gemfile:

gem "feather-ai"

Or install directly:

gem install feather-ai

Configuration

FeatherAi.configure do |c|
  c.provider         = :anthropic           # Default: :anthropic
  c.model            = "claude-sonnet-4-5"  # Default: "claude-sonnet-4-5"
  c.location         = "Perth, WA"          # Optional: biases results to local species
  c.consensus_models = ["claude-sonnet-4-5", "claude-haiku-4-5"]  # Models used in consensus mode
  c.tips_model       = "claude-haiku-4-5"   # Model for photography tips (default)
  c.media_resolution = :high                # Image resolution sent to provider (default)
  c.tools            = []                   # RubyLLM tools available to every identification (default: none)
end

RubyLLM must be configured with your provider credentials before using FeatherAi. See the RubyLLM docs for setup.

Usage

Basic Identification

Identify a bird from an image:

result = FeatherAi.identify("path/to/bird.jpg")

result.common_name   # => "Western Magpie"
result.species       # => "Gymnorhina tibicen"
result.family        # => "Artamidae"
result.confidence    # => :high
result.confident?    # => true
result.region_native? # => true

Identify from audio:

result = FeatherAi.identify(nil, "path/to/bird_call.mp3")

Identify from multiple images at once:

result = FeatherAi.identify(["front.jpg", "side.jpg"])

Identify from both image and audio:

result = FeatherAi.identify("path/to/bird.jpg", "path/to/bird_call.mp3")

Location-Aware Results

Pass a location to bias the model toward species native to that region:

result = FeatherAi.identify("path/to/bird.jpg", location: "Perth, Western Australia")

result.region_native? # => true/false based on species range

A default location can also be set globally in configuration.

Ranked Candidates

Every identification returns up to three candidate species ranked by likelihood, with the top pick first:

result = FeatherAi.identify("path/to/bird.jpg")

result.candidates
# => [{ common_name: "Splendid Fairywren", species: "Malurus splendens", score: 0.9 },
#     { common_name: "Superb Fairywren",   species: "Malurus cyaneus",   score: 0.1 }]

Grounded Identification (Tools)

Pass RubyLLM tools so the model can verify its identification against real data — for example a species/region lookup backed by your own database:

class SpeciesLookupTool < RubyLLM::Tool
  description "Looks up whether a species occurs in a region"
  param :species, desc: "Scientific species name"

  def execute(species:)
    Species.find_by(scientific_name: species)&.slice(:regions, :description) || { found: false }
  end
end

result = FeatherAi.identify("path/to/bird.jpg", location: "Perth, WA", tools: [SpeciesLookupTool])

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.

Consensus Mode

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:

result = FeatherAi.identify("path/to/bird.jpg", consensus: true)

if result.confident?
  puts "Both models agree: #{result.species}"
else
  puts "Models disagree — best guess: #{result.species}"
  result.candidates.each { |c| puts "  #{c[:common_name]} (#{c[:species]}) — #{c[:score]}" }
end

Consensus models are configurable:

FeatherAi.configure do |c|
  c.consensus_models = ["claude-sonnet-4-5", "claude-haiku-4-5"]
end

Photography Tips

Results expose lazy-loaded photography tips for the identified species. The tips are only fetched (via a fast, cheap model) when you access them:

tips = result.photography_tips

tips[:time_of_day]  # => "Early morning or late afternoon for soft light"
tips[:approach]     # => "Move slowly and quietly, approach from below sight line"
tips[:settings]     # => "1/500s or faster, f/2.8-f/4, ISO 400-1600"
tips[:habitat]      # => "Open woodlands, grasslands, and suburban parks"

Result Object

All identification calls return a FeatherAi::Result:

Method Type Description
common_name String Common name (e.g. "Western Magpie")
species String Scientific name (e.g. "Gymnorhina tibicen")
family String Bird family (e.g. "Artamidae")
confidence Symbol :high, :medium, or :low
confident? Boolean true when confidence is :high
region_native? Boolean Whether species is native to the given region
candidates Array Ranked candidate hashes (common_name, species, score); vote-share ranked on consensus disagreement
photography_tips Hash Lazy-loaded shooting advice
to_h Hash All fields as a plain hash

Every result also carries observability data from the LLM call:

Method Type Description
reasoning String Step-by-step visual analysis the model performed
model_id String Model that produced the identification
input_tokens Integer Tokens sent to the model
output_tokens Integer Tokens received from the model
cost Float Estimated USD cost (based on built-in rate tables, or nil)
duration_ms Integer Wall-clock time of the LLM call in milliseconds
source Symbol :vision, :audio, or :multimodal
consensus_models Array Models used when consensus mode was enabled

Rails Integration

Setup

Run the install generator to scaffold the migration:

rails generate feather_ai:install
# or with a custom model name:
rails generate feather_ai:install observation

Run the migration:

rails db:migrate

Model

Add acts_as_sighting to your ActiveRecord model. The model must have a photo attribute (ActiveStorage) and a location string column:

class Sighting < ApplicationRecord
  has_one_attached :photo
  acts_as_sighting
end

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.

Identifying Records

Call identify! on any instance to run identification and persist the results:

sighting = Sighting.create!(photo: params[:photo], location: "Perth, WA")
result = sighting.identify!

sighting.common_name  # => "Western Magpie"
sighting.species      # => "Gymnorhina tibicen"
sighting.confident?   # => true (delegated through result)

identify! downloads the attached photo, calls FeatherAi.identify, updates the record's identification columns, and returns the FeatherAi::Result.

Corrections

Users or moderators can correct AI identifications. First, run the corrections generator to add the necessary columns:

rails generate feather_ai:add_corrections
# or with a custom model name:
rails generate feather_ai:add_corrections observation

Then apply corrections to a record:

sighting.correct!(common_name: "Australian Magpie", species: "Gymnorhina tibicen dorsalis")

sighting.corrected?      # => true
sighting.corrected_at    # => 2026-03-25 12:00:00 UTC

sighting.correction_delta
# => { common_name: { from: "Western Magpie", to: "Australian Magpie" },
#      species:     { from: "Gymnorhina tibicen", to: "Gymnorhina tibicen dorsalis" } }

Correctable fields: common_name, species, family, confidence, region_native.

Instrumentation

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.

ActiveSupport::Notifications.subscribe("identify.feather_ai") do |_name, _start, _finish, _id, payload|
  Rails.logger.info "Identified #{payload[:result].common_name} " \
                     "with model=#{payload[:model]} in #{payload[:result].duration_ms}ms"
end

Payload keys: model, location, has_image, image_count, has_audio, and result (the FeatherAi::Result).

Error Handling

FeatherAi raises specific error classes, all inheriting from FeatherAi::Error:

  • FeatherAi::ConfigurationError — invalid or missing configuration (e.g. no image or audio provided)
  • FeatherAi::IdentificationError — failure during the LLM identification call
begin
  FeatherAi.identify("path/to/bird.jpg")
rescue FeatherAi::ConfigurationError => e
  # handle bad config
rescue FeatherAi::IdentificationError => e
  # handle LLM failure
end

Development

bin/setup           # Install dependencies
bundle exec rspec   # Run tests
bundle exec rubocop # Lint
bundle exec rake    # Tests + lint (same as CI)
bin/console         # Interactive console with gem loaded

Tests use VCR + WebMock to record and replay LLM responses — no API keys are required to run the test suite.

Use FeatherAi.reset! to clear configuration between test examples:

after { FeatherAi.reset! }

Thread Safety

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:

# In an initialiser or boot file — before any threads are created
FeatherAi.configure do |c|
  c.provider = :anthropic
  c.model    = "claude-sonnet-4"
end

FeatherAi.identify is stateless per-call — each invocation constructs its own Identifier or Consensus instance and RubyLLM::Chat session. Concurrent calls are safe.

FeatherAi::Consensus parallelises model calls using Ruby threads (Thread.new), so two LLM requests run concurrently and the total wall-clock time is roughly that of the slower model, not the sum of both.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/Birdup-Australia/Feather.

License

The gem is available as open source under the terms of the MIT License.