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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +67 -0
- data/LICENSE +21 -0
- data/README.md +238 -0
- data/lib/ask/decisions/agent_adapter.rb +178 -0
- data/lib/ask/decisions/argument_resolver.rb +136 -0
- data/lib/ask/decisions/batcher.rb +59 -0
- data/lib/ask/decisions/cache.rb +67 -0
- data/lib/ask/decisions/calibration_harness.rb +112 -0
- data/lib/ask/decisions/calibration_report.rb +168 -0
- data/lib/ask/decisions/confidence_policy.rb +119 -0
- data/lib/ask/decisions/decision_state.rb +122 -0
- data/lib/ask/decisions/failure_classifier.rb +87 -0
- data/lib/ask/decisions/gate.rb +147 -0
- data/lib/ask/decisions/lint.rb +83 -0
- data/lib/ask/decisions/loop_detector.rb +142 -0
- data/lib/ask/decisions/mcp_helper.rb +23 -0
- data/lib/ask/decisions/output_judge.rb +135 -0
- data/lib/ask/decisions/quality_judge.rb +107 -0
- data/lib/ask/decisions/reader.rb +96 -0
- data/lib/ask/decisions/reflection_judge.rb +83 -0
- data/lib/ask/decisions/reranker.rb +78 -0
- data/lib/ask/decisions/static.rb +66 -0
- data/lib/ask/decisions/structured_state_loop.rb +137 -0
- data/lib/ask/decisions/tool_repairer.rb +114 -0
- data/lib/ask/decisions/tool_router.rb +137 -0
- data/lib/ask/decisions/triage.rb +161 -0
- data/lib/ask/decisions/typesafe.rb +189 -0
- data/lib/ask/decisions/version.rb +7 -0
- data/lib/ask/tools/decide.rb +130 -0
- data/lib/ask-decisions.rb +165 -0
- metadata +201 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Decision-based quality judge — replaces the LLM-as-judge evaluator
|
|
6
|
+
# with calibrated Jev scores. Uses composite scoring across rubric
|
|
7
|
+
# dimensions plus a verdict choice.
|
|
8
|
+
#
|
|
9
|
+
# judge = Ask::Decisions::QualityJudge.new(provider)
|
|
10
|
+
# verdict = judge.evaluate(
|
|
11
|
+
# request: "What's the weather in Seattle?",
|
|
12
|
+
# response: "It's 72°F and sunny in Seattle today.",
|
|
13
|
+
# rubric: { accuracy: "Is the answer factually correct?", completeness: "Does it fully address the request?" }
|
|
14
|
+
# )
|
|
15
|
+
# verdict.accepted? # => true
|
|
16
|
+
# verdict.scores # => { accuracy: 4.0, completeness: 3.5 }
|
|
17
|
+
#
|
|
18
|
+
class QualityJudge
|
|
19
|
+
# Default rubric dimensions.
|
|
20
|
+
DEFAULT_RUBRIC = {
|
|
21
|
+
accuracy: "Is the answer factually correct and free of hallucinations?",
|
|
22
|
+
completeness: "Does the answer fully address the user's request?",
|
|
23
|
+
clarity: "Is the answer clear, well-organized, and easy to understand?"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# @param provider [Ask::DecisionProvider]
|
|
27
|
+
def initialize(provider)
|
|
28
|
+
@provider = provider
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Evaluate a response against a rubric.
|
|
32
|
+
#
|
|
33
|
+
# @param request [String] the original user request
|
|
34
|
+
# @param response [String] the assistant's response
|
|
35
|
+
# @param rubric [Hash{Symbol => String}] dimension → instruction
|
|
36
|
+
# @param threshold [Float] minimum weighted score to accept
|
|
37
|
+
# @return [QualityVerdict]
|
|
38
|
+
def evaluate(request:, response:, rubric: DEFAULT_RUBRIC, threshold: 2.5)
|
|
39
|
+
questions = {}
|
|
40
|
+
rubric.each do |dim, instruction|
|
|
41
|
+
questions[dim.to_s] = Ask::Decision::Score.new(
|
|
42
|
+
instructions: instruction,
|
|
43
|
+
criteria: [
|
|
44
|
+
"Poor — factually wrong, incomplete, or unclear",
|
|
45
|
+
"Fair — partially correct but missing key aspects",
|
|
46
|
+
"Good — correct and complete, minor issues only",
|
|
47
|
+
"Excellent — thorough, accurate, and well-presented"
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
questions["verdict"] = Ask::Decision::Choice.new(
|
|
53
|
+
instructions: "Should this response be accepted, revised, or blocked?",
|
|
54
|
+
criteria: {
|
|
55
|
+
"accept" => "The response is good enough to deliver to the user",
|
|
56
|
+
"revise" => "The response has issues that can be fixed with minor edits",
|
|
57
|
+
"block" => "The response is fundamentally wrong or harmful and must be regenerated"
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
state = { request: request, response: response }
|
|
62
|
+
result = @provider.evaluate(state: state, decisions: questions)
|
|
63
|
+
|
|
64
|
+
QualityVerdict.new(result, rubric.keys, threshold)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Verdict from quality evaluation.
|
|
68
|
+
class QualityVerdict
|
|
69
|
+
attr_reader :result, :scores, :verdict, :threshold
|
|
70
|
+
|
|
71
|
+
def initialize(result, dimensions, threshold)
|
|
72
|
+
@result = result
|
|
73
|
+
@threshold = threshold
|
|
74
|
+
@scores = {}
|
|
75
|
+
|
|
76
|
+
dimensions.each do |dim|
|
|
77
|
+
answer = result[dim.to_s]
|
|
78
|
+
@scores[dim] = answer&.score || 0.0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
verdict_answer = result["verdict"]
|
|
82
|
+
@verdict = verdict_answer&.choice || "accept"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def accepted? = @verdict == "accept"
|
|
86
|
+
def revised? = @verdict == "revise"
|
|
87
|
+
def blocked? = @verdict == "block"
|
|
88
|
+
|
|
89
|
+
# Weighted average score across dimensions.
|
|
90
|
+
def average_score
|
|
91
|
+
return 0.0 if @scores.empty?
|
|
92
|
+
@scores.values.sum / @scores.size
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Should the response be accepted based on the score threshold?
|
|
96
|
+
def score_accepts?
|
|
97
|
+
average_score >= @threshold
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def to_s
|
|
101
|
+
scores_str = @scores.map { |k, v| "#{k}: #{('%.1f' % v)}" }.join(", ")
|
|
102
|
+
"#{@verdict} (#{scores_str}, avg: #{('%.1f' % average_score)})"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# One request, several answers, about one piece of state.
|
|
6
|
+
#
|
|
7
|
+
# The questions in a decision call are independent and run in parallel, so
|
|
8
|
+
# asking three costs no more latency than asking one. That makes "ask
|
|
9
|
+
# everything this call might need" the right instinct rather than a false
|
|
10
|
+
# economy — and makes this the shared shape for doing it: a Choice over a
|
|
11
|
+
# described set of options, with anything else the caller needs riding
|
|
12
|
+
# along in the same request.
|
|
13
|
+
#
|
|
14
|
+
# The caller owns *what* to ask — which options, in what words. This owns
|
|
15
|
+
# asking it once and handing back the answers. `ToolRouter` and `Triage`
|
|
16
|
+
# are both façades over it.
|
|
17
|
+
#
|
|
18
|
+
# reader = Ask::Decisions::Reader.new(
|
|
19
|
+
# provider,
|
|
20
|
+
# id: "lane",
|
|
21
|
+
# instructions: "Which of these best describes what the person wants?",
|
|
22
|
+
# options: {"knowledge" => "Asks about the business", "booking" => "Wants to book"},
|
|
23
|
+
# also: {"urgent" => Ask::Decision::Noul.new(instructions: "Is this urgent?")}
|
|
24
|
+
# )
|
|
25
|
+
# batch = reader.read(state: {message: "What time do you close?"})
|
|
26
|
+
# reader.choice(batch).choice # => "knowledge"
|
|
27
|
+
# batch["urgent"].noul # => 0.12
|
|
28
|
+
#
|
|
29
|
+
class Reader
|
|
30
|
+
# How much of an option's description to keep. A description is the
|
|
31
|
+
# separator between one option and the others, and a long one buries the
|
|
32
|
+
# part that separates. What is dropped is said out loud, because a
|
|
33
|
+
# silently clipped option reads as a whole one.
|
|
34
|
+
DEFAULT_LIMIT = 160
|
|
35
|
+
|
|
36
|
+
# The number of characters the elision marker itself needs.
|
|
37
|
+
ELISION_ROOM = 20
|
|
38
|
+
|
|
39
|
+
attr_reader :id, :instructions, :options
|
|
40
|
+
|
|
41
|
+
# @param provider [Ask::DecisionProvider]
|
|
42
|
+
# @param id [String, Symbol] the question id the Choice is asked under.
|
|
43
|
+
# Ids are for the caller's code and are never sent to the model.
|
|
44
|
+
# @param instructions [String] the question the options answer
|
|
45
|
+
# @param options [Hash{String => String}] option => what belongs in it
|
|
46
|
+
# @param also [Hash{String => Decision::Choice,Decision::Score,Decision::Noul}]
|
|
47
|
+
# further questions asked in the same request
|
|
48
|
+
# @param limit [Integer] characters kept per option description
|
|
49
|
+
def initialize(provider, id:, instructions:, options:, also: {}, limit: DEFAULT_LIMIT)
|
|
50
|
+
@provider = provider
|
|
51
|
+
@id = id.to_s
|
|
52
|
+
@instructions = instructions
|
|
53
|
+
@options = options
|
|
54
|
+
@also = also
|
|
55
|
+
@limit = limit
|
|
56
|
+
|
|
57
|
+
if @also.key?(@id)
|
|
58
|
+
raise ArgumentError, "#{@id.inspect} is asked twice: once as the choice, once in also"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Ask everything about +state+ in one request.
|
|
63
|
+
#
|
|
64
|
+
# @param state [String, Hash, Array] the whole context, sent once
|
|
65
|
+
# @param model [String, nil] model override
|
|
66
|
+
# @return [Ask::DecisionResult::Batch]
|
|
67
|
+
def read(state:, model: nil)
|
|
68
|
+
@provider.evaluate(state: state, decisions: questions, model: model)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The Choice answer, or nil when nothing came back for it.
|
|
72
|
+
def choice(batch) = batch[@id]
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def questions
|
|
77
|
+
{id => choice_question}.merge(@also)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def choice_question
|
|
81
|
+
Ask::Decision::Choice.new(instructions: instructions, criteria: described_options)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def described_options
|
|
85
|
+
options.transform_values { |description| truncate(description) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def truncate(value)
|
|
89
|
+
text = value.to_s
|
|
90
|
+
return text if text.length <= @limit
|
|
91
|
+
|
|
92
|
+
"#{text[0, @limit - ELISION_ROOM]}…[#{text.length - @limit + ELISION_ROOM} chars elided]"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Decision-based reflection — replaces the LLM self-critique with a
|
|
6
|
+
# simple Noul: "is there a concrete, actionable improvement?"
|
|
7
|
+
#
|
|
8
|
+
# judge = Ask::Decisions::ReflectionJudge.new(provider)
|
|
9
|
+
# verdict = judge.reflect(
|
|
10
|
+
# request: "Fix the bug in login.rb",
|
|
11
|
+
# response: "I found the issue...",
|
|
12
|
+
# attempt: 2
|
|
13
|
+
# )
|
|
14
|
+
# verdict.improve? # => true
|
|
15
|
+
# verdict.done? # => false
|
|
16
|
+
#
|
|
17
|
+
class ReflectionJudge
|
|
18
|
+
QUESTIONS = {
|
|
19
|
+
has_improvement: Ask::Decision::Noul.new(
|
|
20
|
+
instructions: "Is there a concrete, actionable improvement that would make this response better for the user?"
|
|
21
|
+
),
|
|
22
|
+
quality: Ask::Decision::Score.new(
|
|
23
|
+
instructions: "How close is this response to being fully satisfactory?",
|
|
24
|
+
criteria: [
|
|
25
|
+
"Poor — major issues remain",
|
|
26
|
+
"Fair — some improvements possible",
|
|
27
|
+
"Good — minor polish only",
|
|
28
|
+
"Excellent — ready to deliver"
|
|
29
|
+
]
|
|
30
|
+
)
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# @param provider [Ask::DecisionProvider]
|
|
34
|
+
# @param max_reflections [Integer] stop after this many improvement rounds
|
|
35
|
+
def initialize(provider, max_reflections: 3)
|
|
36
|
+
@provider = provider
|
|
37
|
+
@max_reflections = max_reflections
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Decide whether to improve or stop.
|
|
41
|
+
#
|
|
42
|
+
# @param request [String] the original user request
|
|
43
|
+
# @param response [String] the current response
|
|
44
|
+
# @param attempt [Integer] which reflection round (1-based)
|
|
45
|
+
# @return [ReflectionVerdict]
|
|
46
|
+
def reflect(request:, response:, attempt: 1)
|
|
47
|
+
state = { request: request, response: response, attempt: attempt }
|
|
48
|
+
result = @provider.evaluate(state: state, decisions: QUESTIONS)
|
|
49
|
+
ReflectionVerdict.new(result, attempt, @max_reflections)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Verdict from reflection.
|
|
53
|
+
class ReflectionVerdict
|
|
54
|
+
attr_reader :result, :attempt, :max_reflections
|
|
55
|
+
|
|
56
|
+
def initialize(result, attempt, max_reflections)
|
|
57
|
+
@result = result
|
|
58
|
+
@attempt = attempt
|
|
59
|
+
@max_reflections = max_reflections
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def improve?
|
|
63
|
+
noul = @result["has_improvement"]
|
|
64
|
+
quality = @result["quality"]
|
|
65
|
+
# Improve if there's a concrete improvement AND quality isn't excellent
|
|
66
|
+
(noul&.noul || 0) >= 0.5 && (quality&.score || 0) < 3.5
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def done?
|
|
70
|
+
!improve? || attempt >= @max_reflections
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def quality_score
|
|
74
|
+
@result["quality"]&.score || 0.0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_s
|
|
78
|
+
done? ? "done (quality: #{('%.1f' % quality_score)})" : "improve (quality: #{('%.1f' % quality_score)})"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Reranks retrieved passages using Jev. One Score question per
|
|
6
|
+
# query–candidate pair, scored on a relevance rubric.
|
|
7
|
+
#
|
|
8
|
+
# This is the ask-rag integration: replace a cosine floor with a
|
|
9
|
+
# per-passage relevance judgment. TypeSafe's CLERC cookbook reports
|
|
10
|
+
# top-1 accuracy 5% → 18%, top-10 38% → 62%.
|
|
11
|
+
#
|
|
12
|
+
# reranker = Ask::Decisions::Reranker.new(provider)
|
|
13
|
+
# ranked = reranker.rerank(
|
|
14
|
+
# query: "How do I reset my password?",
|
|
15
|
+
# passages: [
|
|
16
|
+
# { id: "doc1", text: "To reset your password, go to Settings..." },
|
|
17
|
+
# { id: "doc2", text: "Our pricing plans start at $9/month..." }
|
|
18
|
+
# ]
|
|
19
|
+
# )
|
|
20
|
+
# ranked.first # => { id: "doc1", score: 3.8, confidence: 0.9 }
|
|
21
|
+
#
|
|
22
|
+
class Reranker
|
|
23
|
+
# @param provider [Ask::DecisionProvider]
|
|
24
|
+
def initialize(provider)
|
|
25
|
+
@provider = provider
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Rerank passages by relevance to a query.
|
|
29
|
+
#
|
|
30
|
+
# @param query [String] the search query
|
|
31
|
+
# @param passages [Array<Hash>] each with :id and :text
|
|
32
|
+
# @param model [String, nil] model override
|
|
33
|
+
# @return [Array<Hash>] sorted by score descending, each with :id, :score, :confidence
|
|
34
|
+
def rerank(query:, passages:, model: nil)
|
|
35
|
+
return [] if passages.empty?
|
|
36
|
+
|
|
37
|
+
questions = passages.each_with_object({}) do |passage, h|
|
|
38
|
+
id = passage[:id] || passage["id"] || "passage_#{h.size}"
|
|
39
|
+
text = passage[:text] || passage["text"] || ""
|
|
40
|
+
|
|
41
|
+
h["rel_#{id}"] = Ask::Decision::Score.new(
|
|
42
|
+
instructions: "How relevant is this passage to answering the query?",
|
|
43
|
+
criteria: [
|
|
44
|
+
"Not relevant — unrelated to the query",
|
|
45
|
+
"Marginally relevant — shares some keywords but doesn't address the query",
|
|
46
|
+
"Relevant — partially answers the query",
|
|
47
|
+
"Highly relevant — directly answers the query"
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
state = { query: query, passages: passages.map { |p|
|
|
53
|
+
{ id: p[:id] || p["id"], text: truncate(p[:text] || p["text"] || "", 1000) }
|
|
54
|
+
}}
|
|
55
|
+
|
|
56
|
+
result = @provider.evaluate(state: state, decisions: questions, model: model)
|
|
57
|
+
|
|
58
|
+
passages.each_with_index.map do |passage, i|
|
|
59
|
+
id = passage[:id] || passage["id"] || "passage_#{i}"
|
|
60
|
+
answer = result["rel_#{id}"]
|
|
61
|
+
{
|
|
62
|
+
id: id,
|
|
63
|
+
text: passage[:text] || passage["text"],
|
|
64
|
+
score: answer&.score || 0.0,
|
|
65
|
+
confidence: answer&.confidence || 0.0
|
|
66
|
+
}
|
|
67
|
+
end.sort_by { |p| -p[:score] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def truncate(str, limit)
|
|
73
|
+
return "" if str.nil?
|
|
74
|
+
str.length > limit ? "#{str[0, limit]}…" : str
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# A provider that returns canned answers. For tests and offline fixtures.
|
|
6
|
+
#
|
|
7
|
+
# Ask::Decisions.configure do |c|
|
|
8
|
+
# c.default_provider = :static
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# # Or per-call:
|
|
12
|
+
# Ask::Decisions.decide(
|
|
13
|
+
# state: "...",
|
|
14
|
+
# decisions: { "route" => Ask::Decision::Choice.new(instructions: "...", criteria: {...}) },
|
|
15
|
+
# provider: :static
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
class Static < Ask::DecisionProvider
|
|
19
|
+
attr_reader :answers
|
|
20
|
+
|
|
21
|
+
def initialize(answers: {})
|
|
22
|
+
super()
|
|
23
|
+
@answers = answers.transform_keys(&:to_s).freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param state [String, Hash, Array] ignored
|
|
27
|
+
# @param decisions [Hash{String => Decision::Choice|Decision::Score|Decision::Noul}]
|
|
28
|
+
# used to produce placeholder answers for any missing fixtures
|
|
29
|
+
# @return [DecisionResult::Batch]
|
|
30
|
+
def evaluate(state:, decisions:, model: nil)
|
|
31
|
+
answers = decisions.each_with_object({}) do |(id, decision), h|
|
|
32
|
+
h[id.to_s] = @answers[id.to_s] || default_answer(id, decision)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Ask::DecisionResult::Batch.new(answers: answers, model: "static")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def default_answer(id, decision)
|
|
41
|
+
case decision
|
|
42
|
+
when Decision::Choice
|
|
43
|
+
key = decision.criteria.keys.first
|
|
44
|
+
Ask::DecisionResult::ChoiceAnswer.new(
|
|
45
|
+
id: id,
|
|
46
|
+
choice: key,
|
|
47
|
+
probabilities: Hash[decision.criteria.keys.map { |k| [k, 1.0 / decision.criteria.size] }],
|
|
48
|
+
confidence: 1.0
|
|
49
|
+
)
|
|
50
|
+
when Decision::Score
|
|
51
|
+
Ask::DecisionResult::ScoreAnswer.new(
|
|
52
|
+
id: id,
|
|
53
|
+
score: (decision.criteria.size / 2.0),
|
|
54
|
+
legend: Hash[decision.criteria.each_with_index.map { |c, i| [i.to_s, c] }],
|
|
55
|
+
probabilities: Hash[decision.criteria.each_with_index.map { |_, i| [i.to_s, 1.0 / decision.criteria.size] }],
|
|
56
|
+
confidence: 1.0
|
|
57
|
+
)
|
|
58
|
+
when Decision::Noul
|
|
59
|
+
Ask::DecisionResult::NoulAnswer.new(id: id, noul: 0.5)
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError, "Unknown decision type: #{decision.class}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# A structured-state computer-use loop: extract state from a screen
|
|
6
|
+
# (OCR or DOM), ask Jev to pick the next action, execute it, repeat.
|
|
7
|
+
#
|
|
8
|
+
# This is the ask-computer integration. Instead of a vision-LLM loop
|
|
9
|
+
# (expensive, slow), we use OCR/DOM → Jev → action at ~$0.0002/step.
|
|
10
|
+
#
|
|
11
|
+
# loop = Ask::Decisions::StructuredStateLoop.new(provider)
|
|
12
|
+
# result = loop.run(
|
|
13
|
+
# goal: "open Safari and search for Ruby gems",
|
|
14
|
+
# state_fn: -> { extract_screen_state() }, # returns Hash
|
|
15
|
+
# action_fn: ->(action) { execute_action(action) }, # returns void
|
|
16
|
+
# max_steps: 20
|
|
17
|
+
# )
|
|
18
|
+
# result.completed? # => true
|
|
19
|
+
# result.steps # => 8
|
|
20
|
+
#
|
|
21
|
+
class StructuredStateLoop
|
|
22
|
+
ACTIONS = {
|
|
23
|
+
click: "Click on an element identified by its position or label",
|
|
24
|
+
type: "Type text into the focused input field",
|
|
25
|
+
scroll: "Scroll the page or window in a direction",
|
|
26
|
+
key: "Press a keyboard key or combination",
|
|
27
|
+
wait: "Wait for the screen to update",
|
|
28
|
+
done: "The goal has been achieved"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# @param provider [Ask::DecisionProvider]
|
|
32
|
+
def initialize(provider)
|
|
33
|
+
@provider = provider
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Run the decision loop until the goal is achieved or max steps hit.
|
|
37
|
+
#
|
|
38
|
+
# @param goal [String] what the user wants to accomplish
|
|
39
|
+
# @param state_fn [Proc] returns a Hash representing the current screen state
|
|
40
|
+
# @param action_fn [Proc] executes an action Hash, returns void
|
|
41
|
+
# @param max_steps [Integer] safety limit
|
|
42
|
+
# @return [LoopResult]
|
|
43
|
+
def run(goal:, state_fn:, action_fn:, max_steps: 20)
|
|
44
|
+
history = []
|
|
45
|
+
max_steps.times do |step|
|
|
46
|
+
state = state_fn.call
|
|
47
|
+
decision = decide_next(goal: goal, state: state, history: history)
|
|
48
|
+
|
|
49
|
+
if decision.done?
|
|
50
|
+
return LoopResult.new(completed: true, steps: step + 1, history: history)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
action = decision.action
|
|
54
|
+
action_fn.call(action)
|
|
55
|
+
history << { step: step, state_summary: summarize_state(state), action: action }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
LoopResult.new(completed: false, steps: max_steps, history: history)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def decide_next(goal:, state:, history:)
|
|
64
|
+
recent = history.last(5).map { |h| "#{h[:action][:type]}: #{h[:action][:target]}" }
|
|
65
|
+
state_text = summarize_state(state)
|
|
66
|
+
|
|
67
|
+
questions = {
|
|
68
|
+
action_type: Ask::Decision::Choice.new(
|
|
69
|
+
instructions: "What is the next action to achieve the goal?",
|
|
70
|
+
criteria: ACTIONS.transform_keys(&:to_s)
|
|
71
|
+
),
|
|
72
|
+
target: Ask::Decision::Noul.new(
|
|
73
|
+
instructions: "Is the goal already achieved based on the current state?"
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
result = @provider.evaluate(
|
|
78
|
+
state: {
|
|
79
|
+
goal: goal,
|
|
80
|
+
current_state: state_text,
|
|
81
|
+
recent_actions: recent,
|
|
82
|
+
step: history.size
|
|
83
|
+
},
|
|
84
|
+
decisions: questions
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
action_type = result["action_type"]&.choice&.to_sym || :wait
|
|
88
|
+
goal_met = result["target"]&.noul&.>= 0.8
|
|
89
|
+
|
|
90
|
+
StepDecision.new(
|
|
91
|
+
action_type: action_type,
|
|
92
|
+
goal_met: goal_met,
|
|
93
|
+
confidence: result["action_type"]&.confidence
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def summarize_state(state)
|
|
98
|
+
return state.to_s if state.is_a?(String)
|
|
99
|
+
state.map { |k, v| "#{k}: #{v}" }.join("\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A single step decision.
|
|
103
|
+
class StepDecision
|
|
104
|
+
attr_reader :action_type, :confidence
|
|
105
|
+
|
|
106
|
+
def initialize(action_type:, goal_met:, confidence: nil)
|
|
107
|
+
@action_type = action_type
|
|
108
|
+
@goal_met = goal_met
|
|
109
|
+
@confidence = confidence
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def done? = @goal_met
|
|
113
|
+
|
|
114
|
+
def action
|
|
115
|
+
{ type: @action_type, target: nil }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Result of the loop.
|
|
120
|
+
class LoopResult
|
|
121
|
+
attr_reader :steps, :history
|
|
122
|
+
|
|
123
|
+
def initialize(completed:, steps:, history:)
|
|
124
|
+
@completed = completed
|
|
125
|
+
@steps = steps
|
|
126
|
+
@history = history
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def completed? = @completed
|
|
130
|
+
|
|
131
|
+
def to_s
|
|
132
|
+
@completed ? "completed in #{steps} steps" : "not completed after #{steps} steps"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Decision-based tool call repair — replaces the LLM repair pass
|
|
6
|
+
# with a Choice over valid tool names and enum values. This is a
|
|
7
|
+
# real type check, not a prompt-and-hope.
|
|
8
|
+
#
|
|
9
|
+
# repairer = Ask::Decisions::ToolRepairer.new(provider)
|
|
10
|
+
# result = repairer.repair(
|
|
11
|
+
# attempted_tool: "bsh", # typo
|
|
12
|
+
# attempted_args: { command: "ls" },
|
|
13
|
+
# available_tools: [
|
|
14
|
+
# { name: "bash", description: "Run a shell command" },
|
|
15
|
+
# { name: "read", description: "Read a file" }
|
|
16
|
+
# ]
|
|
17
|
+
# )
|
|
18
|
+
# result.repaired_tool # => "bash"
|
|
19
|
+
# result.repaired? # => true
|
|
20
|
+
#
|
|
21
|
+
class ToolRepairer
|
|
22
|
+
# @param provider [Ask::DecisionProvider]
|
|
23
|
+
def initialize(provider)
|
|
24
|
+
@provider = provider
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Attempt to repair a malformed tool call.
|
|
28
|
+
#
|
|
29
|
+
# @param attempted_tool [String] the tool name the LLM tried to use
|
|
30
|
+
# @param attempted_args [Hash] the arguments it passed
|
|
31
|
+
# @param available_tools [Array<Hash>] the valid tool roster
|
|
32
|
+
# @return [RepairResult]
|
|
33
|
+
def repair(attempted_tool:, attempted_args:, available_tools:)
|
|
34
|
+
tool_names = available_tools.map { |t| t[:name] || t["name"] }
|
|
35
|
+
|
|
36
|
+
# If the tool name is valid, try to repair args only.
|
|
37
|
+
if tool_names.include?(attempted_tool)
|
|
38
|
+
return repair_args(
|
|
39
|
+
tool_name: attempted_tool,
|
|
40
|
+
args: attempted_args,
|
|
41
|
+
tools: available_tools
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Tool name is invalid — ask Jev to pick the right one.
|
|
46
|
+
question = Ask::Decision::Choice.new(
|
|
47
|
+
instructions: "The assistant tried to call a tool named '#{attempted_tool}' which does not exist. " \
|
|
48
|
+
"Which available tool is closest to what the assistant intended?",
|
|
49
|
+
criteria: tool_names.each_with_object({}) { |n, h|
|
|
50
|
+
desc = available_tools.find { |t| (t[:name] || t["name"]) == n }
|
|
51
|
+
h[n] = desc ? (desc[:description] || desc["description"] || n) : n
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
result = @provider.evaluate(
|
|
56
|
+
state: { attempted_tool: attempted_tool, attempted_args: attempted_args },
|
|
57
|
+
decisions: { "corrected_tool" => question }
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
corrected = result["corrected_tool"]&.choice
|
|
61
|
+
RepairResult.new(
|
|
62
|
+
repaired_tool: corrected || attempted_tool,
|
|
63
|
+
repaired_args: attempted_args,
|
|
64
|
+
repaired: corrected && corrected != attempted_tool,
|
|
65
|
+
confidence: result["corrected_tool"]&.confidence
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def repair_args(tool_name:, args:, tools:)
|
|
72
|
+
tool_def = tools.find { |t| (t[:name] || t["name"]) == tool_name }
|
|
73
|
+
schema = tool_def&.dig(:params_schema) || tool_def&.dig("params_schema") || {}
|
|
74
|
+
|
|
75
|
+
resolver = ArgumentResolver.new(@provider)
|
|
76
|
+
resolution = resolver.resolve(
|
|
77
|
+
tool_name: tool_name,
|
|
78
|
+
params_schema: schema,
|
|
79
|
+
user_turn: args.to_json
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
merged = resolution.auto_fill_args.merge(args)
|
|
83
|
+
RepairResult.new(
|
|
84
|
+
repaired_tool: tool_name,
|
|
85
|
+
repaired_args: merged,
|
|
86
|
+
repaired: resolution.needs_generation?,
|
|
87
|
+
confidence: resolution.confidence
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Result of tool call repair.
|
|
92
|
+
class RepairResult
|
|
93
|
+
attr_reader :repaired_tool, :repaired_args, :confidence
|
|
94
|
+
|
|
95
|
+
def initialize(repaired_tool:, repaired_args:, repaired:, confidence: nil)
|
|
96
|
+
@repaired_tool = repaired_tool
|
|
97
|
+
@repaired_args = repaired_args
|
|
98
|
+
@repaired = repaired
|
|
99
|
+
@confidence = confidence
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def repaired? = @repaired
|
|
103
|
+
|
|
104
|
+
def to_s
|
|
105
|
+
if repaired?
|
|
106
|
+
"repaired: #{repaired_tool}(#{repaired_args.keys.join(', ')})"
|
|
107
|
+
else
|
|
108
|
+
"unrepairable"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|