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.
- checksums.yaml +7 -0
- data/.idea/.gitignore +10 -0
- data/.idea/Feather.iml +78 -0
- data/.idea/copilot.data.migration.ask2agent.xml +6 -0
- data/.idea/modules.xml +8 -0
- data/.idea/vcs.xml +6 -0
- data/.rspec +3 -0
- data/.rubocop.yml +12 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +87 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +205 -0
- data/Rakefile +12 -0
- data/lib/feather_ai/configuration.rb +21 -0
- data/lib/feather_ai/consensus.rb +115 -0
- data/lib/feather_ai/identifier.rb +160 -0
- data/lib/feather_ai/instrumentation.rb +14 -0
- data/lib/feather_ai/photography_tips.rb +52 -0
- data/lib/feather_ai/rails/acts_as_sighting.rb +87 -0
- data/lib/feather_ai/rails/railtie.rb +14 -0
- data/lib/feather_ai/rails.rb +4 -0
- data/lib/feather_ai/result.rb +92 -0
- data/lib/feather_ai/version.rb +5 -0
- data/lib/feather_ai.rb +41 -0
- data/lib/generators/feather_ai/add_corrections_generator.rb +32 -0
- data/lib/generators/feather_ai/install_generator.rb +32 -0
- data/lib/generators/feather_ai/templates/correction_migration.rb.tt +10 -0
- data/lib/generators/feather_ai/templates/migration.rb.tt +9 -0
- data/sig/Feather.rbs +91 -0
- metadata +89 -0
|
@@ -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,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
|
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
|