ask-decisions 0.2.2 → 0.2.4
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 +4 -4
- data/CHANGELOG.md +21 -0
- data/lib/ask/decisions/agent_adapter.rb +10 -0
- data/lib/ask/decisions/compactor.rb +424 -0
- data/lib/ask/decisions/threshold_judge.rb +75 -0
- data/lib/ask/decisions/version.rb +1 -1
- data/lib/ask-decisions.rb +14 -6
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e64f40bca45e7b9bc9a11f33fabe1e178c30cfd9e61f4460395b77eb8092cc5a
|
|
4
|
+
data.tar.gz: 4e32792a9c4bdc411731beb94d9a078e98d92f3135aaa4da779090fd6c4f96c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e6338ccd1b049f19612e7b33d4e7dbedce5192d4789c29ac62bf6fa075107fa7184764b71c3df9e0edc6284e8d9ed0013225d5ee84a6be9858b5d3ca49d2b874
|
|
7
|
+
data.tar.gz: f9c69aca54eaae484d182ab78c7265de776fafff15e5b4813908028dac1857e8df4f85428fc9474282dd54b4418a6c9981e8cb5e76673f028593daff8be3102b
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [0.2.4] - 2026-09-18
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`resolve_provider` memoizes the provider instance.** Every call to
|
|
12
|
+
`resolve_provider` previously created a new `Typesafe` (or other provider)
|
|
13
|
+
object. In a pipeline with 10+ decision calls per cycle, this meant 10+
|
|
14
|
+
redundant allocations with identical configuration. The provider is now
|
|
15
|
+
cached for the lifetime of the process — it holds only read-only state
|
|
16
|
+
(API key, base URL, model, timeout) set once at boot.
|
|
17
|
+
|
|
18
|
+
## [0.2.3] - 2026-09-18
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`Ask::Decisions::ThresholdJudge`** — binary accept/reject judge for
|
|
23
|
+
simple gate decisions. Runs a single Noul question and thresholds the
|
|
24
|
+
result. Use this when you need "does this pass?" without the full
|
|
25
|
+
rubric of `QualityJudge`. Returns a `ThresholdVerdict` with `passed?`,
|
|
26
|
+
`noul`, and `confidence`.
|
|
27
|
+
|
|
7
28
|
## [0.2.2] - 2026-09-18
|
|
8
29
|
|
|
9
30
|
### Fixed
|
|
@@ -44,6 +44,16 @@ module Ask
|
|
|
44
44
|
@tool_repairer = ToolRepairer.new(@provider)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Build a decision-based compactor for the current provider.
|
|
48
|
+
#
|
|
49
|
+
# compactor = adapter.build_compactor(preserve_recent: 6)
|
|
50
|
+
# result = compactor.compact(session.messages)
|
|
51
|
+
# result.messages # => pruned conversation
|
|
52
|
+
#
|
|
53
|
+
def build_compactor(**opts)
|
|
54
|
+
Ask::Decisions::Compactor.new(@provider, **opts)
|
|
55
|
+
end
|
|
56
|
+
|
|
47
57
|
# Generate before_tool hooks for ask-agent.
|
|
48
58
|
# Returns an array of callables that match the Hooks interface.
|
|
49
59
|
def before_tool_hooks
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Decision-based conversation compactor. Replaces lossy summarization with
|
|
6
|
+
# Jev-scored pruning: every tool call and result is evaluated for relevance,
|
|
7
|
+
# and the irrelevant ones are dropped or truncated — everything kept stays
|
|
8
|
+
# verbatim.
|
|
9
|
+
#
|
|
10
|
+
# This is the Ruby equivalent of the fast-jev-compaction approach: rather
|
|
11
|
+
# than asking an LLM to summarize old turns (which loses file paths, exact
|
|
12
|
+
# errors, constraints, and command details), Jev scores each tool call and
|
|
13
|
+
# result independently, and the compactor applies three-tier decisions.
|
|
14
|
+
#
|
|
15
|
+
# compactor = Ask::Decisions::Compactor.new(provider)
|
|
16
|
+
# result = compactor.compact(messages)
|
|
17
|
+
# result.messages # => pruned message list
|
|
18
|
+
# result.stats # => { kept: 12, dropped: 5, truncated: 3, ... }
|
|
19
|
+
# result.reduction_ratio # => 0.29 (29% of tool content removed)
|
|
20
|
+
#
|
|
21
|
+
# Message format (hashes):
|
|
22
|
+
#
|
|
23
|
+
# { role: "user", content: "Fix the failing test" }
|
|
24
|
+
# { role: "assistant", content: "",
|
|
25
|
+
# tool_calls: [{ id: "call_1", name: "Read", input: { path: "src/a.rb" } }] }
|
|
26
|
+
# { role: "tool", content: "file contents...",
|
|
27
|
+
# tool_call_id: "call_1" }
|
|
28
|
+
#
|
|
29
|
+
# The first message is always kept. The most recent +preserve_recent+
|
|
30
|
+
# messages are never touched. Everything else is a candidate for pruning.
|
|
31
|
+
#
|
|
32
|
+
class Compactor
|
|
33
|
+
# Approximate chars per token for budget calculations.
|
|
34
|
+
CHARS_PER_TOKEN = 4
|
|
35
|
+
|
|
36
|
+
# Jev Noul questions asked per tool call.
|
|
37
|
+
CALL_QUESTION = "Should this tool call be kept — does knowing it was " \
|
|
38
|
+
"made still matter for the current conversation?"
|
|
39
|
+
RESULT_QUESTION = "Should this tool result be kept verbatim — are its " \
|
|
40
|
+
"contents still needed and would re-running the tool " \
|
|
41
|
+
"not suffice?"
|
|
42
|
+
|
|
43
|
+
# @param provider [Ask::DecisionProvider] the Jev/decision provider
|
|
44
|
+
# @param keep_threshold [Float] minimum Noul probability to keep (0.0–1.0)
|
|
45
|
+
# @param preserve_recent [Integer] newest messages never touched
|
|
46
|
+
# @param max_state_tokens [Integer] token ceiling for state sent to Jev
|
|
47
|
+
# @param max_request_tokens [Integer] token ceiling per batch request
|
|
48
|
+
# @param truncate_head_chars [Integer] chars retained from dropped results
|
|
49
|
+
# @param goal [String, nil] ongoing task description for Jev context
|
|
50
|
+
def initialize(provider,
|
|
51
|
+
keep_threshold: 0.5,
|
|
52
|
+
preserve_recent: 4,
|
|
53
|
+
max_state_tokens: 25_000,
|
|
54
|
+
max_request_tokens: 30_000,
|
|
55
|
+
truncate_head_chars: 300,
|
|
56
|
+
goal: nil)
|
|
57
|
+
@provider = provider
|
|
58
|
+
@keep_threshold = keep_threshold
|
|
59
|
+
@preserve_recent = preserve_recent
|
|
60
|
+
@max_state_tokens = max_state_tokens
|
|
61
|
+
@max_request_tokens = max_request_tokens
|
|
62
|
+
@truncate_head_chars = truncate_head_chars
|
|
63
|
+
@goal = goal
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Compact a conversation by scoring every tool call and result with Jev,
|
|
67
|
+
# then dropping or truncating the irrelevant ones.
|
|
68
|
+
#
|
|
69
|
+
# @param messages [Array<Hash>] conversation messages
|
|
70
|
+
# @return [Result] compacted messages and stats
|
|
71
|
+
def compact(messages)
|
|
72
|
+
messages = normalize(messages)
|
|
73
|
+
return Result.new(messages.dup, original_count: messages.size) if messages.size < 2
|
|
74
|
+
|
|
75
|
+
pairs = build_pairs(messages)
|
|
76
|
+
pinned = compute_pinned(messages)
|
|
77
|
+
|
|
78
|
+
return Result.new(messages.dup, original_count: messages.size) if pairs.empty?
|
|
79
|
+
|
|
80
|
+
state = build_state(messages, pairs)
|
|
81
|
+
questions = build_questions(pairs, pinned)
|
|
82
|
+
batch = batch_and_execute(state, questions)
|
|
83
|
+
decisions = extract_decisions(batch, pairs)
|
|
84
|
+
pruned = apply_decisions(messages, decisions, pinned)
|
|
85
|
+
stats = compute_stats(messages, pruned, decisions)
|
|
86
|
+
|
|
87
|
+
Result.new(pruned, stats: stats, original_count: messages.size)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# ── Normalization ────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def normalize(messages)
|
|
95
|
+
Array(messages).map { |m| m.is_a?(Hash) ? m : hashify(m) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def hashify(msg)
|
|
99
|
+
{
|
|
100
|
+
role: msg.respond_to?(:role) ? msg.role.to_s : "unknown",
|
|
101
|
+
content: msg.respond_to?(:content) ? msg.content.to_s : msg.to_s,
|
|
102
|
+
tool_calls: extract_tool_calls_from_object(msg),
|
|
103
|
+
tool_call_id: msg.respond_to?(:tool_call_id) ? msg.tool_call_id : nil
|
|
104
|
+
}.compact
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extract_tool_calls_from_object(msg)
|
|
108
|
+
return nil unless msg.respond_to?(:tool_calls) && msg.tool_calls
|
|
109
|
+
Array(msg.tool_calls).map do |tc|
|
|
110
|
+
{
|
|
111
|
+
id: tc.respond_to?(:id) ? tc.id : tc[:id],
|
|
112
|
+
name: tc.respond_to?(:name) ? tc.name : tc[:name],
|
|
113
|
+
input: tc.respond_to?(:arguments) ? tc.arguments : (tc[:input] || tc[:arguments])
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ── Pairing ──────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
# Group messages into tool_call / tool_result pairs by tool_call_id.
|
|
121
|
+
# Returns an array of { call_msg:, result_msg:, call: } hashes.
|
|
122
|
+
def build_pairs(messages)
|
|
123
|
+
call_index = {}
|
|
124
|
+
messages.each_with_index do |msg, idx|
|
|
125
|
+
Array(msg[:tool_calls]).each do |tc|
|
|
126
|
+
call_index[tc[:id]] = { call_msg_idx: idx, call: tc }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
pairs = []
|
|
131
|
+
messages.each_with_index do |msg, idx|
|
|
132
|
+
tcid = msg[:tool_call_id]
|
|
133
|
+
next unless tcid && call_index[tcid]
|
|
134
|
+
|
|
135
|
+
entry = call_index[tcid]
|
|
136
|
+
pairs << {
|
|
137
|
+
call_msg_idx: entry[:call_msg_idx],
|
|
138
|
+
result_msg_idx: idx,
|
|
139
|
+
call: entry[:call],
|
|
140
|
+
call_id: tcid
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
pairs.sort_by { |p| p[:call_msg_idx] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# First message and the most recent +preserve_recent+ messages are pinned.
|
|
148
|
+
# Pinned messages are never removed, but their tool calls are still
|
|
149
|
+
# evaluated by Jev — a recent tool result can still be irrelevant.
|
|
150
|
+
def compute_pinned(messages)
|
|
151
|
+
pinned = Set.new([0])
|
|
152
|
+
start = [messages.size - @preserve_recent, 1].max
|
|
153
|
+
(start...messages.size).each { |i| pinned.add(i) }
|
|
154
|
+
pinned
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Whether a message index is safe to remove (not pinned).
|
|
158
|
+
def removable?(idx, pinned)
|
|
159
|
+
!pinned.include?(idx)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ── State ────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
# Build the state Jev sees: full conversation with tool results replaced
|
|
165
|
+
# by short placeholder notes. Tool inputs are included, text is included,
|
|
166
|
+
# nothing is summarized.
|
|
167
|
+
def build_state(messages, pairs)
|
|
168
|
+
result_index = {}
|
|
169
|
+
pairs.each { |p| result_index[p[:result_msg_idx]] = p }
|
|
170
|
+
|
|
171
|
+
lines = messages.each_with_index.map do |msg, idx|
|
|
172
|
+
content = msg[:content].to_s
|
|
173
|
+
|
|
174
|
+
if result_index[idx]
|
|
175
|
+
result_index[idx][:result_preview] = content
|
|
176
|
+
chars = content.length
|
|
177
|
+
"[tool result: ok, #{chars} chars (omitted)]"
|
|
178
|
+
elsif msg[:tool_calls] && !msg[:tool_calls].empty?
|
|
179
|
+
tc_strs = msg[:tool_calls].map do |tc|
|
|
180
|
+
input_str = format_input(tc[:input])
|
|
181
|
+
"#{tc[:name]}(#{input_str})"
|
|
182
|
+
end
|
|
183
|
+
tc_strs.empty? ? content : "#{content}\n#{tc_strs.join("\n")}"
|
|
184
|
+
else
|
|
185
|
+
content
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
{ conversation: lines, goal: @goal }.compact
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def format_input(input)
|
|
193
|
+
return "" unless input
|
|
194
|
+
str = input.is_a?(String) ? input : JSON.generate(input)
|
|
195
|
+
str.length > 100 ? "#{str[0, 100]}..." : str
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ── Questions ────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def build_questions(pairs, pinned)
|
|
201
|
+
questions = {}
|
|
202
|
+
pairs.each do |pair|
|
|
203
|
+
# Skip pairs where both the call and result live in pinned messages.
|
|
204
|
+
# Pinned messages are never touched — Jev does not score them.
|
|
205
|
+
next if pinned.include?(pair[:call_msg_idx]) && pinned.include?(pair[:result_msg_idx])
|
|
206
|
+
|
|
207
|
+
call_id = pair[:call_id]
|
|
208
|
+
tc = pair[:call]
|
|
209
|
+
input_str = format_input(tc[:input])
|
|
210
|
+
|
|
211
|
+
questions["keep_call_#{call_id}"] = Ask::Decision::Noul.new(
|
|
212
|
+
instructions: "#{CALL_QUESTION}\n\nTool: #{tc[:name]}\nInput: #{input_str}"
|
|
213
|
+
)
|
|
214
|
+
questions["keep_result_#{call_id}"] = Ask::Decision::Noul.new(
|
|
215
|
+
instructions: "#{RESULT_QUESTION}\n\nTool: #{tc[:name]}\nInput: #{input_str}"
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
questions
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ── Batching ─────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
# Split questions into batches that fit the request token budget,
|
|
224
|
+
# send each batch concurrently (sequentially in Ruby, but the same
|
|
225
|
+
# pattern as fast-jev-compaction), and merge results.
|
|
226
|
+
def batch_and_execute(state, questions)
|
|
227
|
+
return empty_batch if questions.empty?
|
|
228
|
+
|
|
229
|
+
batches = partition_batches(state, questions)
|
|
230
|
+
return execute_batch(state, questions) if batches.size <= 1
|
|
231
|
+
|
|
232
|
+
results = batches.map { |batch| execute_batch(state, batch) }
|
|
233
|
+
merge_batches(results)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def partition_batches(state, questions)
|
|
237
|
+
state_tokens = estimate_tokens(JSON.generate(state))
|
|
238
|
+
pairs = questions.to_a
|
|
239
|
+
batches = []
|
|
240
|
+
current = {}
|
|
241
|
+
current_tokens = state_tokens
|
|
242
|
+
|
|
243
|
+
pairs.each do |id, decision|
|
|
244
|
+
q_tokens = estimate_tokens(JSON.generate(decision.to_h))
|
|
245
|
+
if current_tokens + q_tokens > @max_request_tokens && current.any?
|
|
246
|
+
batches << current
|
|
247
|
+
current = {}
|
|
248
|
+
current_tokens = state_tokens
|
|
249
|
+
end
|
|
250
|
+
current[id] = decision
|
|
251
|
+
current_tokens += q_tokens
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
batches << current if current.any?
|
|
255
|
+
batches
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def execute_batch(state, questions)
|
|
259
|
+
@provider.evaluate(
|
|
260
|
+
state: JSON.generate(state),
|
|
261
|
+
decisions: questions
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def empty_batch
|
|
266
|
+
Ask::DecisionResult::Batch.new(answers: {})
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def merge_batches(batches)
|
|
270
|
+
merged = {}
|
|
271
|
+
batches.each do |batch|
|
|
272
|
+
batch.answers.each { |k, v| merged[k] = v }
|
|
273
|
+
end
|
|
274
|
+
Ask::DecisionResult::Batch.new(answers: merged)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# ── Decisions ────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
def extract_decisions(batch, pairs)
|
|
280
|
+
decisions = {}
|
|
281
|
+
pairs.each do |pair|
|
|
282
|
+
call_id = pair[:call_id]
|
|
283
|
+
keep_call_answer = batch["keep_call_#{call_id}"]
|
|
284
|
+
keep_result_answer = batch["keep_result_#{call_id}"]
|
|
285
|
+
|
|
286
|
+
keep_call = keep_call_answer&.noul || 0.0
|
|
287
|
+
keep_result = keep_result_answer&.noul || 0.0
|
|
288
|
+
|
|
289
|
+
decisions[call_id] = {
|
|
290
|
+
keep_call: keep_call,
|
|
291
|
+
keep_result: keep_result,
|
|
292
|
+
action: classify_action(keep_call, keep_result)
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
decisions
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Three-tier decision classification.
|
|
299
|
+
def classify_action(keep_call, keep_result)
|
|
300
|
+
if keep_result >= @keep_threshold
|
|
301
|
+
:keep
|
|
302
|
+
elsif keep_call >= @keep_threshold
|
|
303
|
+
:truncate
|
|
304
|
+
else
|
|
305
|
+
:drop
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# ── Apply ────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def apply_decisions(messages, decisions, pinned)
|
|
312
|
+
pairs = build_pairs(messages)
|
|
313
|
+
remove_indices = Set.new
|
|
314
|
+
truncate_indices = {}
|
|
315
|
+
|
|
316
|
+
pairs.each do |pair|
|
|
317
|
+
decision = decisions[pair[:call_id]]
|
|
318
|
+
next unless decision
|
|
319
|
+
|
|
320
|
+
case decision[:action]
|
|
321
|
+
when :drop
|
|
322
|
+
# Only remove messages that are not pinned.
|
|
323
|
+
if removable?(pair[:call_msg_idx], pinned)
|
|
324
|
+
remove_indices.add(pair[:call_msg_idx])
|
|
325
|
+
end
|
|
326
|
+
if removable?(pair[:result_msg_idx], pinned)
|
|
327
|
+
remove_indices.add(pair[:result_msg_idx])
|
|
328
|
+
end
|
|
329
|
+
when :truncate
|
|
330
|
+
truncate_indices[pair[:result_msg_idx]] = pair[:call]
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
messages.each_with_index.filter_map do |msg, idx|
|
|
335
|
+
next if remove_indices.include?(idx)
|
|
336
|
+
|
|
337
|
+
if truncate_indices[idx]
|
|
338
|
+
truncate_message(msg, truncate_indices[idx])
|
|
339
|
+
else
|
|
340
|
+
msg
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def truncate_message(msg, call)
|
|
346
|
+
original = msg[:content].to_s
|
|
347
|
+
return msg if original.length <= @truncate_head_chars
|
|
348
|
+
|
|
349
|
+
head = original[0, @truncate_head_chars]
|
|
350
|
+
truncated_content = "#{head}\n...[result truncated: #{original.length - @truncate_head_chars} chars omitted — #{call[:name]} result was kept as call-only]"
|
|
351
|
+
msg.merge(content: truncated_content)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# ── Stats ────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
def compute_stats(original, pruned, decisions)
|
|
357
|
+
action_counts = decisions.values.map { |d| d[:action] }.tally
|
|
358
|
+
original_chars = original.sum { |m| m[:content].to_s.length }
|
|
359
|
+
pruned_chars = pruned.sum { |m| m[:content].to_s.length }
|
|
360
|
+
|
|
361
|
+
{
|
|
362
|
+
messages_before: original.size,
|
|
363
|
+
messages_after: pruned.size,
|
|
364
|
+
kept: action_counts[:keep] || 0,
|
|
365
|
+
truncated: action_counts[:truncate] || 0,
|
|
366
|
+
dropped: action_counts[:drop] || 0,
|
|
367
|
+
tool_pairs_evaluated: decisions.size,
|
|
368
|
+
chars_before: original_chars,
|
|
369
|
+
chars_after: pruned_chars,
|
|
370
|
+
reduction_ratio: original_chars > 0 ? (1.0 - pruned_chars.to_f / original_chars) : 0.0
|
|
371
|
+
}
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# ── Token estimation ─────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
def estimate_tokens(text)
|
|
377
|
+
str = text.to_s
|
|
378
|
+
letters = str.count("a-zA-Z")
|
|
379
|
+
digits = str.count("0-9")
|
|
380
|
+
others = str.length - letters - digits
|
|
381
|
+
(letters / 6.0 + digits / 2.0 + others).ceil
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# ── Batch result ─────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
def extract_call_id(answer_id, prefix)
|
|
387
|
+
answer_id.to_s.sub(/\A#{prefix}/, "")
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# ── Result ───────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
# Holds the compacted messages and compaction statistics.
|
|
393
|
+
class Result
|
|
394
|
+
attr_reader :messages, :stats, :original_count
|
|
395
|
+
|
|
396
|
+
def initialize(messages, stats: {}, original_count: nil)
|
|
397
|
+
@messages = messages
|
|
398
|
+
@stats = stats
|
|
399
|
+
@original_count = original_count || messages.size
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Fraction of tool content removed (0.0 = nothing removed, 1.0 = everything).
|
|
403
|
+
def reduction_ratio
|
|
404
|
+
stats[:reduction_ratio] || 0.0
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Whether any tool calls were dropped or truncated.
|
|
408
|
+
def compacted?
|
|
409
|
+
(stats[:dropped] || 0) > 0 || (stats[:truncated] || 0) > 0
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def to_s
|
|
413
|
+
if compacted?
|
|
414
|
+
"compacted #{stats[:messages_before]}→#{stats[:messages_after]} messages " \
|
|
415
|
+
"(#{stats[:dropped]} dropped, #{stats[:truncated]} truncated, " \
|
|
416
|
+
"#{(reduction_ratio * 100).round(1)}% reduction)"
|
|
417
|
+
else
|
|
418
|
+
"no compaction needed (#{original_count} messages)"
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Binary accept/reject judge — simpler than QualityJudge when you
|
|
6
|
+
# just need "does this pass?" without a full rubric. Runs a single
|
|
7
|
+
# Noul question and thresholds the result.
|
|
8
|
+
#
|
|
9
|
+
# judge = Ask::Decisions::ThresholdJudge.new(provider)
|
|
10
|
+
# verdict = judge.evaluate(
|
|
11
|
+
# subject: "The code changes look correct and have tests.",
|
|
12
|
+
# question: "Is this code change safe to merge?"
|
|
13
|
+
# )
|
|
14
|
+
# verdict.passed? # => true
|
|
15
|
+
# verdict.confidence # => 0.92
|
|
16
|
+
#
|
|
17
|
+
# Use QualityJudge when you need multi-dimensional scoring.
|
|
18
|
+
# Use ThresholdJudge when you need a fast binary gate.
|
|
19
|
+
#
|
|
20
|
+
class ThresholdJudge
|
|
21
|
+
# @param provider [Ask::DecisionProvider]
|
|
22
|
+
def initialize(provider)
|
|
23
|
+
@provider = provider
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Evaluate a subject against a yes/no question.
|
|
27
|
+
#
|
|
28
|
+
# @param subject [String] the content to evaluate
|
|
29
|
+
# @param question [String] the yes/no question
|
|
30
|
+
# @param threshold [Float] minimum noul value to pass (default 0.7)
|
|
31
|
+
# @return [ThresholdVerdict]
|
|
32
|
+
def evaluate(subject:, question:, threshold: 0.7)
|
|
33
|
+
decisions = {
|
|
34
|
+
"verdict" => Ask::Decision::Noul.new(
|
|
35
|
+
instructions: question
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
result = @provider.evaluate(state: subject, decisions: decisions)
|
|
40
|
+
ThresholdVerdict.new(result, threshold)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Verdict from threshold evaluation.
|
|
44
|
+
class ThresholdVerdict
|
|
45
|
+
attr_reader :result, :threshold
|
|
46
|
+
|
|
47
|
+
def initialize(result, threshold)
|
|
48
|
+
@result = result
|
|
49
|
+
@threshold = threshold
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The raw noul value (0–1, where 1 = strong yes).
|
|
53
|
+
def noul
|
|
54
|
+
answer = @result["verdict"]
|
|
55
|
+
answer&.noul || 0.0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Did the subject pass the threshold?
|
|
59
|
+
def passed?
|
|
60
|
+
noul >= @threshold
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Confidence: distance from 0.5 (0 = uncertain, 0.5 = certain).
|
|
64
|
+
def confidence
|
|
65
|
+
(noul - 0.5).abs
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_s
|
|
69
|
+
status = passed? ? "PASS" : "FAIL"
|
|
70
|
+
"#{status} (noul: #{('%.2f' % noul)}, confidence: #{('%.2f' % confidence)})"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/ask-decisions.rb
CHANGED
|
@@ -21,12 +21,14 @@ require_relative "ask/decisions/failure_classifier"
|
|
|
21
21
|
require_relative "ask/decisions/loop_detector"
|
|
22
22
|
require_relative "ask/decisions/confidence_policy"
|
|
23
23
|
require_relative "ask/decisions/quality_judge"
|
|
24
|
+
require_relative "ask/decisions/threshold_judge"
|
|
24
25
|
require_relative "ask/decisions/reflection_judge"
|
|
25
26
|
require_relative "ask/decisions/tool_repairer"
|
|
26
27
|
require_relative "ask/decisions/reranker"
|
|
27
28
|
require_relative "ask/decisions/structured_state_loop"
|
|
28
29
|
require_relative "ask/decisions/calibration_report"
|
|
29
30
|
require_relative "ask/decisions/calibration_harness"
|
|
31
|
+
require_relative "ask/decisions/compactor"
|
|
30
32
|
|
|
31
33
|
# The Decide tool requires ask-tools (Ask::Tool base class). Load conditionally
|
|
32
34
|
# so the core gem works without a tools dependency.
|
|
@@ -120,13 +122,19 @@ module Ask
|
|
|
120
122
|
end
|
|
121
123
|
|
|
122
124
|
# Resolve a provider by name, raising on unknown.
|
|
125
|
+
# Memoized: the provider is stateless after boot (API key,
|
|
126
|
+
# base URL, model, timeout are all read-only), so one instance
|
|
127
|
+
# per configuration lifetime is safe and avoids redundant
|
|
128
|
+
# allocations across the pipeline.
|
|
123
129
|
def resolve_provider(name)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
@resolved_provider ||= begin
|
|
131
|
+
klass = Ask::DecisionProvider.resolve(name)
|
|
132
|
+
|
|
133
|
+
if configuration.provider_options.any?
|
|
134
|
+
klass.new(**configuration.provider_options)
|
|
135
|
+
else
|
|
136
|
+
klass.new
|
|
137
|
+
end
|
|
130
138
|
end
|
|
131
139
|
end
|
|
132
140
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-decisions
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -154,6 +154,7 @@ files:
|
|
|
154
154
|
- lib/ask/decisions/cache.rb
|
|
155
155
|
- lib/ask/decisions/calibration_harness.rb
|
|
156
156
|
- lib/ask/decisions/calibration_report.rb
|
|
157
|
+
- lib/ask/decisions/compactor.rb
|
|
157
158
|
- lib/ask/decisions/confidence_policy.rb
|
|
158
159
|
- lib/ask/decisions/decision_state.rb
|
|
159
160
|
- lib/ask/decisions/failure_classifier.rb
|
|
@@ -168,6 +169,7 @@ files:
|
|
|
168
169
|
- lib/ask/decisions/reranker.rb
|
|
169
170
|
- lib/ask/decisions/static.rb
|
|
170
171
|
- lib/ask/decisions/structured_state_loop.rb
|
|
172
|
+
- lib/ask/decisions/threshold_judge.rb
|
|
171
173
|
- lib/ask/decisions/tool_repairer.rb
|
|
172
174
|
- lib/ask/decisions/triage.rb
|
|
173
175
|
- lib/ask/decisions/typesafe.rb
|