jevalyn 0.1.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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevalyn
4
+ # A single typed question. Jev has exactly three kinds and Jevalyn validates the
5
+ # shape of each one when the Decision class is defined, so a malformed rubric is a
6
+ # boot-time error rather than a 422 in production.
7
+ class Question
8
+ # Jev accepts up to 255 options on a Choice.
9
+ MAX_CHOICE_OPTIONS = 255
10
+
11
+ # A Score needs at least two levels and takes up to ten.
12
+ MIN_SCORE_LEVELS = 2
13
+ MAX_SCORE_LEVELS = 10
14
+
15
+ TYPES = %i[noul choice score].freeze
16
+
17
+ attr_reader :name, :instructions, :criteria
18
+
19
+ def self.build(name, type:, instructions:, criteria: nil)
20
+ klass = case type.to_sym
21
+ when :noul then Noul
22
+ when :choice then Choice
23
+ when :score then Score
24
+ else
25
+ raise InvalidQuestionError,
26
+ "Unknown question type #{type.inspect} for :#{name}. " \
27
+ "Jev supports #{TYPES.map(&:inspect).join(", ")}."
28
+ end
29
+
30
+ klass.new(name, instructions: instructions, criteria: criteria)
31
+ end
32
+
33
+ def initialize(name, instructions:, criteria: nil)
34
+ @name = name.to_sym
35
+ @instructions = instructions
36
+ @criteria = criteria
37
+ validate!
38
+ freeze
39
+ end
40
+
41
+ def type = self.class::TYPE
42
+
43
+ # The JSON body for this question, as the API wants it.
44
+ def to_payload
45
+ payload = { "type" => type.to_s, "instructions" => instructions }
46
+ payload["criteria"] = criteria_payload unless criteria_payload.nil?
47
+ payload
48
+ end
49
+
50
+ # Wraps the raw answer hash the API returned for this question.
51
+ def build_answer(raw)
52
+ answer_class.new(self, raw)
53
+ end
54
+
55
+ private
56
+
57
+ def criteria_payload = criteria
58
+
59
+ def validate!
60
+ validate_instructions!
61
+ validate_criteria!
62
+ end
63
+
64
+ # The API accepts a string, object or array here -- anything JSON-shaped that
65
+ # reads as an instruction. What it does not accept is nothing.
66
+ def validate_instructions!
67
+ return if instructions.is_a?(String) && !instructions.strip.empty?
68
+ return if instructions.is_a?(Hash) && !instructions.empty?
69
+ return if instructions.is_a?(Array) && !instructions.empty?
70
+
71
+ raise InvalidQuestionError,
72
+ "Question :#{name} needs non-empty :instructions (a String, Hash or Array), " \
73
+ "got #{instructions.inspect}."
74
+ end
75
+
76
+ def validate_criteria!
77
+ raise NotImplementedError
78
+ end
79
+
80
+ # Probability that the answer to a yes/no question is yes.
81
+ class Noul < Question
82
+ TYPE = :noul
83
+
84
+ def answer_class = Answer::Noul
85
+
86
+ private
87
+
88
+ # Optional. When given it explains what a yes and a no mean.
89
+ def validate_criteria!
90
+ return if criteria.nil?
91
+
92
+ unless criteria.is_a?(Hash)
93
+ raise InvalidQuestionError,
94
+ "Question :#{name} is a :noul, so :criteria must be a Hash with " \
95
+ ":true and/or :false keys, got #{criteria.class}."
96
+ end
97
+
98
+ unknown = criteria.keys.map(&:to_s) - %w[true false]
99
+ return if unknown.empty?
100
+
101
+ raise InvalidQuestionError,
102
+ "Question :#{name} is a :noul, so :criteria accepts only :true and :false. " \
103
+ "Unknown key(s): #{unknown.join(", ")}."
104
+ end
105
+
106
+ def criteria_payload
107
+ return nil if criteria.nil?
108
+
109
+ criteria.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
110
+ end
111
+ end
112
+
113
+ # One option out of a labelled set.
114
+ class Choice < Question
115
+ TYPE = :choice
116
+
117
+ def answer_class = Answer::Choice
118
+
119
+ # The option keys, in declaration order.
120
+ def options = criteria.keys.map(&:to_sym)
121
+
122
+ private
123
+
124
+ def validate_criteria!
125
+ unless criteria.is_a?(Hash) && !criteria.empty?
126
+ raise InvalidQuestionError,
127
+ "Question :#{name} is a :choice, so :criteria must be a non-empty Hash of " \
128
+ "option => description, got #{criteria.inspect}."
129
+ end
130
+
131
+ if criteria.size > MAX_CHOICE_OPTIONS
132
+ raise InvalidQuestionError,
133
+ "Question :#{name} declares #{criteria.size} options; Jev accepts at most " \
134
+ "#{MAX_CHOICE_OPTIONS}."
135
+ end
136
+
137
+ bad = criteria.reject { |_, value| value.nil? || value.is_a?(String) }
138
+ return if bad.empty?
139
+
140
+ raise InvalidQuestionError,
141
+ "Question :#{name} has non-String descriptions for option(s) " \
142
+ "#{bad.keys.join(", ")}. Use a String, or nil when the option needs no detail."
143
+ end
144
+
145
+ def criteria_payload
146
+ criteria.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
147
+ end
148
+ end
149
+
150
+ # A rating against ordered levels.
151
+ class Score < Question
152
+ TYPE = :score
153
+
154
+ def answer_class = Answer::Score
155
+
156
+ # The level descriptions, lowest first.
157
+ def levels = criteria.map(&:to_s)
158
+
159
+ private
160
+
161
+ def validate_criteria!
162
+ unless criteria.is_a?(Array)
163
+ raise InvalidQuestionError,
164
+ "Question :#{name} is a :score, so :criteria must be an ordered Array of " \
165
+ "level descriptions, got #{criteria.class}."
166
+ end
167
+
168
+ unless criteria.size.between?(MIN_SCORE_LEVELS, MAX_SCORE_LEVELS)
169
+ raise InvalidQuestionError,
170
+ "Question :#{name} declares #{criteria.size} level(s); a Jev score takes " \
171
+ "between #{MIN_SCORE_LEVELS} and #{MAX_SCORE_LEVELS}."
172
+ end
173
+
174
+ bad = criteria.reject { |level| level.is_a?(String) || level.is_a?(Symbol) }
175
+ return if bad.empty?
176
+
177
+ raise InvalidQuestionError,
178
+ "Question :#{name} has non-String level(s): #{bad.map(&:inspect).join(", ")}."
179
+ end
180
+
181
+ def criteria_payload = levels
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Jevalyn
6
+ # Wires Jevalyn into a Rails app: the generators, a sensible logger, and
7
+ # app/decisions as an autoload path so decision classes live next to models.
8
+ class Railtie < ::Rails::Railtie
9
+ config.jevalyn = ActiveSupport::OrderedOptions.new if defined?(ActiveSupport::OrderedOptions)
10
+
11
+ generators do
12
+ require "generators/jevalyn/install/install_generator"
13
+ require "generators/jevalyn/decision/decision_generator"
14
+ require "generators/jevalyn/guardrail/guardrail_generator"
15
+ end
16
+
17
+ initializer "jevalyn.logger" do
18
+ Jevalyn.config.logger ||= Rails.logger
19
+ end
20
+
21
+ # A Rails app almost never wants a real API call in its test suite, and a key is
22
+ # usually absent there anyway. Opt back in per example with `:jevalyn_live`.
23
+ initializer "jevalyn.mock_mode" do
24
+ Jevalyn.config.mock_mode = true if Rails.env.test? && Jevalyn.config.api_key.nil?
25
+ end
26
+
27
+ initializer "jevalyn.autoload_paths" do |app|
28
+ decisions = app.root.join("app/decisions")
29
+ app.config.autoload_paths << decisions.to_s if decisions.exist?
30
+ end
31
+
32
+ # `rails jevalyn:ping` -- checks the key and the network without leaving the shell.
33
+ rake_tasks do
34
+ load File.expand_path("tasks/jevalyn.rake", __dir__)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevalyn
4
+ # Everything one evaluation returned: the typed answers, which model answered, and
5
+ # what it cost. A Result is read-only and safe to pass around or serialise into a job.
6
+ class Result
7
+ # What a client sends in the `usage` position when nothing came back.
8
+ EMPTY_USAGE = { "input_tokens" => 0, "output_tokens" => 0 }.freeze
9
+
10
+ attr_reader :answers, :raw, :questions
11
+
12
+ # questions -- Hash of name => Question, in declaration order.
13
+ # raw -- the parsed response body, untouched.
14
+ # thresholds -- Hash of name => confidence floor, already resolved by the Decision.
15
+ # A nil floor means that question was not asked to clear anything.
16
+ def initialize(questions:, raw:, thresholds: nil)
17
+ @questions = questions
18
+ @raw = raw || {}
19
+ @thresholds = normalize_thresholds(thresholds)
20
+ @answers = build_answers
21
+ end
22
+
23
+ # The versioned model that actually answered, e.g. "jev-1.13.0". Worth logging:
24
+ # an alias like jev-latest moves under you, and this is what tells you when it did.
25
+ def model = raw["model"]
26
+
27
+ def usage = raw["usage"] || EMPTY_USAGE
28
+
29
+ def input_tokens = usage["input_tokens"].to_i
30
+
31
+ # Free, as of jev-1.13 -- TypeSafe bills on input tokens only.
32
+ def output_tokens = usage["output_tokens"].to_i
33
+
34
+ def [](name)
35
+ answer(name).value
36
+ end
37
+
38
+ # The Answer object for a question, rather than its value.
39
+ def answer(name)
40
+ @answers.fetch(name.to_sym) do
41
+ raise UnknownQuestionError,
42
+ "No answer named #{name.inspect}. This result has: " \
43
+ "#{@answers.keys.map(&:inspect).join(", ")}."
44
+ end
45
+ end
46
+
47
+ def key?(name) = @answers.key?(name.to_sym)
48
+
49
+ # Plain Hash of question name => unwrapped value.
50
+ def values
51
+ @answers.transform_values(&:value)
52
+ end
53
+ alias to_h values
54
+
55
+ # The floor each question is judged against. Read it to see what a Decision
56
+ # actually resolved, which is worth logging alongside the answers.
57
+ attr_reader :thresholds
58
+
59
+ def threshold_for(name)
60
+ @thresholds[name.to_sym]
61
+ end
62
+
63
+ # True when every answer clears its own floor. Pass a number to judge them all
64
+ # against that one instead. Nouls are measured on how far they sit from a coin
65
+ # flip, since Jev returns no confidence for them.
66
+ def certain?(threshold = nil)
67
+ uncertain_questions(threshold).empty?
68
+ end
69
+
70
+ def uncertain?(threshold = nil) = !certain?(threshold)
71
+
72
+ # True when one named answer clears its floor.
73
+ def certain_for?(name, threshold = :__declared__)
74
+ threshold = threshold_for(name) if threshold == :__declared__
75
+
76
+ answer(name).certain?(threshold)
77
+ end
78
+
79
+ # Names of the answers that fell below their floor -- the ones worth routing to
80
+ # a human or a slower model.
81
+ def uncertain_questions(threshold = nil)
82
+ @answers.reject do |name, answer|
83
+ answer.certain?(threshold || @thresholds[name])
84
+ end.keys
85
+ end
86
+
87
+ # The least certain answer's certainty, across every question.
88
+ def min_certainty
89
+ @answers.values.filter_map(&:certainty).min
90
+ end
91
+
92
+ # How far each answer sits above (or below) its own floor. Negative means it
93
+ # missed. Useful for logging which decisions are running close to the line.
94
+ def certainty_margins
95
+ @answers.each_with_object({}) do |(name, answer), out|
96
+ floor = @thresholds[name]
97
+ certainty = answer.certainty
98
+ out[name] = floor.nil? || certainty.nil? ? nil : (certainty - floor).round(10)
99
+ end
100
+ end
101
+
102
+ def each(&) = @answers.each(&)
103
+
104
+ include Enumerable
105
+
106
+ def inspect
107
+ pairs = values.map { |name, value| "#{name}=#{value.inspect}" }.join(" ")
108
+ "#<#{self.class.name.nil? ? "Jevalyn::Result" : self.class.name} #{pairs} model=#{model.inspect}>"
109
+ end
110
+
111
+ # Builds a Result subclass with a reader per question, so a Decision's answers
112
+ # read as `result.department` rather than `result[:department]`.
113
+ def self.class_for(questions)
114
+ Class.new(self) do
115
+ questions.each_value do |question|
116
+ name = question.name
117
+
118
+ define_method(name) { self[name] }
119
+ define_method(:"#{name}_answer") { answer(name) }
120
+ define_method(:"#{name}_certainty") { answer(name).certainty }
121
+ define_method(:"#{name}_probabilities") { answer(name).probabilities }
122
+ define_method(:"#{name}_threshold") { threshold_for(name) }
123
+ define_method(:"#{name}_certain?") { |threshold = :__declared__| certain_for?(name, threshold) }
124
+ define_method(:"#{name}_uncertain?") { |threshold = :__declared__| !certain_for?(name, threshold) }
125
+
126
+ case question.type
127
+ when :noul
128
+ define_method(:"#{name}?") { |threshold = 0.5| answer(name).true?(threshold) }
129
+ when :choice
130
+ define_method(:"#{name}_confidence") { answer(name).confidence }
131
+ when :score
132
+ define_method(:"#{name}_confidence") { answer(name).confidence }
133
+ define_method(:"#{name}_label") { answer(name).label }
134
+ define_method(:"#{name}_level") { answer(name).level }
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ # Accepts a Hash of floors, a single number meaning "all of them", or nothing.
143
+ def normalize_thresholds(thresholds)
144
+ case thresholds
145
+ when nil then questions.keys.to_h { |name| [name, nil] }.freeze
146
+ when Numeric then questions.keys.to_h { |name| [name, thresholds] }.freeze
147
+ when Hash
148
+ questions.keys.to_h { |name| [name, thresholds[name] || thresholds[name.to_s]] }.freeze
149
+ else
150
+ raise ConfigurationError,
151
+ "thresholds must be a Hash of question => floor, a single number, or nil; " \
152
+ "got #{thresholds.class}."
153
+ end
154
+ end
155
+
156
+ def build_answers
157
+ raw_answers = raw["answers"] || {}
158
+
159
+ questions.each_with_object({}) do |(name, question), out|
160
+ out[name] = question.build_answer(raw_answers[name.to_s])
161
+ end.freeze
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevalyn
4
+ # Sends work to a handler based on a decision, with a confidence floor underneath.
5
+ #
6
+ # Two shapes, and they compose. A plain dispatch table:
7
+ #
8
+ # router = Jevalyn::Router.new do |r|
9
+ # r.route :simple_lookup, to: OrderLookup
10
+ # r.route :open_ended, to: llm_client # anything responding to #call
11
+ # end
12
+ #
13
+ # router.dispatch(:simple_lookup, state: order)
14
+ #
15
+ # Or a decision-driven router, which is the reason this class exists -- Jev picks
16
+ # the branch, and anything it is not sure about goes somewhere safer:
17
+ #
18
+ # router = Jevalyn::Router.new(SupportTriage, on: :department) do |r|
19
+ # r.route :billing, to: BillingInbox
20
+ # r.route :technical, to: ->(state, result) { Oncall.page(state, result) }
21
+ # r.route :sales, to: SalesInbox
22
+ # r.uncertain_below to: HumanQueue
23
+ # end
24
+ #
25
+ # router.call(ticket)
26
+ #
27
+ # Handlers are called with (state, result) when they take two arguments and (state)
28
+ # when they take one, so a plain Proc, a Decision, a job class and a service object
29
+ # all work without adapters.
30
+ class Router
31
+ Route = Struct.new(:key, :handler, keyword_init: true)
32
+
33
+ attr_reader :decision, :question, :routes
34
+
35
+ # decision -- optional Jevalyn::Decision subclass that picks the branch.
36
+ # on -- the choice question whose answer names the route. Defaults to the
37
+ # decision's only question when it declares just one.
38
+ def initialize(decision = nil, on: nil)
39
+ @decision = decision
40
+ @question = on
41
+ @routes = {}
42
+ @fallback = nil
43
+ @floor = nil
44
+ @floor_handler = nil
45
+
46
+ yield self if block_given?
47
+
48
+ validate_question! if decision
49
+ end
50
+
51
+ # Registers a handler under a key.
52
+ def route(key, to:)
53
+ @routes[key.to_sym] = Route.new(key: key.to_sym, handler: to)
54
+ self
55
+ end
56
+
57
+ # Where anything unrouted goes. Without one, an unrouted key raises.
58
+ def fallback(to:)
59
+ @fallback = to
60
+ self
61
+ end
62
+
63
+ # Where an answer that misses its confidence floor goes, whatever the answer was.
64
+ # This is the confidence-gated half: a wrong-but-confident answer is a routing bug,
65
+ # a not-confident answer is a known unknown and belongs with a human or a slower
66
+ # model.
67
+ #
68
+ # With no threshold it uses the floor the question itself declares, so the number
69
+ # lives in one place:
70
+ #
71
+ # r.uncertain_below to: HumanQueue # the decision's own floor
72
+ # r.uncertain_below 0.9, to: HumanQueue # stricter, just for this router
73
+ def uncertain_below(threshold = nil, to:)
74
+ @floor = threshold
75
+ @floor_handler = to
76
+ self
77
+ end
78
+
79
+ # Calls a registered handler directly, skipping the decision.
80
+ def dispatch(key, state:, result: nil)
81
+ registered = @routes[key.to_sym]
82
+
83
+ unless registered
84
+ return invoke(@fallback, state, result) if @fallback
85
+
86
+ raise UnknownQuestionError,
87
+ "No route for #{key.inspect}. Registered: #{@routes.keys.map(&:inspect).join(", ")}."
88
+ end
89
+
90
+ invoke(registered.handler, state, result)
91
+ end
92
+
93
+ # Runs the decision, then dispatches on its answer.
94
+ def call(state, **options)
95
+ unless decision
96
+ raise ConfigurationError,
97
+ "This Router has no decision, so it can only #dispatch(key, state:). " \
98
+ "Build it as Router.new(SomeDecision, on: :question) to use #call."
99
+ end
100
+
101
+ result = decision.evaluate(state, **options)
102
+ answer = result.answer(question_name)
103
+
104
+ floor = @floor || result.threshold_for(question_name)
105
+ return invoke(@floor_handler, state, result) if @floor_handler && answer.uncertain?(floor)
106
+
107
+ dispatch(answer.value, state: state, result: result)
108
+ end
109
+
110
+ # The decision's answer without dispatching -- useful in specs and consoles.
111
+ def decide(state, **options)
112
+ decision.evaluate(state, **options)
113
+ end
114
+
115
+ def keys = @routes.keys
116
+
117
+ private
118
+
119
+ def question_name
120
+ @question || decision.question_names.first
121
+ end
122
+
123
+ def validate_question!
124
+ name = question_name
125
+
126
+ unless decision.questions.key?(name)
127
+ raise ConfigurationError,
128
+ "#{decision.name} declares no question named #{name.inspect}. " \
129
+ "It has: #{decision.question_names.map(&:inspect).join(", ")}."
130
+ end
131
+
132
+ declared = decision.questions[name]
133
+
134
+ unless declared.type == :choice
135
+ raise ConfigurationError,
136
+ "Router routes on a :choice question; #{decision.name}##{name} is a " \
137
+ ":#{declared.type}."
138
+ end
139
+
140
+ unrouted = declared.options - @routes.keys
141
+ return if unrouted.empty? || @fallback || @floor_handler
142
+
143
+ raise ConfigurationError,
144
+ "#{decision.name}##{name} can answer #{unrouted.map(&:inspect).join(", ")}, " \
145
+ "which this Router has no route for. Add them, or a `fallback to:`."
146
+ end
147
+
148
+ # Handlers vary in shape on purpose: a Decision subclass, a Proc that only wants
149
+ # the state, and a service object that wants both should all work unadapted.
150
+ def invoke(handler, state, result)
151
+ return handler.evaluate(state) if handler.is_a?(Class) && handler <= Decision
152
+
153
+ unless handler.respond_to?(:call)
154
+ raise ConfigurationError,
155
+ "Route handler #{handler.inspect} must respond to #call, or be a " \
156
+ "Jevalyn::Decision subclass."
157
+ end
158
+
159
+ return handler.call(state, result) if accepts_two?(handler)
160
+
161
+ handler.call(state)
162
+ end
163
+
164
+ def accepts_two?(handler)
165
+ method = handler.respond_to?(:parameters) ? handler : handler.method(:call)
166
+ parameters = method.parameters
167
+
168
+ return true if parameters.any? { |type, _| type == :rest }
169
+
170
+ parameters.count { |type, _| %i[req opt].include?(type) } >= 2
171
+ rescue NameError
172
+ false
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Jevalyn
6
+ # Turns whatever you hand a Decision into something the API accepts as `state`:
7
+ # a String, an Object or an Array.
8
+ #
9
+ # The convention is one method. If an object responds to #jevalyn_state, that is the
10
+ # state -- which is how a model says "send these five columns, not all forty".
11
+ # Otherwise Jevalyn falls back to #as_json, then to #to_s.
12
+ module State
13
+ # Jev's per-request budget: 64k tokens for state plus every question, and 32k for
14
+ # state plus the single longest question. Tokens are not characters, so this is a
15
+ # deliberately loose guard against posting a whole database row set by accident.
16
+ ROUGH_CHARS_PER_TOKEN = 4
17
+ SOFT_CHAR_LIMIT = 32_000 * ROUGH_CHARS_PER_TOKEN
18
+
19
+ module_function
20
+
21
+ def serialize(object)
22
+ state = coerce(object)
23
+
24
+ if state.nil? || (state.respond_to?(:empty?) && state.empty?)
25
+ raise ConfigurationError,
26
+ "State is empty. Jev needs something to evaluate -- pass a String, a Hash, " \
27
+ "an Array, or an object that responds to #jevalyn_state."
28
+ end
29
+
30
+ state
31
+ end
32
+
33
+ def coerce(object)
34
+ case object
35
+ when nil then nil
36
+ when String then object
37
+ when Symbol then object.to_s
38
+ # Numbers and booleans are valid JSON and read better to the model as
39
+ # themselves than as quoted strings.
40
+ when Numeric, true, false then object
41
+ when Hash then stringify(object)
42
+ when Array then object.map { |item| coerce(item) }
43
+ else
44
+ coerce_object(object)
45
+ end
46
+ end
47
+
48
+ # Rough token estimate, for logging and for the oversize warning. Not exact --
49
+ # only the API knows the real count, and it reports it back in `usage`.
50
+ def estimated_tokens(state)
51
+ json = state.is_a?(String) ? state : JSON.generate(state)
52
+ (json.length / ROUGH_CHARS_PER_TOKEN.to_f).ceil
53
+ end
54
+
55
+ def oversized?(state)
56
+ json = state.is_a?(String) ? state : JSON.generate(state)
57
+ json.length > SOFT_CHAR_LIMIT
58
+ end
59
+
60
+ def coerce_object(object)
61
+ return coerce(object.jevalyn_state) if object.respond_to?(:jevalyn_state)
62
+
63
+ if defined?(::ActiveRecord::Base) && object.is_a?(::ActiveRecord::Base)
64
+ return StateAdapters::ActiveRecordAdapter.serialize(object)
65
+ end
66
+
67
+ if defined?(::ActiveRecord::Relation) && object.is_a?(::ActiveRecord::Relation)
68
+ return object.map { |record| coerce(record) }
69
+ end
70
+
71
+ return coerce(object.as_json) if object.respond_to?(:as_json)
72
+
73
+ object.to_s
74
+ end
75
+
76
+ def stringify(hash)
77
+ hash.each_with_object({}) { |(key, value), out| out[key.to_s] = coerce(value) }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jevalyn
4
+ module StateAdapters
5
+ # Serialises an ActiveRecord model into the `state` field.
6
+ #
7
+ # The default is `record.as_json`, which sends every column. That is usually the
8
+ # wrong thing: it burns tokens on ids and timestamps Jev has no use for, and it
9
+ # ships PII to a third party that did not need it. Narrow it with `only:`, or
10
+ # define #jevalyn_state on the model and forget the adapter exists.
11
+ class ActiveRecordAdapter
12
+ # Columns that are noise in almost every decision.
13
+ DEFAULT_EXCEPT = %w[id created_at updated_at].freeze
14
+
15
+ def self.serialize(record, only: nil, except: nil)
16
+ return record.jevalyn_state if only.nil? && except.nil? && record.respond_to?(:jevalyn_state)
17
+
18
+ options = {}
19
+ options[:only] = Array(only).map(&:to_s) if only
20
+ options[:except] = Array(except).map(&:to_s) if except
21
+
22
+ State.stringify(record.as_json(**options))
23
+ end
24
+ end
25
+ end
26
+ end