jev-feels 0.2.0 → 1.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.
data/lib/jev/result.rb ADDED
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jev
4
+ module Result
5
+ def self.parse(answer, definition)
6
+ raise InvalidResponseError, "Jev response is missing an answer" unless answer.is_a?(Hash)
7
+
8
+ case definition.type
9
+ when :noul then Noul.parse(answer)
10
+ when :choice then Choice.parse(answer)
11
+ when :score then Score.parse(answer, definition)
12
+ else
13
+ raise InvalidResponseError, "unknown Jev definition type: #{definition.type.inspect}"
14
+ end
15
+ end
16
+
17
+ def self.finite_unit(value, label)
18
+ raise InvalidResponseError, "Jev response is missing #{label}" unless value.is_a?(Numeric)
19
+
20
+ value = Float(value)
21
+ raise InvalidResponseError, "Jev #{label} is not finite" unless value.finite?
22
+
23
+ value
24
+ end
25
+
26
+ class Noul
27
+ attr_reader :probability
28
+
29
+ def initialize(probability:)
30
+ @probability = probability
31
+ freeze
32
+ end
33
+
34
+ def self.parse(answer)
35
+ noul = answer["noul"]
36
+ raise InvalidResponseError, "Jev response is missing a noul probability" unless noul.is_a?(Numeric)
37
+
38
+ noul = Float(noul)
39
+ raise InvalidResponseError, "Jev noul probability is not finite" unless noul.finite?
40
+
41
+ # ponytail: clamp out-of-range noul; raise if Jev starts returning uncalibrated values
42
+ new(probability: noul.clamp(0.0, 1.0))
43
+ end
44
+
45
+ def type
46
+ :noul
47
+ end
48
+
49
+ def collapsed(threshold: 0.5)
50
+ probability >= threshold
51
+ end
52
+ end
53
+
54
+ class Choice
55
+ attr_reader :choice, :confidence, :probabilities
56
+
57
+ def initialize(choice:, confidence:, probabilities:)
58
+ @choice = choice
59
+ @confidence = confidence
60
+ @probabilities = probabilities
61
+ freeze
62
+ end
63
+
64
+ def self.parse(answer)
65
+ winner = answer["choice"]
66
+ raise InvalidResponseError, "Jev response is missing a choice" if winner.nil?
67
+
68
+ raw = answer["probabilities"]
69
+ raise InvalidResponseError, "Jev response is missing choice probabilities" unless raw.is_a?(Hash)
70
+
71
+ probabilities = raw.to_h { |key, value| [key.to_sym, Result.finite_unit(value, "choice probability")] }
72
+ new(
73
+ choice: winner.to_sym,
74
+ confidence: Result.finite_unit(answer["confidence"], "choice confidence").clamp(0.0, 1.0),
75
+ probabilities: probabilities.freeze
76
+ )
77
+ end
78
+
79
+ def type
80
+ :choice
81
+ end
82
+
83
+ def collapsed(*)
84
+ choice
85
+ end
86
+ end
87
+
88
+ class Score
89
+ attr_reader :score, :confidence, :probabilities, :levels, :level
90
+
91
+ def initialize(score:, confidence:, probabilities:, levels:, level:)
92
+ @score = score
93
+ @confidence = confidence
94
+ @probabilities = probabilities
95
+ @levels = levels
96
+ @level = level
97
+ freeze
98
+ end
99
+
100
+ def self.parse(answer, definition)
101
+ names = definition.level_names
102
+ probabilities = score_probabilities(answer["probabilities"], names)
103
+ score = Result.finite_unit(answer["score"], "score")
104
+ new(
105
+ score: score,
106
+ confidence: Result.finite_unit(answer["confidence"], "score confidence").clamp(0.0, 1.0),
107
+ probabilities: probabilities,
108
+ levels: definition.levels,
109
+ level: named_level(probabilities, names)
110
+ )
111
+ end
112
+
113
+ def self.score_probabilities(raw, names)
114
+ raise InvalidResponseError, "Jev response is missing score probabilities" unless raw.is_a?(Hash)
115
+
116
+ raw.to_h do |key, value|
117
+ index = Integer(key)
118
+ [names ? names.fetch(index, index) : index, Result.finite_unit(value, "score probability")]
119
+ end.freeze
120
+ rescue ArgumentError, TypeError
121
+ raise InvalidResponseError, "Jev score probabilities are not keyed by level number"
122
+ end
123
+
124
+ def self.named_level(probabilities, names)
125
+ return unless names
126
+
127
+ key, = probabilities.max_by { |_, weight| weight }
128
+ key if key.is_a?(Symbol)
129
+ end
130
+ private_class_method :named_level
131
+
132
+ def type
133
+ :score
134
+ end
135
+
136
+ def collapsed(*)
137
+ score
138
+ end
139
+ end
140
+
141
+ class Batch
142
+ def initialize(results)
143
+ @results = results.transform_keys(&:to_sym).freeze
144
+ freeze
145
+ end
146
+
147
+ def [](key)
148
+ @results[key.to_sym]
149
+ end
150
+
151
+ def to_h
152
+ @results.transform_values(&:collapsed)
153
+ end
154
+
155
+ def deconstruct_keys(keys)
156
+ collapsed = to_h
157
+ keys ? collapsed.slice(*keys.map(&:to_sym)) : collapsed
158
+ end
159
+
160
+ def type
161
+ :batch
162
+ end
163
+ end
164
+ end
165
+ end
data/lib/jev/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jev
4
- VERSION = "0.2.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "feels/active_model"
data/lib/jev.rb CHANGED
@@ -3,10 +3,16 @@
3
3
  require_relative "jev/version"
4
4
  require_relative "jev/errors"
5
5
  require_relative "jev/configuration"
6
+ require_relative "jev/definition"
6
7
  require_relative "jev/registry"
8
+ require_relative "jev/result"
7
9
  require_relative "jev/transport"
8
10
  require_relative "jev/client"
11
+ require_relative "jev/query"
12
+ require_relative "jev/match"
13
+ require_relative "jev/harness"
9
14
  require_relative "jev/feels"
15
+ require_relative "jev/model"
10
16
 
11
17
  module Jev
12
18
  class << self
@@ -21,31 +27,23 @@ module Jev
21
27
  @configuration = Configuration.new
22
28
  end
23
29
 
24
- def define(scope_or_name, name_or_description, description = nil)
25
- if scope_or_name.is_a?(Module)
26
- raise ArgumentError, "description is required" if description.nil?
27
-
28
- registry.define(scope_or_name, name_or_description, description)
29
- else
30
- raise ArgumentError, "wrong number of arguments (given 3, expected 2)" unless description.nil?
31
-
32
- registry.define(nil, scope_or_name, name_or_description)
33
- end
30
+ def define(scope_or_name, name_or_description, description = nil, **options)
31
+ scope, name, instructions = unpack_define(scope_or_name, name_or_description, description)
32
+ extras = adhoc_options(options)
33
+ registry.define(scope, name, Definition.build(name: name, instructions: instructions, **extras))
34
34
  end
35
35
 
36
36
  def definition(scope_or_name, name = nil)
37
- if name.nil?
38
- registry.fetch(scope_or_name, scope: nil, fallback: false)
39
- else
40
- raise ArgumentError, "scope must be a Module" unless scope_or_name.is_a?(Module)
41
-
42
- registry.fetch(name, scope: scope_or_name, fallback: false)
43
- end
37
+ found =
38
+ if name.nil?
39
+ registry.fetch(scope_or_name, scope: nil, fallback: false, inherit: false)
40
+ else
41
+ registry.fetch(name, scope: scope_or_name, fallback: false, inherit: false)
42
+ end
43
+ found&.public_value
44
44
  end
45
45
 
46
46
  def definitions(scope = nil)
47
- raise ArgumentError, "scope must be a Module" unless scope.nil? || scope.is_a?(Module)
48
-
49
47
  registry.all(scope)
50
48
  end
51
49
 
@@ -54,26 +52,106 @@ module Jev
54
52
  end
55
53
 
56
54
  def feels(text, scope_or_predicate, predicate = nil)
57
- scope, predicate = unpack_predicate(scope_or_predicate, predicate)
58
- Client.new(configuration).probability(coerce_text(text), resolve(predicate, scope: scope))
55
+ measure_one(text, scope_or_predicate, predicate, id: Client::QUESTION_ID).probability
59
56
  end
60
57
 
61
- def feels?(text, scope_or_predicate, predicate = nil, threshold: configuration.threshold)
62
- feels(text, scope_or_predicate, predicate) >= normalize_threshold(threshold)
58
+ def feels?(text, scope_or_predicate, predicate = nil, **opts)
59
+ validate_noul_decision!(opts)
60
+ decide_noul(feels(text, scope_or_predicate, predicate), opts)
61
+ end
62
+
63
+ def decide(text, scope_or_predicate, predicate = nil, confidence: nil, **options)
64
+ require_adhoc_type(scope_or_predicate, predicate, options, :choices)
65
+ confidence = normalize_unit(confidence, "confidence") unless confidence.nil?
66
+ result = typed_measure(:choice, text, scope_or_predicate, predicate, **options)
67
+ return if confidence && result.confidence < confidence
68
+
69
+ result.choice
70
+ end
71
+
72
+ def score(text, scope_or_predicate, predicate = nil, **options)
73
+ require_adhoc_type(scope_or_predicate, predicate, options, :levels)
74
+ typed_measure(:score, text, scope_or_predicate, predicate, **options).score
75
+ end
76
+
77
+ def measure(text, scope_or_predicate = nil, predicate = nil, **options, &block)
78
+ if block
79
+ raise ArgumentError, "unknown keyword: #{options.keys.first}" unless options.empty?
80
+ raise ArgumentError, "predicate is not used with a measure block" unless predicate.nil?
81
+
82
+ measure_batch(text, scope_or_predicate, &block)
83
+ else
84
+ raise ArgumentError, "question is required" if scope_or_predicate.nil?
85
+
86
+ measure_one(text, scope_or_predicate, predicate, **options)
87
+ end
88
+ end
89
+
90
+ def match(text, scope_or_predicate, predicate = nil, confidence: nil, **, &block)
91
+ raise ArgumentError, "Jev.match requires a block" unless block
92
+
93
+ matcher = Matcher.new(decide(text, scope_or_predicate, predicate, confidence: confidence, **))
94
+ block.arity.zero? ? matcher.instance_eval(&block) : yield(matcher)
95
+ matcher.result
96
+ end
97
+
98
+ def stub(answers, &)
99
+ Harness.stub(answers, &)
100
+ end
101
+
102
+ def record(&)
103
+ Harness.record(&)
104
+ end
105
+
106
+ def replay(tape, &)
107
+ Harness.replay(tape, &)
63
108
  end
64
109
 
65
110
  def normalize_threshold(value)
66
- raise ArgumentError, "threshold must be a Float between 0.0 and 1.0" unless value.is_a?(Numeric)
111
+ normalize_unit(value, "threshold")
112
+ end
113
+
114
+ def resolve_definition(predicate, scope: nil, **options)
115
+ case predicate
116
+ when Symbol
117
+ raise ArgumentError, "unknown keyword: #{options.keys.first}" unless options.empty?
118
+
119
+ registry.fetch(predicate, scope: scope) ||
120
+ raise(UndefinedDefinition, undefined_message(predicate, scope))
121
+ when String
122
+ Definition.build(name: :adhoc, instructions: predicate, **adhoc_options(options))
123
+ else
124
+ raise ArgumentError, "predicate must be a Symbol or String"
125
+ end
126
+ end
127
+
128
+ def name_for_instructions(instructions)
129
+ registry.name_for_instructions(instructions)
130
+ end
131
+
132
+ def level_names_for(instructions)
133
+ registry.find_by_instructions(instructions)&.level_names
134
+ end
135
+ private :resolve_definition, :name_for_instructions, :level_names_for
136
+
137
+ private
138
+
139
+ def normalize_unit(value, name)
140
+ raise ArgumentError, "#{name} must be a Float between 0.0 and 1.0" unless value.is_a?(Numeric)
67
141
 
68
142
  value = Float(value)
69
143
  unless value.finite? && value.between?(0.0, 1.0)
70
- raise ArgumentError, "threshold must be a Float between 0.0 and 1.0"
144
+ raise ArgumentError, "#{name} must be a Float between 0.0 and 1.0"
71
145
  end
72
146
 
73
147
  value
74
148
  end
75
149
 
76
- private
150
+ def require_adhoc_type(scope_or_predicate, predicate, options, key)
151
+ _scope, name = unpack_predicate(scope_or_predicate, predicate)
152
+ return unless name.is_a?(String)
153
+ raise ArgumentError, "#{key} is required" unless options.key?(key)
154
+ end
77
155
 
78
156
  attr_reader :registry
79
157
 
@@ -86,21 +164,13 @@ module Jev
86
164
 
87
165
  def unpack_predicate(scope_or_predicate, predicate)
88
166
  return [nil, scope_or_predicate] if predicate.nil?
89
- raise ArgumentError, "scope must be a Module" unless scope_or_predicate.is_a?(Module)
167
+ raise ArgumentError, "scope must be a Module, String, or Symbol" unless scoped?(scope_or_predicate)
90
168
 
91
169
  [scope_or_predicate, predicate]
92
170
  end
93
171
 
94
- def resolve(predicate, scope: nil)
95
- case predicate
96
- when Symbol
97
- registry.fetch(predicate, scope: scope) ||
98
- raise(UndefinedDefinition, undefined_message(predicate, scope))
99
- when String
100
- predicate
101
- else
102
- raise ArgumentError, "predicate must be a Symbol or String"
103
- end
172
+ def scoped?(value)
173
+ value.is_a?(Module) || value.is_a?(String) || value.is_a?(Symbol)
104
174
  end
105
175
 
106
176
  def undefined_message(predicate, scope)
@@ -110,8 +180,88 @@ module Jev
110
180
  "Undefined Jev definition: #{predicate.inspect}"
111
181
  end
112
182
  end
183
+
184
+ def unpack_define(scope_or_name, name_or_description, description)
185
+ if description.nil?
186
+ if scope_or_name.is_a?(Module) || name_or_description.is_a?(Symbol)
187
+ raise ArgumentError, "description is required"
188
+ end
189
+
190
+ [nil, scope_or_name, name_or_description]
191
+ else
192
+ [scope_or_name, name_or_description, description]
193
+ end
194
+ end
195
+
196
+ def adhoc_options(options)
197
+ choices = options.delete(:choices)
198
+ levels = options.delete(:levels)
199
+ raise ArgumentError, "unknown keyword: #{options.keys.first}" unless options.empty?
200
+
201
+ { choices: choices, levels: levels }
202
+ end
203
+
204
+ def measure_one(text, scope_or_predicate, predicate, id: nil, **)
205
+ scope, predicate = unpack_predicate(scope_or_predicate, predicate)
206
+ definition = resolve_definition(predicate, scope: scope, **)
207
+ question_id = id || (predicate.is_a?(Symbol) ? predicate.to_s : definition.type.to_s)
208
+ Client.new(configuration).ask(coerce_text(text), { question_id => definition }).fetch(question_id)
209
+ end
210
+
211
+ def typed_measure(type, text, scope_or_predicate, predicate, **)
212
+ result = measure_one(text, scope_or_predicate, predicate, **)
213
+ return result if result.type == type
214
+
215
+ label = predicate_label(scope_or_predicate, predicate)
216
+ raise ArgumentError, "#{label} is a #{result.type} definition, not a #{type}"
217
+ end
218
+
219
+ def predicate_label(scope_or_predicate, predicate)
220
+ (_scope, name) = unpack_predicate(scope_or_predicate, predicate)
221
+ name.inspect
222
+ end
223
+
224
+ def measure_batch(text, scope, &)
225
+ questions = collect_questions(scope, &)
226
+ answers = Client.new(configuration).ask(coerce_text(text), questions.transform_keys(&:to_s))
227
+ Result::Batch.new(questions.keys.to_h { |key| [key, answers.fetch(key.to_s)] })
228
+ end
229
+
230
+ def collect_questions(scope)
231
+ query = Query.new(scope)
232
+ yield query
233
+ raise ArgumentError, "measure block must declare at least one question" if query.questions.empty?
234
+
235
+ query.questions
236
+ end
237
+
238
+ def validate_noul_decision!(opts)
239
+ unknown = opts.keys - %i[threshold at_least]
240
+ raise ArgumentError, "unknown keyword: #{unknown.first}" unless unknown.empty?
241
+ if opts.key?(:at_least) && opts.key?(:threshold)
242
+ raise ArgumentError,
243
+ "cannot use threshold: and at_least: together"
244
+ end
245
+
246
+ normalize_unit(opts[:at_least], "at_least") if opts.key?(:at_least)
247
+ normalize_threshold(opts[:threshold]) if opts.key?(:threshold)
248
+ end
249
+
250
+ def decide_noul(probability, opts)
251
+ if opts.key?(:at_least)
252
+ at_least = normalize_unit(opts[:at_least], "at_least")
253
+ return true if probability >= at_least
254
+ return false if probability + at_least <= 1.0
255
+
256
+ nil
257
+ else
258
+ probability >= normalize_threshold(opts.fetch(:threshold, configuration.threshold))
259
+ end
260
+ end
113
261
  end
114
262
 
263
+ private_constant :Query, :Matcher, :Harness
264
+
115
265
  reset_configuration!
116
266
  @registry = Registry.new
117
267
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jev-feels
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maxim Veysgeym
@@ -11,27 +11,38 @@ cert_chain: []
11
11
  date: 2026-09-20 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
- Tiny Ruby API for semantic text checks. Jev.feels?(:urgent) is an ordinary
15
- condition, not an SDK session. Jev stays an implementation detail.
14
+ Tiny Ruby API for semantic text decisions. Jev.feels?(:urgent), Jev.decide,
15
+ and Jev.score are ordinary Ruby, not an SDK session. Jev stays an
16
+ implementation detail.
16
17
  email:
17
18
  - Qew7@users.noreply.github.com
18
19
  executables: []
19
20
  extensions: []
20
21
  extra_rdoc_files: []
21
22
  files:
23
+ - CHANGELOG.md
22
24
  - LICENSE
23
25
  - README.md
24
26
  - context7.json
25
27
  - lib/feels.rb
28
+ - lib/feels/active_model.rb
26
29
  - lib/feels/string.rb
27
30
  - lib/jev-feels.rb
31
+ - lib/jev-feels/active_model.rb
28
32
  - lib/jev-feels/string.rb
29
33
  - lib/jev.rb
34
+ - lib/jev/active_model.rb
30
35
  - lib/jev/client.rb
31
36
  - lib/jev/configuration.rb
37
+ - lib/jev/definition.rb
32
38
  - lib/jev/errors.rb
33
39
  - lib/jev/feels.rb
40
+ - lib/jev/harness.rb
41
+ - lib/jev/match.rb
42
+ - lib/jev/model.rb
43
+ - lib/jev/query.rb
34
44
  - lib/jev/registry.rb
45
+ - lib/jev/result.rb
35
46
  - lib/jev/transport.rb
36
47
  - lib/jev/version.rb
37
48
  homepage: https://github.com/Qew7/jev-feels
@@ -40,7 +51,7 @@ licenses:
40
51
  metadata:
41
52
  homepage_uri: https://github.com/Qew7/jev-feels
42
53
  source_code_uri: https://github.com/Qew7/jev-feels
43
- changelog_uri: https://github.com/Qew7/jev-feels/blob/main/README.md
54
+ changelog_uri: https://github.com/Qew7/jev-feels/blob/master/CHANGELOG.md
44
55
  allowed_push_host: https://rubygems.org
45
56
  post_install_message:
46
57
  rdoc_options: []