ask-decisions 0.1.1

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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Routes a user turn to the right tool by asking Jev to pick from the
6
+ # tool roster. Emits the same shape as a tool call so the existing
7
+ # ToolExecutor can run it unchanged.
8
+ #
9
+ # router = Ask::Decisions::ToolRouter.new(provider, tools: tool_roster)
10
+ # result = router.route(
11
+ # user_turn: "check the weather in seattle tomorrow",
12
+ # recent_turns: [...]
13
+ # )
14
+ # result.tool # => "web_search"
15
+ # result.confidence # => 0.92
16
+ # result.answer_directly? # => false
17
+ #
18
+ class ToolRouter
19
+ # Non-tool outcomes that the Choice question includes.
20
+ NON_TOOL_OUTCOMES = {
21
+ "answer_directly" => "Answer the user directly without calling any tool",
22
+ "ask_clarifying_question" => "Ask the user a clarifying question before acting",
23
+ "none" => "No action needed; the turn is a follow-up or acknowledgment"
24
+ }.freeze
25
+
26
+ # The question the roster answers. The ids in +criteria+ are the tool
27
+ # names, so the answer comes back as a tool the caller can run.
28
+ INSTRUCTIONS = "Which tool should the assistant use to handle the user's latest request? " \
29
+ "If no tool is needed, pick answer_directly, ask_clarifying_question, or none."
30
+
31
+ # @param provider [Ask::DecisionProvider]
32
+ # @param tools [Array<Hash>] tool roster, each with "name" and "description"
33
+ # @param none_threshold [Float] below this confidence, fall back to LLM
34
+ # @param criteria [Hash, nil] routing-grade descriptions, tool name =>
35
+ # when to choose it. Worth supplying whenever the roster holds tools
36
+ # that overlap: a tool's own description is written for the model that
37
+ # already holds it, and two accurate descriptions can still fail to
38
+ # separate their tools from the outside. Omitted, each tool's own
39
+ # description is used.
40
+ # @param limit [Integer] characters kept per description
41
+ def initialize(provider, tools:, none_threshold: 0.5, criteria: nil, limit: 160)
42
+ @provider = provider
43
+ @tools = tools
44
+ @none_threshold = none_threshold
45
+ @criteria = criteria
46
+ @limit = limit
47
+ end
48
+
49
+ # Route a user turn to a tool or non-tool outcome.
50
+ #
51
+ # @param user_turn [String] the latest user message
52
+ # @param recent_turns [String, nil] recent conversation context (truncated)
53
+ # @param model [String, nil] model override
54
+ # @return [RouteResult]
55
+ def route(user_turn:, recent_turns: nil, model: nil)
56
+ state = build_state(user_turn: user_turn, recent_turns: recent_turns)
57
+ RouteResult.new(reader.choice(reader.read(state: state, model: model)))
58
+ end
59
+
60
+ private
61
+
62
+ # The roster, as the options of one Choice question.
63
+ def reader
64
+ @reader ||= Ask::Decisions::Reader.new(
65
+ @provider,
66
+ id: "tool.route",
67
+ instructions: INSTRUCTIONS,
68
+ options: described_roster,
69
+ limit: @limit
70
+ )
71
+ end
72
+
73
+ def described_roster
74
+ described = @tools.each_with_object({}) do |tool, options|
75
+ name = tool[:name] || tool["name"]
76
+ options[name] = @criteria&.dig(name) || @criteria&.dig(name.to_s) ||
77
+ tool[:description] || tool["description"] || ""
78
+ end
79
+ described.merge(NON_TOOL_OUTCOMES)
80
+ end
81
+
82
+ def build_state(user_turn:, recent_turns: nil)
83
+ state = {user_turn: user_turn}
84
+ state[:recent_turns] = truncate(recent_turns, 2000) if recent_turns
85
+ state
86
+ end
87
+
88
+ def truncate(str, limit)
89
+ return "" if str.nil?
90
+ str.length > limit ? "#{str[0, limit]}…" : str
91
+ end
92
+
93
+
94
+ # Result of routing.
95
+ class RouteResult
96
+ attr_reader :choice_answer
97
+
98
+ def initialize(choice_answer)
99
+ @choice_answer = choice_answer
100
+ end
101
+
102
+ # The selected tool name or non-tool outcome.
103
+ def tool = choice_answer&.choice
104
+
105
+ def confidence = choice_answer&.confidence
106
+
107
+ def probabilities = choice_answer&.probabilities
108
+
109
+ # Should we call a tool, or handle this differently?
110
+ def answer_directly? = tool == "answer_directly"
111
+ def ask_clarifying? = tool == "ask_clarifying_question"
112
+ def no_action? = tool == "none"
113
+ def call_tool? = !answer_directly? && !ask_clarifying? && !no_action?
114
+
115
+ # Is the confidence above the threshold for autonomous action?
116
+ def confident?(threshold = nil)
117
+ threshold ||= 0.7
118
+ return false if confidence.nil?
119
+ confidence >= threshold
120
+ end
121
+
122
+ # Should we fall back to the LLM loop?
123
+ def fallback?(none_threshold = 0.5)
124
+ confidence.nil? || confidence < none_threshold
125
+ end
126
+
127
+ def to_s
128
+ if call_tool?
129
+ "tool: #{tool} (#{('%.2f' % (confidence || 0))})"
130
+ else
131
+ "#{tool} (#{('%.2f' % (confidence || 0))})"
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Reads an incoming message once and answers every question the turn
6
+ # needs about it: which lane it belongs to, how the person sounds, and
7
+ # whether they want a human.
8
+ #
9
+ # Lanes are the coarse intents a message can be sorted into — "a
10
+ # question about the business", "wants to book", "wants a person" — and
11
+ # they are deliberately fewer and coarser than the tools they lead to.
12
+ #
13
+ # That coarseness is the point. Routing a message straight to one of
14
+ # twenty tools asks the model to make a distinction the message does not
15
+ # carry: seven tools may all answer from the same knowledge, and which
16
+ # one holds the answer is discovered by calling them, not by reading the
17
+ # request. Lanes are the part that *is* decidable from the message; code
18
+ # maps the lane to its tools, and a second, narrower decision happens
19
+ # only when one is needed.
20
+ #
21
+ # Ranks measured on a 19-tool roster: tool-level routing 10/16, lane
22
+ # routing 19/20 — same model, same messages.
23
+ #
24
+ # triage = Ask::Decisions::Triage.new(provider, lanes: {
25
+ # "knowledge" => "Asks about the business, its services, prices, hours, or policies",
26
+ # "booking" => "Wants to book an appointment or asks what times are free",
27
+ # "human" => "Wants to speak to a person, or describes an emergency",
28
+ # "close" => "Says goodbye or is done",
29
+ # "chat" => "Small talk or a greeting needing no action",
30
+ # "unclear" => "None of these is clear; a clarifying question is needed first"
31
+ # })
32
+ # verdict = triage.read(message: "What time do you close on Saturdays?")
33
+ # verdict.lane # => "knowledge"
34
+ # verdict.confidence # => 1.0
35
+ # verdict.certain?(0.7) # => true
36
+ #
37
+ # All questions go in one request: asking more costs no extra latency.
38
+ class Triage
39
+ # The verdict on one message. +lane+ is always one of the lanes the
40
+ # caller supplied, or nil when the call failed.
41
+ class Verdict
42
+ attr_reader :lane, :confidence, :sentiment, :wants_human, :answers
43
+
44
+ def initialize(lane:, confidence:, sentiment: nil, wants_human: nil, answers: {})
45
+ @lane = lane
46
+ @confidence = confidence
47
+ @sentiment = sentiment
48
+ @wants_human = wants_human
49
+ @answers = answers
50
+ end
51
+
52
+ # A failed or unreadable call produces no verdict: nothing is known,
53
+ # and the caller keeps whatever it would have done without us.
54
+ def known? = !lane.nil?
55
+
56
+ # Is the lane above the threshold for acting on it without asking
57
+ # again? Callers pass the one threshold they are willing to be wrong
58
+ # at; there is no universal one.
59
+ def certain?(threshold = 0.7)
60
+ return false unless confidence
61
+ confidence >= threshold
62
+ end
63
+
64
+ # Does the person want a person? An unanswered question is not a yes,
65
+ # and neither is a coin flip: 0.5 means the model had no idea, which
66
+ # is the one answer that must not read as consent.
67
+ def wants_human?(threshold = 0.5)
68
+ return false unless wants_human
69
+ wants_human > threshold
70
+ end
71
+
72
+ def to_s
73
+ return "no verdict" unless known?
74
+ "#{lane} (#{format('%.2f', confidence || 0)})"
75
+ end
76
+ end
77
+
78
+ # The auxiliary questions every triage asks, on top of the lane.
79
+ # They ride along in the same request, so they are free — which is the
80
+ # whole reason to ask for them up front rather than in a second call.
81
+ SENTIMENT = Ask::Decision::Score.new(
82
+ instructions: "How does the person writing sound? Judge their mood, not the topic.",
83
+ criteria: ["Upset or angry", "Neutral", "Warm or pleased"]
84
+ )
85
+
86
+ WANTS_HUMAN = Ask::Decision::Noul.new(
87
+ instructions: "Does the person want to speak to a human rather than deal with a bot? " \
88
+ "Contact details, business hours, and product questions do not count as wanting a human."
89
+ )
90
+
91
+ # The question the lanes answer.
92
+ DEFAULT_INSTRUCTIONS = "Which of these best describes what the person writing wants? " \
93
+ "Choose by what they are asking for, not by how they phrase it."
94
+
95
+ attr_reader :lanes
96
+
97
+ # @param provider [Ask::DecisionProvider]
98
+ # @param lanes [Hash] lane name => a sentence describing what belongs in it
99
+ # @param instructions [String] the question the lanes answer
100
+ # @param context_limit [Integer] how much context to carry into state
101
+ def initialize(provider, lanes:, instructions: nil, context_limit: 600)
102
+ @provider = provider
103
+ @lanes = lanes
104
+ @instructions = instructions || DEFAULT_INSTRUCTIONS
105
+ @context_limit = context_limit
106
+ end
107
+
108
+ # Read a message and return a Verdict.
109
+ #
110
+ # @param message [String] the message to read
111
+ # @param context [String, nil] a short description of who is writing to whom
112
+ # @param model [String, nil] model override
113
+ # @return [Verdict]
114
+ def read(message:, context: nil, model: nil)
115
+ answers = reader.read(state: build_state(message, context), model: model)
116
+ lane = answers[reader.id]
117
+
118
+ Verdict.new(
119
+ lane: lane&.choice,
120
+ confidence: lane&.confidence,
121
+ sentiment: answers["sentiment"]&.score,
122
+ wants_human: answers["wants_human"]&.noul,
123
+ answers: {
124
+ "lane" => lane,
125
+ "sentiment" => answers["sentiment"],
126
+ "wants_human" => answers["wants_human"]
127
+ }
128
+ )
129
+ end
130
+
131
+ private
132
+
133
+ # The lane, the mood, and the want for a person — one request, because
134
+ # the questions are independent and the call costs the same either way.
135
+ def reader
136
+ @reader ||= Ask::Decisions::Reader.new(
137
+ @provider,
138
+ id: "lane",
139
+ instructions: @instructions,
140
+ options: @lanes,
141
+ also: {"sentiment" => SENTIMENT, "wants_human" => WANTS_HUMAN}
142
+ )
143
+ end
144
+
145
+ # The state is the message and a line of context. Longer state has
146
+ # been measured to make Jev worse, not better — everything irrelevant
147
+ # is a chance to misread what is relevant.
148
+ def build_state(message, context)
149
+ state = {message: truncate(message, 2000)}
150
+ state[:context] = truncate(context, @context_limit) if context && !context.to_s.empty?
151
+ state
152
+ end
153
+
154
+ def truncate(value, limit)
155
+ text = value.to_s
156
+ return text if text.length <= limit
157
+ "#{text[0, limit - 20]}…[#{text.length - limit + 20} chars elided]"
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "ask-auth"
5
+ rescue LoadError
6
+ # ask-auth is optional — only needed for automatic credential resolution.
7
+ end
8
+
9
+ module Ask
10
+ module Decisions
11
+ # HTTP client for the TypeSafe / System One API.
12
+ #
13
+ # provider = Ask::Decisions::Typesafe.new
14
+ # result = provider.evaluate(
15
+ # state: "Help! My payouts are failing.",
16
+ # decisions: {
17
+ # "is_urgent" => Ask::Decision::Noul.new(instructions: "Does this convey urgency?"),
18
+ # "route" => Ask::Decision::Choice.new(
19
+ # instructions: "Which team should handle this?",
20
+ # criteria: { "billing" => "Payments", "technical" => "Bugs" }
21
+ # )
22
+ # }
23
+ # )
24
+ # result["is_urgent"].noul # => 0.92
25
+ # result["route"].choice # => "technical"
26
+ # result["route"].confidence # => 0.82
27
+ #
28
+ # The API key is resolved in order:
29
+ # 1. +api_key+ passed to the constructor
30
+ # 2. +Ask::Decisions.configuration.api_key+
31
+ # 3. +Ask::Auth.resolve(:typesafe_api_key)+
32
+ # 4. +ENV["TYPESAFE_API_KEY"]+
33
+ #
34
+ class Typesafe < Ask::DecisionProvider
35
+ API_BASE = "https://api.typesafe.ai"
36
+ API_PATH = "/v1/systemone"
37
+
38
+ RETRYABLE_STATUSES = [429, 529].freeze
39
+
40
+ attr_reader :api_key, :api_base, :model, :timeout
41
+
42
+ def initialize(api_key: nil, api_base: nil, model: nil, timeout: nil, **_opts)
43
+ super()
44
+ @api_key = api_key || resolve_api_key
45
+ @api_base = (api_base || Ask::Decisions.configuration.api_base || API_BASE).chomp("/")
46
+ @model = model || Ask::Decisions.configuration.default_model || "jev-latest"
47
+ @timeout = timeout || Ask::Decisions.configuration.timeout || 5.0
48
+ end
49
+
50
+ # @return [DecisionResult::Batch]
51
+ def evaluate(state:, decisions:, model: nil)
52
+ model_name = model || @model
53
+ payload = build_payload(state, decisions, model_name)
54
+ response = post(payload)
55
+ parse_response(response, decisions.keys)
56
+ end
57
+
58
+ private
59
+
60
+ # --- Request building ---
61
+
62
+ def build_payload(state, decisions, model_name)
63
+ questions = decisions.transform_values(&:to_h)
64
+ {
65
+ state: state,
66
+ model: model_name,
67
+ questions: questions
68
+ }
69
+ end
70
+
71
+ def post(payload)
72
+ uri = URI.parse("#{@api_base}#{API_PATH}")
73
+ body = JSON.generate(payload)
74
+
75
+ http = Net::HTTP.new(uri.host, uri.port)
76
+ http.use_ssl = (uri.scheme == "https")
77
+ http.open_timeout = @timeout
78
+ http.read_timeout = @timeout
79
+
80
+ request = Net::HTTP::Post.new(uri.path)
81
+ request["Authorization"] = "Bearer #{@api_key}"
82
+ request["Content-Type"] = "application/json"
83
+ request["User-Agent"] = "ask-decisions/#{Ask::Decisions::VERSION}"
84
+ request.body = body
85
+
86
+ response = http.request(request)
87
+ handle_response(response)
88
+ end
89
+
90
+ def handle_response(response)
91
+ status = response.code.to_i
92
+ body = response.body
93
+
94
+ case status
95
+ when 200..299
96
+ JSON.parse(body)
97
+ when 401, 403
98
+ raise Ask::Unauthorized, "TypeSafe: #{status} #{extract_message(body)}"
99
+ when 429
100
+ retry_after = response["retry-after"]&.to_f
101
+ raise Ask::RateLimitError.new(
102
+ "TypeSafe: rate limited",
103
+ retry_after: retry_after
104
+ )
105
+ when 422
106
+ raise Ask::ProviderError.new("TypeSafe: #{extract_message(body)}", status_code: status)
107
+ when 500
108
+ raise Ask::ServerError, "TypeSafe: server error #{extract_message(body)}"
109
+ when 529
110
+ retry_after = response["retry-after"]&.to_f
111
+ raise Ask::RateLimitError.new(
112
+ "TypeSafe: overloaded",
113
+ retry_after: retry_after
114
+ )
115
+ else
116
+ raise Ask::ProviderError.new(
117
+ "TypeSafe: HTTP #{status} #{extract_message(body)}",
118
+ status_code: status
119
+ )
120
+ end
121
+ end
122
+
123
+ # --- Response parsing ---
124
+
125
+ def parse_response(parsed, question_ids)
126
+ answers = {}
127
+ question_ids.each do |id|
128
+ raw = parsed.dig("answers", id)
129
+ next unless raw
130
+
131
+ answers[id] = parse_answer(id, raw)
132
+ end
133
+
134
+ Ask::DecisionResult::Batch.new(
135
+ answers: answers,
136
+ model: parsed["model"],
137
+ usage: parsed["usage"],
138
+ latency: nil # set by caller if timing
139
+ )
140
+ end
141
+
142
+ def parse_answer(id, raw)
143
+ case raw["type"]
144
+ when "choice"
145
+ Ask::DecisionResult::ChoiceAnswer.new(
146
+ id: id,
147
+ choice: raw["choice"],
148
+ probabilities: raw["probabilities"] || {},
149
+ confidence: raw["confidence"]&.to_f
150
+ )
151
+ when "score"
152
+ Ask::DecisionResult::ScoreAnswer.new(
153
+ id: id,
154
+ score: raw["score"]&.to_f,
155
+ legend: raw["legend"] || {},
156
+ probabilities: raw["probabilities"] || {},
157
+ confidence: raw["confidence"]&.to_f
158
+ )
159
+ when "noul"
160
+ Ask::DecisionResult::NoulAnswer.new(
161
+ id: id,
162
+ noul: raw["noul"]&.to_f
163
+ )
164
+ else
165
+ raise Ask::ProviderError, "TypeSafe: unknown answer type #{raw["type"].inspect} for #{id}"
166
+ end
167
+ end
168
+
169
+ # --- Helpers ---
170
+
171
+ def resolve_api_key
172
+ Ask::Decisions.configuration.api_key ||
173
+ (defined?(Ask::Auth) && begin
174
+ Ask::Auth.resolve(:typesafe_api_key)
175
+ rescue Ask::Auth::MissingCredential
176
+ nil
177
+ end) ||
178
+ ENV["TYPESAFE_API_KEY"]
179
+ end
180
+
181
+ def extract_message(body)
182
+ parsed = JSON.parse(body)
183
+ parsed["error"]["message"] || parsed["error"] || body
184
+ rescue JSON::ParserError
185
+ body
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ VERSION = "0.1.1"
6
+ end
7
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Tools
7
+ # A tool that delegates a judgment to a decision model (e.g. Jev).
8
+ # This is the bridge for LLM-driven agents: instead of guessing which
9
+ # tool to call, the agent asks Decide and gets typed answers with
10
+ # calibrated probabilities.
11
+ #
12
+ # The agent sees this as one more tool alongside bash, read, etc.:
13
+ #
14
+ # decide(state: "...", questions: '{
15
+ # "route": {
16
+ # "type": "choice",
17
+ # "instructions": "Which tool should handle this?",
18
+ # "criteria": {
19
+ # "bash": "Run a shell command",
20
+ # "read": "Read a file",
21
+ # "none": "Answer directly without tools"
22
+ # }
23
+ # }
24
+ # }')
25
+ #
26
+ class Decide < Ask::Tool
27
+ description "Delegate a judgment to a decision model. Returns typed answers " \
28
+ "with calibrated probabilities. Use this when you need to classify, " \
29
+ "route, score, or judge something — instead of guessing, ask the " \
30
+ "decision model. One call can ask many questions at once."
31
+
32
+ param :state, type: :string, desc: "The content to evaluate (JSON string or plain text)", required: true
33
+ param :questions, type: :string, desc: "Questions as JSON: {\"id\": {\"type\": \"choice|score|noul\", \"instructions\": \"...\", \"criteria\": {...}}}", required: true
34
+
35
+ def execute(state:, questions:)
36
+ parsed_state = parse_json_safe(state)
37
+ parsed_questions = parse_json_safe(questions)
38
+
39
+ unless parsed_questions.is_a?(Hash)
40
+ return Ask::Result.failure("questions must be a JSON object mapping id → question")
41
+ end
42
+
43
+ decisions = build_decisions(parsed_questions)
44
+ if decisions.empty?
45
+ return Ask::Result.failure("no valid questions found in the input")
46
+ end
47
+
48
+ provider = resolve_provider
49
+ result = provider.evaluate(state: parsed_state, decisions: decisions)
50
+
51
+ # Format answers for the LLM — the agent needs to read this.
52
+ output = format_answers(result)
53
+ Ask::Result.success(output)
54
+ rescue JSON::ParserError => e
55
+ Ask::Result.failure("invalid JSON: #{e.message}")
56
+ rescue => e
57
+ Ask::Result.failure("decide failed: #{e.message}")
58
+ end
59
+
60
+ private
61
+
62
+ # Parse JSON, but return the original string if it's not JSON.
63
+ def parse_json_safe(str)
64
+ return str if str.is_a?(Hash) || str.is_a?(Array)
65
+ JSON.parse(str)
66
+ rescue JSON::ParserError
67
+ str
68
+ end
69
+
70
+ # Convert the raw JSON question map into Ask::Decision objects.
71
+ def build_decisions(parsed)
72
+ parsed.each_with_object({}) do |(id, q), h|
73
+ q = q.is_a?(Hash) ? q : {}
74
+ type = q["type"]&.downcase
75
+ instructions = q["instructions"]
76
+ next unless type && instructions
77
+
78
+ case type
79
+ when "choice"
80
+ criteria = q["criteria"] || {}
81
+ h[id] = Ask::Decision::Choice.new(instructions: instructions, criteria: criteria)
82
+ when "score"
83
+ criteria = Array(q["criteria"])
84
+ h[id] = Ask::Decision::Score.new(instructions: instructions, criteria: criteria)
85
+ when "noul"
86
+ criteria = q["criteria"] # optional
87
+ opts = { instructions: instructions }
88
+ opts[:criteria] = criteria if criteria
89
+ h[id] = Ask::Decision::Noul.new(**opts)
90
+ end
91
+ end
92
+ end
93
+
94
+ # Resolve the decision provider from configuration.
95
+ def resolve_provider
96
+ provider_name = Ask::Decisions.configuration.default_provider
97
+ Ask::Decisions.resolve_provider(provider_name)
98
+ end
99
+
100
+ # Format the result batch as a readable JSON string for the LLM.
101
+ def format_answers(result)
102
+ output = { model: result.model, answers: {} }
103
+ result.each do |answer|
104
+ case answer
105
+ when Ask::DecisionResult::ChoiceAnswer
106
+ output[:answers][answer.id] = {
107
+ type: "choice",
108
+ choice: answer.choice,
109
+ probabilities: answer.probabilities,
110
+ confidence: answer.confidence
111
+ }
112
+ when Ask::DecisionResult::ScoreAnswer
113
+ output[:answers][answer.id] = {
114
+ type: "score",
115
+ score: answer.score,
116
+ confidence: answer.confidence,
117
+ legend: answer.legend
118
+ }
119
+ when Ask::DecisionResult::NoulAnswer
120
+ output[:answers][answer.id] = {
121
+ type: "noul",
122
+ noul: answer.noul
123
+ }
124
+ end
125
+ end
126
+ JSON.generate(output)
127
+ end
128
+ end
129
+ end
130
+ end