ruby-laya 0.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.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ # One typed question, validated and rendered the way the checkpoints were trained on.
5
+ #
6
+ # A question definition arrives as a Hash, with either String or Symbol keys:
7
+ #
8
+ # { "type" => "choice", "instructions" => "...", "criteria" => { "billing" => "invoices" } }
9
+ # { type: :score, instructions: "...", criteria: ["not urgent", "soon", "urgent"] }
10
+ # { type: :noul, instructions: "..." }
11
+ #
12
+ # Each type knows how to check itself, how to render its options, and how to turn the model's
13
+ # probabilities into an {Answer}, so nothing downstream switches on the type again.
14
+ class Question
15
+ attr_reader :id, :instructions, :criteria
16
+
17
+ # Build the question `definition` describes, raising ArgumentError when it cannot be answered.
18
+ def self.build(id, definition)
19
+ unless definition.is_a?(Hash)
20
+ raise ArgumentError, "question #{id.inspect}: definition must be a Hash, got #{definition.class}"
21
+ end
22
+
23
+ type = Util.get(definition, "type")
24
+ klass = TYPES[type.to_s]
25
+ unless klass
26
+ raise ArgumentError, "question #{id.inspect}: unknown type #{type.inspect}; " \
27
+ "use one of #{TYPES.keys.sort}"
28
+ end
29
+ unless Util.key?(definition, "instructions")
30
+ raise ArgumentError, "question #{id.inspect}: no 'instructions'; " \
31
+ "add the text the model should answer"
32
+ end
33
+
34
+ klass.new(id, Util.get(definition, "instructions"), Util.get(definition, "criteria"))
35
+ end
36
+
37
+ def initialize(id, instructions, criteria)
38
+ @id = id
39
+ @instructions = instructions.is_a?(String) ? instructions : PyJSON.dumps(instructions, ensure_ascii: true)
40
+ @criteria = normalise_criteria(criteria)
41
+ validate!
42
+ end
43
+
44
+ def type
45
+ self.class::TYPE
46
+ end
47
+
48
+ def qtype
49
+ QTYPES.fetch(type)
50
+ end
51
+
52
+ # The option texts the model scores, in label order.
53
+ def options
54
+ Common.render_options(internal)
55
+ end
56
+
57
+ # The shape `Laya::Common` and upstream's Python both work in.
58
+ def internal
59
+ { t: type, ins: instructions, crit: criteria }
60
+ end
61
+
62
+ # Build this question's answer from the probabilities over its options.
63
+ def answer(**)
64
+ raise NotImplementedError, "#{self.class} must build its own answer"
65
+ end
66
+
67
+ # Pick one label from a set of options.
68
+ class Choice < Question
69
+ TYPE = "choice"
70
+
71
+ def labels
72
+ criteria.keys
73
+ end
74
+
75
+ def answer(probabilities:, confidence:, action_probability:)
76
+ Answer::Choice.new(
77
+ choice: labels[probabilities.each_with_index.max_by { |p, i| [p, -i] }.last],
78
+ probabilities: labels.zip(probabilities.map { |p| p.round(4) }).to_h,
79
+ confidence: confidence, action_probability: action_probability
80
+ )
81
+ end
82
+
83
+ private
84
+
85
+ # A list of labels is the same question as a Hash of labels with no descriptions.
86
+ def normalise_criteria(criteria)
87
+ criteria.is_a?(Array) ? criteria.to_h { |label| [label, nil] } : criteria
88
+ end
89
+
90
+ def validate!
91
+ unless criteria.is_a?(Hash)
92
+ raise ArgumentError, "question #{id.inspect}: a choice question takes 'criteria' as a Hash " \
93
+ "of label => description, or an Array of labels"
94
+ end
95
+ return unless criteria.empty?
96
+
97
+ raise ArgumentError, "question #{id.inspect}: a choice question needs at least one criterion"
98
+ end
99
+ end
100
+
101
+ # Place the state on an ordinal rubric, level 0 first.
102
+ class Score < Question
103
+ TYPE = "score"
104
+
105
+ def answer(probabilities:, confidence:, action_probability:)
106
+ Answer::Score.new(
107
+ score: probabilities.each_with_index.sum { |p, i| i * p }.round(4),
108
+ legend: criteria.each_with_index.to_h { |level, i| [i.to_s, level] },
109
+ probabilities: probabilities.each_with_index.to_h { |p, i| [i.to_s, p.round(4)] },
110
+ confidence: confidence, action_probability: action_probability
111
+ )
112
+ end
113
+
114
+ private
115
+
116
+ def validate!
117
+ unless criteria.is_a?(Array)
118
+ raise ArgumentError, "question #{id.inspect}: a score question takes 'criteria' as an Array " \
119
+ "of level descriptions, index 0 first"
120
+ end
121
+ return unless criteria.empty?
122
+
123
+ raise ArgumentError, "question #{id.inspect}: a score question needs at least one level"
124
+ end
125
+ end
126
+
127
+ # Answer a yes/no statement with a calibrated probability.
128
+ class Noul < Question
129
+ TYPE = "noul"
130
+
131
+ # A noul question's confidence is how far the probability sits from a coin flip, computed
132
+ # from the unrounded value as upstream does, so the caller's `confidence` is not used.
133
+ def answer(probabilities:, action_probability:, confidence: nil) # rubocop:disable Lint/UnusedMethodArgument
134
+ probability = probabilities[1]
135
+ Answer::Noul.new(
136
+ probability: probability.round(4),
137
+ confidence: [probability, 1.0 - probability].max.round(4),
138
+ action_probability: action_probability
139
+ )
140
+ end
141
+
142
+ private
143
+
144
+ # `true:` and `false:` may arrive as booleans, symbols or strings; the renderer wants strings.
145
+ def normalise_criteria(criteria)
146
+ return criteria unless criteria.is_a?(Hash)
147
+
148
+ criteria.to_h { |key, value| [key.to_s.downcase, value] }
149
+ end
150
+
151
+ def validate!
152
+ return if criteria.nil? || criteria.is_a?(Hash)
153
+
154
+ raise ArgumentError, "question #{id.inspect}: a noul question takes 'criteria' as a Hash with " \
155
+ "optional 'true'/'false' descriptions, or omits it"
156
+ end
157
+ end
158
+
159
+ TYPES = { Choice::TYPE => Choice, Score::TYPE => Score, Noul::TYPE => Noul }.freeze
160
+
161
+ private
162
+
163
+ def normalise_criteria(criteria)
164
+ criteria
165
+ end
166
+
167
+ def validate!
168
+ nil
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ # Builds the question hash the runtime takes, from the three declarations that describe a
5
+ # decision. {Decision} and {Ask} both delegate here, so the two front doors cannot drift.
6
+ module Questions
7
+ module_function
8
+
9
+ # One label from a set.
10
+ #
11
+ # choice(:department, "Which team?", billing: "invoices", technical: "outages")
12
+ # choice(:intent, "Which intent?", criteria_hash) # labels that are not valid keywords
13
+ # choice(:tone, "Which tone?", %w[formal casual]) # labels with no description
14
+ def choice(instructions, criteria = nil, **labels)
15
+ criteria = labels if criteria.nil? || (criteria.respond_to?(:empty?) && criteria.empty? && labels.any?)
16
+ criteria = criteria.to_h { |label| [label, nil] } if criteria.is_a?(Array)
17
+ { "type" => "choice", "instructions" => instructions, "criteria" => stringify(criteria) }
18
+ end
19
+
20
+ # A position on ordered levels, lowest first.
21
+ #
22
+ # score(:urgency, "How urgent?", levels: ["not urgent", "soon", "critical"])
23
+ def score(instructions, levels)
24
+ { "type" => "score", "instructions" => instructions, "criteria" => Array(levels) }
25
+ end
26
+
27
+ # The probability that a statement holds. `yes` and `no` describe the two ends when the
28
+ # statement alone is ambiguous.
29
+ def noul(instructions, yes: nil, no: nil)
30
+ question = { "type" => "noul", "instructions" => instructions }
31
+ criteria = { "true" => yes, "false" => no }.compact
32
+ question["criteria"] = criteria unless criteria.empty?
33
+ question
34
+ end
35
+
36
+ # Criteria keys reach the model as text, so a symbol label is sent as its name.
37
+ def stringify(criteria)
38
+ raise ArgumentError, "criteria must be a Hash or an Array of labels" unless criteria.is_a?(Hash)
39
+
40
+ criteria.to_h { |label, description| [label.to_s, description] }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ # One question's answer.
5
+ #
6
+ # Subclasses carry what their question type produces, and every one of them renders the payload
7
+ # upstream's Python returns through {#to_h}, so a Ruby result can be logged, compared or served
8
+ # exactly as the Python one.
9
+ class Answer
10
+ attr_reader :type, :confidence, :action_probability
11
+
12
+ def initialize(type:, confidence:, action_probability:)
13
+ @type = type
14
+ @confidence = confidence
15
+ @action_probability = action_probability
16
+ end
17
+
18
+ def to_h
19
+ payload.merge("confidence" => confidence, "action" => { "act_probability" => action_probability })
20
+ end
21
+
22
+ def inspect
23
+ "#<#{self.class.name} #{summary}>"
24
+ end
25
+
26
+ def to_s
27
+ summary
28
+ end
29
+
30
+ private
31
+
32
+ def payload
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def summary
37
+ raise NotImplementedError
38
+ end
39
+
40
+ # The top label of a `choice` question, with a probability per option.
41
+ class Choice < Answer
42
+ attr_reader :choice, :probabilities
43
+
44
+ def initialize(choice:, probabilities:, **rest)
45
+ super(type: "choice", **rest)
46
+ @choice = choice
47
+ @probabilities = probabilities
48
+ end
49
+
50
+ # The probability of the chosen label, or of `label` when one is given.
51
+ def probability(label = choice)
52
+ probabilities.fetch(label) do
53
+ probabilities.fetch(label.to_s) do
54
+ raise KeyError, "no option #{label.inspect}; this question offered #{probabilities.keys.inspect}"
55
+ end
56
+ end
57
+ end
58
+
59
+ # The chosen label stands in for itself, so a call site reads as the decision it is:
60
+ #
61
+ # answer == :billing # => true
62
+ # answer.billing? # => true
63
+ # case answer.to_sym ... # Ruby asks the `when` value, so compare the symbol there
64
+ def ==(other)
65
+ case other
66
+ when Symbol, String then choice.to_s == other.to_s
67
+ when Answer::Choice then choice == other.choice
68
+ else super
69
+ end
70
+ end
71
+ alias eql? ==
72
+
73
+ def hash = [self.class, choice].hash
74
+ def to_sym = choice.to_sym
75
+ def to_s = choice.to_s
76
+
77
+ # `answer.billing?` for any label this question offered.
78
+ def method_missing(name, *args)
79
+ label = name.to_s.delete_suffix("?")
80
+ return super unless name.to_s.end_with?("?") && args.empty? && offered?(label)
81
+
82
+ choice.to_s == label
83
+ end
84
+
85
+ def respond_to_missing?(name, include_private = false)
86
+ (name.to_s.end_with?("?") && offered?(name.to_s.delete_suffix("?"))) || super
87
+ end
88
+
89
+ def offered?(label)
90
+ probabilities.keys.any? { |option| option.to_s == label }
91
+ end
92
+
93
+ private
94
+
95
+ def payload
96
+ { "type" => "choice", "choice" => choice, "probabilities" => probabilities }
97
+ end
98
+
99
+ def summary
100
+ format("%s %.1f%%", choice, probability * 100)
101
+ end
102
+ end
103
+
104
+ # The expected level of a `score` question on its ordinal rubric.
105
+ class Score < Answer
106
+ attr_reader :score, :legend, :probabilities
107
+
108
+ def initialize(score:, legend:, probabilities:, **rest)
109
+ super(type: "score", **rest)
110
+ @score = score
111
+ @legend = legend
112
+ @probabilities = probabilities
113
+ end
114
+
115
+ # The rubric text nearest the expected level.
116
+ def label
117
+ legend.fetch(score.round.clamp(0, legend.length - 1).to_s)
118
+ end
119
+
120
+ def to_f = score
121
+ def levels = legend.length
122
+
123
+ # Compare against a level index or its text.
124
+ def ==(other)
125
+ case other
126
+ when Numeric then score == other
127
+ when Symbol, String then label.to_s == other.to_s
128
+ when Answer::Score then score == other.score
129
+ else super
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def payload
136
+ { "type" => "score", "score" => score, "legend" => legend, "probabilities" => probabilities }
137
+ end
138
+
139
+ def summary
140
+ format("%.2f of %d (%s)", score, legend.length - 1, label)
141
+ end
142
+ end
143
+
144
+ # The calibrated probability that a `noul` statement holds.
145
+ class Noul < Answer
146
+ attr_reader :probability
147
+
148
+ def initialize(probability:, **rest)
149
+ super(type: "noul", **rest)
150
+ @probability = probability
151
+ end
152
+ alias noul probability
153
+
154
+ # True when the statement is more likely than `threshold` to hold.
155
+ def true?(threshold = 0.5)
156
+ probability > threshold
157
+ end
158
+
159
+ def false?(threshold = 0.5) = !true?(threshold)
160
+ def to_f = probability
161
+
162
+ def ==(other)
163
+ case other
164
+ when true, false then true? == other
165
+ when Numeric then probability == other
166
+ when Answer::Noul then probability == other.probability
167
+ else super
168
+ end
169
+ end
170
+
171
+ private
172
+
173
+ def payload
174
+ { "type" => "noul", "noul" => probability }
175
+ end
176
+
177
+ def summary
178
+ format("%.1f%%", probability * 100)
179
+ end
180
+ end
181
+ end
182
+
183
+ # Everything one `predict` produced: an answer per question, the token usage, and the routing
184
+ # decision when a {Router} made one.
185
+ class Result
186
+ include Enumerable
187
+
188
+ MODEL_NAME = "laya-rl-agent"
189
+
190
+ attr_reader :model, :answers, :usage, :routing, :shortlist
191
+
192
+ def initialize(answers:, usage:, model: MODEL_NAME, routing: nil, shortlist: nil)
193
+ @answers = answers
194
+ @usage = usage
195
+ @model = model
196
+ @routing = routing
197
+ @shortlist = shortlist
198
+ end
199
+
200
+ # The answer to `id`. A question asked under a symbol can be read back as a string and the
201
+ # other way around, so a result reads the same whichever form the caller reached for.
202
+ def [](id)
203
+ answers.fetch(id) do
204
+ answers.fetch(id.to_s) do
205
+ answers.fetch(id.to_sym) do
206
+ raise KeyError, "no question #{id.inspect} in this result; asked: #{answers.keys.inspect}"
207
+ end
208
+ end
209
+ end
210
+ rescue NoMethodError
211
+ raise KeyError, "no question #{id.inspect} in this result; asked: #{answers.keys.inspect}"
212
+ end
213
+
214
+ # Answers are also readable by name: `result.department` is `result[:department]`.
215
+ def method_missing(name, *args)
216
+ return super unless args.empty? && answered?(name)
217
+
218
+ self[name]
219
+ end
220
+
221
+ def respond_to_missing?(name, include_private = false)
222
+ answered?(name) || super
223
+ end
224
+
225
+ def answered?(name)
226
+ answers.key?(name) || answers.key?(name.to_s) || answers.key?(name.to_sym)
227
+ rescue NoMethodError
228
+ false
229
+ end
230
+
231
+ def each(&)
232
+ answers.each(&)
233
+ end
234
+
235
+ def input_tokens
236
+ usage.fetch("input_tokens")
237
+ end
238
+
239
+ # The payload upstream's Python `predict` returns, ready for JSON.
240
+ def to_h
241
+ payload = { "model" => model, "answers" => answers.transform_values(&:to_h), "usage" => usage }
242
+ payload["routing"] = routing.to_h if routing
243
+ payload["shortlist"] = shortlist if shortlist
244
+ payload
245
+ end
246
+
247
+ def to_json(*)
248
+ to_h.to_json(*)
249
+ end
250
+
251
+ def with_routing(decision)
252
+ Result.new(answers: answers, usage: usage, model: model, routing: decision, shortlist: shortlist)
253
+ end
254
+
255
+ def with_shortlist(meta)
256
+ Result.new(answers: answers, usage: usage, model: model, routing: routing, shortlist: meta)
257
+ end
258
+
259
+ def inspect
260
+ "#<Laya::Result #{answers.keys.inspect}#{" via #{routing.model}" if routing}>"
261
+ end
262
+ end
263
+ end