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,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Accumulates questions and executes them in a single API call.
|
|
6
|
+
#
|
|
7
|
+
# Jev evaluates all questions in one request in parallel, and adding questions
|
|
8
|
+
# barely changes latency or cost. This batching discipline is the core
|
|
9
|
+
# ergonomic of the decision layer — send every question the turn might need
|
|
10
|
+
# in one call, let Ruby ignore the answers it doesn't reach.
|
|
11
|
+
#
|
|
12
|
+
# result = Ask::Decisions.batch(state: "...") do |b|
|
|
13
|
+
# b.ask("route", Ask::Decision::Choice.new(...))
|
|
14
|
+
# b.ask("urgent", Ask::Decision::Noul.new(...))
|
|
15
|
+
# b.ask("quality", Ask::Decision::Score.new(...))
|
|
16
|
+
# end
|
|
17
|
+
# result["route"].choice
|
|
18
|
+
#
|
|
19
|
+
class Batcher
|
|
20
|
+
attr_reader :provider, :state, :model, :questions
|
|
21
|
+
|
|
22
|
+
def initialize(provider, state:, model: nil)
|
|
23
|
+
@provider = provider
|
|
24
|
+
@state = state
|
|
25
|
+
@model = model
|
|
26
|
+
@questions = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Add a question to the batch.
|
|
30
|
+
#
|
|
31
|
+
# @param id [String] the question id (used to access the answer)
|
|
32
|
+
# @param decision [Ask::Decision::Choice, Ask::Decision::Score, Ask::Decision::Noul]
|
|
33
|
+
# @return [self]
|
|
34
|
+
def ask(id, decision)
|
|
35
|
+
@questions[id.to_s] = decision
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Execute all accumulated questions in a single API call.
|
|
40
|
+
#
|
|
41
|
+
# @return [Ask::DecisionResult::Batch]
|
|
42
|
+
def execute
|
|
43
|
+
return empty_result if @questions.empty?
|
|
44
|
+
|
|
45
|
+
@provider.evaluate(
|
|
46
|
+
state: @state,
|
|
47
|
+
decisions: @questions,
|
|
48
|
+
model: @model
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def empty_result
|
|
55
|
+
Ask::DecisionResult::Batch.new(answers: {})
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Caches DecisionProvider results keyed on (state, questions).
|
|
6
|
+
# Jev is self-consistent — identical inputs return identical outputs —
|
|
7
|
+
# so a cache is safe and prevents redundant calls when the same decision
|
|
8
|
+
# is made multiple times in a turn (e.g. guard + rerun).
|
|
9
|
+
#
|
|
10
|
+
# cached = Ask::Decisions::Cache.new(provider, ttl: 120)
|
|
11
|
+
# cached.evaluate(state: s, decisions: qs) # hits the API
|
|
12
|
+
# cached.evaluate(state: s, decisions: qs) # returns the cached result
|
|
13
|
+
#
|
|
14
|
+
class Cache
|
|
15
|
+
attr_reader :provider, :ttl
|
|
16
|
+
|
|
17
|
+
def initialize(provider, ttl: 120)
|
|
18
|
+
@provider = provider
|
|
19
|
+
@ttl = ttl
|
|
20
|
+
@store = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def evaluate(state:, decisions:, model: nil)
|
|
24
|
+
key = cache_key(state, decisions, model)
|
|
25
|
+
entry = @store[key]
|
|
26
|
+
|
|
27
|
+
if entry && !expired?(entry)
|
|
28
|
+
return entry[:result]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
result = @provider.evaluate(state: state, decisions: decisions, model: model)
|
|
32
|
+
@store[key] = { result: result, timestamp: Time.now.to_f }
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear
|
|
37
|
+
@store.clear
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def size
|
|
42
|
+
@store.size
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def cache_key(state, decisions, model)
|
|
48
|
+
digest = ::JSON.generate({
|
|
49
|
+
state: state,
|
|
50
|
+
decisions: decisions.transform_values(&:to_h),
|
|
51
|
+
model: model
|
|
52
|
+
})
|
|
53
|
+
[state_hash(digest), model].join(":")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def state_hash(data)
|
|
57
|
+
# Use a simple digest for cache keys — not cryptographic, just dedup.
|
|
58
|
+
require "digest" unless defined?(::Digest)
|
|
59
|
+
Digest::MD5.hexdigest(data)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def expired?(entry)
|
|
63
|
+
(Time.now.to_f - entry[:timestamp]) > @ttl
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Runs a set of test cases against a decision provider and produces
|
|
6
|
+
# a calibration report. Used to measure whether Jev's confidence is
|
|
7
|
+
# trustworthy on YOUR decisions.
|
|
8
|
+
#
|
|
9
|
+
# harness = Ask::Decisions::CalibrationHarness.new(provider)
|
|
10
|
+
#
|
|
11
|
+
# harness.add_case(
|
|
12
|
+
# id: "urgent_message",
|
|
13
|
+
# state: "Help! My server is down!",
|
|
14
|
+
# decisions: { "urgent" => Ask::Decision::Noul.new(instructions: "Is this urgent?") },
|
|
15
|
+
# expected: { "urgent" => { noul_above: 0.7 } }
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# report = harness.run
|
|
19
|
+
# puts report
|
|
20
|
+
#
|
|
21
|
+
class CalibrationHarness
|
|
22
|
+
def initialize(provider)
|
|
23
|
+
@provider = provider
|
|
24
|
+
@cases = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Add a test case.
|
|
28
|
+
#
|
|
29
|
+
# @param id [String] a descriptive id for this case
|
|
30
|
+
# @param state [String, Hash] the state to evaluate
|
|
31
|
+
# @param decisions [Hash] the questions to ask
|
|
32
|
+
# @param expected [Hash] expected outcomes for assertions:
|
|
33
|
+
# - { choice_is: "value" } — check choice answer
|
|
34
|
+
# - { noul_above: 0.7 } — check noul is above threshold
|
|
35
|
+
# - { noul_below: 0.3 } — check noul is below threshold
|
|
36
|
+
# - { score_above: 2.0 } — check score is above threshold
|
|
37
|
+
# - { confidence_above: 0.7 } — check confidence
|
|
38
|
+
# @param runs [Integer] how many times to run this case (for variance)
|
|
39
|
+
def add_case(id:, state:, decisions:, expected: {}, runs: 1)
|
|
40
|
+
@cases << { id: id, state: state, decisions: decisions, expected: expected, runs: runs }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Run all test cases and produce a calibration report.
|
|
44
|
+
#
|
|
45
|
+
# @return [CalibrationReport::Summary]
|
|
46
|
+
def run
|
|
47
|
+
report = CalibrationReport.new
|
|
48
|
+
|
|
49
|
+
@cases.each do |tc|
|
|
50
|
+
tc[:runs].times do |run_idx|
|
|
51
|
+
result = @provider.evaluate(
|
|
52
|
+
state: tc[:state],
|
|
53
|
+
decisions: tc[:decisions]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
tc[:decisions].each do |decision_id, decision|
|
|
57
|
+
answer = result[decision_id.to_s]
|
|
58
|
+
next unless answer
|
|
59
|
+
|
|
60
|
+
predicted, confidence = extract_prediction(answer)
|
|
61
|
+
outcome = check_expected(tc[:expected][decision_id], answer)
|
|
62
|
+
correct = predicted == outcome
|
|
63
|
+
|
|
64
|
+
report.record(
|
|
65
|
+
decision_id: "#{tc[:id]}.#{decision_id}",
|
|
66
|
+
confidence: confidence || 0.5,
|
|
67
|
+
predicted: predicted.to_s,
|
|
68
|
+
outcome: outcome.to_s,
|
|
69
|
+
metadata: { run: run_idx, case_id: tc[:id] }
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
report.summarize
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def extract_prediction(answer)
|
|
81
|
+
case answer
|
|
82
|
+
when DecisionResult::ChoiceAnswer
|
|
83
|
+
[answer.choice, answer.confidence]
|
|
84
|
+
when DecisionResult::ScoreAnswer
|
|
85
|
+
[answer.score, answer.confidence]
|
|
86
|
+
when DecisionResult::NoulAnswer
|
|
87
|
+
[answer.noul, nil] # noul has no confidence
|
|
88
|
+
else
|
|
89
|
+
[nil, nil]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check_expected(expected, answer)
|
|
94
|
+
return "pass" unless expected
|
|
95
|
+
|
|
96
|
+
if expected[:choice_is]
|
|
97
|
+
answer.respond_to?(:choice) && answer.choice == expected[:choice_is] ? "pass" : "fail"
|
|
98
|
+
elsif expected[:noul_above]
|
|
99
|
+
answer.respond_to?(:noul) && answer.noul >= expected[:noul_above] ? "pass" : "fail"
|
|
100
|
+
elsif expected[:noul_below]
|
|
101
|
+
answer.respond_to?(:noul) && answer.noul <= expected[:noul_below] ? "pass" : "fail"
|
|
102
|
+
elsif expected[:score_above]
|
|
103
|
+
answer.respond_to?(:score) && answer.score >= expected[:score_above] ? "pass" : "fail"
|
|
104
|
+
elsif expected[:confidence_above]
|
|
105
|
+
answer.respond_to?(:confidence) && answer.confidence && answer.confidence >= expected[:confidence_above] ? "pass" : "fail"
|
|
106
|
+
else
|
|
107
|
+
"pass"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Records decision outcomes and produces a calibration report —
|
|
6
|
+
# reliability curves per decision id, variance across runs, and
|
|
7
|
+
# threshold sweep.
|
|
8
|
+
#
|
|
9
|
+
# This is the measurement loop that makes confidence thresholds
|
|
10
|
+
# trustworthy. Without it, thresholds are guesses.
|
|
11
|
+
#
|
|
12
|
+
# report = Ask::Decisions::CalibrationReport.new
|
|
13
|
+
#
|
|
14
|
+
# # Record a decision and its outcome.
|
|
15
|
+
# report.record(
|
|
16
|
+
# decision_id: "tool.route",
|
|
17
|
+
# confidence: 0.89,
|
|
18
|
+
# predicted: "bash",
|
|
19
|
+
# outcome: "bash", # what actually happened
|
|
20
|
+
# latency: 0.12
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# # Generate the report.
|
|
24
|
+
# summary = report.summarize
|
|
25
|
+
# summary.accuracy # => 0.92
|
|
26
|
+
# summary.calibration # => 0.85 (Brier-like score)
|
|
27
|
+
# summary.by_confidence # => [{ range: "0.9–1.0", count: 15, accuracy: 0.93 }, ...]
|
|
28
|
+
#
|
|
29
|
+
class CalibrationReport
|
|
30
|
+
attr_reader :records
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@records = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Record a decision and its outcome.
|
|
37
|
+
#
|
|
38
|
+
# @param decision_id [String] the decision id
|
|
39
|
+
# @param confidence [Float] the model's confidence
|
|
40
|
+
# @param predicted [String] what the model chose
|
|
41
|
+
# @param outcome [String] what actually happened (ground truth)
|
|
42
|
+
# @param latency [Float, nil] response time in seconds
|
|
43
|
+
# @param metadata [Hash] extra data (tool name, risk level, etc.)
|
|
44
|
+
def record(decision_id:, confidence:, predicted:, outcome:, latency: nil, metadata: {})
|
|
45
|
+
@records << {
|
|
46
|
+
decision_id: decision_id.to_s,
|
|
47
|
+
confidence: confidence.to_f,
|
|
48
|
+
predicted: predicted.to_s,
|
|
49
|
+
outcome: outcome.to_s,
|
|
50
|
+
latency: latency,
|
|
51
|
+
metadata: metadata,
|
|
52
|
+
timestamp: Time.now.to_f
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Generate the calibration summary.
|
|
57
|
+
#
|
|
58
|
+
# @return [Summary]
|
|
59
|
+
def summarize
|
|
60
|
+
Summary.new(@records)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Reset all records.
|
|
64
|
+
def clear
|
|
65
|
+
@records.clear
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Summary of recorded decisions.
|
|
69
|
+
class Summary
|
|
70
|
+
attr_reader :total, :correct, :accuracy, :calibration, :by_confidence, :by_decision_id
|
|
71
|
+
|
|
72
|
+
def initialize(records)
|
|
73
|
+
@records = records
|
|
74
|
+
@total = records.size
|
|
75
|
+
@correct = records.count { |r| r[:predicted] == r[:outcome] }
|
|
76
|
+
@accuracy = @total > 0 ? @correct.to_f / @total : 0.0
|
|
77
|
+
@calibration = compute_calibration(records)
|
|
78
|
+
@by_confidence = bucket_by_confidence(records)
|
|
79
|
+
@by_decision_id = group_by_decision_id(records)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Overall accuracy.
|
|
83
|
+
def accuracy_pct
|
|
84
|
+
("%.1f%%" % (@accuracy * 100))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Average latency.
|
|
88
|
+
def avg_latency
|
|
89
|
+
latencies = @records.filter_map { |r| r[:latency] }
|
|
90
|
+
return nil if latencies.empty?
|
|
91
|
+
latencies.sum / latencies.size
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Print a human-readable report.
|
|
95
|
+
def to_s
|
|
96
|
+
lines = []
|
|
97
|
+
lines << "=== Calibration Report ==="
|
|
98
|
+
lines << "Total decisions: #{@total}"
|
|
99
|
+
lines << "Correct: #{@correct} (#{accuracy_pct})"
|
|
100
|
+
lines << "Calibration (Brier): #{('%.4f' % @calibration)}" if @calibration
|
|
101
|
+
lines << "Avg latency: #{('%.0fms' % (avg_latency * 1000))}" if avg_latency
|
|
102
|
+
lines << ""
|
|
103
|
+
lines << "By confidence band:"
|
|
104
|
+
@by_confidence.each do |band|
|
|
105
|
+
lines << " #{band[:range]}: #{band[:count]} decisions, #{band[:accuracy_pct]} accuracy"
|
|
106
|
+
end
|
|
107
|
+
lines << ""
|
|
108
|
+
lines << "By decision id:"
|
|
109
|
+
@by_decision_id.each do |id, data|
|
|
110
|
+
lines << " #{id}: #{data[:count]} decisions, #{data[:accuracy_pct]} accuracy"
|
|
111
|
+
end
|
|
112
|
+
lines.join("\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Brier-like calibration score: mean squared error between
|
|
118
|
+
# confidence and outcome (1 if correct, 0 if wrong).
|
|
119
|
+
def compute_calibration(records)
|
|
120
|
+
return nil if records.empty?
|
|
121
|
+
records.sum do |r|
|
|
122
|
+
actual = r[:predicted] == r[:outcome] ? 1.0 : 0.0
|
|
123
|
+
(r[:confidence] - actual) ** 2
|
|
124
|
+
end / records.size
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Bucket records by confidence ranges.
|
|
128
|
+
def bucket_by_confidence(records)
|
|
129
|
+
ranges = [
|
|
130
|
+
{ min: 0.9, max: 1.0, label: "0.9–1.0" },
|
|
131
|
+
{ min: 0.7, max: 0.9, label: "0.7–0.9" },
|
|
132
|
+
{ min: 0.5, max: 0.7, label: "0.5–0.7" },
|
|
133
|
+
{ min: 0.3, max: 0.5, label: "0.3–0.5" },
|
|
134
|
+
{ min: 0.0, max: 0.3, label: "0.0–0.3" }
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
ranges.filter_map do |range|
|
|
138
|
+
bucket = records.select { |r| r[:confidence] >= range[:min] && r[:confidence] < range[:max] }
|
|
139
|
+
next if bucket.empty?
|
|
140
|
+
correct = bucket.count { |r| r[:predicted] == r[:outcome] }
|
|
141
|
+
acc = correct.to_f / bucket.size
|
|
142
|
+
{
|
|
143
|
+
range: range[:label],
|
|
144
|
+
count: bucket.size,
|
|
145
|
+
accuracy: acc,
|
|
146
|
+
accuracy_pct: "%.1f%%" % (acc * 100),
|
|
147
|
+
avg_confidence: bucket.sum { |r| r[:confidence] } / bucket.size
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Group records by decision id.
|
|
153
|
+
def group_by_decision_id(records)
|
|
154
|
+
records.group_by { |r| r[:decision_id] }.transform_values do |group|
|
|
155
|
+
correct = group.count { |r| r[:predicted] == r[:outcome] }
|
|
156
|
+
acc = correct.to_f / group.size
|
|
157
|
+
{
|
|
158
|
+
count: group.size,
|
|
159
|
+
accuracy: acc,
|
|
160
|
+
accuracy_pct: "%.1f%%" % (acc * 100),
|
|
161
|
+
avg_confidence: group.sum { |r| r[:confidence] } / group.size
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Gates actions on confidence thresholds. Extends the existing
|
|
6
|
+
# approval system with a confidence axis: high confidence → act,
|
|
7
|
+
# medium → require approval, low → escalate to LLM/human.
|
|
8
|
+
#
|
|
9
|
+
# policy = Ask::Decisions::ConfidencePolicy.new
|
|
10
|
+
# policy.add_rule(:bash, risk: :low, act_threshold: 0.5, review_threshold: 0.3)
|
|
11
|
+
# policy.add_rule(:write, risk: :high, act_threshold: 0.9, review_threshold: 0.7)
|
|
12
|
+
#
|
|
13
|
+
# decision = policy.evaluate(tool: "bash", confidence: 0.6)
|
|
14
|
+
# decision.action # => :act
|
|
15
|
+
#
|
|
16
|
+
# decision = policy.evaluate(tool: "write", confidence: 0.8)
|
|
17
|
+
# decision.action # => :review (need approval)
|
|
18
|
+
#
|
|
19
|
+
class ConfidencePolicy
|
|
20
|
+
# Default rules by risk level. Matches the pi-jev calibration:
|
|
21
|
+
# read-only tools act at 0.5, destructive tools need 0.9.
|
|
22
|
+
DEFAULT_RULES = {
|
|
23
|
+
low: { act_threshold: 0.5, review_threshold: 0.3 },
|
|
24
|
+
medium: { act_threshold: 0.7, review_threshold: 0.5 },
|
|
25
|
+
high: { act_threshold: 0.9, review_threshold: 0.7 }
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@rules = {} # tool_name → { risk:, act_threshold:, review_threshold: }
|
|
30
|
+
@default_risk = :medium
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Add a rule for a specific tool.
|
|
34
|
+
#
|
|
35
|
+
# @param tool [String, Symbol] the tool name
|
|
36
|
+
# @param risk [Symbol] :low, :medium, or :high
|
|
37
|
+
# @param act_threshold [Float, nil] override the act threshold
|
|
38
|
+
# @param review_threshold [Float, nil] override the review threshold
|
|
39
|
+
def add_rule(tool, risk: :medium, act_threshold: nil, review_threshold: nil)
|
|
40
|
+
defaults = DEFAULT_RULES[risk] || DEFAULT_RULES[:medium]
|
|
41
|
+
@rules[tool.to_s] = {
|
|
42
|
+
risk: risk,
|
|
43
|
+
act_threshold: act_threshold || defaults[:act_threshold],
|
|
44
|
+
review_threshold: review_threshold || defaults[:review_threshold]
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Set the default risk level for tools without explicit rules.
|
|
49
|
+
def default_risk=(risk)
|
|
50
|
+
@default_risk = risk
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Evaluate whether an action should proceed based on confidence.
|
|
54
|
+
#
|
|
55
|
+
# @param tool [String] the tool name
|
|
56
|
+
# @param confidence [Float, nil] the decision's confidence
|
|
57
|
+
# @return [PolicyDecision]
|
|
58
|
+
def evaluate(tool:, confidence: nil)
|
|
59
|
+
rule = @rules[tool.to_s] || DEFAULT_RULES[@default_risk] || DEFAULT_RULES[:medium]
|
|
60
|
+
|
|
61
|
+
action = classify(confidence, rule)
|
|
62
|
+
PolicyDecision.new(
|
|
63
|
+
tool: tool,
|
|
64
|
+
confidence: confidence,
|
|
65
|
+
action: action,
|
|
66
|
+
risk: rule[:risk],
|
|
67
|
+
act_threshold: rule[:act_threshold],
|
|
68
|
+
review_threshold: rule[:review_threshold]
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def classify(confidence, rule)
|
|
75
|
+
return :escalate if confidence.nil?
|
|
76
|
+
|
|
77
|
+
if confidence >= rule[:act_threshold]
|
|
78
|
+
:act
|
|
79
|
+
elsif confidence >= rule[:review_threshold]
|
|
80
|
+
:review
|
|
81
|
+
else
|
|
82
|
+
:escalate
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Decision from policy evaluation.
|
|
87
|
+
class PolicyDecision
|
|
88
|
+
attr_reader :tool, :confidence, :action, :risk, :act_threshold, :review_threshold
|
|
89
|
+
|
|
90
|
+
def initialize(tool:, confidence:, action:, risk:, act_threshold:, review_threshold:)
|
|
91
|
+
@tool = tool
|
|
92
|
+
@confidence = confidence
|
|
93
|
+
@action = action
|
|
94
|
+
@risk = risk
|
|
95
|
+
@act_threshold = act_threshold
|
|
96
|
+
@review_threshold = review_threshold
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def act? = action == :act
|
|
100
|
+
def review? = action == :review
|
|
101
|
+
def escalate? = action == :escalate
|
|
102
|
+
|
|
103
|
+
def to_s
|
|
104
|
+
case action
|
|
105
|
+
when :act then "act on #{tool} (confidence #{fmt})"
|
|
106
|
+
when :review then "review #{tool} (confidence #{fmt}, need approval)"
|
|
107
|
+
when :escalate then "escalate #{tool} (confidence #{fmt}, below threshold)"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def fmt
|
|
114
|
+
confidence ? "%.2f" % confidence : "nil"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Projects session state into a compact document for Jev. Filters out
|
|
6
|
+
# irrelevant fields, truncates to a token budget, and produces the
|
|
7
|
+
# structured state that System One models expect.
|
|
8
|
+
#
|
|
9
|
+
# Jev degrades with irrelevant detail ("context rot"), so the
|
|
10
|
+
# projection layer is the single most important piece of the
|
|
11
|
+
# integration: it determines accuracy by deciding what the model sees.
|
|
12
|
+
#
|
|
13
|
+
# state = Ask::Decisions::DecisionState.build(
|
|
14
|
+
# user_turn: "check the weather in seattle tomorrow",
|
|
15
|
+
# recent_turns: [...],
|
|
16
|
+
# tools: [...],
|
|
17
|
+
# plan: "Investigate the weather API...",
|
|
18
|
+
# budget: 8000
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
class DecisionState
|
|
22
|
+
# Approximate chars per token (conservative for mixed content).
|
|
23
|
+
CHARS_PER_TOKEN = 4
|
|
24
|
+
|
|
25
|
+
# Build a compact state document from session components.
|
|
26
|
+
#
|
|
27
|
+
# @param user_turn [String] the latest user message
|
|
28
|
+
# @param recent_turns [Array<Hash>, nil] recent conversation messages
|
|
29
|
+
# @param tools [Array<Hash>, nil] tool roster (name + description)
|
|
30
|
+
# @param agent_roster [Array<Hash>, nil] available agents
|
|
31
|
+
# @param plan [String, nil] current plan/task description
|
|
32
|
+
# @param memory [Array<String>, nil] relevant memory entries
|
|
33
|
+
# @param todos [Array<Hash>, nil] current todo items
|
|
34
|
+
# @param budget [Integer] max characters for the state
|
|
35
|
+
# @return [Hash] the projected state
|
|
36
|
+
def self.build(
|
|
37
|
+
user_turn:,
|
|
38
|
+
recent_turns: nil,
|
|
39
|
+
tools: nil,
|
|
40
|
+
agent_roster: nil,
|
|
41
|
+
plan: nil,
|
|
42
|
+
memory: nil,
|
|
43
|
+
todos: nil,
|
|
44
|
+
budget: 32_000 * CHARS_PER_TOKEN
|
|
45
|
+
)
|
|
46
|
+
state = {}
|
|
47
|
+
remaining = budget
|
|
48
|
+
|
|
49
|
+
# Always include the user turn (highest priority).
|
|
50
|
+
truncated_turn = truncate(user_turn, remaining - 100)
|
|
51
|
+
state[:user_turn] = truncated_turn
|
|
52
|
+
remaining -= truncated_turn.length
|
|
53
|
+
|
|
54
|
+
# Add recent turns if there's budget.
|
|
55
|
+
if recent_turns && remaining > 500
|
|
56
|
+
recent = truncate_messages(recent_turns, remaining / 2)
|
|
57
|
+
state[:recent_turns] = recent
|
|
58
|
+
remaining -= recent.to_s.length
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Add tools if there's budget.
|
|
62
|
+
if tools && remaining > 200
|
|
63
|
+
tool_summary = tools.map do |t|
|
|
64
|
+
name = t[:name] || t["name"]
|
|
65
|
+
desc = t[:description] || t["description"] || ""
|
|
66
|
+
"#{name}: #{truncate(desc, 80)}"
|
|
67
|
+
end
|
|
68
|
+
state[:available_tools] = tool_summary
|
|
69
|
+
remaining -= tool_summary.to_s.length
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Add plan if there's budget.
|
|
73
|
+
if plan && remaining > 100
|
|
74
|
+
state[:plan] = truncate(plan, remaining / 3)
|
|
75
|
+
remaining -= state[:plan].length
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Add memory if there's budget.
|
|
79
|
+
if memory && remaining > 100
|
|
80
|
+
state[:memory] = memory.first(5).map { |m| truncate(m, 200) }
|
|
81
|
+
remaining -= state[:memory].to_s.length
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Add todos if there's budget.
|
|
85
|
+
if todos && remaining > 100
|
|
86
|
+
state[:todos] = todos.first(10).map do |t|
|
|
87
|
+
{ task: t[:task] || t["task"], status: t[:status] || t["status"] }
|
|
88
|
+
end
|
|
89
|
+
remaining -= state[:todos].to_s.length
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
state
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Truncate a string to a character budget.
|
|
96
|
+
def self.truncate(str, limit)
|
|
97
|
+
return "" if str.nil?
|
|
98
|
+
str = str.to_s
|
|
99
|
+
return str if str.length <= limit
|
|
100
|
+
"#{str[0, limit - 20]}…[#{str.length - limit + 20} chars elided]"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Truncate an array of messages to fit within a character budget,
|
|
104
|
+
# keeping the most recent messages first.
|
|
105
|
+
def self.truncate_messages(messages, budget)
|
|
106
|
+
return [] if messages.nil? || messages.empty?
|
|
107
|
+
result = []
|
|
108
|
+
used = 0
|
|
109
|
+
# Walk from most recent to oldest.
|
|
110
|
+
messages.reverse_each do |msg|
|
|
111
|
+
text = msg[:content] || msg["content"] || msg.to_s
|
|
112
|
+
entry = { role: msg[:role] || msg["role"], content: truncate(text, 2000) }
|
|
113
|
+
entry_len = entry.to_s.length
|
|
114
|
+
break if used + entry_len > budget
|
|
115
|
+
result.unshift(entry)
|
|
116
|
+
used += entry_len
|
|
117
|
+
end
|
|
118
|
+
result
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|