jev-feels 0.1.0 → 1.0.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: cb1c944edfc8044a07d64239c6452cf8717eee7a2878777b010ce06c7e328794
4
- data.tar.gz: 4c4092c5de15890bb9897934dffe82c515a236e441444caf016e282d20046060
3
+ metadata.gz: e06314aa51799f9434268858022138a8a871b4f58332907b4be0f5d187885073
4
+ data.tar.gz: 8e5e28527de0e411f38ca91b660cb6a43c77a955af2a0780227c9401d4fdfe35
5
5
  SHA512:
6
- metadata.gz: c01170a2578338e6d8359399873593357c7896a2fa536eca538edd82830925d597e0bf6a6b5ffddb2fce804e9c974be6fe9a7eb7b62109d4078987f53743de75
7
- data.tar.gz: 41919c36db8eb4497f57acac8ee0c02bcca2f8cb5e7a5bf7b347dadda62253e80a7b5a20274f5db199ea5642e5eb98c82909ac8b9546920a336bc9e5c54d2e8e
6
+ metadata.gz: 11ff5c82f81642c4e60c1c39f4567de4b3d4e4a7c0ab6bd31a4a19be9cca47b5e2642f1b1388b2cc507ab45b563d02e7ea09767f489954ef1b3bb3d57659a798
7
+ data.tar.gz: 184c045587028d305a7fa8310c298687998981b655ded9e71e8c7990cb30bfeeaecac59bed11940ef3903bde5dfd49eb99a78c37e0c7e3a83f5a8274e8692589
data/README.md CHANGED
@@ -25,6 +25,37 @@ if Jev.feels?(email, :urgent)
25
25
  end
26
26
  ```
27
27
 
28
+ ## Ruby on Rails
29
+
30
+ Configure once, declare predicates on the model, ask the record:
31
+
32
+ ```ruby
33
+ # Gemfile
34
+ gem "jev-feels"
35
+ ```
36
+
37
+ ```ruby
38
+ # config/initializers/jev.rb
39
+ Jev.configure do |config|
40
+ config.api_key = ENV.fetch("JEV_API_KEY")
41
+ end
42
+ ```
43
+
44
+ ```ruby
45
+ class SupportEmail < ApplicationRecord
46
+ include Jev::Model
47
+
48
+ feels :body, :urgent, "Outage, customers cannot sign in"
49
+ end
50
+
51
+ email.feels?(:urgent)
52
+ email.feels(:urgent)
53
+ ```
54
+
55
+ The class is the scope, the first argument is the field. No model name, no `email.body.feels?`.
56
+
57
+ String sugar is still opt-in (`using Jev::Feels` or `require "jev-feels/string"`) if you want `body.feels?(:urgent)` on a raw string.
58
+
28
59
  ## String sugar
29
60
 
30
61
  `require "feels"` does not change `String`. Opt in with a refinement:
@@ -39,6 +70,7 @@ Or, if you really want a global patch:
39
70
 
40
71
  ```ruby
41
72
  require "feels/string"
73
+ # or: require "jev-feels/string"
42
74
 
43
75
  email.feels?(:urgent)
44
76
  ```
@@ -77,11 +109,16 @@ Jev.define :urgent, "Requires immediate attention or action"
77
109
  Jev.define :spam, "Unsolicited or unwanted promotional content"
78
110
  Jev.define :angry, "Expresses anger or hostility"
79
111
 
112
+ Jev.define Email, :urgent, "Outage, customers cannot sign in"
113
+ Jev.define Comment, :urgent, "Legal takedown or self-harm"
114
+
80
115
  Jev.definition(:urgent)
116
+ Jev.definition(Email, :urgent)
81
117
  Jev.definitions
118
+ Jev.definitions(Comment)
82
119
  ```
83
120
 
84
- `define` replaces an existing name. `definitions` returns a frozen copy of the registry.
121
+ `define` replaces an existing name in that scope. Scopes are stored by class name, so `Email`, `"Email"` and `:Email` are the same key. `include Jev::Model` plus `feels :body, :urgent, "..."` does the same define and remembers the field. `Jev.feels?(text, Email, :urgent)` uses Email's definition, then a superclass, then the global `:urgent`. `definitions` returns a frozen copy.
85
122
 
86
123
  ## Configuration
87
124
 
data/context7.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://context7.com/schema/context7.json",
3
3
  "projectTitle": "jev-feels",
4
- "description": "Ruby-like semantic text predicates via Jev. Jev.feels?(:urgent) is an ordinary condition, not an SDK session.",
4
+ "description": "Ruby-like semantic text predicates via Jev. Jev.feels?(:urgent) and model#feels?(:urgent) are ordinary conditions, not an SDK session.",
5
5
  "excludeFolders": ["spec"]
6
6
  }
data/lib/jev/model.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jev
4
+ module Model
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def inherited(subclass)
11
+ super
12
+ subclass.extend ClassMethods
13
+ end
14
+
15
+ def feels(attribute, name, description)
16
+ raise ArgumentError, "attribute must be a Symbol" unless attribute.is_a?(Symbol)
17
+
18
+ Jev.define(self, name, description)
19
+ (@jev_feels_attributes ||= {})[name.to_sym] = attribute
20
+ end
21
+
22
+ def jev_feels_attribute(name)
23
+ current = self
24
+ while current && current != Object
25
+ found = current.instance_variable_get(:@jev_feels_attributes)&.[](name)
26
+ return found if found
27
+
28
+ current = current.superclass
29
+ end
30
+ end
31
+ end
32
+
33
+ def feels(predicate)
34
+ Jev.feels(jev_text_for(predicate), self.class, predicate)
35
+ end
36
+
37
+ def feels?(predicate, threshold: Jev.configuration.threshold)
38
+ Jev.feels?(jev_text_for(predicate), self.class, predicate, threshold: threshold)
39
+ end
40
+
41
+ private
42
+
43
+ def jev_text_for(predicate)
44
+ attribute = self.class.jev_feels_attribute(predicate.to_sym) || raise(
45
+ UndefinedDefinition, "Undefined Jev definition: #{predicate.inspect} for #{self.class}"
46
+ )
47
+
48
+ public_send(attribute).to_s
49
+ end
50
+ end
51
+ end
data/lib/jev/registry.rb CHANGED
@@ -7,23 +7,77 @@ module Jev
7
7
  @definitions = {}
8
8
  end
9
9
 
10
- def define(name, description)
10
+ def define(scope, name, description)
11
11
  key = name.to_sym
12
12
  value = description.to_s.dup.freeze
13
- @mutex.synchronize { @definitions[key] = value }
13
+ scope_key = storage_key(scope)
14
+ @mutex.synchronize do
15
+ (@definitions[scope_key] ||= {})[key] = value
16
+ end
14
17
  value
15
18
  end
16
19
 
17
- def fetch(name)
18
- @mutex.synchronize { @definitions[name.to_sym] }
20
+ def fetch(name, scope: nil, fallback: true, inherit: true)
21
+ key = name.to_sym
22
+ @mutex.synchronize do
23
+ return @definitions.dig(nil, key) if scope.nil?
24
+
25
+ lookup_keys(scope, inherit: inherit).each do |scope_key|
26
+ found = @definitions.dig(scope_key, key)
27
+ return found if found
28
+ end
29
+ return @definitions.dig(nil, key) if fallback
30
+
31
+ nil
32
+ end
19
33
  end
20
34
 
21
- def all
22
- @mutex.synchronize { @definitions.dup.freeze }
35
+ def all(scope = nil)
36
+ @mutex.synchronize { (@definitions[storage_key(scope)] || {}).dup.freeze }
23
37
  end
24
38
 
25
39
  def reset!
26
40
  @mutex.synchronize { @definitions.clear }
27
41
  end
42
+
43
+ private
44
+
45
+ def storage_key(scope)
46
+ return if scope.nil?
47
+
48
+ key =
49
+ case scope
50
+ when String, Symbol then scope.to_s
51
+ when Module then scope.name || scope
52
+ else raise ArgumentError, "scope must be a Module, String, or Symbol"
53
+ end
54
+ raise ArgumentError, "scope must be a non-empty String" if key.is_a?(String) && key.empty?
55
+
56
+ key
57
+ end
58
+
59
+ def lookup_keys(scope, inherit:)
60
+ keys = [storage_key(scope)]
61
+ return keys unless inherit
62
+
63
+ klass = scope.is_a?(Class) ? scope : constantize(scope)
64
+ return keys unless klass.is_a?(Class)
65
+
66
+ current = klass.superclass
67
+ while current && current != Object
68
+ keys << (current.name || current)
69
+ current = current.superclass
70
+ end
71
+ keys.uniq
72
+ end
73
+
74
+ def constantize(name)
75
+ name = name.to_s if name.is_a?(Symbol)
76
+ return unless name.is_a?(String)
77
+
78
+ name.split("::").reduce(Object) { |mod, part| mod.const_get(part, false) }
79
+ rescue NameError
80
+ nil
81
+ end
28
82
  end
29
83
  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.1.0"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "feels/string"
data/lib/jev.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "jev/registry"
7
7
  require_relative "jev/transport"
8
8
  require_relative "jev/client"
9
9
  require_relative "jev/feels"
10
+ require_relative "jev/model"
10
11
 
11
12
  module Jev
12
13
  class << self
@@ -21,28 +22,41 @@ module Jev
21
22
  @configuration = Configuration.new
22
23
  end
23
24
 
24
- def define(name, description)
25
- registry.define(name, description)
25
+ def define(scope_or_name, name_or_description, description = nil)
26
+ if description.nil?
27
+ if scope_or_name.is_a?(Module) || name_or_description.is_a?(Symbol)
28
+ raise ArgumentError, "description is required"
29
+ end
30
+
31
+ registry.define(nil, scope_or_name, name_or_description)
32
+ else
33
+ registry.define(scope_or_name, name_or_description, description)
34
+ end
26
35
  end
27
36
 
28
- def definition(name)
29
- registry.fetch(name)
37
+ def definition(scope_or_name, name = nil)
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
30
43
  end
31
44
 
32
- def definitions
33
- registry.all
45
+ def definitions(scope = nil)
46
+ registry.all(scope)
34
47
  end
35
48
 
36
49
  def reset_definitions!
37
50
  registry.reset!
38
51
  end
39
52
 
40
- def feels(text, predicate)
41
- Client.new(configuration).probability(coerce_text(text), resolve(predicate))
53
+ def feels(text, scope_or_predicate, predicate = nil)
54
+ scope, predicate = unpack_predicate(scope_or_predicate, predicate)
55
+ Client.new(configuration).probability(coerce_text(text), resolve(predicate, scope: scope))
42
56
  end
43
57
 
44
- def feels?(text, predicate, threshold: configuration.threshold)
45
- feels(text, predicate) >= normalize_threshold(threshold)
58
+ def feels?(text, scope_or_predicate, predicate = nil, threshold: configuration.threshold)
59
+ feels(text, scope_or_predicate, predicate) >= normalize_threshold(threshold)
46
60
  end
47
61
 
48
62
  def normalize_threshold(value)
@@ -67,17 +81,36 @@ module Jev
67
81
  text
68
82
  end
69
83
 
70
- def resolve(predicate)
84
+ def unpack_predicate(scope_or_predicate, predicate)
85
+ return [nil, scope_or_predicate] if predicate.nil?
86
+ raise ArgumentError, "scope must be a Module, String, or Symbol" unless scoped?(scope_or_predicate)
87
+
88
+ [scope_or_predicate, predicate]
89
+ end
90
+
91
+ def scoped?(value)
92
+ value.is_a?(Module) || value.is_a?(String) || value.is_a?(Symbol)
93
+ end
94
+
95
+ def resolve(predicate, scope: nil)
71
96
  case predicate
72
97
  when Symbol
73
- definition(predicate) ||
74
- raise(UndefinedDefinition, "Undefined Jev definition: #{predicate.inspect}")
98
+ registry.fetch(predicate, scope: scope) ||
99
+ raise(UndefinedDefinition, undefined_message(predicate, scope))
75
100
  when String
76
101
  predicate
77
102
  else
78
103
  raise ArgumentError, "predicate must be a Symbol or String"
79
104
  end
80
105
  end
106
+
107
+ def undefined_message(predicate, scope)
108
+ if scope
109
+ "Undefined Jev definition: #{predicate.inspect} for #{scope}"
110
+ else
111
+ "Undefined Jev definition: #{predicate.inspect}"
112
+ end
113
+ end
81
114
  end
82
115
 
83
116
  reset_configuration!
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.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maxim Veysgeym
@@ -25,11 +25,13 @@ files:
25
25
  - lib/feels.rb
26
26
  - lib/feels/string.rb
27
27
  - lib/jev-feels.rb
28
+ - lib/jev-feels/string.rb
28
29
  - lib/jev.rb
29
30
  - lib/jev/client.rb
30
31
  - lib/jev/configuration.rb
31
32
  - lib/jev/errors.rb
32
33
  - lib/jev/feels.rb
34
+ - lib/jev/model.rb
33
35
  - lib/jev/registry.rb
34
36
  - lib/jev/transport.rb
35
37
  - lib/jev/version.rb
@@ -39,7 +41,7 @@ licenses:
39
41
  metadata:
40
42
  homepage_uri: https://github.com/Qew7/jev-feels
41
43
  source_code_uri: https://github.com/Qew7/jev-feels
42
- changelog_uri: https://github.com/Qew7/jev-feels/blob/main/README.md
44
+ changelog_uri: https://github.com/Qew7/jev-feels/blob/master/README.md
43
45
  allowed_push_host: https://rubygems.org
44
46
  post_install_message:
45
47
  rdoc_options: []