feather-ai 0.2.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.
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatherAi
4
+ # Multi-model consensus identification to improve accuracy.
5
+ class Consensus
6
+ def initialize(config: FeatherAi.configuration)
7
+ @config = config
8
+ @models = config.consensus_models
9
+ end
10
+
11
+ def identify(image = nil, audio = nil, location: nil)
12
+ payload = { models: @models, location: location || @config.location }
13
+
14
+ Instrumentation.instrument("consensus.feather_ai", payload) do
15
+ results = fetch_results_from_models(image, audio, location)
16
+ shared_attrs = aggregate_metrics(results)
17
+ result = build_consensus_result(results, shared_attrs)
18
+
19
+ payload[:agreed] = result.confident?
20
+ payload[:result] = result
21
+ result
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def fetch_results_from_models(image, audio, location)
28
+ @models.map do |model|
29
+ config_for_model = config_with_model(model)
30
+ Thread.new { Identifier.new(config: config_for_model).identify(image, audio, location: location) }
31
+ end.map(&:value)
32
+ end
33
+
34
+ def aggregate_metrics(results)
35
+ {
36
+ consensus_models: @models,
37
+ input_tokens: sum_field(results, :input_tokens),
38
+ output_tokens: sum_field(results, :output_tokens),
39
+ duration_ms: sum_field(results, :duration_ms),
40
+ cost: sum_field(results, :cost),
41
+ source: results.first&.source
42
+ }
43
+ end
44
+
45
+ def build_consensus_result(results, shared_attrs)
46
+ if agree?(results)
47
+ build_agreed_result(results.first, shared_attrs)
48
+ else
49
+ build_disagreed_result(results, shared_attrs)
50
+ end
51
+ end
52
+
53
+ def build_agreed_result(primary, shared_attrs)
54
+ Result.new(**shared_attrs, **agreed_result_attrs(primary))
55
+ end
56
+
57
+ def build_disagreed_result(results, shared_attrs)
58
+ Result.new(**shared_attrs, **disagreed_result_attrs(results))
59
+ end
60
+
61
+ def agreed_result_attrs(primary)
62
+ {
63
+ common_name: primary.common_name,
64
+ species: primary.species,
65
+ family: primary.family,
66
+ confidence: :high,
67
+ region_native: primary.region_native?,
68
+ model_id: primary.model_id,
69
+ photography_tips_loader: tips_loader_for(primary)
70
+ }
71
+ end
72
+
73
+ def disagreed_result_attrs(results)
74
+ {
75
+ common_name: nil,
76
+ species: nil,
77
+ family: calculate_agreed_family(results),
78
+ confidence: :low,
79
+ region_native: false,
80
+ model_id: nil,
81
+ candidates: results
82
+ }
83
+ end
84
+
85
+ def tips_loader_for(primary)
86
+ config = @config
87
+ lambda {
88
+ PhotographyTips.new(
89
+ species: primary.species,
90
+ common_name: primary.common_name,
91
+ config: config
92
+ ).fetch
93
+ }
94
+ end
95
+
96
+ def calculate_agreed_family(results)
97
+ results.map(&:family).uniq.length == 1 ? results.first.family : nil
98
+ end
99
+
100
+ def sum_field(results, field)
101
+ values = results.map(&field)
102
+ values.any?(&:nil?) ? nil : values.sum
103
+ end
104
+
105
+ def agree?(results)
106
+ results.map { |r| r.species&.strip&.downcase }.uniq.length == 1
107
+ end
108
+
109
+ def config_with_model(model)
110
+ dup_config = @config.dup
111
+ dup_config.model = model
112
+ dup_config
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatherAi
4
+ # Core bird identification using LLM vision and audio transcription.
5
+ # rubocop:disable Metrics/ClassLength
6
+ class Identifier
7
+ SCHEMA = RubyLLM::Schema.create do
8
+ string :common_name, description: "Common name of the bird"
9
+ string :species, description: "Scientific species name (Genus species)"
10
+ string :family, description: "Bird family name"
11
+ string :confidence, description: "Identification confidence: high, medium, or low"
12
+ boolean :region_native, description: "Whether this species is native to the given region"
13
+ end
14
+
15
+ # Approximate mid-2025 rates (USD per 1M tokens).
16
+ # Use your provider's dashboard for billing accuracy — these are estimates.
17
+ PROVIDER_RATES = {
18
+ anthropic: { input: 3.00, output: 15.00 }
19
+ }.freeze
20
+
21
+ def initialize(config: FeatherAi.configuration)
22
+ @config = config
23
+ end
24
+
25
+ def identify(image = nil, audio = nil, location: nil)
26
+ validate_inputs!(image, audio)
27
+
28
+ effective_location = location || @config.location
29
+ source = derive_source(image, audio)
30
+ payload = instrumentation_payload(effective_location, image, audio)
31
+
32
+ Instrumentation.instrument("identify.feather_ai", payload) do
33
+ response, duration_ms = perform_identification(image, audio, effective_location)
34
+ result = build_result(response, duration_ms, source)
35
+ payload[:result] = result
36
+ result
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def validate_inputs!(image, audio)
43
+ return unless image.nil? && audio.nil?
44
+
45
+ raise FeatherAi::ConfigurationError, "At least one of image or audio must be provided"
46
+ end
47
+
48
+ def instrumentation_payload(location, image, audio)
49
+ {
50
+ model: @config.model,
51
+ location: location,
52
+ has_image: !image.nil?,
53
+ has_audio: !audio.nil?
54
+ }
55
+ end
56
+
57
+ def perform_identification(image, audio, location)
58
+ chat = configure_chat(location)
59
+ message = build_message(image, audio)
60
+
61
+ start_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
62
+ response = chat.ask(message)
63
+ duration_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - start_ms
64
+
65
+ [response, duration_ms]
66
+ end
67
+
68
+ def configure_chat(location)
69
+ chat = RubyLLM.chat(model: @config.model)
70
+ chat.with_instructions(system_prompt(location))
71
+ chat.with_schema(SCHEMA)
72
+ chat
73
+ end
74
+
75
+ def build_result(response, duration_ms, source)
76
+ parsed = response.content
77
+ Result.new(
78
+ **parsed_identification_attrs(parsed),
79
+ **response_observability_attrs(response, duration_ms, source)
80
+ )
81
+ end
82
+
83
+ def parsed_identification_attrs(parsed)
84
+ {
85
+ common_name: parsed["common_name"],
86
+ species: parsed["species"],
87
+ family: parsed["family"],
88
+ confidence: parsed["confidence"],
89
+ region_native: parsed["region_native"],
90
+ photography_tips_loader: tips_loader(parsed)
91
+ }
92
+ end
93
+
94
+ def response_observability_attrs(response, duration_ms, source)
95
+ {
96
+ model_id: response.model_id,
97
+ input_tokens: response.input_tokens,
98
+ output_tokens: response.output_tokens,
99
+ cost: compute_cost(response.input_tokens, response.output_tokens),
100
+ duration_ms: duration_ms,
101
+ source: source
102
+ }
103
+ end
104
+
105
+ def tips_loader(parsed)
106
+ lambda {
107
+ PhotographyTips.new(
108
+ species: parsed["species"],
109
+ common_name: parsed["common_name"],
110
+ config: @config
111
+ ).fetch
112
+ }
113
+ end
114
+
115
+ def derive_source(image, audio)
116
+ if image && audio
117
+ :multimodal
118
+ elsif image
119
+ :vision
120
+ else
121
+ :audio
122
+ end
123
+ end
124
+
125
+ # Returns a USD cost estimate based on token counts, or nil when the count
126
+ # is unavailable or the configured provider has no rate table defined here.
127
+ def compute_cost(input_tokens, output_tokens)
128
+ return nil if input_tokens.nil? || output_tokens.nil?
129
+
130
+ rates = PROVIDER_RATES[@config.provider]
131
+ return nil if rates.nil?
132
+
133
+ ((input_tokens * rates[:input]) + (output_tokens * rates[:output])) / 1_000_000.0
134
+ end
135
+
136
+ def system_prompt(location)
137
+ base = "You are an expert ornithologist. Identify the bird from the provided image and/or audio. " \
138
+ "Return structured identification data."
139
+ return base unless location
140
+
141
+ "#{base} The observer is located in #{location} — prioritize species native to that region."
142
+ end
143
+
144
+ def build_message(image, audio)
145
+ parts = []
146
+
147
+ parts << { type: :image, content: image } if image
148
+
149
+ if audio
150
+ transcript = RubyLLM.transcribe(audio)
151
+ parts << { type: :text, content: "Bird call/song transcript: #{transcript}" }
152
+ end
153
+
154
+ parts << { type: :text, content: "Identify the bird shown and/or heard above." }
155
+
156
+ parts
157
+ end
158
+ end
159
+ # rubocop:enable Metrics/ClassLength
160
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatherAi
4
+ # Instrumentation hooks for ActiveSupport::Notifications.
5
+ module Instrumentation
6
+ def self.instrument(event_name, payload = {}, &)
7
+ if defined?(ActiveSupport::Notifications)
8
+ ActiveSupport::Notifications.instrument(event_name, payload, &)
9
+ else
10
+ yield
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatherAi
4
+ # Generates photography tips for identified bird species.
5
+ class PhotographyTips
6
+ SCHEMA = RubyLLM::Schema.create do
7
+ string :time_of_day, description: "Best time of day to photograph this species"
8
+ string :approach, description: "How to approach without disturbing the bird"
9
+ string :settings, description: "Recommended camera settings (shutter speed, aperture, ISO)"
10
+ string :habitat, description: "Where to find this species for photography"
11
+ end
12
+
13
+ def initialize(species:, common_name:, config: FeatherAi.configuration)
14
+ @species = species
15
+ @common_name = common_name
16
+ @config = config
17
+ end
18
+
19
+ def fetch
20
+ Instrumentation.instrument("photography_tips.feather_ai", instrumentation_payload) do
21
+ parsed = fetch_from_llm
22
+ build_tips_hash(parsed)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def instrumentation_payload
29
+ { species: @species, common_name: @common_name }
30
+ end
31
+
32
+ def fetch_from_llm
33
+ chat = RubyLLM.chat(model: @config.tips_model)
34
+ chat.with_schema(SCHEMA)
35
+ chat.ask(prompt).content
36
+ end
37
+
38
+ def build_tips_hash(parsed)
39
+ {
40
+ time_of_day: parsed["time_of_day"],
41
+ approach: parsed["approach"],
42
+ settings: parsed["settings"],
43
+ habitat: parsed["habitat"]
44
+ }
45
+ end
46
+
47
+ def prompt
48
+ "Provide concise bird photography tips for #{@common_name} (#{@species}). " \
49
+ "Include best time of day, how to approach, recommended camera settings, and ideal habitat for photography."
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatherAi
4
+ module Rails
5
+ module ActsAsSighting
6
+ CORRECTABLE_FIELDS = %i[common_name species family confidence region_native].freeze
7
+
8
+ # Class methods for ActiveRecord models.
9
+ module ClassMethods
10
+ def acts_as_sighting
11
+ include InstanceMethods
12
+ end
13
+ end
14
+
15
+ # Instance methods for bird sighting records.
16
+ module InstanceMethods
17
+ def identify!
18
+ photo_file = photo.download
19
+ result = FeatherAi.identify(photo_file, location: location)
20
+ update_from_result!(result)
21
+ result
22
+ ensure
23
+ close_photo_file(photo_file)
24
+ end
25
+
26
+ def correct!(attrs)
27
+ return if attrs.empty?
28
+
29
+ validate_correctable_fields!(attrs.keys)
30
+ update!(prefix_and_timestamp_attrs(attrs))
31
+ end
32
+
33
+ private
34
+
35
+ def update_from_result!(result)
36
+ update!(
37
+ common_name: result.common_name,
38
+ species: result.species,
39
+ family: result.family,
40
+ confidence: result.confidence.to_s,
41
+ region_native: result.region_native?
42
+ )
43
+ end
44
+
45
+ def close_photo_file(photo_file)
46
+ photo_file&.close! if photo_file.respond_to?(:close!)
47
+ end
48
+
49
+ def validate_correctable_fields!(keys)
50
+ invalid_keys = keys.map(&:to_sym) - CORRECTABLE_FIELDS
51
+ return if invalid_keys.empty?
52
+
53
+ raise ArgumentError,
54
+ "Unknown correctable field(s): #{invalid_keys.join(", ")}. " \
55
+ "Allowed fields: #{CORRECTABLE_FIELDS.join(", ")}"
56
+ end
57
+
58
+ def prefix_and_timestamp_attrs(attrs)
59
+ corrected_attrs = attrs.each_with_object({}) do |(field, value), hash|
60
+ hash[:"corrected_#{field}"] = value
61
+ end
62
+ corrected_attrs.merge(corrected_at: Time.current)
63
+ end
64
+
65
+ public
66
+
67
+ def corrected?
68
+ !corrected_at.nil?
69
+ end
70
+
71
+ def correction_delta
72
+ return {} unless corrected?
73
+
74
+ CORRECTABLE_FIELDS.each_with_object({}) do |field, delta|
75
+ corrected_value = public_send(:"corrected_#{field}")
76
+ next if corrected_value.nil?
77
+
78
+ # NOTE: `public_send(field)` reads the original AI column, not the
79
+ # corrected column, so :from always reflects the AI identification
80
+ # regardless of how many times correct! has been called.
81
+ delta[field] = { from: public_send(field), to: corrected_value }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatherAi
4
+ module Rails
5
+ # Rails integration via Railtie.
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "feather_ai.acts_as_sighting" do
8
+ ActiveSupport.on_load(:active_record) do
9
+ extend FeatherAi::Rails::ActsAsSighting::ClassMethods
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails/acts_as_sighting"
4
+ require_relative "rails/railtie"
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatherAi
4
+ # Immutable value object wrapping all identification output.
5
+ class Result
6
+ attr_reader :common_name, :species, :family, :confidence, :region_native, :candidates,
7
+ :input_tokens, :output_tokens, :cost, :model_id, :duration_ms, :source,
8
+ :consensus_models
9
+
10
+ def initialize(attrs = {})
11
+ assign_identification_attrs(attrs)
12
+ assign_photography_attrs(attrs)
13
+ assign_observability_attrs(attrs)
14
+ end
15
+
16
+ def confident?
17
+ @confidence == :high
18
+ end
19
+
20
+ def region_native?
21
+ @region_native == true
22
+ end
23
+
24
+ def photography_tips
25
+ return @photography_tips_data if defined?(@photography_tips_loaded)
26
+
27
+ @photography_tips_loaded = true
28
+ @photography_tips_data = @photography_tips_loader&.call
29
+ end
30
+
31
+ def to_h
32
+ identification_hash
33
+ .merge(observability_hash)
34
+ .merge(photography_hash)
35
+ end
36
+
37
+ private
38
+
39
+ def assign_identification_attrs(attrs)
40
+ @common_name = attrs[:common_name]
41
+ @species = attrs[:species]
42
+ @family = attrs[:family]
43
+ @confidence = attrs[:confidence]&.to_sym
44
+ @region_native = attrs[:region_native]
45
+ @candidates = attrs[:candidates] || []
46
+ end
47
+
48
+ def assign_photography_attrs(attrs)
49
+ @photography_tips_loader = attrs[:photography_tips_loader]
50
+ @photography_tips_data = attrs[:photography_tips]
51
+ @photography_tips_loaded = true if attrs.key?(:photography_tips)
52
+ end
53
+
54
+ def assign_observability_attrs(attrs)
55
+ @input_tokens = attrs[:input_tokens]
56
+ @output_tokens = attrs[:output_tokens]
57
+ @cost = attrs[:cost]
58
+ @model_id = attrs[:model_id]
59
+ @duration_ms = attrs[:duration_ms]
60
+ @source = attrs[:source]
61
+ @consensus_models = attrs[:consensus_models]
62
+ end
63
+
64
+ def identification_hash
65
+ {
66
+ common_name: @common_name,
67
+ species: @species,
68
+ family: @family,
69
+ confidence: @confidence,
70
+ confident: confident?,
71
+ region_native: region_native?,
72
+ candidates: @candidates
73
+ }
74
+ end
75
+
76
+ def observability_hash
77
+ {
78
+ model_id: @model_id,
79
+ input_tokens: @input_tokens,
80
+ output_tokens: @output_tokens,
81
+ cost: @cost,
82
+ duration_ms: @duration_ms,
83
+ source: @source,
84
+ consensus_models: @consensus_models
85
+ }
86
+ end
87
+
88
+ def photography_hash
89
+ { photography_tips: photography_tips }
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatherAi
4
+ VERSION = "0.2.0"
5
+ end
data/lib/feather_ai.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "ruby_llm/schema"
5
+
6
+ require_relative "feather_ai/version"
7
+ require_relative "feather_ai/configuration"
8
+ require_relative "feather_ai/instrumentation"
9
+ require_relative "feather_ai/result"
10
+ require_relative "feather_ai/photography_tips"
11
+ require_relative "feather_ai/identifier"
12
+ require_relative "feather_ai/consensus"
13
+
14
+ # Top-level module for bird identification using LLMs.
15
+ module FeatherAi
16
+ class Error < StandardError; end
17
+ class ConfigurationError < Error; end
18
+ class IdentificationError < Error; end
19
+
20
+ class << self
21
+ def configuration
22
+ @configuration ||= Configuration.new
23
+ end
24
+
25
+ def configure
26
+ yield configuration
27
+ end
28
+
29
+ def reset!
30
+ @configuration = nil
31
+ end
32
+
33
+ def identify(image = nil, audio = nil, location: nil, consensus: false)
34
+ if consensus
35
+ Consensus.new.identify(image, audio, location: location)
36
+ else
37
+ Identifier.new.identify(image, audio, location: location)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module FeatherAi
7
+ module Generators
8
+ # Rails generator for adding user correction fields.
9
+ class AddCorrectionsGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Creates a migration to add user correction fields to your sighting model"
15
+
16
+ argument :model_name, type: :string, default: "sighting",
17
+ desc: "Name of the model to add correction fields to"
18
+
19
+ def self.next_migration_number(path)
20
+ next_migration_number = current_migration_number(path) + 1
21
+ ::ActiveRecord::Migration.next_migration_number(next_migration_number)
22
+ end
23
+
24
+ def create_migration
25
+ migration_template(
26
+ "correction_migration.rb.tt",
27
+ "db/migrate/add_feather_ai_correction_fields_to_#{model_name.underscore.pluralize}.rb"
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module FeatherAi
7
+ module Generators
8
+ # Rails generator for installing FeatherAi bird identification fields.
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Creates a migration to add bird identification fields to your sighting model"
15
+
16
+ argument :model_name, type: :string, default: "sighting",
17
+ desc: "Name of the model to add sighting fields to"
18
+
19
+ def self.next_migration_number(path)
20
+ next_migration_number = current_migration_number(path) + 1
21
+ ::ActiveRecord::Migration.next_migration_number(next_migration_number)
22
+ end
23
+
24
+ def create_migration
25
+ migration_template(
26
+ "migration.rb.tt",
27
+ "db/migrate/add_feather_ai_fields_to_#{model_name.underscore.pluralize}.rb"
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,10 @@
1
+ class AddFeatherAiCorrectionFieldsTo<%= model_name.camelize.pluralize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :<%= model_name.underscore.pluralize %>, :corrected_common_name, :string
4
+ add_column :<%= model_name.underscore.pluralize %>, :corrected_species, :string
5
+ add_column :<%= model_name.underscore.pluralize %>, :corrected_family, :string
6
+ add_column :<%= model_name.underscore.pluralize %>, :corrected_confidence, :string
7
+ add_column :<%= model_name.underscore.pluralize %>, :corrected_region_native, :boolean
8
+ add_column :<%= model_name.underscore.pluralize %>, :corrected_at, :datetime
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ class AddFeatherAiFieldsTo<%= model_name.camelize.pluralize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :<%= model_name.underscore.pluralize %>, :common_name, :string
4
+ add_column :<%= model_name.underscore.pluralize %>, :species, :string
5
+ add_column :<%= model_name.underscore.pluralize %>, :family, :string
6
+ add_column :<%= model_name.underscore.pluralize %>, :confidence, :string
7
+ add_column :<%= model_name.underscore.pluralize %>, :region_native, :boolean
8
+ end
9
+ end