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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fe6fd73be561e63544265ffe4065905c432680e925c9df5d2e8b5e5f3e2b7896
4
- data.tar.gz: 59389ca6fb4726cf9d921eff7cc35598c95c619154f97c7cfb85a844e8e9cc02
3
+ metadata.gz: 6fdffc37629602a70343da6bd60aaf87cfee10436f5b04283ef1efce46028393
4
+ data.tar.gz: 384f09f0ee5c310f82c8e137af71bb77634dcd6f3bad411a858797bc2129e1d8
5
5
  SHA512:
6
- metadata.gz: b944d00f3194ab931e56e3d39ace11c6bb0b058c288dd147574c68a1369a10627d1982d75a4669d5a1eed9d225b2930082684e68086f54b10947503cfb90ae80
7
- data.tar.gz: b83e78248a0a843718850c5333f339dae78b0c63881184da0902847fd360454fb58cebb1b6269dc2d94869968820d937a1e570473f3e5de96811f22221603ac8
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ask
4
- VERSION = "0.11.4"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/ask.rb CHANGED
@@ -30,3 +30,6 @@ require_relative "ask/models"
30
30
  require_relative "ask/document"
31
31
  require_relative "ask/state"
32
32
  require_relative "ask/provider_tool"
33
+ require_relative "ask/decision"
34
+ require_relative "ask/decision_result"
35
+ require_relative "ask/decision_provider"
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.11.4
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