fino-redis 1.9.1 → 1.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af692af92df29315432f0ba40738f8fef5ac0a3ad9eef3cc6b255e7012bb39f9
4
- data.tar.gz: 54c19833f212f5010a1bc368b796a6368a0590689bc224cf0197dcfb9cb42a0c
3
+ metadata.gz: dd947fdfbb60faf85772aa56e55c2b083e091c1763ae9aeb1e258a74f6d070f8
4
+ data.tar.gz: abd69324d6697b67f6dcd7a1028f65307f5be8d7ee7829878001bddb92ba82f9
5
5
  SHA512:
6
- metadata.gz: b2e018e28ad7cb6b9a677aa71706e114ba26f1948597e77af973b883f4789ed4a1dd98f640ffedaf1f479996a2cc23b7f7a63fef7d1bdaf893bcba733801b89d
7
- data.tar.gz: 65952b10d45da818e6a362e1f3a474563ec24943d8721730f5bcc896388a1c8901b1edc71f8b94cf599b256c736cd6c6d6a88dc6886b556a55596207447c0633
6
+ metadata.gz: 5b0e4a1ab2b1c46beee78244f43aac3c90a19e7dac495e296f993ec4a8e0d2d9aeb1dc5e55d80e41d4a72169d614b80d6f6b3c985dd72db365cbe1623fdfc936
7
+ data.tar.gz: 4023d962fbf8642dc144ff90cb7955e456eef1e2deac72def575fc856a5a2eb6f2b2eb447a0be5fcc46f8f999e12603f27403e55d075d5b0b94f69925f3923a2
data/README.md CHANGED
@@ -83,6 +83,126 @@ Fino.disable(:maintenance_mode, for: "qa")
83
83
  Fino.enabled?(:maintenance_mode, for: "qa") #=> false
84
84
  ```
85
85
 
86
+ ### Select setting
87
+
88
+ The simplest way to define select setting in fino is the following
89
+
90
+ ```ruby
91
+ Fino.configure do
92
+ # ...
93
+ section :storefront, label: "Storefront" do
94
+ setting :purchase_button_color,
95
+ :select,
96
+ options: [
97
+ Fino::Settings::Select::Option.new(label: "Red", value: "red"),
98
+ Fino::Settings::Select::Option.new(label: "Blue", value: "blue")
99
+ ],
100
+ default: "red",
101
+ description: "Color of the purchase button"
102
+ end
103
+ # ...
104
+ end
105
+ ```
106
+
107
+ Options must be an array of `Fino::Settings::Select::Option` instances
108
+
109
+ Then you can interact with the setting like that
110
+
111
+ ```ruby
112
+ # Read selected option
113
+ selected_option = Fino.value(:purchase_button_color, at: :storefront)
114
+ # => #<Fino::Settings::Select::Option:0x0000000124fecd90 @label="Red", @metadata={}, @value="red">
115
+
116
+ # Read setting value
117
+ selected_option.value
118
+ # => "red"
119
+
120
+ # Read options
121
+ Fino.setting(:purchase_button_color, at: :storefront).options
122
+ # => [#<Fino::Settings::Select::Option:0x0000000124fecd90 @label="Red", @metadata={}, @value="red">,
123
+ #<Fino::Settings::Select::Option:0x0000000124fecca0 @label="Blue", @metadata={}, @value="blue">]
124
+ ```
125
+
126
+ #### Dynamic option
127
+
128
+ Options can also be defined dynamically using any callable object. Let's take a look at dynamic options in an example
129
+ of LLM model setting using [RubyLLM](https://github.com/crmne/ruby_llm) by @crmne
130
+
131
+ To define dynamic select options, use `:select` as setting type and provide a callable to `options`. Your object's
132
+ `call` method might be called with `refresh` option which is true only when user initiates options refresh manually.
133
+ This is useful for updating models list in RubyLLM for example
134
+
135
+ ```ruby
136
+ section :llm, label: "LLM" do
137
+ setting :model,
138
+ :select,
139
+ options: proc { |refresh:|
140
+ RubyLLM.models.refresh! if refresh
141
+ models = RubyLLM.models.chat_models
142
+
143
+ openai_models = models.by_provider(:openai)
144
+ anthropic_models = models.by_provider(:anthropic)
145
+
146
+ build_pricing_label = proc do |model|
147
+ text_pricing = model.pricing&.text_tokens
148
+ next unless text_pricing && text_pricing.input && text_pricing.output
149
+
150
+ "$#{text_pricing.input} / $#{text_pricing.output} per 1M tokens"
151
+ end
152
+
153
+ [*openai_models, *anthropic_models].map do |model|
154
+ Fino::Settings::Select::Option.new(
155
+ label: model.name,
156
+ value: model.id,
157
+ metadata: {
158
+ provider: model.provider_class.name,
159
+ pricing: build_pricing_label.call(model)
160
+ }.compact
161
+ )
162
+ end
163
+ },
164
+ default: "gpt-5",
165
+ description: "Chat model for AI-powered features"
166
+ end
167
+ end
168
+ ```
169
+
170
+ ```ruby
171
+ # Read selected option
172
+ selected_option = Fino.setting(:model, at: :llm).value
173
+ # => #<Fino::Settings::Select::Option:0x0000000126257728
174
+ # @label="GPT-4",
175
+ # @metadata={provider: "OpenAI", pricing: "$30 / $60 per 1M tokens"},
176
+ # @value="gpt-4">
177
+
178
+ # Read setting value
179
+ selected_option.value
180
+ # => "gpt-4"
181
+
182
+ # Read options
183
+ Fino.setting(:model, at: :llm).options
184
+ # => [#<Fino::Settings::Select::Option:0x000000012629e448
185
+ # @label="GPT-5.3 Codex Spark",
186
+ # @metadata={provider: "OpenAI", pricing: "$1.75 / $14 per 1M tokens"},
187
+ # @value="gpt-5.3-codex-spark">,
188
+ # #<Fino::Settings::Select::Option:0x000000012629e1c8
189
+ # @label="GPT-5.4",
190
+ # @metadata={provider: "OpenAI", pricing: "$2.5 / $15 per 1M tokens"},
191
+ # @value="gpt-5.4">, ...]
192
+
193
+ # Refresh options
194
+ Fino.setting(:model, at: :llm).refresh!
195
+ # I, [2026-03-22T18:23:13.615270 #67293] INFO -- RubyLLM: Fetching models from providers:
196
+ # I, [2026-03-22T18:23:13.615934 #67293] INFO -- RubyLLM: Fetching models from models.dev API...
197
+ ```
198
+
199
+ #### Use with RubyLLM
200
+
201
+ ```ruby
202
+ chat = RubyLLM.chat(model: Fino.setting(:model, at: :llm).value)
203
+ chat.ask "Why Ruby?"
204
+ ```
205
+
86
206
  ### Overrides
87
207
 
88
208
  ```ruby
@@ -116,6 +236,44 @@ Fino.value(:model, at: :openai, for: "user_1") #=> "gpt-6"
116
236
  Fino.value(:model, at: :openai, for: "user_2") #=> "gpt-5"
117
237
  ```
118
238
 
239
+ #### Experiment analysis
240
+
241
+ Fino adapters might support A/B testing analysis, both built-in `solid` and `redis` adapters do
242
+
243
+ When you run an A/B test for a setting, fino automatically calculates variant based on a stable identifier you pass as
244
+ a `for` option
245
+
246
+ ```ruby
247
+ Fino.set(model: "gpt-5", at: :openai, variants: { 20.0 => "gpt-6" })
248
+
249
+ Fino.value(:model, at: :openai, for: "user_1") #=> "gpt-6"
250
+ Fino.value(:model, at: :openai, for: "user_1") #=> "gpt-6"
251
+
252
+ Fino.value(:model, at: :openai, for: "user_2") #=> "gpt-5"
253
+ ```
254
+
255
+ Later in your code, when user performs a "desired" action, simply call
256
+
257
+ ```ruby
258
+ Fino.convert!(:model, at: :openai, for: "user_2")
259
+ ```
260
+
261
+ to record a "convertion" for `user_2`. As Fino knows the right variant for `user_2`, conversion will be counted
262
+ todards it. Thanks to that later you'll be able to call
263
+
264
+ ```ruby
265
+ Fino.analyse(:model, at: :openai)
266
+ ```
267
+
268
+ to receive a detailed report over variants performance. Also bar charts comparing all variants and a chart displaying
269
+ amount of conversions over time per variant will be accessible on UI with `fino-rails`
270
+
271
+ To reset analysis data for an experiment:
272
+
273
+ ```ruby
274
+ Fino.reset_analysis!(:model, at: :openai)
275
+ ```
276
+
119
277
  ### Unit conversion
120
278
 
121
279
  Fino is able to convert numeric settings into various units
@@ -258,6 +416,14 @@ end
258
416
  end
259
417
  ```
260
418
 
419
+ ## Development
420
+
421
+ To create and mugrate dummy app db to test solid adapter, do
422
+
423
+ ```bash
424
+ cd spec/dummy && bin/rails db:create db:migrate
425
+ ```
426
+
261
427
  ## Releasing
262
428
 
263
429
  `rake release`
@@ -7,6 +7,9 @@ class Fino::Redis::Adapter
7
7
 
8
8
  DEFAULT_REDIS_NAMESPACE = "fino"
9
9
  SETTINGS_NAMESPACE = "s"
10
+ CONVERSIONS_NAMESPACE = "c"
11
+ CONVERSIONS_KEYS_NAMESPACE = "ck"
12
+ CONVERSIONS_TTL = 7 * 24 * 60 * 60 # 7 days
10
13
  PERSISTED_SETTINGS_KEYS_REDIS_KEY = "psl"
11
14
  SCOPE_PREFIX = "s"
12
15
  VARIANT_PREFIX = "v"
@@ -30,7 +33,7 @@ class Fino::Redis::Adapter
30
33
  end
31
34
 
32
35
  def write(setting_definition, value, overrides, variants)
33
- serialize_value = ->(raw_value) { setting_definition.type_class.serialize(raw_value) }
36
+ serialize_value = ->(raw_value) { setting_definition.serialize(raw_value) }
34
37
 
35
38
  hash = { VALUE_KEY => serialize_value.call(value) }
36
39
 
@@ -86,6 +89,44 @@ class Fino::Redis::Adapter
86
89
  end
87
90
  end
88
91
 
92
+ #
93
+ # A/B testing analysis
94
+ #
95
+
96
+ def record_ab_testing_conversion(setting_definition, variant, scope, time)
97
+ timestamp_ms = (time.to_f * 1000).to_i
98
+
99
+ key = build_redis_key(CONVERSIONS_NAMESPACE, setting_definition.key, variant.id)
100
+ tracking_key = build_redis_key(CONVERSIONS_KEYS_NAMESPACE, setting_definition.key)
101
+
102
+ redis.pipelined do |pipeline|
103
+ pipeline.zadd(key, timestamp_ms, scope.to_s, nx: true)
104
+ pipeline.expire(key, CONVERSIONS_TTL)
105
+ pipeline.sadd(tracking_key, key)
106
+ end
107
+ end
108
+
109
+ def read_ab_testing_conversions(setting_definition, variants)
110
+ keys = variants.map { |v| build_redis_key(CONVERSIONS_NAMESPACE, setting_definition.key, v.id) }
111
+
112
+ results = redis.pipelined do |pipeline|
113
+ keys.each { |key| pipeline.zrange(key, 0, -1, withscores: true) }
114
+ end
115
+
116
+ variants.zip(results).to_h
117
+ end
118
+
119
+ def clear_ab_testing_conversions(setting_key)
120
+ tracking_key = build_redis_key(CONVERSIONS_KEYS_NAMESPACE, setting_key)
121
+ keys = redis.smembers(tracking_key)
122
+ return unless keys.any?
123
+
124
+ redis.pipelined do |pipeline|
125
+ keys.each { |key| pipeline.del(key) }
126
+ pipeline.del(tracking_key)
127
+ end
128
+ end
129
+
89
130
  private
90
131
 
91
132
  attr_reader :redis, :redis_namespace
data/lib/fino/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fino
4
- VERSION = "1.9.1"
4
+ VERSION = "1.11.0"
5
5
  REQUIRED_RUBY_VERSION = ">= 3.2.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fino-redis
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.1
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Egor Iskrenkov
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 1.9.1
18
+ version: 1.11.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: 1.9.1
25
+ version: 1.11.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: redis
28
28
  requirement: !ruby/object:Gem::Requirement