email.feels?(:urgent) is a named question on a field, not a prompt in a service.
# Gemfile
gem "jev-feels"class SupportEmail < ApplicationRecord
include Jev::Model
feels :body, :urgent
feels :subject, :not_important, "Subject of this email is not important"
decide :body, :support_team
validates_feeling :body, :urgent, threshold: 0.9
end
email.feels?(:urgent) # => true
email.decide(:support_team) # => :billing# config/initializers/jev.rb
require "feels/active_model"
Jev.configure do |config|
config.api_key = ENV.fetch("JEV_API_KEY")
end
Jev.define :urgent, "Requires immediate attention or action"
Jev.define :support_team, "Which team should handle this?", choices: {
billing: "payments and refunds",
technical: "bugs and outages",
other: "none of the above"
}The class is the scope, the first argument is the field. validates_feeling accepts allow_nil:, allow_blank:, if:, unless:, on:, message:, at_least:. If those skip the check, there is no HTTP call. A nil from at_least: is a validation failure.
class Ticket
include Jev::Model
feels :description, :urgent
decide :description, :support_team
end
ticket.feels?(:urgent)
ticket.decide(:support_team)Jev::Model reads ticket.description (any method that returns a string). Bind-only uses the global Jev.define. Pass a string to define on the class: feels :description, :happy, "Sounds cheerful".
require "feels" does not change String. Opt in with a refinement:
using Jev::Feels
email.body.feels?(:urgent)
email.body.decide(:support_team)
email.body.score(:severity)Or, if you really want a global patch:
require "feels/string"
# or: require "jev-feels/string"Jev.define is the vocabulary of the app. A symbol without extras is a yes/no check:
Jev.define :urgent, "Requires immediate attention or action"
Jev.define :spam, "Unsolicited or unwanted promotional content"A decision (decide), with up to 255 choices:
Jev.define :support_team,
"Which support team should handle this?",
choices: {
billing: "payments, invoices, charges and refunds",
technical: "bugs, outages and integrations",
sales: "purchase and plan questions",
other: "none of the above"
}A scale (score), with 2 to 10 levels. Hash order is low to high:
Jev.define :severity,
"How severe is this customer issue?",
levels: {
cosmetic: "minor visual or cosmetic issue",
degraded: "functionality is degraded but a workaround exists",
blocking: "the customer cannot complete an important task",
critical: "major outage, data loss, security issue, or severe business impact"
}Scoped names still work:
Jev.define Email, :urgent, "Outage, customers cannot sign in"
Jev.define Comment, :urgent, "Legal takedown or self-harm"
Jev.definition(:urgent)
Jev.definition(Email, :urgent)
Jev.definitions
Jev.definitions(Comment)define replaces an existing name in that scope. Scopes are stored by class name, so Email, "Email" and :Email are the same key. A yes/no definition reads back as its instruction string. choices: / levels: read back as a Jev::Definition. On a class that includes Jev::Model, feels / decide / score bind a field; see PORO and Rails.
A Symbol must already have a definition or you get Jev::UndefinedDefinition. A String is an ad-hoc question and is not registered.
Jev.feels(email.body, :urgent) # => 0.93
Jev.feels?(email.body, :urgent) # => true / false
Jev.decide(ticket, :support_team) # => :billing
Jev.score(ticket, :severity) # => 2.37feels is the calibrated probability (0.0..1.0). feels? is feels >= threshold. The default threshold is 0.5.
email.body.feels?(:urgent, threshold: 0.8)decide returns the winning Symbol. score returns the fractional ordinal position — not a percentage.
Ad-hoc questions skip define:
email.body.feels?("sounds like the sender is about to cancel their subscription")
Jev.decide(
ticket,
"Which support team should handle this?",
choices: {
billing: "payments and refunds",
technical: "bugs and outages",
other: "none of the above"
}
)Jev.feels?(text, Email, :urgent) uses Email's definition, then a superclass, then the global :urgent.
threshold: is a single true/false cut. 0.51 and 0.99 are the same true.
at_least: is minimum certainty in either direction. The middle band is nil:
text.feels?(:urgent, at_least: 0.8)
# p >= 0.8 => true
# p <= 0.2 => false
# 0.2 < p < 0.8 => nilWithout at_least:, feels? is still always true or false.
Do not pass both threshold: and at_least:.
decide uses confidence, which is not the winner's probability:
Jev.decide(ticket, :support_team, confidence: 0.8)
# => :billing or nilConvenience methods collapse a result. measure keeps the rest:
result = Jev.measure(ticket, :urgent)
result.probability
result.type # => :noul
result = Jev.measure(ticket, :support_team)
result.choice
result.confidence
result.probabilities
result.type # => :choice
result = Jev.measure(ticket, :severity)
result.score
result.confidence
result.probabilities
result.levels
result.level # => :blocking
result.type # => :scorefeels? # boolean, or nil with at_least:
feels # probability
decide # Symbol, or nil with confidence:
score # FloatSeveral questions, one Jev request, one state:
result = Jev.measure(ticket) do |q|
q.feels :urgent
q.feels :angry
q.decide :support_team
q.score :severity
end
result[:urgent] # => Jev::Result::Noul
result[:support_team] # => Jev::Result::Choice
result[:severity] # => Jev::Result::Score
result.to_h
# => { urgent: true, angry: false, support_team: :billing, severity: 2.37 }A batch only uses defined names. Ad-hoc strings stay on a single feels? / decide / score.
Jev::Result::Batch uses collapsed values. No extra HTTP calls:
case Jev.measure(ticket) { |q|
q.feels :urgent
q.decide :support_team
q.score :severity
}
in { urgent: true, support_team: :billing }
escalate_billing(ticket)
in { support_team: :technical }
route_to_technical(ticket)
in { urgent: true, severity: 2.0.. }
escalate(ticket)
else
manual_review(ticket)
endfeels? collapses to true/false, decide to a Symbol, score stays a Float so ranges work.
A small dispatch for one decide. Low confidence goes to otherwise:
Jev.match(ticket, :support_team) do
on(:billing) { route_to_billing(ticket) }
on(:technical) { route_to_technical(ticket) }
on(:sales) { route_to_sales(ticket) }
otherwise { manual_review(ticket) }
end
Jev.match(ticket, :support_team, confidence: 0.8) do
on(:billing, :sales) { route_to_commercial(ticket) }
otherwise { manual_review(ticket) }
endStub by definition name. The block restores the previous transport, including in other threads:
Jev.stub(
urgent: 0.95,
support_team: :billing,
severity: 2.4
) do
expect(ticket.feels?(:urgent)).to be(true)
expect(Jev.decide(ticket, :support_team)).to eq(:billing)
endA full decide / score stub is a hash with choice or score, plus confidence and probabilities. To own the HTTP shape, set config.transport.
When Score probabilities are omitted, the stub distributes probability between adjacent levels so their weighted mean equals the score. The score must be within the declared scale (0..levels.size - 1); otherwise it raises ArgumentError. Explicit probabilities and confidence are preserved; confidence defaults to 1.0.
Record real answers and replay them later. Replay never uses the network. The tape matches the request (state and questions), not a queue index. API keys and Authorization headers are not stored.
tape = Jev.record do
ticket.feels?(:urgent)
Jev.decide(ticket, :support_team)
end
json = tape.to_json
Jev.replay(tape) do
ticket.feels?(:urgent)
Jev.decide(ticket, :support_team)
endJev.reset_configuration!
Jev.reset_definitions!Jev.configure do |config|
config.api_key = ENV["JEV_API_KEY"]
endbase_url defaults to https://api.typesafe.ai, timeout to 10 seconds, threshold to 0.5. Override only if you need to.
All errors inherit from Jev::Error. HTTP failures are wrapped; the original exception is available as cause.
| Error | When |
|---|---|
Jev::ConfigurationError |
Missing API key |
Jev::UndefinedDefinition |
Unknown symbol predicate |
Jev::AuthenticationError |
HTTP 401 |
Jev::RateLimitError |
HTTP 429 |
Jev::InvalidResponseError |
Unparseable or shapeless body |
Jev::RequestError |
Timeouts, network errors, other HTTP failures |
Jev::ReplayError |
Replay tape does not contain this request |
Malformed definitions (choices: and levels: together, empty Choice, too few Score levels, duplicate keys, invalid threshold: / at_least: / confidence:) raise ArgumentError.
Jev / TypeSafe System One. POST https://api.typesafe.ai/v1/systemone. That is an implementation detail. The public API is Jev.feels / Jev.feels? / Jev.decide / Jev.score / Jev.measure / Jev.match.