ask-core 0.11.4 → 0.12.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 +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/ask/decision.rb +87 -0
- data/lib/ask/decision_provider.rb +85 -0
- data/lib/ask/decision_result.rb +161 -0
- data/lib/ask/version.rb +1 -1
- data/lib/ask.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6fdffc37629602a70343da6bd60aaf87cfee10436f5b04283ef1efce46028393
|
|
4
|
+
data.tar.gz: 384f09f0ee5c310f82c8e137af71bb77634dcd6f3bad411a858797bc2129e1d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2248010d045f7045986842c3b0d4d8fd7f908024db5c3d8d0f836a1f9bb8201496d1dd2bf26cc1107e7c26dbd16cd37bfa506a2f7ce60fc207cfee6fa860c719
|
|
7
|
+
data.tar.gz: a55c2603441a9591741c2996bad3b3cac0499fc5945cad8117ec82067274d67f2902a24e7ce922f3794578329ad05479eaef5691003df7ed573e97ce99a37737
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## [0.12.0] - 2026-09-18
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **The decision vocabulary — `Ask::Decision`, `Ask::DecisionResult`,
|
|
6
|
+
`Ask::DecisionProvider`.** Typed questions that return structured answers
|
|
7
|
+
with calibrated probabilities: `Choice` (pick one of a defined set), `Score`
|
|
8
|
+
(a position on ordered levels), and `Noul` (a yes/no with a probability).
|
|
9
|
+
`DecisionProvider` is the registry that makes the backing engine swappable,
|
|
10
|
+
and `DecisionResult::Batch` answers a whole set of questions asked together.
|
|
11
|
+
|
|
12
|
+
The vocabulary lives in ask-core, and only the vocabulary: dependency-free
|
|
13
|
+
value objects every gem can speak, with no client, no retries and no
|
|
14
|
+
transport. The implementation is `ask-decisions`; a provider that talks to
|
|
15
|
+
TypeSafe/Jev, or to anything else, registers itself and is reachable from
|
|
16
|
+
everywhere the vocabulary is.
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
question = Ask::Decision::Choice.new(
|
|
20
|
+
instructions: "Which team should handle this?",
|
|
21
|
+
criteria: {billing: "Payment issues", technical: "Bugs or outages"}
|
|
22
|
+
)
|
|
23
|
+
provider.evaluate(state: "Charged twice for A-104", decisions: {"route" => question})
|
|
24
|
+
# => #<ChoiceAnswer choice="billing" confidence=0.89>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
New public API, so the minor version moves. Nothing existing changed.
|
|
28
|
+
|
|
1
29
|
## [0.10.0] - 2026-08-05
|
|
2
30
|
|
|
3
31
|
### Added
|
data/lib/ask/decision.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
# Decision primitives — the vocabulary for typed questions that return structured
|
|
5
|
+
# answers with calibrated probabilities. The three types mirror TypeSafe's System
|
|
6
|
+
# One API: Choice, Score, and Noul.
|
|
7
|
+
#
|
|
8
|
+
# These are vendor-neutral value objects in ask-core so that every gem in the
|
|
9
|
+
# ecosystem can speak decisions without depending on a specific provider gem.
|
|
10
|
+
#
|
|
11
|
+
# @example A Choice question
|
|
12
|
+
# Ask::Decision::Choice.new(
|
|
13
|
+
# instructions: "Which team should handle this?",
|
|
14
|
+
# criteria: { billing: "Payment issues", technical: "Bugs or outages" }
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# @example A Noul question
|
|
18
|
+
# Ask::Decision::Noul.new(
|
|
19
|
+
# instructions: "Does this message express urgency?"
|
|
20
|
+
# )
|
|
21
|
+
#
|
|
22
|
+
module Decision
|
|
23
|
+
# A Choice question: pick one option from a defined set.
|
|
24
|
+
#
|
|
25
|
+
# @attr_reader instructions [String] the question to evaluate
|
|
26
|
+
# @attr_reader criteria [Hash{String => String}] option → rubric description
|
|
27
|
+
class Choice
|
|
28
|
+
attr_reader :instructions, :criteria
|
|
29
|
+
|
|
30
|
+
def initialize(instructions:, criteria:)
|
|
31
|
+
@instructions = instructions
|
|
32
|
+
@criteria = criteria.freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def type = :choice
|
|
37
|
+
|
|
38
|
+
# Wire format for a single question entry.
|
|
39
|
+
def to_h
|
|
40
|
+
{ type: "choice", instructions: instructions, criteria: criteria }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A Score question: rate the state on ordered, descriptive levels.
|
|
45
|
+
#
|
|
46
|
+
# @attr_reader instructions [String] what to rate
|
|
47
|
+
# @attr_reader criteria [Array<String>] ordered level descriptions (≥2)
|
|
48
|
+
class Score
|
|
49
|
+
attr_reader :instructions, :criteria
|
|
50
|
+
|
|
51
|
+
def initialize(instructions:, criteria:)
|
|
52
|
+
@instructions = instructions
|
|
53
|
+
@criteria = Array(criteria).freeze
|
|
54
|
+
raise ArgumentError, "Score criteria must have at least 2 levels" if @criteria.size < 2
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def type = :score
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
{ type: "score", instructions: instructions, criteria: criteria }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A Noul question: is this statement true? Returns a probability (0–1).
|
|
66
|
+
#
|
|
67
|
+
# @attr_reader instructions [String] the yes/no question
|
|
68
|
+
# @attr_reader criteria [Hash, nil] optional { "true" => "...", "false" => "..." }
|
|
69
|
+
class Noul
|
|
70
|
+
attr_reader :instructions, :criteria
|
|
71
|
+
|
|
72
|
+
def initialize(instructions:, criteria: nil)
|
|
73
|
+
@instructions = instructions
|
|
74
|
+
@criteria = criteria&.freeze
|
|
75
|
+
freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def type = :noul
|
|
79
|
+
|
|
80
|
+
def to_h
|
|
81
|
+
h = { type: "noul", instructions: instructions }
|
|
82
|
+
h[:criteria] = criteria if criteria
|
|
83
|
+
h
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
# Abstract base class for decision providers. Mirrors Ask::Provider but for
|
|
5
|
+
# structured decisions (Choice / Score / Noul) instead of text generation.
|
|
6
|
+
#
|
|
7
|
+
# Subclasses implement {#evaluate} and register via {.register}. The active
|
|
8
|
+
# provider is resolved via {.resolve}.
|
|
9
|
+
#
|
|
10
|
+
# @example Registering a provider
|
|
11
|
+
# Ask::DecisionProvider.register(:typesafe, Ask::Decisions::Typesafe)
|
|
12
|
+
#
|
|
13
|
+
# @example Resolving and using a provider
|
|
14
|
+
# provider = Ask::DecisionProvider.resolve(:typesafe)
|
|
15
|
+
# result = provider.evaluate(state: "...", decisions: { "q" => choice_q })
|
|
16
|
+
#
|
|
17
|
+
class DecisionProvider
|
|
18
|
+
REGISTRY_MUTEX = Mutex.new
|
|
19
|
+
private_constant :REGISTRY_MUTEX
|
|
20
|
+
|
|
21
|
+
# @return [Object] provider configuration
|
|
22
|
+
attr_reader :config
|
|
23
|
+
|
|
24
|
+
def initialize(config = {})
|
|
25
|
+
@config = config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Evaluate a state against a map of decisions and return a Result::Batch.
|
|
29
|
+
#
|
|
30
|
+
# @param state [String, Hash, Array] the content to evaluate
|
|
31
|
+
# @param decisions [Hash{String => Decision::Choice|Decision::Score|Decision::Noul}]
|
|
32
|
+
# map of id → question
|
|
33
|
+
# @param model [String, nil] model to use (provider-specific)
|
|
34
|
+
# @return [DecisionResult::Batch]
|
|
35
|
+
# @raise [NotImplementedError] in subclasses that don't implement this
|
|
36
|
+
def evaluate(state:, decisions:, model: nil)
|
|
37
|
+
raise NotImplementedError, "#{self.class} must implement #evaluate"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# --- Slug / name ---
|
|
41
|
+
|
|
42
|
+
def slug = self.class.slug
|
|
43
|
+
def name = self.class.name
|
|
44
|
+
|
|
45
|
+
# --- Registry (class-level) ---
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def register(name, provider_class)
|
|
49
|
+
REGISTRY_MUTEX.synchronize { registry[name.to_sym] = provider_class }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve(name)
|
|
53
|
+
REGISTRY_MUTEX.synchronize do
|
|
54
|
+
registry[name.to_sym] || raise(Ask::UnknownProvider,
|
|
55
|
+
"Unknown decision provider: #{name.inspect}. " \
|
|
56
|
+
"Available: #{registry.keys.join(', ')}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def providers
|
|
61
|
+
REGISTRY_MUTEX.synchronize { registry.dup }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def clear_providers!
|
|
65
|
+
REGISTRY_MUTEX.synchronize { @registry = {} }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def slug
|
|
69
|
+
name.split("::").last.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
70
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
71
|
+
.downcase
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def name
|
|
75
|
+
to_s.split("::").last
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def registry
|
|
81
|
+
@registry ||= {}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
# Typed answer objects returned by a DecisionProvider. Each answer type
|
|
5
|
+
# carries the raw values plus convenience helpers for common patterns.
|
|
6
|
+
#
|
|
7
|
+
# @example Reading a Choice answer
|
|
8
|
+
# result = Ask.decide(state: "...", decisions: { "route" => choice_q })
|
|
9
|
+
# result["route"].choice # => "technical"
|
|
10
|
+
# result["route"].confidence # => 0.84
|
|
11
|
+
# result["route"].ranked # => [["technical", 0.84], ["billing", 0.12], ...]
|
|
12
|
+
# result["route"].confident?(0.7) # => true
|
|
13
|
+
#
|
|
14
|
+
module DecisionResult
|
|
15
|
+
# A single answer from a decision call.
|
|
16
|
+
module Answer
|
|
17
|
+
# @return [String] the question id
|
|
18
|
+
attr_reader :id
|
|
19
|
+
|
|
20
|
+
# @return [Float] raw latency of this answer in seconds (set by provider)
|
|
21
|
+
attr_reader :latency
|
|
22
|
+
|
|
23
|
+
def initialize(id:, latency: nil)
|
|
24
|
+
@id = id
|
|
25
|
+
@latency = latency
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Choice answer: the picked option, full probability distribution, and confidence.
|
|
30
|
+
class ChoiceAnswer
|
|
31
|
+
include Answer
|
|
32
|
+
|
|
33
|
+
attr_reader :choice, :probabilities, :confidence, :ranked
|
|
34
|
+
|
|
35
|
+
def initialize(id:, choice:, probabilities:, confidence: nil, latency: nil)
|
|
36
|
+
super(id: id, latency: latency)
|
|
37
|
+
@choice = choice
|
|
38
|
+
@probabilities = probabilities.freeze
|
|
39
|
+
@confidence = confidence
|
|
40
|
+
@ranked = probabilities.sort_by { |_, v| -v }.freeze
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def type = :choice
|
|
45
|
+
|
|
46
|
+
# The option with the highest probability.
|
|
47
|
+
def best = ranked.first
|
|
48
|
+
|
|
49
|
+
# Whether the confidence meets a threshold. For Choice answers this uses
|
|
50
|
+
# the calibrated confidence value.
|
|
51
|
+
def confident?(threshold = 0.7)
|
|
52
|
+
return false if confidence.nil?
|
|
53
|
+
confidence >= threshold
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Score answer: a probability-weighted position across your levels.
|
|
58
|
+
class ScoreAnswer
|
|
59
|
+
include Answer
|
|
60
|
+
|
|
61
|
+
attr_reader :score, :legend, :probabilities, :confidence, :ranked
|
|
62
|
+
|
|
63
|
+
def initialize(id:, score:, legend:, probabilities:, confidence: nil, latency: nil)
|
|
64
|
+
super(id: id, latency: latency)
|
|
65
|
+
@score = score
|
|
66
|
+
@legend = legend.freeze
|
|
67
|
+
@probabilities = probabilities.freeze
|
|
68
|
+
@confidence = confidence
|
|
69
|
+
@ranked = probabilities.sort_by { |_, v| -v }.freeze
|
|
70
|
+
freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def type = :score
|
|
74
|
+
|
|
75
|
+
def best = ranked.first
|
|
76
|
+
|
|
77
|
+
def confident?(threshold = 0.7)
|
|
78
|
+
return false if confidence.nil?
|
|
79
|
+
confidence >= threshold
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The probability-weighted expectation across levels (same as .score).
|
|
83
|
+
def expected = score
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Noul answer: the probability that the statement is true (0–1).
|
|
87
|
+
# Noul answers do NOT carry a separate confidence value.
|
|
88
|
+
class NoulAnswer
|
|
89
|
+
include Answer
|
|
90
|
+
|
|
91
|
+
attr_reader :noul
|
|
92
|
+
|
|
93
|
+
def initialize(id:, noul:, latency: nil)
|
|
94
|
+
super(id: id, latency: latency)
|
|
95
|
+
@noul = noul
|
|
96
|
+
freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def type = :noul
|
|
100
|
+
|
|
101
|
+
# Noul has no confidence field, so confidence-gating uses the value's
|
|
102
|
+
# distance from 0.5. Returns the distance; 0 means fully uncertain,
|
|
103
|
+
# 0.5 means fully certain.
|
|
104
|
+
def strength
|
|
105
|
+
(noul - 0.5).abs
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Gate a noul answer against a threshold. A noul > 0.5 is a "yes",
|
|
109
|
+
# and this method checks both the direction and the strength.
|
|
110
|
+
#
|
|
111
|
+
# @param threshold [Float] minimum strength (distance from 0.5) to pass
|
|
112
|
+
# @return [Boolean]
|
|
113
|
+
def confident?(threshold = 0.3)
|
|
114
|
+
strength >= threshold
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def yes? = noul >= 0.5
|
|
118
|
+
def no? = noul < 0.5
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Aggregate result from a batch of decisions. Answers are accessed by id.
|
|
122
|
+
class Batch
|
|
123
|
+
include Enumerable
|
|
124
|
+
|
|
125
|
+
attr_reader :answers, :usage, :model, :latency, :min_confidence
|
|
126
|
+
|
|
127
|
+
def initialize(answers:, model: nil, usage: nil, latency: nil)
|
|
128
|
+
@answers = answers.freeze
|
|
129
|
+
@model = model
|
|
130
|
+
@usage = usage
|
|
131
|
+
@latency = latency
|
|
132
|
+
@min_confidence = @answers.values
|
|
133
|
+
.select { |a| a.respond_to?(:confidence) && !a.confidence.nil? }
|
|
134
|
+
.map(&:confidence)
|
|
135
|
+
.min
|
|
136
|
+
freeze
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Access an answer by id.
|
|
140
|
+
def [](id)
|
|
141
|
+
@answers[id.to_s]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def each(&block)
|
|
145
|
+
@answers.each_value(&block)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Whether every choice/score answer meets a confidence threshold.
|
|
149
|
+
def all_confident?(threshold = 0.7)
|
|
150
|
+
return true if @answers.empty?
|
|
151
|
+
@answers.values.all? do |a|
|
|
152
|
+
!a.respond_to?(:confidence) || a.confidence.nil? || a.confidence >= threshold
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def to_h
|
|
157
|
+
@answers.transform_values { |a| a.respond_to?(:to_h) ? a.to_h : a }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
data/lib/ask/version.rb
CHANGED
data/lib/ask.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -82,6 +82,9 @@ files:
|
|
|
82
82
|
- lib/ask/content.rb
|
|
83
83
|
- lib/ask/conversation.rb
|
|
84
84
|
- lib/ask/data_uri.rb
|
|
85
|
+
- lib/ask/decision.rb
|
|
86
|
+
- lib/ask/decision_provider.rb
|
|
87
|
+
- lib/ask/decision_result.rb
|
|
85
88
|
- lib/ask/document.rb
|
|
86
89
|
- lib/ask/errors.rb
|
|
87
90
|
- lib/ask/mime.rb
|